diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 46cd95402..2784d94b3 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.9.26.2' + default: '2026.9.27.1' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index d0e556393..b70e47469 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.9.26.2' + default: '2026.9.27.1' image: description: > The runner label the job runs on (macos-15, xcode-27). It is part of the diff --git a/.github/tools/check_file_lengths.sh b/.github/tools/check_file_lengths.sh new file mode 100755 index 000000000..7e92617a5 --- /dev/null +++ b/.github/tools/check_file_lengths.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Guard: no file under the prepare.cppm decomposition grows past 2,500 lines. +# +# WHY +# +# prepare.cppm was 16,105 lines: one exported function, prepare_build, ~85% +# of the file, ~180 top-level locals sharing a stack frame. It was split into +# a primary interface (src/build/prepare.cppm) holding declarations, an +# implementation partition (src/build/prepare/state.cppm) and implementation +# units, each phase a function taking PrepareState& instead of closing over +# the old locals directly -- see the layout comment at the top of +# prepare.cppm, and mcpp-community/mcpp#721 for the GCC 16.1 constraint that +# shapes it. +# +# A size cap with no gate is a target nobody re-checks. The decomposition's +# whole point was to keep any one file's compile from blocking on the rest — +# a phase file that quietly grows back to several thousand lines is the same +# defect it fixed, arrived at one small commit at a time. This fails the +# build the day that happens, at the commit that did it, rather than leaving +# it for the next person who tries to read the file. +# +# THE RULE +# +# Every file directly under src/build/prepare/, plus src/build/prepare.cppm +# itself, stays at or under 2,500 lines. There is no per-file waiver: a file +# that needs one is a file that needs splitting the way graph.cpp/graph_load.cpp +# and toolchain.cpp/toolchain_decision.cpp already were. +# +# Usage: bash .github/tools/check_file_lengths.sh [repo_dir] + +set -uo pipefail + +REPO_DIR="${1:-$(pwd)}" +cd "$REPO_DIR" || { echo "FAIL: cannot cd to $REPO_DIR" >&2; exit 1; } + +LIMIT=2500 +PRIMARY="src/build/prepare.cppm" +DIR="src/build/prepare" + +[ -f "$PRIMARY" ] || { echo "FAIL: $PRIMARY does not exist — this guard has gone stale" >&2; exit 1; } +[ -d "$DIR" ] || { echo "FAIL: $DIR does not exist — this guard has gone stale" >&2; exit 1; } + +fail=0 +checked=0 + +check_one() { + file="$1" + n=$(wc -l < "$file") + checked=$((checked + 1)) + if [ "$n" -gt "$LIMIT" ]; then + echo "FAIL: $file is $n lines (limit $LIMIT)" >&2 + fail=1 + fi +} + +check_one "$PRIMARY" +while IFS= read -r f; do + check_one "$f" +done < <(find "$DIR" -maxdepth 1 -type f \( -name '*.cppm' -o -name '*.cpp' \) | sort) + +if [ "$fail" = 1 ]; then + cat >&2 <> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -312,7 +312,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.26.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.27.1 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -403,7 +403,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.26.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.27.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 909f5f7e3..adfb4bbcc 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -384,7 +384,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.26.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.9.27.1 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index a8ae41317..7cf6af3b1 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -62,6 +62,12 @@ jobs: - name: Check modules/ wiring run: bash .github/tools/check_modules_wiring.sh + # Same placement, same reason: pure text, no toolchain, under a second. + # Catches the prepare.cppm decomposition growing back into one huge + # file one commit at a time — see the script's header. + - name: Check src/build/prepare* file lengths + run: bash .github/tools/check_file_lengths.sh + # Same placement, same reason: pure text, no toolchain, under a second. # # This one is a HARD gate (unlike lint-ci-assertions.sh below) because it diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index d5edf236e..fd048e898 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -135,7 +135,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.9.26.2' + XLINGS_VERSION: '2026.9.27.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -289,7 +289,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.26.2' + XLINGS_VERSION: '2026.9.27.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5f7e3c45..987c78bb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.9.26.2' + XLINGS_VERSION: '2026.9.27.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -314,7 +314,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.26.2' + XLINGS_VERSION: '2026.9.27.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -385,7 +385,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.9.26.2-linux-aarch64.tar.gz" + XLA="xlings-2026.9.27.1-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -394,9 +394,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.9.26.2/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.9.27.1/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.9.26.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.9.27.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -474,7 +474,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.26.2' + XLINGS_VERSION: '2026.9.27.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -657,7 +657,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.9.26.2' + XLINGS_VERSION: '2026.9.27.1' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d402cc6..0fc06010e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,81 @@ > 本文件追踪 `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`(用户已设置时保留用户的值)。 + +### 兼容性 + +以下变化对已有工程可见: + +- 未解析的 `workspace = true` 此前静默成为空版本依赖,现在在加载时报错并点名条目(#714)。 + 修正方法是在工作空间根的 `[workspace.dependencies]` 中声明该包,或在条目上写明版本。 +- `[build] sources = []` 不再推断库目标。依赖这一推断的包在 `[targets]` 中声明库目标。 +- note 代码 `MCPP_BUILD_DATABASE_HOST_TOOL_UNBUILT` 由 `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED` + 取代,不保留旧代码。 + +### 其他 + +- 测试:e2e 804 覆盖不属于任何工作空间的路径宿主工具包的工具链选择(#710);e2e 802 固定 gcc + 工具链,使其断言不依赖机器的默认工具链;单元测试 `test_prepare_helpers` 覆盖各阶段共用的纯函数 + (`--features` 请求语法、宏名、git 远端是否本地等)。 +- ELF 检查以一次读取载入文件。此前逐字节读入,`mcpp test` 的链接后检查在测试程序较多时耗时 + 数十分钟。 +- **`src/build/prepare.cppm` 的内部分解。** 原文件 16,105 行,其中函数 `prepare_build` 占 85%。 + 它拆为按阶段划分的函数,跨阶段的状态集中在 `PrepareState`(取代原来约 180 个共用一个栈帧的 + 局部变量)。主接口 `prepare.cppm` 只保留导出的类型、内联函数与声明;实现位于 `src/build/prepare/` 下的 + 实现单元与实现分区 `:state`,各单元只导入自己用到的模块,每个文件不超过 2,500 行,由 CI 检查。 + 代码逐段移动,不重排语句;公开接口、导出符号与默认参数不变,七个场景的规划输出与分解前逐字节 + 一致。修改一个阶段只重新编译该实现单元:本机修改 P13 中的一处字符串,增量构建 12 s,分解前 + 71 s。模块组织受 GCC 16.1 的内部编译器错误约束,见 #721。 +- 规范: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/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/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/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/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/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/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 0eac98d79..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()) { @@ -4270,6 +4286,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 +4314,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; @@ -4317,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 88f35a432..4e9f94a82 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 @@ -2059,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/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/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 new file mode 100644 index 000000000..51a162519 --- /dev/null +++ b/modules/versioning/src/xpkg_version.cppm @@ -0,0 +1,347 @@ +// 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); + +// 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 { + +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; +} + +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 new file mode 100644 index 000000000..80ad988a4 --- /dev/null +++ b/modules/versioning/tests/test_xpkg_version.cpp @@ -0,0 +1,112 @@ +#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"); +} + +// 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/execute.cppm b/src/build/execute.cppm index 476c25345..fd55db407 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'; } } @@ -539,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; @@ -1022,7 +1048,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 +1468,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 +1585,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 +1739,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( @@ -2176,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/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/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 aa79fd470..a3a368338 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1,7 +1,55 @@ // mcpp.build.prepare — BuildContext + prepare_build: the build-orchestration // core (workspace -> toolchain -> dependency resolution -> features -> // modgraph -> fingerprint -> plan -> lockfile). -// Bodies moved verbatim from the CLI layer. Zero behavior change. +// +// LAYOUT. This file is the primary interface: the exported types, the exported +// inline functions, and the declarations of every other exported function, +// default arguments included (they belong on the declaration, not the +// definition). Nothing else is defined here. The implementation lives under +// src/build/prepare/: +// state.cppm implementation partition `:state`: PrepareState (the +// working state every phase reads and writes, by +// reference, in place of prepare_build's former ~180 +// locals), the phase functions' declarations, and the +// declarations of the helpers the phases share. +// driver.cpp prepare_build: construct PrepareState, run the phases +// in order, return what the last one builds. +// manifest.cpp P0 -- the manifest and its workspace. +// toolchain.cpp P1, P2 -- the toolchain specification and target axis; +// the toolchain resolver's definition. +// xlings.cpp P3 -- xlings payloads before the graph. +// graph_load.cpp P4a -- loading one git, path or version dependency. +// graph.cpp P4b -- the worklist, the graph, the cycle check. +// toolchain_decision.cpp P5 -- the toolchain, decided once the graph exists. +// features.cpp P6-P8 -- features, capabilities, host tools, and the +// dependencies' build programs. +// target_side.cpp P9, P10 -- the target side and each dependency's link form. +// scan.cpp P11, P12 -- the module scan, validation, fingerprint. +// plan.cpp P13 -- the BuildContext, mcpp.lock, resolution.json. +// config.cpp, options.cpp, toolchain_env.cpp, fetch.cpp +// the helpers the phases share: manifest merges and +// feature requests; invocation options; target rows, +// sysroots and build-program environments; git remotes +// and xlings provisioning. +// Every file stays at or below 2,500 lines (.github/tools/check_file_lengths.sh). +// +// A GCC 16.1 CONSTRAINT SHAPES ALL OF THIS. Measured locally and recorded in +// mcpp-community/mcpp#721 (the archived attempt is branch wip/prepare-split): +// inserting a new INTERFACE unit into +// mcpp.build.prepare's import chain — a separately named module, or an +// interface partition (`export module mcpp.build.prepare:x;`) — makes GCC +// 16.1 segfault in add_imported_namespace while reading `import mcpp.cli;` +// in src/main.cpp, regardless of that unit's content. Implementation units, +// and an implementation partition imported only by implementation units, do +// not trigger it. Therefore: this file imports no partition and defines +// nothing beyond the declarations above; `:state` is an implementation +// partition, never an interface partition, and only driver.cpp and the +// phaseN files import it. Anyone adding a new interface partition or a new +// named module to this chain should build with GCC 16.1 first — the failure +// is immediate and unambiguous. +// +// Bodies moved verbatim from the CLI layer, then from one file into many. +// Zero behavior change either time. module; #include @@ -9,228 +57,46 @@ module; export module mcpp.build.prepare; -// The cfg() predicate evaluator and the fingerprint canonicalisers moved out — -// see mcpp.build.prepare_inputs. Re-exported so every existing caller of -// `target_dir` / `canonical_compile_flags` keeps working: a split whose only -// visible effect is that other files stop compiling is not an improvement. export import mcpp.build.prepare_inputs; import std; -import mcpp.targetside; -import mcpp.diag; -import mcpp.build.refusal; -import mcpp.xlings.address_set; import mcpp.build.version_floor; -import mcpp.home; import mcpp.platform.axis; -import mcpp.libs.json; -import mcpp.log; import mcpp.manifest; import mcpp.source_kind; import mcpp.modgraph.glob; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.modgraph.validate; -import mcpp.toolchain.clang; import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses -import mcpp.toolchain.cenv; // [c-abi] declaration → compiler configuration (design 2026-09-18) -import mcpp.toolchain.cenv_probe; // [c-abi] declaration is checked, not trusted (design §3.2) -import mcpp.toolchain.predefines; // the macros this engine defines: contract and emission in one module -import mcpp.toolchain.cppfly; import mcpp.toolchain.detect; import mcpp.toolchain.dialect; import mcpp.toolchain.fingerprint; -import mcpp.toolchain.msvc; import mcpp.toolchain.registry; import mcpp.toolchain.linkmodel; -import mcpp.toolchain.gcc; // For `resolve_version_match` / `list_installed_versions`: a bare compiler // family named by the dependency graph resolves to a concrete version through // exactly the path `mcpp toolchain default ` uses. import mcpp.toolchain.lifecycle; import mcpp.toolchain.stdmod; -import mcpp.freestanding.target; // the target sysroot layout (libdir) -import mcpp.freestanding.linkline; // the ISA profile, for the std module command import mcpp.toolchain.post_install; import mcpp.toolchain.abi; -import mcpp.toolchain.triple; -import mcpp.build.linkage_form; // #519 — which form each dependency takes import mcpp.build.plan; -import mcpp.build.schedule.policy; import mcpp.build.flags; // compute_flags — the per-role contracts (#418) -import mcpp.build.distribution; // dist::Role / dist::Contract to_string -import mcpp.platform.capacity; // the host fallback handed to schedule::decide import mcpp.build.graph_shape; // #407: the graph says which mode wrote it -import mcpp.build.runtime_validation; // declared artifact -> identity verdict -import mcpp.build.cache_key; -import mcpp.pack.abi_tag; // the tag a prebuilt dependency is checked against -import mcpp.pack.prebuilt; // …and the check itself -import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its manifest import mcpp.build.build_program; -import mcpp.build.directives; // directive table: mark / fold_private_tail -import mcpp.build.tool_store; // #355 host tools: store layout + key + overrides -import mcpp.build.dep_graph; // queries over the resolved edge graph -import mcpp.build.provisions; // #359 build-time provisions: table + propagation -import mcpp.build.resources; // #365 Windows resources: synthesise / scan / find rc import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build -import mcpp.lockfile; -import mcpp.config; import mcpp.xlings; -import mcpp.xlings.subos_info; import mcpp.xlings.runtime_selection; import mcpp.runtime.binding; -import mcpp.platform.runtime_search; import mcpp.toolchain.post_install; import mcpp.platform; -import mcpp.platform.macos; -import mcpp.build.runner_lookup; -import mcpp.fetcher; -import mcpp.fetcher.progress; -import mcpp.pm.resolver; -import mcpp.pm.index_spec; -import mcpp.pm.index_contract; -import mcpp.pm.index_route; -import mcpp.pm.index_refresh; -import mcpp.pm.mangle; -import mcpp.pm.compat; -import mcpp.pm.dep_spec; -import mcpp.pm.dependency_selector; -import mcpp.pm.lock_io; -import mcpp.version_req; -import mcpp.ui; -import mcpp.log; import mcpp.wire; // Severity, for PlanNote (#699 item 2, E3) -import mcpp.fallback.install_integrity; import mcpp.bmi_cache; -import mcpp.project; namespace mcpp::build { -// mcpp#237: surface xpkg-descriptor mcpp-segment keys this mcpp did not -// recognise. The parser collects them into `xpkgUnknownKeys` and skips the -// value; without this a typo like `dependencies = {...}` (correct key: `deps`) -// dropped the dependency with no diagnostic. Called at the descriptor-adoption -// sites (a fetched dep with no mcpp.toml, synthesized from the index `mcpp={}` -// block) — the single place the descriptor becomes a build input. Warning (not -// hard error) keeps forward-compat: an older mcpp building a newer descriptor -// should not fail outright, only tell the user what it ignored. -inline void warn_unknown_xpkg_keys(const mcpp::manifest::Manifest& dm, - std::string_view depLabel) { - // A LAYER NAME THIS ENGINE DOES NOT KNOW IS A VERSION GAP, NOT A TYPO, - // WHEN IT ARRIVES FROM A DEPENDENCY. - // - // The reserved `mcpp:` prefix is a closed set so a misspelling cannot - // silently disable a behaviour. Refusing a DEPENDENCY's manifest for it made - // the set closed in a second sense nobody intended: a published package - // could never declare a layer named after the reader was released. - // Ignoring the layer and saying so is what this engine already does for - // every other unknown key, and it is the only response that lets the - // vocabulary grow. - for (auto const& cap : dm.unknownCapabilities) { - auto why = mcpp::targetside::parse_capability(cap); - mcpp::ui::warning(std::format( - "dependency '{}': {}\n" - " Ignored, and this build proceeds without that layer. " - "A newer mcpp may resolve it.", - depLabel, - why ? std::format("`{}` names no capability mcpp knows.", cap) - : why.error())); - } - for (auto const& key : dm.xpkgUnknownKeys) { - auto suggestion = mcpp::manifest::closest_known_xpkg_key(key); - if (suggestion.empty()) - mcpp::ui::warning(std::format( - "dependency '{}': unknown mcpp-segment key '{}' in its xpkg " - "descriptor — ignored (schema mismatch or typo)", depLabel, key)); - else - mcpp::ui::warning(std::format( - "dependency '{}': unknown mcpp-segment key '{}' in its xpkg " - "descriptor — ignored; did you mean '{}'?", depLabel, key, suggestion)); - } -} - -// `stale`, when given, turns the function into a comparison: nothing is -// created or written, and every declared file that is missing or differs from -// its declared content is appended. A build that describes itself rather than -// running (BuildOverrides::plan_only) reads the root package's generated files -// this way, because they live in the source tree it promises not to write. -std::expected -materialize_generated_files(const std::filesystem::path& root, - const mcpp::manifest::Manifest& manifest, - std::vector* stale = nullptr) -{ - for (auto const& [relPath, content] : manifest.buildConfig.generatedFiles) { - if (relPath.empty()) { - return std::unexpected("generated_files contains an empty path"); - } - if (relPath.is_absolute()) { - return std::unexpected(std::format( - "generated_files path '{}' must be relative", relPath.generic_string())); - } - auto const genericPath = relPath.generic_string(); - for (std::size_t begin = 0; begin <= genericPath.size();) { - auto const end = genericPath.find('/', begin); - auto const part = genericPath.substr(begin, end == std::string::npos - ? std::string::npos - : end - begin); - if (part == "..") { - return std::unexpected(std::format( - "generated_files path '{}' must not escape the package root", - relPath.generic_string())); - } - if (end == std::string::npos) { - break; - } - begin = end + 1; - } - - auto out = root / relPath.lexically_normal(); - - // Skip the write when the on-disk content is already identical: ninja - // is mtime-driven, and an unconditional rewrite bumps the mtime every - // build, recompiling every TU that #includes the materialized file - // (via depfiles) — e.g. a frozen-snapshot config.h included by - // thousands of TUs. Change detection is already owned by the - // fingerprint (content is folded in above), so skipping only - // preserves the mtime — mirroring the build.mcpp cache design, - // which likewise avoids mtime churn on unchanged outputs. - { - std::ifstream is(out, std::ios::binary); - if (is) { - std::string existing((std::istreambuf_iterator(is)), - std::istreambuf_iterator()); - if (is && existing == content) { - continue; - } - } - } - if (stale) { - stale->push_back(out); - continue; - } - - std::error_code ec; - std::filesystem::create_directories(out.parent_path(), ec); - if (ec) { - return std::unexpected(std::format( - "cannot create directory for generated file '{}': {}", - out.string(), ec.message())); - } - std::ofstream os(out, std::ios::binary); - if (!os) { - return std::unexpected(std::format( - "cannot write generated file '{}'", out.string())); - } - os << content; - if (!os) { - return std::unexpected(std::format( - "failed while writing generated file '{}'", out.string())); - } - } - return {}; -} - // L1 cfg merge for ONE package's manifest (root or ANY dependency — path, // git, or version/registry): append the matching conditional // cflags/cxxflags/ldflags and sources (G1b) to its buildConfig. Sources also @@ -278,52 +144,7 @@ materialize_generated_files(const std::filesystem::path& root, // entries. A disagreement is reported, because it is the one case where the // author wrote two things and only one of them can happen. export void merge_conditional_xlings(mcpp::manifest::Manifest& m, - const mcpp::manifest::ConditionalConfig& cc) -{ - // ONE DEFINITION OF IDENTITY, and it is not local to this merge. It used - // to be `parse_address(a).target` — the bare name, so `xim:cuda` and a - // hypothetical `scode:cuda` collided, and the graph split a few thousand - // lines below compared whole address strings instead. See - // mcpp.xlings.address_set for what the two definitions cost. - auto package_of = [](std::string_view address) { - return mcpp::xlings::addrset::package_key(address); - }; - for (auto const& a : cc.xlings.deps) { - const auto pkg = package_of(a); - auto it = std::ranges::find_if(m.xlings.deps, [&](const std::string& e) { - return package_of(e) == pkg; - }); - if (it == m.xlings.deps.end()) { m.xlings.deps.push_back(a); continue; } - if (*it != a) - mcpp::diag::warning("xlings/axis-override", std::format( - "'{}' is declared on both tool axes, as '{}' and as '{}'. The " - "[target.] entry is the more specific statement and " - "is the one used. Declare a tool that runs on the build machine " - "in the top-level [xlings.workspace], and what the produced " - "code is compiled against under [target..xlings." - "workspace] — see docs/05 section 2.13.", pkg, *it, a)); - *it = a; - } - // Keyed by PACKAGE, so the same override applies without a second search. - for (auto const& [pkg, pin] : cc.xlings.workspace) - m.xlings.workspace.insert_or_assign(pkg, pin); - // Keyed by ADDRESS. `insert_or_assign` rather than `try_emplace` for the - // same reason: the address that survived above is the conditional one. - for (auto const& [addr, w] : cc.xlings.depWhen) - m.xlings.depWhen.insert_or_assign(addr, w); - for (auto const& [f, addrs] : cc.xlings.featureDeps) { - auto& dst = m.xlings.featureDeps[f]; - for (auto const& a : addrs) { - const auto pkg = package_of(a); - auto it = std::ranges::find_if(dst, [&](const std::string& e) { - return package_of(e) == pkg; - }); - if (it == dst.end()) dst.push_back(a); else *it = a; - } - } - for (auto const& [addr, pin] : cc.xlings.featurePins) - m.xlings.featurePins.insert_or_assign(addr, pin); -} + const mcpp::manifest::ConditionalConfig& cc); // A `[target..xlings…]` selector MUST NOT name a RESOLVED layer. // @@ -348,335 +169,10 @@ export void merge_conditional_xlings(mcpp::manifest::Manifest& m, // for: `[feature-xlings.]` selects a tool by what the project asked for, // and a feature is known before anything is provisioned. export std::optional -layer_predicated_xlings_refusal(const mcpp::manifest::Manifest& m) -{ - for (auto const& cc : m.conditionalConfigs) { - if (cc.xlings.empty()) continue; - if (!cfgpred::uses_layer(cc.predicate)) continue; - std::string named; - for (auto const& a : cc.xlings.deps) { - if (!named.empty()) named += ", "; - named += a; - } - for (auto const& [f, addrs] : cc.xlings.featureDeps) - for (auto const& a : addrs) { - if (!named.empty()) named += ", "; - named += std::format("{} (feature '{}')", a, f); - } - return std::format( - "[target.'{}'] declares tools ({}), but its predicate names a " - "target-side layer. A layer is answered by dependency resolution, " - "which happens after tools are installed and after build programs " - "run, so a tool conditioned on one would be declared and never " - "installed. Condition it on the target instead " - "(`[target.'cfg(os = \"linux\")'.xlings.workspace]`), on the " - "accelerator (`[target.'cfg(accelerator = \"cuda\")'.xlings" - ".workspace]`, which IS answered before provisioning), or on a " - "feature (`[feature-xlings.]`). See docs/05 section 2.13.", - cc.predicate, named); - } - return std::nullopt; -} - -// Two declarations of one dependency, compared by the identity their keys -// normalise to rather than by the keys themselves: `fw` and `mcpplibs.fw` are -// one package under two map keys (`selector.stableMapKey`), and a comparison -// of keys would leave both entries in the map for the resolver to see. -bool same_dependency_identity(const mcpp::manifest::DependencySpec& a, - const mcpp::manifest::DependencySpec& b) { - if (a.shortName.empty() || b.shortName.empty()) return false; - return a.namespace_ == b.namespace_ && a.shortName == b.shortName; -} - -void replace_dependencies( - std::map& into, - const std::map& from) -{ - for (auto const& [key, spec] : from) { - std::erase_if(into, [&](auto const& entry) { - return entry.first == key || same_dependency_identity(entry.second, spec); - }); - into[key] = spec; - } -} +layer_predicated_xlings_refusal(const mcpp::manifest::Manifest& m); export void merge_conditional_config(mcpp::manifest::Manifest& m, - const cfgpred::Ctx& ctx) -{ - // Recorded before the first merge; see Manifest::beforeConditionalMerge. - if (!m.beforeConditionalMerge) - m.beforeConditionalMerge = std::make_shared(m); - // A DISTRIBUTION package may carry a leg's link line twice: as `ldflags` - // (GNU spelling, which is all an older mcpp reads) and as the neutral - // `[target..runtime]` pair, which mcpp renders per dialect. Applying - // both would put `-L` on a native `cl.exe` command line, which is exactly - // what the neutral form exists to avoid — so where the neutral form is - // present it REPLACES the ldflags rather than adding to them. - // - // Scoped to distribution packages on purpose: a hand-written manifest that - // states both may well mean both (`ldflags` also carries things like - // `-Wl,--as-needed`), and silently dropping half of it would be its own - // silent failure. - const bool generatedPackage = mcpp::pack::is_distribution_package(m); - - for (auto const& cc : m.conditionalConfigs) { - // THE TWO PASSES MUST BE DISJOINT, AND `matches()` ALONE DOES NOT - // MAKE THEM SO. A layer key answers false here because `layersKnown` is - // false — but `cfg(any(linux, c-abi = "musl"))` still matches on its - // triple leg, and the second pass would match it again and `append()` - // the same inputs twice. Membership, not the answer, decides ownership: - // a predicate that NAMES a layer belongs to the second pass entirely. - if (cfgpred::uses_layer(cc.predicate)) continue; - if (!cfgpred::matches(cc.predicate, ctx)) continue; - const bool neutralWins = generatedPackage - && (!cc.linkLibraryDirs.empty() || !cc.libraries.empty() - || !cc.frameworks.empty()); - // One append() for every field the axis may carry (#258). Matching - // sections land AFTER the base entries, so a conditional rule beats - // a broader unconditional one under GNU last-wins — which is what - // makes an off-OS REMOVAL expressible (`-U` after the base `-D`). - if (neutralWins) { - // Drop the LIBRARY REFERENCES, not the whole ldflags list. - // - // Clearing it outright was a measured regression: a PE/MinGW shared - // leg's ldflags also carry `-Wl,-Bdynamic`, without which `-static` - // leaves ld in static-only mode and it refuses the import library - // with `have you installed the static version of the mathkit - // library?`. e2e 257 caught it. - // - // The neutral form replaces exactly what it can express — a library - // and where to find it. Anything else in that block says something - // it cannot say, and must survive. - auto inputs = cc.inputs; - std::erase_if(inputs.ldflags, [](std::string_view f) { - return f.starts_with("-L") || f.starts_with("-l") - || f.starts_with("/LIBPATH:"); - }); - mcpp::manifest::append(m.buildConfig, inputs); - } else { - mcpp::manifest::append(m.buildConfig, cc.inputs); - } - // The neutral half goes where `render_link_intent_flags` will find it. - for (auto const& d : cc.linkLibraryDirs) - m.runtimeConfig.linkIntent.linkLibraryDirs.push_back(d); - for (auto const& l : cc.libraries) - m.runtimeConfig.linkIntent.libraries.push_back(l); - for (auto const& f : cc.frameworks) - m.runtimeConfig.linkIntent.frameworks.push_back(f); - merge_conditional_xlings(m, cc); - // `[target..abi]`: recorded for every package; rendered only for - // the root, where prepare_build reads it. Last matching section wins, - // the rule every other conditional scalar follows. - if (cc.abiThreadsDeclared) { - m.buildConfig.abiThreads = cc.abiThreads; - m.buildConfig.abiThreadsDeclared = true; - } - if (cc.abiExceptionsDeclared) { - m.buildConfig.abiExceptions = cc.abiExceptions; - m.buildConfig.abiExceptionsDeclared = true; - } - // `[target.] requires_abi` / `.feature-requires-abi` (A6): a - // requirement on the TARGET axis, unioned in -- not overwritten -- - // because more than one matching selector may ask for the same - // member, and every one of them is a true statement. The selector - // text rides along so the unmet-requirement check can name what - // asked, the same courtesy `[package] requires_abi` gets by naming - // "the package" and a feature's entry by naming the feature. - if (cc.requiresAbiThreads) - m.targetRequiresAbiThreads.push_back(cc.predicate); - if (cc.requiresAbiExceptions) - m.targetRequiresAbiExceptions.push_back(cc.predicate); - for (auto const& [f, val] : cc.featureRequiresAbiThreads) - if (val) m.targetFeatureRequiresAbiThreads[f].push_back(cc.predicate); - for (auto const& [f, val] : cc.featureRequiresAbiExceptions) - if (val) m.targetFeatureRequiresAbiExceptions[f].push_back(cc.predicate); - // `modules.sources` is the scanner's own view and is not part of - // BuildInputs, so conditional sources are mirrored into it here. - for (auto const& s : cc.inputs.sources) - m.modules.sources.push_back(s); - // A matching conditional declaration of a dependency REPLACES the - // declaration of the same identity, and a later matching section - // replaces an earlier one: the rule every conditional scalar above - // follows (#634, A1). This used to be `insert()`, which kept the - // unconditional entry, so `linkage = "shared"` written for one row was - // dropped on that row without a word. No manifest among 509 scanned - // declared one dependency in both tables, so no build that worked - // changes; the declaring table rides on the spec (`declaredIn`) into - // the resolution record. - replace_dependencies(m.dependencies, cc.dependencies); - replace_dependencies(m.devDependencies, cc.devDependencies); - replace_dependencies(m.buildDependencies, cc.buildDependencies); - // #359: `[target..feature-deps.]`. The feature is - // registered by the parser regardless of the predicate; only what it - // pulls in is conditional. - for (auto const& [fname, deps] : cc.featureDeps) - replace_dependencies(m.featureDeps[fname], deps); - // `[target..targets.] kind`: the row's form of a library - // target, applied before resolution, so the link-form resolution - // reads it exactly as it reads `[targets.] kind`. `load` has - // already refused a name that is not a library target. - // - // `linkage` (#642 E1) is the row's default form, and a row's statement - // REPLACES the statement it follows, whichever of the two each one is: - // a default after `kind = "shared"` returns the target to the library - // form a consumer may choose from, and a `kind` after a default clears - // the default. Last matching section wins, as for every conditional - // scalar. - for (auto const& [name, row] : cc.targetKinds) { - for (auto& t : m.targets) { - if (t.name != name) continue; - t.kindDeclaredBy = row.statement; - t.kindFromRow = true; - if (!row.linkage.empty()) { - t.kind = mcpp::manifest::Target::Library; - t.linkageDefault = row.linkage; - t.linkageDeclaredBy = row.statement; - } else { - t.kind = row.kind; - t.linkageDefault.clear(); - t.linkageDeclaredBy.clear(); - } - } - } - } -} - -// ── An element whose words changed in 2026.9.17.1 (#655) ───────────────────── -// -// A compile-flag element used to reach the compiler as its host's command-line -// reader made it (POSIX `sh`, or the MSVCRT rules), after ninja had replaced -// `$` sequences, with a `-D` element containing a space quoted whole (#234). -// It now reaches the compiler as `flag_words` reads it, and a `defines` value -// is one word. Most spellings mean the same under both readings; the ones that -// do not are told what the compiler receives now and what it received before. -// The previous reading is modelled on quote removal only: `sh` expansions -// (`$VAR`, globs) are not reproduced. -// -// WHEN IT IS SAID. The notes are collected while manifests load and released -// where the output directory is decided, only if that directory has no -// build.ninja yet. The fingerprint names the mcpp version and every flag, so -// that is the first plan after an upgrade, after a flag was edited, or in a -// fresh checkout. A build that repeats a plan says nothing, so a manifest that -// is already spelled for the new reading is not warned about on every run. -std::vector>& pending_flag_words_notes() { - static std::vector> notes; - return notes; -} -std::vector previous_release_words(std::string element, bool define) { - if (define) element = "-D" + element; - if ((element.starts_with("-D") || element.starts_with("/D")) - && element.find(' ') != std::string::npos) - element = mcpp::build::shell_quote_arg(element); - std::string line; - for (std::size_t i = 0; i < element.size(); ++i) { - if (element[i] != '$' || i + 1 == element.size()) { line.push_back(element[i]); continue; } - const char n = element[i + 1]; - if (n == '$' || n == ' ' || n == ':') { line.push_back(n); ++i; continue; } - // A ninja variable reference: `${name}` or `$name`, empty on a compile edge. - std::size_t j = i + 1; - if (n == '{') { - while (j < element.size() && element[j] != '}') ++j; - } else { - while (j + 1 < element.size() - && (std::isalnum(static_cast(element[j + 1])) - || element[j + 1] == '_' || element[j + 1] == '-')) - ++j; - } - i = j; - } - return mcpp::manifest::host_command_words(line, mcpp::platform::is_windows); -} - -void report_flag_words_changes(const mcpp::manifest::Manifest& m) { - auto show = [](const std::vector& words) { - std::string out = "["; - for (auto const& w : words) - out += std::format("{}'{}'", out.size() > 1 ? ", " : "", w); - return out + "]"; - }; - auto note_change = [&](std::string what, std::string hint) { - auto note = std::pair{std::move(what), std::move(hint)}; - auto& notes = pending_flag_words_notes(); - if (std::ranges::find(notes, note) == notes.end()) notes.push_back(std::move(note)); - }; - auto const who = m.package.name.empty() ? std::string("(root)") : m.package.name; - auto check = [&](std::string_view where, const std::vector& list, - bool define) { - for (auto const& e : list) { - auto now = define ? std::vector{"-D" + e} - : mcpp::manifest::flag_words(e); - auto before = previous_release_words(e, define); - if (now == before) continue; - note_change(std::format( - "{}: {} element '{}' reaches the compiler as {}; mcpp before " - "2026.9.17.1 passed {} on this host", - who, where, e, show(now), show(before)), - std::string( - "a compile-flag element is read by one syntax on every host, and a " - "`defines` entry is one value (docs/04-mcpp-toml.md, " - "\"Compile-flag syntax\"); spell the element so that it reads as the " - "words meant")); - } - }; - // THE SAME QUESTION FOR THE LINK FLAGS, which take the reading from - // 2026.9.26.2 (#703). Before, a `-L` or `-Wl,-rpath,` element was escaped - // for ninja, so its text reached the host's reader as written, and any - // other element was pasted into the ninja rule, so ninja replaced its `$` - // sequences first. `$ORIGIN` written plainly reads the same under both - // models, because neither reproduces the shell's expansion that lost it; - // an element escaped for ninja or for the shell by hand is what differs. - auto check_link = [&](std::string_view where, const std::vector& list) { - for (auto const& e : list) { - auto now = mcpp::manifest::flag_words(e); - auto before = e.starts_with("-L") || e.starts_with("-Wl,-rpath,") - ? mcpp::manifest::host_command_words(e, mcpp::platform::is_windows) - : previous_release_words(e, false); - if (now == before) continue; - note_change(std::format( - "{}: {} element '{}' reaches the linker as {}; mcpp before " - "2026.9.26.2 passed {} on this host", - who, where, e, show(now), show(before)), - std::string( - "a link-flag element is read by the compile-flag syntax, so `$ORIGIN` " - "reaches the linker as written and an element escaped for ninja or the " - "shell by hand is no longer unescaped (docs/04-mcpp-toml.md, " - "\"Compile-flag syntax\"); spell the element so that it reads as the " - "words meant")); - } - }; - auto const& bc = m.buildConfig; - check_link("[build] ldflags", bc.ldflags); - check("[build] cflags", bc.cflags, false); - check("[build] cxxflags", bc.cxxflags, false); - check("[build] defines", bc.defines, true); - for (auto const& gf : bc.globFlags) { - check("flags cflags", gf.cflags, false); - check("flags cxxflags", gf.cxxflags, false); - check("flags asmflags", gf.asmflags, false); - check("flags defines", gf.defines, true); - } - for (auto const& [feature, defines] : bc.featureDefines) - check(std::format("features.{} defines", feature), defines, true); - for (auto const& [feature, globs] : bc.featureFlags) { - for (auto const& gf : globs) { - check(std::format("features.{} cflags", feature), gf.cflags, false); - check(std::format("features.{} cxxflags", feature), gf.cxxflags, false); - check(std::format("features.{} asmflags", feature), gf.asmflags, false); - check(std::format("features.{} defines", feature), gf.defines, true); - } - } - for (auto const& t : m.targets) { - check(std::format("targets.{} cflags", t.name), t.cflags, false); - check(std::format("targets.{} cxxflags", t.name), t.cxxflags, false); - check(std::format("targets.{} defines", t.name), t.defines, true); - } -} - -// The macro name a `defines` entry or a `-D` word defines: the text before -// the first `=`, or the whole text when there is no value. -std::string_view define_name(std::string_view entry) { - return entry.substr(0, entry.find('=')); -} + const cfgpred::Ctx& ctx); // Desugar `[build].defines` into `-D` on both C and C++ flag channels. // @@ -707,49 +203,7 @@ std::string_view define_name(std::string_view entry) { // free via the -D/-U/-I subset the ninja backend filters out of packageCflags. // A define is a value, so it enters the flag list as one word // (`flag_element`): `N="x"` reaches the compiler as `-DN="x"` on every host. -export void fold_build_defines_into_flags(mcpp::manifest::BuildConfig& bc) { - if (bc.defines.empty()) return; - - std::vector resolved; // entries, first-seen order - std::vector named; // every name this call touches - auto touch = [&](std::string_view name) { - if (std::ranges::find(named, name) == named.end()) - named.emplace_back(name); - }; - for (auto const& d : bc.defines) { - if (d.starts_with('!')) { - const auto name = std::string_view(d).substr(1); - std::erase_if(resolved, [&](const std::string& e) { - return define_name(e) == name; - }); - touch(name); - continue; - } - const auto name = define_name(d); - touch(name); - auto it = std::ranges::find_if(resolved, [&](const std::string& e) { - return define_name(e) == name; - }); - if (it != resolved.end()) *it = d; - else resolved.push_back(d); - } - - auto superseded = [&](const std::string& element) { - auto words = mcpp::manifest::flag_words(element); - if (words.size() != 1 || !words.front().starts_with("-D")) return false; - const auto name = define_name(std::string_view(words.front()).substr(2)); - return std::ranges::find(named, name) != named.end(); - }; - std::erase_if(bc.cflags, superseded); - std::erase_if(bc.cxxflags, superseded); - - for (auto const& d : resolved) { - const auto element = mcpp::manifest::flag_element("-D" + d); - bc.cflags.push_back(element); - bc.cxxflags.push_back(element); - } - bc.defines.clear(); -} +export void fold_build_defines_into_flags(mcpp::manifest::BuildConfig& bc); // The post-condition of the normalisation pipeline, as the snapshot checks it: // every `defines` entry has been folded into the flag lists. A non-empty list @@ -757,223 +211,7 @@ export void fold_build_defines_into_flags(mcpp::manifest::BuildConfig& bc) { // dropped without a diagnostic (#690). Returns the internal-error text, or // nothing when the manifest may be captured. export std::optional -unfolded_defines_error(const mcpp::manifest::Manifest& m) { - auto const& d = m.buildConfig.defines; - if (d.empty()) return std::nullopt; - return std::format( - "internal error: [build].defines of package '{}' reached the build " - "graph unfolded ({} entr{}, first '{}'); a merge ran after " - "fold_build_defines_into_flags (please report)", - m.package.name.empty() ? std::string("(root)") : m.package.name, - d.size(), d.size() == 1 ? "y" : "ies", d.front()); -} - -// WHAT A MEMBER RECEIVES FROM ITS WORKSPACE WHEN IT IS REACHED AS A DEPENDENCY. -// -// Three parts of the inheritance matter to a dependency: `[workspace.package]` -// (a member may omit `version`), `x.workspace = true` dependency entries -// (without the merge the entry reaches resolution with neither version nor -// path), and `[workspace.build]`. They are applied at the dependency's LOAD -// site, before the conditional merge and the `defines` fold, which is the -// order the root follows; `makePackageRoot` only captures the result (#690). -// `[toolchain]`, `[target.]` and `[indices]` are decided by the root -// for the whole graph and are not applied to a dependency. -// -// One function for every way a member is reached: a sibling `path` -// dependency, a member of a git-hosted workspace, and a member inside an -// index package's archive. The same commit then compiles the same way in its -// own checkout and in every consumer's graph. -std::optional -inherit_as_workspace_member(mcpp::manifest::Manifest& member, - const mcpp::manifest::Manifest& workspace, - const std::filesystem::path& workspaceRoot, - const std::filesystem::path& memberDir) { - mcpp::project::inherit_workspace_package(member, workspace); - mcpp::project::merge_workspace_deps(member, workspace, workspaceRoot); - mcpp::project::inherit_workspace_build(member, workspace, workspaceRoot); - return mcpp::project::workspace_inheritance_error(member, memberDir); -} - -// The workspace whose `members` list `memberDir`, searched upward from its -// parent and never above `bound` (an index package's install root: the -// archive is the only tree the package's author wrote). -std::optional> -workspace_listing(const std::filesystem::path& memberDir, - const std::filesystem::path& bound) { - auto inside = [&](const std::filesystem::path& p) { - auto rel = p.lexically_normal().lexically_relative(bound.lexically_normal()); - return !rel.empty() && *rel.begin() != ".."; - }; - for (auto p = memberDir.parent_path(); inside(p); p = p.parent_path()) { - if (std::filesystem::exists(p / "mcpp.toml")) { - if (auto ws = mcpp::manifest::load(p / "mcpp.toml"); - ws && ws->workspace.present - && mcpp::project::is_workspace_member(*ws, p, memberDir)) - return std::pair{std::move(*ws), p}; - } - if (p == p.parent_path()) break; - } - return std::nullopt; -} - -// ── The SECOND conditional pass: predicates that name a target-side layer ──── -// -// #540/#494. `docs/14` documents a package adapting to the C library it was -// built over — `[target.'cfg(c-abi = "musl")'.build] std-module-flags = -// ["-D_GNU_SOURCE"]`, wrong for picolibc — and `stdModuleFlags` was moved onto -// BuildInputs FOR this, its member comment saying membership "is what makes the -// cfg axis carry it". Nothing evaluated the predicate: `cfgpred::Ctx` was built -// from the triple alone, so every such section was dropped in silence and the -// package built with the wrong C-library configuration, successfully. -// -// WHY A SECOND PASS AND NOT AN EARLIER CONTEXT. A layer is answerable only -// after dependency resolution — a package in the graph may supply the C library -// (openkal-musl under a `-gnu` triple), which is exactly why the triple's `env` -// segment is a REQUEST and not the answer (docs/specs/target-side.md §3.4). The -// first merge runs before resolution because conditional DEPENDENCIES have to. -// -// WHERE IT RUNS. Between `tsd::resolve` and the P1689 scan — the same window in -// which build.mcpp already contributes build inputs by mirroring into -// `packages[0]`. Everything downstream reads the snapshot from there on: the -// scan, `stdModuleFlags` collection, the fingerprint, and `compute_flags`. -// -// SCOPE. Build INPUTS only, which is what docs/14 promises ("available in -// [build] sections only"). Dependencies are excluded by construction — they are -// already resolved by now — and a section that tries is reported rather than -// silently ignored; see `warn_layer_predicate_dependencies`. -bool merge_layer_conditional_config(mcpp::manifest::Manifest& m, - const cfgpred::Ctx& ctx) -{ - bool any = false; - for (auto const& cc : m.conditionalConfigs) { - if (!cfgpred::uses_layer(cc.predicate)) continue; - if (!cfgpred::matches(cc.predicate, ctx)) continue; - any = true; - mcpp::manifest::append(m.buildConfig, cc.inputs); - // Same mirror the first pass does: `modules.sources` is the scanner's - // own view and is not a BuildInputs member. - for (auto const& s : cc.inputs.sources) - m.modules.sources.push_back(s); - for (auto const& d : cc.linkLibraryDirs) - m.runtimeConfig.linkIntent.linkLibraryDirs.push_back(d); - for (auto const& l : cc.libraries) - m.runtimeConfig.linkIntent.libraries.push_back(l); - for (auto const& f : cc.frameworks) - m.runtimeConfig.linkIntent.frameworks.push_back(f); - // NO `merge_conditional_xlings` HERE, DELIBERATELY. A cc that reaches - // this pass has a layer in its predicate, and one carrying tools was - // refused long before — see `layer_predicated_xlings_refusal`. Folding - // it here would be folding after the tools were provisioned and after - // every build.mcpp ran, which is the silent-absence failure the - // refusal exists to prevent. - } - // Re-fold only when something was added. The call is safe either way — the - // function clears `defines` after folding and says so — but skipping it - // keeps this pass a no-op for the overwhelming majority of manifests, which - // name no layer at all. - if (any) fold_build_defines_into_flags(m.buildConfig); - return any; -} - -// Feature-activation closure — THE single implementation (build.mcpp env -// contract, Stage 2a feature-deps, and the main feature pass all call this): -// seed = [features].default ∪ requested, expanded transitively over implies; -// the literal name "default" is never itself a feature. -// -// `seedDefault` is the funnel for consumer-side `default-features = false` -// (#242, Cargo parity): when false the dependency's own `[features].default` -// is NOT seeded, so only the explicitly `requested` features (and their -// transitive `implies`) activate. The root package always seeds its own -// default (seedDefault=true); a dependency passes its dep spec's -// `defaultFeatures` flag. `requested` is applied identically either way. -std::vector feature_closure(const mcpp::manifest::Manifest& pm, - const std::vector& requested, - bool seedDefault = true) -{ - std::vector act, q; - if (seedDefault) - if (auto it = pm.featuresMap.find("default"); it != pm.featuresMap.end()) - q.insert(q.end(), it->second.begin(), it->second.end()); - q.insert(q.end(), requested.begin(), requested.end()); - std::set seen; - while (!q.empty()) { - auto f = q.back(); q.pop_back(); - if (f == "default" || !seen.insert(f).second) continue; - act.push_back(f); - if (auto it = pm.featuresMap.find(f); it != pm.featuresMap.end()) - q.insert(q.end(), it->second.begin(), it->second.end()); - } - return act; -} - -// --features value → tokens (comma/space separated). -std::vector feature_request_tokens(std::string_view s) { - std::vector out; - for (std::size_t p = 0; p < s.size();) { - auto c = s.find_first_of(", ", p); - auto tok = s.substr(p, c == std::string_view::npos ? std::string_view::npos : c - p); - if (!tok.empty()) out.emplace_back(tok); - if (c == std::string_view::npos) break; - p = c + 1; - } - return out; -} - -// The root's own features among the --features tokens. -// -// A TOKEN CONTAINING `/` IS NOT A FEATURE OF THE ROOT (#649 E8). It can only -// mean "open this feature of that dependency", which is what the same token -// means inside `[features]`, so it is taken out here and applied as a forward -// of the root (`feature_forward_request`). It used to stay in this list, where -// a root without `[features]` turned it into `-DMCPP_FEATURE_SPIKE_FW_INSTALLER` -// and a root with the table reported it as an undeclared feature; neither -// opened the dependency's feature. -std::vector parse_feature_request(std::string_view s) { - std::vector out; - for (auto& tok : feature_request_tokens(s)) - if (tok.find('/') == std::string::npos) out.push_back(std::move(tok)); - return out; -} - -// The `/` tokens of --features, in the keyspace of a -// `[features]` forward. A token with an empty half is kept whole and named by -// the caller, rather than being dropped as the manifest parser drops it: on a -// command line the user typed it just now. -std::vector feature_forward_request_tokens(std::string_view s) { - std::vector out; - for (auto& tok : feature_request_tokens(s)) - if (tok.find('/') != std::string::npos) out.push_back(std::move(tok)); - return out; -} - -bool is_std_module(std::string_view name) { - return name == "std" || name == "std.compat"; -} - -bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, - const mcpp::manifest::Manifest& manifest, - const std::filesystem::path& projectRoot) { - for (auto& u : graph.units) { - for (auto& req : u.requires_) { - if (is_std_module(req.logicalName)) - return true; - } - } - - // Some target entry files can be added to the plan after the package scan. - // Check them here so std BMI setup matches what make_plan will compile: they - // are read by the same scan_entry_file make_plan reads them with. - const auto extTable = mcpp::extension_table_for(manifest.buildConfig.moduleExtensions, - manifest.buildConfig.deviceExtensions); - for (auto& t : manifest.targets) { - if (t.main.empty()) continue; - const auto entry = mcpp::modgraph::scan_entry_file(projectRoot / t.main, - manifest.package.name, extTable); - for (auto const& req : entry.requires_) - if (is_std_module(req.logicalName)) return true; - } - return false; -} +unfolded_defines_error(const mcpp::manifest::Manifest& m); // How this invocation may use the global dependency cache. // @@ -1077,47 +315,13 @@ export constexpr std::string_view tc_origin_name(TcOrigin o) { // • nothing usable at all — the bare-Windows case. Lead with the MinGW-w64 // route, which needs no Visual Studio and is already a verified target, // and keep the "install the C++ workload" option second. -export std::string msvc_unavailable_guidance(const mcpp::toolchain::Toolchain& tc) { - namespace pins = mcpp::toolchain::triple::pins; - const bool haveVcTools = tc.compiler == mcpp::toolchain::CompilerId::MSVC; - if (haveVcTools && mcpp::toolchain::msvc::find_msvc_tools_dir()) { - return std::format( - "msvc {} was detected at {}, but no Windows SDK was found —\n" - " cl.exe cannot compile without the UCRT/SDK headers.\n" - " Install the 'Windows 11 SDK' component via the Visual Studio\n" - " Installer (it is part of the Desktop development with C++\n" - " workload), then retry.", - tc.version, tc.binaryPath.string()); - } - return std::format( - "this build targets the MSVC ABI, which needs Visual Studio /\n" - " Build Tools (MSVC STL + Windows SDK) — neither was found.\n" - "\n" - " No Visual Studio? Use the self-contained MinGW-w64 toolchain\n" - " (no Visual Studio required, `import std` works):\n" - " mcpp toolchain default {} --target {}\n" - "\n" - " Have Visual Studio? Install the 'Desktop development with C++'\n" - " workload — it provides the MSVC STL and the Windows SDK.", - pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget); -} +export std::string msvc_unavailable_guidance(const mcpp::toolchain::Toolchain& tc); export enum class CacheMode { Global, Local, Off }; -export std::optional parse_cache_mode(std::string_view v) { - if (v == "global") return CacheMode::Global; - if (v == "local") return CacheMode::Local; - if (v == "off" || v == "none") return CacheMode::Off; - return std::nullopt; -} +export std::optional parse_cache_mode(std::string_view v); -export std::string_view cache_mode_name(CacheMode m) { - switch (m) { - case CacheMode::Local: return "local"; - case CacheMode::Off: return "off"; - default: return "global"; - } -} +export std::string_view cache_mode_name(CacheMode m); // A condition a planning pass reports instead of acting on (plan_only): the // code is stable and the message is for people. Emitted as diagnostics by the @@ -1187,6 +391,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 @@ -1281,13 +488,7 @@ export struct BuildContext { // unparseable value falls through to the next source rather than silently // meaning "global" — see prepare_build, which also reports it. export CacheMode resolve_cache_mode(const mcpp::manifest::Manifest& m, - std::string_view override_mode) { - if (auto v = parse_cache_mode(override_mode)) return *v; - if (const char* e = std::getenv("MCPP_BUILD_CACHE"); e && *e) - if (auto v = parse_cache_mode(e)) return *v; - if (auto v = parse_cache_mode(m.buildConfig.cacheMode)) return *v; - return CacheMode::Global; -} + std::string_view override_mode); // The ONE profile-name resolver. Shared with execute.cppm's fast paths: // they deliberately skip prepare_build, so before this existed they had no @@ -1310,11 +511,7 @@ export CacheMode resolve_cache_mode(const mcpp::manifest::Manifest& m, // rule in one function, which is why this function exists at all. export std::string resolve_profile_name(const mcpp::manifest::Manifest& m, std::string_view override_name, - std::string_view fallback = "dev") { - if (!override_name.empty()) return std::string(override_name); - if (!m.buildConfig.defaultProfile.empty()) return m.buildConfig.defaultProfile; - return fallback.empty() ? std::string("dev") : std::string(fallback); -} + std::string_view fallback = "dev"); // THE OVERRIDE NAME THE COMMAND LINE STATES, OR "" FOR NONE (#649 E9). // @@ -1325,12 +522,7 @@ export std::string resolve_profile_name(const mcpp::manifest::Manifest& m, // same line built `release`. Its result is `resolve_profile_name`'s // `override_name`. export std::string profile_override_from_flags(std::string_view profileOption, - bool release, bool dev) { - if (!profileOption.empty()) return std::string(profileOption); - if (release) return "release"; - if (dev) return "dev"; - return {}; -} + bool release, bool dev); // Command-level overrides (--target / --static). // Empty defaults preserve pre-existing behaviour exactly. @@ -1376,6 +568,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 @@ -1468,415 +666,6 @@ export struct BuildOverrides { // ── git dependency helpers ────────────────────────────────────────────────── -// Is this git remote reachable without a network round-trip? -// -// `--offline` means "never touch the network" (docs/04-mcpp-toml.md), and its -// standing promise is that anything already on disk still builds. A remote that -// names a local directory — or a file:// URL — is served by plain filesystem -// reads, so refusing it would break that promise without buying any isolation. -// The dependency-download gate further down draws the same line. -// -// Recognising a scheme (`https://`, `ssh://`, `git://`) or scp-like syntax -// (`git@host:path`) as remote first keeps a Windows drive letter (`C:\repo`, -// which contains a colon but no `@`) on the local side. -bool is_local_git_remote(std::string_view url) { - if (url.starts_with("file://")) return true; - if (url.contains("://")) return false; - if (url.contains('@') && url.contains(':')) return false; - std::error_code ec; - return std::filesystem::exists(std::filesystem::path(url), ec); -} - -// The commit a cached clone is actually parked on, or "" if it cannot be read. -// -// Used to detect a clone that was interrupted between `git clone` and -// `git checkout` — the directory exists and looks like a repository, but sits -// on the wrong commit. Only meaningful when the expected revision is a sha, -// i.e. for branch deps after resolution. -// -// stderr is folded in so a git warning cannot leak to the user's terminal; -// the last line is taken so such a warning cannot corrupt the sha either. -std::string git_cache_head(const std::filesystem::path& gitRoot) { - auto r = mcpp::platform::process::capture(std::format( - "git -C {} rev-parse HEAD 2>&1", - mcpp::platform::shell::quote(gitRoot.string()))); - if (r.exit_code != 0) return {}; - std::string out = r.output; - while (!out.empty() && (out.back() == '\n' || out.back() == '\r' - || out.back() == ' ' || out.back() == '\t')) - out.pop_back(); - if (auto nl = out.find_last_of("\r\n"); nl != std::string::npos) - out.erase(0, nl + 1); - return out; -} - -// `prepare_build` builds the BuildContext for any verb that compiles. -// includeDevDeps: when true, dev-dependencies are also fetched + scanned -// into the modgraph. mcpp test passes true; build/run pass false. -// extraTargets: additional Target entries (e.g. synthetic test targets) -// appended to the manifest before the modgraph runs. -// overrides: --target / --static. -namespace { -// A dependency that "cannot be found" while an index is unreadable is almost -// never missing — it is unreachable, and the two need different actions from -// the user (publish it vs upgrade mcpp). The floor error is printed when the -// index is first opened, which can be hundreds of lines earlier; the message -// that STOPS the build has to carry the cause, because that is the one a user -// reads. See mcpp::pm::unusable_index_hint. -// Spelling-independent `[target.]` lookup. -// -// A section keyed `x86_64-w64-mingw32` matches a resolved `x86_64-windows-gnu`, -// and unparseable keys compare exactly (the escape hatch for custom triples). -// Factored out of the toolchain-override path because the sysroot override must -// use the SAME matching: two lookups that disagreed about spelling would give a -// section that applies to `toolchain` and not to `sysroot`, which is a defect -// nobody would think to look for. -const mcpp::manifest::TargetEntry* -find_target_entry(const mcpp::manifest::Manifest& m, - const mcpp::toolchain::triple::Triple& t) -{ - if (auto it = m.targetOverrides.find(t.str()); it != m.targetOverrides.end()) - return &it->second; - for (auto const& [key, entry] : m.targetOverrides) { - if (auto k = mcpp::toolchain::triple::parse(key); k && k->str() == t.str()) - return &entry; - } - return nullptr; -} - -// The project's `[target.].sysroot`, or nullptr when it declared none. -const std::string* -sysroot_override(const mcpp::manifest::Manifest& m, - const mcpp::toolchain::triple::Triple& t) -{ - auto* e = find_target_entry(m, t); - return (e && e->sysrootDeclared) ? &e->sysroot : nullptr; -} - -// 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 -// STL and CRT, and the Windows SDK that follows it -- is the sysroot, named by -// `[target.].sysroot` (default `msvc@system`). Until this existed the -// clang driver searched the machine for headers and libraries while mcpp -// searched it again for `std.ixx`, by a different order, so a machine with two -// installations could compile one toolset's `std.ixx` against another's -// headers, and the choice reached neither the cache key nor any report. The -// choice is made here once, recorded on the toolchain, and handed to the -// driver by the link model. -// -// `msvc@system` that finds nothing returns without binding, so the build -// reaches the "targeting the MSVC ABI without a usable MSVC" diagnosis that -// already names the alternatives. -std::expected -bind_msvc_sysroot(mcpp::toolchain::Toolchain& tc, - const mcpp::manifest::Manifest& m, - const std::function()>& cfgOf) -{ - namespace msvc = mcpp::toolchain::msvc; - auto tt = mcpp::toolchain::triple::parse(tc.targetTriple); - if (!tt) return {}; - const std::string* declared = sysroot_override(m, *tt); - const std::string text = declared ? *declared : std::string("msvc@system"); - auto spec = mcpp::toolchain::parse_toolchain_spec(text); - if (!spec) - return std::unexpected(std::format( - "[target.{}].sysroot = '{}': {}", tt->str(), text, spec.error())); - if (spec->family != mcpp::toolchain::Family::Msvc) - return std::unexpected(std::format( - "[target.{}].sysroot = '{}': on an MSVC-ABI row the sysroot is an " - "MSVC toolset (msvc@system, msvc@ or xim:msvc@)", - tt->str(), text)); - - msvc::ToolsetNeeds needs; - needs.cl = false; // clang compiles against the toolset; it does not run cl.exe - const bool systemSel = spec->version.empty() || spec->version == "system"; - std::vector instances; - std::optional choice; - if (!spec->ecosystemOnly) { - instances = msvc::enumerate_vs_instances(); - choice = msvc::select_system_toolset( - instances, msvc::msvc_env_snapshot(), - systemSel ? std::string_view("system") : std::string_view(spec->version), - needs); - if (!choice && systemSel) return {}; - } - - std::string origin = "system"; - if (!choice) { - // THE PACKAGE: `xim:` asked for it, or no installed toolset matched. - auto cfg = cfgOf(); - if (!cfg) return std::unexpected(cfg.error()); - mcpp::toolchain::ToolchainSpec pkgSpec = *spec; - pkgSpec.target = {}; - auto pkg = mcpp::toolchain::to_xim_package(pkgSpec); - mcpp::fetcher::Fetcher fetcher(**cfg); - mcpp::fetcher::InstallProgressHandler progress; - auto payload = fetcher.resolve_xpkg_path(pkg.target(), /*autoInstall=*/true, - &progress); - if (!payload) { - // `xim:` never looked at the machine, so the refusal does not - // report on it. - if (spec->ecosystemOnly) - return std::unexpected(std::format( - "[target.{}].sysroot = '{}': the package could not be " - "provided: {}\n" - " packages: `mcpp toolchain list --available msvc`", - tt->str(), text, payload.error().message)); - std::string onMachine; - for (auto const& line : msvc::describe_system_toolsets(instances, needs)) - onMachine += "\n " + line; - return std::unexpected(std::format( - "[target.{}].sysroot = '{}' matches no toolset on this machine, " - "and the package could not be provided: {}\n" - " installed on this machine:{}\n" - " packages: `mcpp toolchain list --available msvc`", - tt->str(), text, payload.error().message, - onMachine.empty() ? std::string(" none") : onMachine)); - } - auto inst = mcpp::toolchain::resolve_managed_msvc( - mcpp::config::make_xlings_env(**cfg), pkg, /*identifyVersion=*/false); - if (!inst) return std::unexpected(inst.error()); - choice.emplace(); - choice->vsRoot = inst->vsRoot; - choice->version = inst->toolsVersion; - choice->toolsDir = inst->vsRoot / "VC" / "Tools" / "MSVC" / inst->toolsVersion; - choice->product = "xim:msvc@" + inst->toolsVersion; - choice->via = "package"; - origin = "managed"; - } - for (auto const& n : choice->notes) mcpp::ui::info("note", n); - - // THE SDK FOLLOWS THE TOOLSET'S ORIGIN: a package binds the windows-sdk - // installed with it, a machine's toolset takes the machine's SDK by the - // search `msvc@system` has always used. The same function the cl.exe row - // uses, asked about the toolset directory rather than a cl.exe. - auto sdk = msvc::resolve_sdk_for(choice->toolsDir / "bin"); - if (!sdk.note.empty()) mcpp::ui::info("note", sdk.note); - - tc.msvcToolsDir = choice->toolsDir; - tc.msvcToolsVersion = choice->version; - tc.msvcOrigin = origin; - tc.msvcProduct = choice->product; - if (sdk.sdk) { - tc.windowsSdkRoot = sdk.sdk->root; - tc.windowsSdkVersion = sdk.sdk->version; - } - // The STL is this toolset's, so its version is the standard library's. - tc.stdlibVersion = choice->version; - - // THE STD MODULE OF THIS TOOLSET, replacing the `std.ixx` detection found - // by its own search. A toolset without one leaves `import std` unavailable - // rather than borrowing another toolset's. - // - // Only `std` is rebound. Detection never gave this row a `std.compat` - // source, and the clang builder for it passes the file without - // `-x c++-module`: given `std.compat.ixx`, clang takes it for linker - // input, `--precompile` writes nothing and exits 0, and the next command - // fails on the missing BMI (measured on the Windows runners). - std::error_code ec; - const auto ixx = choice->toolsDir / "modules" / "std.ixx"; - const bool msvcStl = tc.stdModuleSource.empty() - || tc.stdModuleSource.filename() == "std.ixx"; - if (msvcStl && std::filesystem::exists(ixx, ec)) { - tc.stdModuleSource = ixx; - tc.hasImportStd = true; - tc.importStdMinLevel = msvc::std_module_min_level_for_stl(ixx); - } else if (msvcStl && !tc.stdModuleSource.empty()) { - tc.stdModuleSource.clear(); - tc.hasImportStd = false; - } - - mcpp::ui::info("Resolved", std::format( - "sysroot {} → MSVC {} ({}: {}){}", spec->spec_str(), choice->version, - origin, choice->product, - tc.windowsSdkVersion.empty() - ? std::string{} - : std::format(" · Windows SDK {}", tc.windowsSdkVersion))); - return {}; -} - -// ON THE CL.EXE ROW THE COMPILER IS ITS OWN SYSROOT: cl.exe cannot compile -// against another toolset's STL. A declared sysroot is therefore a second -// statement of the compiler's toolset, and one that names a different -// toolset is refused rather than silently ignored. -std::expected -check_cl_row_sysroot(const mcpp::toolchain::Toolchain& tc, - const mcpp::manifest::Manifest& m) -{ - auto tt = mcpp::toolchain::triple::parse(tc.targetTriple); - if (!tt) return {}; - const std::string* declared = sysroot_override(m, *tt); - if (!declared) return {}; - auto spec = mcpp::toolchain::parse_toolchain_spec(*declared); - if (!spec || spec->version.empty() || spec->version == "system") return {}; - // /bin/Host//cl.exe → is named by the toolset. - const auto toolset = tc.binaryPath.parent_path().parent_path() - .parent_path().parent_path().filename().string(); - if (mcpp::toolchain::msvc::toolset_version_matches(spec->version, toolset)) - return {}; - return std::unexpected(std::format( - "[target.{}].sysroot = '{}' names a different toolset than the " - "compiler ({}, toolset {}). With cl.exe the compiler is its own " - "sysroot: pin the toolset in the toolchain (`msvc@`) and " - "drop `sysroot`, or build with clang to compile against another " - "toolset.", - tt->str(), *declared, tc.binaryPath.string(), toolset)); -} - -// The target-facing answers a `build.mcpp` may ask the engine for. -// -// ONE function because there are TWO call sites — the root project and each -// dependency — and four values derived independently in two places is the -// shape this codebase keeps paying for. A board package that got the right -// answer as a root project and a stale one as a dependency would fail only in -// the consuming build, which is the harder direction to debug. -// A NETWORK STEP OF A BUILD, RETRIED — AND IT HAD NO RETRY AT ALL. -// -// A dependency resolved by `git` is fetched on every machine that has not -// cached it, and a transport that hiccups once failed the whole build: -// -// error: git clone of 'https://github.com/…' failed: -// Cloning into '/home/runner/.mcpp/git/63269d80b47f71e6'... -// -// — no message from git, which is what a connection that dies mid-transfer -// looks like. Measured twice on 2026-08-23: once in continuous integration and -// once locally as `TLS connect error: … unexpected eof while reading`. -// -// THREE ATTEMPTS, AND THE LAST FAILURE IS REPORTED UNCHANGED. A wrong URL -// and a missing branch fail exactly as a transient fault does, so this cannot -// tell them apart and does not try: a permanent failure costs three seconds and -// produces the message it always did. Hiding a real error behind a retry is the -// worse trade, which is why the count is small and the report is untouched. -// -// BOTH NETWORK STEPS, not one. The first version retried only the clone — -// and a probe with a nonexistent repository failed in ONE second, because the -// step that runs first is `git ls-remote` and it was still bare. A retry on -// half of a path is a retry that reports success at having been added. -// -// `between` runs after a failed attempt: the clone needs the partial directory -// removed, or git's next attempt fails with "already exists and is not an empty -// directory" — a second, different error that says nothing about the first. -mcpp::platform::process::RunResult run_with_network_retry( - std::string_view command, - const std::function& between = {}) { - mcpp::platform::process::RunResult r{}; - mcpp::platform::env::note_network_access(); // the envelope's `effects` (#648 A4) - for (int attempt = 1; attempt <= 3; ++attempt) { - r = mcpp::platform::process::capture(command); - if (r.exit_code == 0) return r; - if (between) between(); - if (attempt < 3) - std::this_thread::sleep_for(std::chrono::seconds(attempt)); - } - return r; -} - -// `[package]`, for the build program of the package that declares it. -// -// ONE CALL RATHER THAN A FIELD PER SITE. Two places build a -// `BuildProgramEnv` -- the dependency loop and the root -- and the values a -// build program is told about its own package are the same question in both. -// Setting them field by field at each site is how the two answers drift: the -// root gained `packageName` and the dependency loop gained it separately, and -// a value added to only one of them is a rule package that works for a root -// project and not for a dependency, with nothing failing to say so. -// Forward-declared: defined below (#622 A11), and `fill_target_build_env` -// needs it before that point in the file. -std::string min_platform_version(const mcpp::manifest::Manifest& m, - const mcpp::toolchain::triple::Triple& t, - const std::filesystem::path& compilerPath); - -void fill_package_build_env(mcpp::build::BuildProgramEnv& e, - const mcpp::manifest::Manifest& m) -{ - e.packageName = m.package.name; - e.packageNamespace = m.package.namespace_; - e.packageVersion = m.package.version; - e.packageDescription = m.package.description; - e.packageLicense = m.package.license; - e.packageRepo = m.package.repo; - // ';' rather than ',': an author entry is conventionally `Name ` - // and a name may carry a comma, so a comma-joined list cannot be split back - // into the entries it was made from. - e.packageAuthors.clear(); - for (auto const& a : m.package.authors) { - if (!e.packageAuthors.empty()) e.packageAuthors += ';'; - e.packageAuthors += a; - } -} - -void fill_target_build_env(mcpp::build::BuildProgramEnv& e, - const mcpp::manifest::Manifest& m, - const mcpp::toolchain::Toolchain* tc, - const mcpp::config::GlobalConfig* cfg) -{ - // The registry SubOS is where payloads are installed, whichever - // toolchain or link mode resolved, so this is set before the toolchain - // gate below. - if (cfg) { - const auto view = mcpp::xlings::paths::sysroot(mcpp::config::make_xlings_env(*cfg)); - e.pkgConfigLibdir = (view / "usr" / "lib" / "pkgconfig").generic_string() - + mcpp::platform::env::path_list_separator() - + (view / "usr" / "share" / "pkgconfig").generic_string(); - } - e.toolchainDir = (tc && !tc->binaryPath.empty()) - ? tc->binaryPath.parent_path().parent_path().string() : std::string{}; - e.targetSysroot = tc ? tc->targetSysrootRoot.string() : std::string{}; - e.compilerId = !tc ? std::string{} - : tc->compiler == mcpp::toolchain::CompilerId::GCC ? "gcc" - : tc->compiler == mcpp::toolchain::CompilerId::Clang ? "clang" - : tc->compiler == mcpp::toolchain::CompilerId::MSVC ? "msvc" - : std::string{}; - e.targetLibc = tc ? tc->targetSysrootPkg : std::string{}; - // Read from the toolchain the engine resolved, the same field the cache - // key, the ABI tag and the toolchain fingerprint read. Not re-derived from - // `compilerId`: clang answers "libc++" or "libstdc++" depending on how the - // payload was configured, and deriving it here would restate an assumption - // the resolver already measured. - e.cxxStdlib = tc ? tc->stdlibId : std::string{}; - if (!tc) return; - - // The two flags mcpp passes to ITS OWN compiler, so a rule package driving - // a second compiler passes the same two. Both read from the single - // producer that already decides them for the engine's own command lines — - // `resolve_link_model` for the sysroot, `gcc::binutils_prefix_dir` for the - // `-B` — rather than a fifth re-derivation of either. - if (auto lm = mcpp::toolchain::resolve_link_model(*tc); - lm.mode == mcpp::toolchain::CLibMode::Sysroot) - e.toolchainSysroot = lm.sysroot.string(); - e.toolchainBinutilsDir = mcpp::toolchain::gcc::binutils_prefix_dir(*tc).string(); - - // The C LIBRARY's sub-directory for this ISA profile, from the freestanding - // table — the same single read point the compile flags use. - // - // Gated on there being a C library at all, and the gate is the point: the - // value is a multilib convention, so on the zero-libc tier there is nothing - // for it to be a convention OF. Emitting `rv64gc/lp64d` there would hand a - // kernel a path into a directory that does not exist, and the name of the - // accessor would be a lie. All three libc-facing answers are empty together. - if (!e.targetSysroot.empty()) - if (auto spec = mcpp::freestanding::resolve(tc->targetTriple)) - e.targetLibcProfile = std::string(spec->libdir); - - // Which builtins library the RESOLVED toolchain ships. Freestanding only: - // on a hosted target the driver links them without being asked, and - // handing a package a name it must not use would invite it to. - if (auto t = mcpp::toolchain::triple::parse(tc->targetTriple); - t && t->is_freestanding()) { - e.targetBuiltinsLib = mcpp::toolchain::is_clang(*tc) - ? "clang_rt.builtins-" + t->arch - : std::string("gcc"); - } - - // #622 A11: MCPP_TARGET_MIN_PLATFORM_VERSION. One call, so a new consumer - // (`dist-apple`, `dist-apk`) reads the same answer the compiler flag and - // the fingerprint slot already resolved, rather than restating it. - if (auto tt = mcpp::toolchain::triple::parse(tc->targetTriple)) - e.minPlatformVersion = min_platform_version(m, *tt, tc->binaryPath); -} - // ── Tool tiers: which of a manifest's declared packages this verb needs ───── // // THE AXIS PACKAGE DEPENDENCIES HAVE HAD SINCE THE BEGINNING, AND TOOLS @@ -1897,13921 +686,43 @@ void fill_target_build_env(mcpp::build::BuildProgramEnv& e, // that declared it is itself being developed", so `isRoot` decides it. Every // other tier reaches a consumer, which is the whole point of a board package // knowing its own machine. -enum class ToolPurpose { Build, Run }; - -std::vector -applicable_xlings_addresses(const mcpp::manifest::Manifest& man, - const std::vector& activeFeatures, - ToolPurpose purpose, bool isRoot) { - using W = mcpp::manifest::ToolWhen; - std::vector out; - auto wanted = [&](const std::string& address) { - switch (man.xlings.when_of(address)) { - case W::Always: return true; - case W::Build: return true; - case W::Run: return purpose == ToolPurpose::Run; - case W::Dev: return isRoot; - } - return true; - }; - auto add = [&](const std::string& address) { - if (!wanted(address)) return; - if (std::ranges::find(out, address) == out.end()) out.push_back(address); - }; - for (auto const& a : man.xlings.deps) add(a); - // `[feature-xlings.]` contributes only while `` is active. A consumer - // that never asks for `hardware` never downloads a probe driver — which is - // the same mechanism `[feature-deps]` gives a package, applied to tools. - for (auto const& f : activeFeatures) - if (auto it = man.xlings.featureDeps.find(f); - it != man.xlings.featureDeps.end()) - for (auto const& a : it->second) add(a); - return out; -} - -// Install a set of `[xlings.workspace]` addresses, and record that the list was -// done. -// -// EXTRACTED SO THE DEPENDENCY GRAPH CAN USE THE SAME PATH. This was the -// root project's provisioning, inline and reachable only from there. A -// board-support package that declares the emulator its machine needs is -// precisely the thing that should say so once, and a consumer that has to -// repeat the declaration to get it installed is the duplication such a package -// exists to remove — so the graph pass calls this with what the dependencies -// declared, under the same stamp discipline and the same auto-install gate. -// -// `label` names the caller in every message, because "which of the two passes -// is this" is the first thing a reader of the failure needs. -std::expected -provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, - const std::vector& declaredDeps, - const std::filesystem::path& legacyStampRoot, - std::string_view label) { - if (declaredDeps.empty()) return {}; - // THE STAMP RECORDS A GLOBAL EFFECT, SO IT LIVES WHERE THE - // EFFECT DOES. It used to sit in `/.mcpp/`, while the - // installation goes to the registry a few lines below — the - // scope difference is deliberate and explained there. Two - // consequences followed from the mismatch: wiping or replacing - // `MCPP_HOME` left a project still claiming the packages were - // installed, and `mcpp clean` (which removes `target/` and - // never `.mcpp/`) could not clear it. Keyed by the LIST, not by - // 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. - 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 - // that host a different key space for no reason anyone could - // see. A collision is not a correctness problem either way — - // the file stores the LIST and the comparison below is against - // its content, so two lists sharing a key re-provision rather - // than silently adopt each other's record. - auto stamp_key = [&] { - std::uint64_t h = 1469598103934665603ull; // FNV-1a - for (auto const& d : declaredDeps) - for (unsigned char ch : d + "\n") - { h ^= ch; h *= 1099511628211ull; } - return std::format("xlings-deps-{:016x}", h); - }; - const auto stamp = stampDir / stamp_key(); - // Idempotence by CONTENT, not by existence: editing the list - // has to re-provision, and an unchanged list must not pay for - // an xlings round-trip on every build. - auto join_deps = [&](std::string_view sep) { - std::string out; - for (auto const& d : declaredDeps) { - if (!out.empty()) out += sep; - out += d; - } - return out; - }; - std::string want; - for (auto const& d : declaredDeps) { want += d; want += '\n'; } - std::string have; - if (std::ifstream in{stamp}; in) - have.assign(std::istreambuf_iterator(in), {}); - // The stamp the previous location left behind. Read, never - // deleted: an older mcpp sharing the checkout still uses it, - // and a stale extra file is cheaper than a downgrade that - // re-provisions on every build. - // - // IT DOES NOT MEAN "PROVISIONED SUCCESSFULLY". The release - // that wrote it did not read the result — that is the defect - // above — so it means only "this list was attempted". Treating - // it as proof would carry the bug across the very upgrade that - // fixes it: a project whose dependency never installed would - // adopt the stamp and stay silently broken. - // - // So it is consulted in ONE place, below, where the alternative - // is worse: the auto-install gate. Online, nothing is adopted - // and every project re-provisions once, which is a cheap round - // trip that re-validates the claim. - const auto legacyStamp = legacyStampRoot / ".mcpp" / ".xlings-deps.stamp"; - auto legacy_stamp_matches = [&] { - std::string legacy; - if (std::ifstream in{legacyStamp}; in) - legacy.assign(std::istreambuf_iterator(in), {}); - return legacy == want; - }; - bool needProvision = (have != want); - if (needProvision) { - // THE AUTO-INSTALL GATE, WHICH THIS PATH DID NOT HAVE. - // - // `[toolchain]` is the precedent this whole mechanism cites - // ("the same 'declare it and mcpp provisions it on first - // use' contract"), and that path refuses on either knob and - // names the one that fired — see the auto-install branch - // above. This one honoured neither, so a CI exporting - // MCPP_NO_AUTO_INSTALL specifically to prevent an unasked - // download got one anyway, from a path that had never heard - // of the variable. - // - // Placed inside `have != want`, so it gates the ATTEMPT and - // not the block: a project whose packages are already - // provisioned still builds offline, which is the behaviour - // that would otherwise regress. - if (mcpp::platform::env::offline_mode() - || mcpp::platform::env::no_auto_install()) { - // THE ONE PLACE THE LEGACY STAMP IS TRUSTED, and the - // reason is that relocating a record must not refuse a - // build that worked yesterday. Every project that had - // already provisioned carries the old stamp and no new - // one, so on the first build after upgrading it reads - // as un-provisioned — and here, with the network shut - // off, there is no way to find out otherwise. Refusing - // would be a regression caused entirely by moving a - // file, which is the least defensible kind. - // - // Proceeding is the pre-upgrade behaviour exactly: if - // the packages really are missing, the build fails - // downstream on a missing header, as it did before. - // The registry stamp is NOT written — nothing here - // verified anything. - if (!legacy_stamp_matches()) { - 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 declared but not provisioned, " - "and auto-install is off.\n" - " declared: {}\n" - " install them yourself with:\n" - " xlings install {}\n" - " or {} to let mcpp do it.", - label, join_deps(", "), join_deps(" "), release)); - } - mcpp::log::verbose("xlings", - std::format("{}: auto-install is off and this project " - "carries a pre-2026.9.1.1 provisioning stamp for the " - "same list; proceeding without re-checking", label)); - // Deliberately NOT writing the registry stamp: nothing - // here verified anything, and a record of a check that - // did not happen is the defect this release removes. - needProvision = false; - } - } - if (needProvision) { - mcpp::ui::status("Provisioning", - std::format("{} ({})", label, join_deps(", "))); - // An address whose index a `[index.repos.]` table - // redirects is installed from that source, and says so: an - // installation from a branch checkout must not read as one - // from the published index (#634, C4). - std::set redirected; - for (auto const& d : declaredDeps) { - const auto colon = d.find(':'); - if (colon == std::string::npos) continue; - const auto index = d.substr(0, colon); - for (auto const& r : cfg.indexRepos) { - if (!r.fromConfig || r.name != index) continue; - if (r.name == "mcpplibs" && r.url == mcpp::config::kMcpplibsIndexUrl) - continue; - if (redirected.insert(index).second) - mcpp::ui::status("Index", std::format( - "{} -> {} ([index.repos.{}] in config.toml)", - r.name, r.url, r.name)); - } - } - // GLOBAL scope, and the scope is the whole point. - // - // The obvious alternative -- `install_packages` against - // `make_project_xlings_env` -- installs at PROJECT scope, - // and that measurably does not work: on a fresh MCPP_HOME - // the headers land in - // `/.mcpp/.xlings/subos/_/usr/include` while - // `--sysroot` names `/registry/subos/default`, - // so `#include ` still failed with the dependency - // installed and declared. Two SubOS views, and the payload - // in the one the compiler does not read. - // - // `make_xlings_env` is the GLOBAL env, so this lands in the - // registry whose SubOS *is* mcpp's sysroot -- the same - // place `[toolchain]` has always installed into. A project - // dependency and a toolchain dependency now agree on where - // they live, which is the only arrangement in which one - // `--sysroot` can see both. - // - // `install_packages` rather than `resolve_xpkg_path`: the - // latter requires `@` and rejects a bare - // `mesa`, while a manifest is entitled to name a package - // without pinning it. install_packages resolves the version - // itself and reports an ambiguous name with its candidates, - // which is the error the author can act on. - // Built with the JSON library rather than by formatting - // the strings in. `deps` is manifest input, so a name - // containing a quote or a backslash would otherwise emit - // malformed JSON and the failure would surface as an - // unrelated xlings parse error naming neither the manifest - // nor the key. - nlohmann::json args; - args["targets"] = declaredDeps; - args["yes"] = true; - - mcpp::fetcher::InstallProgressHandler progress; - auto r = mcpp::xlings::call( - mcpp::config::make_xlings_env(cfg), "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. - // - // `xlings::call` returns `expected` and - // is in the VALUE state whenever the child ran at all — the - // error channel means "the call did not happen". A - // capability's own status arrives inside `CallResult`, - // parsed off the NDJSON `{"kind":"result","exitCode":N}` - // line, because the xlings process itself exits 0 by design - // once it has spoken the protocol. - // - // Measured before this fix: a manifest declaring a package - // that cannot exist printed `Provisioning [xlings] deps - // (…)`, xlings answered `E_NOT_FOUND` with `exitCode: 1`, - // and mcpp stamped it as done and reported a successful - // build. #531 was written because "the declaration looked - // accepted and did nothing" is the worst shape a config key - // can have; unread, its own fix reproduced that shape and - // the stamp made it permanent. - // - // The correct idiom is not new — the dependency install - // path in this same file reads `r->exitCode` — it was - // simply not applied here. - const bool called = r.has_value(); - const int childRc = called ? r->exitCode : -1; - if (!called || childRc != 0) { - // Prefer xlings' own message: for an unresolvable name - // it names the repos it searched and whether the index - // is current, which is the part the author can act on. - std::string why = !called ? r.error() - : (r->error ? r->error->message - : std::format("xlings exited {}", childRc)); - if (auto captured = progress.captured_error(); - !captured.empty() && called && !r->error) - why = captured; - // The hint is where "run `xlings update` if the package - // was just published" lives, and for the commonest - // failure — a name that is not in the synced index — - // it is the whole of the actionable content. - if (called && r->error && !r->error->hint.empty()) - why += "\n " + r->error->hint; - // Shaped like the toolchain failure: say what failed and - // hand back a command the user can run themselves. An - // ambiguous bare name ("mesa" matching two repos) lands - // here, and xlings' own message names the candidates. - return std::unexpected(std::format( - "provisioning {} failed: {}\n" - " you can install them manually with:\n" - " xlings install {}", - label, why, join_deps(" "))); - } - // 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 - // than not recording it — the next build skips the attempt. - std::error_code sec; - std::filesystem::create_directories(stamp.parent_path(), sec); - if (std::ofstream out{stamp}; out) out << want; - } - return {}; -} - -// THE PROJECT'S MINIMUM PLATFORM VERSION FOR THIS TARGET, in one place. -// -// Two platforms fuse it into the effective triple and each names it in its own -// words: macOS's deployment target lives in `[build]` because it applies to -// every Apple artefact a project produces, and Android's API level lives in -// `[target.]` because it applies to one row. `llvm_triple` takes one -// parameter for both, so the choice between them is made here rather than at -// each of its call sites -- there are two, and a decision made twice is the -// shape this codebase records most often. -std::string min_platform_version(const mcpp::manifest::Manifest& m, - const mcpp::toolchain::triple::Triple& t, - const std::filesystem::path& compilerPath) { - if (t.is_android()) { - if (auto it = m.targetOverrides.find(t.str()); it != m.targetOverrides.end()) - if (it->second.minApiLevel > 0) - return std::to_string(it->second.minApiLevel); - // AND THERE IS NO SUCH THING AS LEAVING IT OUT. This returned an empty - // string with the comment "the NDK's own default, which clang - // supplies", which was never verified and is false. Measured: - // - // --target=aarch64-unknown-linux-android (no level) - // sys/cdefs.h:365:2: error: Unversioned target triples are not - // supported! - // - // bionic refuses it, so the level is mandatory and a project that - // never heard of API levels still needs one. The NDK declares the - // floor it supports in `meta/platforms.json` and that is the honest - // default -- the payload's own answer, which moves when the payload - // does. macOS is the same shape and already works this way: its - // default comes from the platform module, not from the manifest. - // THE PAYLOAD'S OWN ANSWER FIRST, AND THE ENGINE'S DERIVATION AS - // THE FALLBACK. `platform_floor` in `.mcpp-toolchain.json` is the - // same number by a channel that does not require this engine to know - // that an NDK keeps it in `meta/platforms.json`, nor that file's - // schema. A payload shipping no descriptor still resolves, which is - // what makes the descriptor additive. - // - // A MALFORMED descriptor is read as absence HERE ONLY, because this - // function has no error channel and does not need one: a - // payload-provided compiler reaches this point through - // `payload_frontend`, which refuses a malformed descriptor by name - // before any of these decisions are made. - if (auto desc = - mcpp::toolchain::payload_descriptor_for_compiler(compilerPath); - desc && *desc && !(*desc)->platformFloor.empty()) - return (*desc)->platformFloor; - if (auto level = mcpp::toolchain::ndk_min_api_level(compilerPath); - level > 0) - return std::to_string(level); - return {}; // the caller refuses; see android_api_level_refusal - } - // APPLE'S TWO PLATFORMS ANSWER FROM TWO KEYS, ONE SLOT. - // - // "14.0" is a macOS version and means nothing to an iOS SDK, so the - // project states them separately -- and only one of them can apply to any - // one target, which is why they still share this function's single return - // and the single fingerprint slot behind it. - // - // Empty is a legal answer here and not a refusal, unlike Android's, and - // for the iOS rows prepare fills it with the located SDK's version before - // this is read: an unversioned `arm64-apple-ios` made clang refuse - // thread-local storage (measured, Xcode 16.4), so the driver's own - // default is not the SDK's. Bionic rejects the unversioned triple - // outright, which is the other half of the asymmetry. - if (t.is_ios()) return m.buildConfig.iosDeploymentTarget; - // AND ONLY FOR A macOS TARGET. `deployment_target` itself no longer - // consults the host (#685); the discriminator is `t.os`, which is this - // function's own target parameter and is available regardless of what - // machine mcpp runs on. A non-Apple target (Linux, Windows, wasm, - // freestanding) answers empty here, same as it always has. - if (t.os == "macos") - return mcpp::platform::macos::deployment_target( - /*targetIsMacos=*/true, m.buildConfig.macosDeploymentTarget); - return {}; -} - -std::string with_index_cause(std::string msg) { - if (auto hint = mcpp::pm::unusable_index_hint(); !hint.empty()) - msg += "\n" + hint; - return msg; -} -} // namespace - +// Exported (unlike most of this file's helpers) because PrepareState, in the +// implementation partition `:state`, holds one and needs the type visible +// through `import mcpp.build.prepare;` -- a partition sees only what its +// imports export, module-linkage is not enough across that boundary. +export enum class ToolPurpose { Build, Run }; + +// 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); + +// The pure helpers the phases share, exported for tests/unit/test_prepare_helpers.cpp; +// their definitions and design notes are in src/build/prepare/config.cpp and +// src/build/prepare/fetch.cpp. +export std::vector previous_release_words(std::string element, bool define); +export std::string_view define_name(std::string_view entry); +export std::vector feature_request_tokens(std::string_view s); +export std::vector parse_feature_request(std::string_view s); +export std::vector feature_forward_request_tokens(std::string_view s); +export bool is_local_git_remote(std::string_view url); + +// PrepareState (the state every phase reads and writes) and the phase +// declarations live in the implementation partition `:state` — see the +// file-header comment above for why this file imports no partition at all, +// interface or implementation, and therefore cannot name PrepareState here. +// prepare_build's own definition is driver.cpp; this is only its declaration, +// carrying the default arguments (they belong on exactly one declaration, +// and this is the one every caller sees). export std::expected - -prepare_build(bool print_fingerprint, - bool includeDevDeps = false, +prepare_build(bool print_fingerprint, bool includeDevDeps = false, std::vector extraTargets = {}, - BuildOverrides overrides = {}) { - // Which tool tiers this invocation needs. Named once so the two - // provisioning passes cannot disagree — a `mcpp build` that installed the - // run tier and a `mcpp run` that did not would be the same defect twice. - const ToolPurpose toolPurpose = - overrides.will_run ? ToolPurpose::Run : ToolPurpose::Build; - pending_flag_words_notes().clear(); - - // A refusal decided early and released late. `host_can_serve` answers - // "does a payload on this machine produce this target", which is knowable - // before dependency resolution and is only half the question: a package in - // the graph can supply the target's system, and the graph is not known - // here. Held until it is, and released only if nothing supplies it. - std::string unservedTargetDiagnosis; - - // THE LOCATED APPLE SDK, RESOLVED ONCE AND READ ONCE. - // - // `xcrun` is a process. Calling it at the refusal below and again where - // the answer is stored would be two calls whose answers can differ -- the - // developer directory can be switched between them -- and this repository - // has a standing rule that a value crossing two sites is resolved at one. - std::optional appleSdkLocated; - // The iOS floor was not written and was taken from the located SDK. The - // refusal of a dependency's platform floor names where the value came - // from, and after the fill below the manifest no longer says. - bool iosFloorFromSdk = false; - // Non-empty when a target row's convention replaced a toolchain the user - // had set with `mcpp toolchain default`. Reported on the status line, - // because a substitution nobody is told about is a rule that can only be - // learned by experiment — writing the same value a second time in - // `[target.]` and observing that it works. - std::string pinReplacedDefault; - // THE HOST SPEC AS IT STOOD BEFORE A TARGET ROW'S CONVENTION REPLACED IT, - // whatever its origin. `build.mcpp` is compiled and run on this machine, - // so its compiler is a host fact; the row's pin is a target fact. Before - // this snapshot existed, `host_tc_for_build_program` read `tcSpec` after - // the row had overwritten it and resolved the row's payload "for the - // host" -- which works by accident for a payload whose compiler can also - // target the host (an NDK clang) and cannot work for one that cannot: - // `em++` produces WebAssembly under every invocation, and every project - // with a build program failed under `--target wasm32-emscripten` inside - // `emcc.py` (#622, measured by the dist-web member's first build). - // - // Empty when the row replaced nothing — no [toolchain], no global - // default, no [target.] entry existed before the row's pin applied. - // THIS IS NOT "the row's pin remains the only spec there is": on a - // fresh $HOME whose first-ever invocation names a hosted `--target` - // (nothing to be "before"), that reading resolved the SAME payload the - // row just picked — `em++` again — as the host compiler, which is the - // exact defect this field exists to close, just with no prior value to - // restore. `host_tc_for_build_program` resolves the platform's own - // native default in that case instead (`native_first_run_spec()`), the - // same one a plain `mcpp build` would have installed. - std::optional hostSpecBeforeRowPin; - // THE PACKAGE WHOSE `requires` CHOSE THE COMPILER, AND WHAT IT ASKED FOR. - // - // Non-empty only when the graph's requirement actually changed the answer. - // Reported on the status line for the same reason `pinReplacedDefault` is: - // a compiler the user did not name is a decision they did not make, and one - // reported without its reason is a rule learned by experiment. - std::string graphCompilerRequiredBy; // "openkal-llvm-runtime@0.1.3" - std::string graphCompilerFamily; // "llvm" - std::string graphCompilerReplaced; // the spec it displaced, for the line - // The C library the target triple asked for, taken before the triple is - // canonicalised. Empty when the project declined to name one. - std::string requestedCAbi; - // The target as the project spelled it, when that differs from the - // canonical identity. Report only; empty means they coincide. - std::string targetDisplayName; - // The target row's toolchain convention, held until the graph is known. - // Empty when the row names none or the project named its own. - std::string targetPinCandidate; - // AND WHETHER THAT PIN IS A CONVENTION OR A CAPABILITY, RECORDED AT - // THE SAME READ. - // - // A hosted row's pin answers "which payload supplies this target's C - // library", so a graph that supplies one instead makes it inapplicable. - // A freestanding row's pin answers a different question — the table says - // so in its own words: "the pin is llvm on every host because clang/lld - // are cross-compilers by construction". A host g++ cannot emit - // riscv64-none-elf at all, and no dependency changes that. - // - // Taken here rather than re-derived at the decision point, because the row - // is read exactly once and both facts come out of that read. - bool targetPinIsCapability = false; - // THE ROW'S PIN, KEPT EVEN WHEN THE PROJECT NAMED ITS OWN COMPILER — - // which is exactly when `targetPinCandidate` above is left empty. - // - // The candidate answers "should mcpp apply its convention"; this answers - // "what does the convention SAY", and the two differ precisely in the case - // that needs a diagnosis: a project that overrode the convention and has - // nothing supplying what the convention was there to supply. - std::string targetRowPin; - std::string targetRowName; - // Whether the resolved toolchain spec names the machine's own Visual - // Studio. Decided inside `resolve_target_toolchain`, read by - // `host_tc_for_build_program`, which is why it is declared out here. - bool tcSpecIsMsvc = false; - - auto root = overrides.project_root.empty() - ? mcpp::project::find_manifest_root(std::filesystem::current_path()) - : std::optional(overrides.project_root); - if (!root) { - return std::unexpected("no mcpp.toml found in current directory or any parent"); - } - // THE PROJECT'S PATH IS PART OF EVERY DOCUMENT A BUILD WRITES, and those - // documents are UTF-8 text (build.ninja, compile_commands.json). A - // directory whose path has no UTF-8 spelling used to fail the first build - // with `internal: unhandled exception: [json.exception.type_error.316]` - // (#693, measured on Linux with a Latin-1 name and on a Windows code page - // 1252 host with a name that page can spell). It is refused here, by name. - if (!mcpp::modgraph::try_narrow(*root)) { - return std::unexpected(std::format( - "the project directory '{}' has no UTF-8 spelling.\n" - " {}\n" - " Every file a build writes names this directory in UTF-8; " - "rename or move it.", - mcpp::modgraph::escaped_spelling(*root), - mcpp::modgraph::no_utf8_spelling_reason())); - } - // NOTE: `workRoot` is deliberately NOT derived here. `root` is not final - // yet — the workspace block below reassigns it to the selected member - // (`root = memberDir`), and anchoring the write root to the pre-switch - // value puts a member's target/, mcpp.lock and .mcpp/ at the WORKSPACE - // root. See the derivation right after that block. - - // A registry package in `compat` form (Form B) ships NO mcpp.toml — its - // manifest is synthesized from the `.lua` descriptor by the resolver. So a - // nested build of such a package cannot re-read one off disk, and the - // caller hands over the manifest it already synthesized instead. - // - // Passing it in rather than re-deriving it is also the more correct of the - // two: re-deriving could produce a DIFFERENT manifest than the one the - // parent resolved against (the L1 cfg merge and feature-activated deps - // have already been folded in by then). - // THE EFFECTIVE MANIFEST, FROM THE ONE LOADER EVERY COMMAND USES. - // - // A command issued inside a member directory receives the member's - // manifest after workspace inheritance, exactly as `publish`, `pack`, - // `emit xpkg` and `toolchain list` do (#690, W4). A command at the - // workspace root receives the root manifest as written; the `-p ` - // switch below loads and inherits the member it names. - // - // A PRELOADED manifest (a host-tool sub-build) is already effective: the - // resolver loaded it at the dependency's load site, where a member - // inherits (see `inherit_as_workspace_member`). It is not inherited a second time; the - // workspace it belongs to is still recorded below, so that its own sibling - // dependencies inherit as members. - std::optional effective; - std::expected m = - std::unexpected(std::string{}); - if (overrides.preloaded_manifest) { - m = *overrides.preloaded_manifest; - } else { - auto loaded = mcpp::project::load_effective_manifest(*root); - if (!loaded) return std::unexpected(loaded.error()); - m = loaded->manifest; - effective = std::move(*loaded); - } - - // AND ONLY FOR THE ROOT. A layer name this engine does not know is a - // typo in the manifest the author is looking at, and a version gap in a - // dependency's. The reserved `mcpp:` prefix exists so the first is an error - // rather than a silently disabled behaviour; refusing the second as well - // meant the layer vocabulary could never be extended by a published package - // (`warn_unknown_xpkg_keys` carries that half). - if (!m->unknownCapabilities.empty()) { - auto const& cap = m->unknownCapabilities.front(); - auto why = mcpp::targetside::parse_capability(cap); - return std::unexpected(std::format( - "{}: {}", (*root / "mcpp.toml").string(), - why ? std::format("`{}` names no capability mcpp knows.", cap) - : why.error())); - } - - // A DISTRIBUTION package is not a source tree, and building "in" one is a - // failure that looks like a success: `interface/` holds declarations whose - // definitions are in the prebuilt archive, so the build compiles the - // declarations, produces a near-empty library, links nothing, and reports - // Finished. The archive it was supposed to carry never enters the picture. - // - // Only the ROOT is refused. As a dependency this is exactly what the - // package is for — the consumer compiles the interface and links the - // artifact, which is the whole design. - if (!overrides.preloaded_manifest && mcpp::pack::is_distribution_package(*m)) { - return std::unexpected(std::format( - "'{}' is a distribution package produced by `mcpp pack`, not a source tree.\n" - " Its sources are interface declarations; the definitions are in the\n" - " prebuilt artifacts beside them, so building here would produce an\n" - " empty library and say it succeeded.\n" - " Use it: add it to a project as a dependency —\n" - " [dependencies]\n" - " {} = {{ path = \"{}\" }}", - root->string(), m->package.name, root->string())); - } - - // ─── Workspace handling ──────────────────────────────────────────── - // If the manifest has [workspace] and is a virtual workspace (no [package]), - // or if -p filter is set, switch to the target member's manifest. - std::optional wsManifest; // keep workspace manifest alive - std::filesystem::path runtimeWorkspaceRoot; - if (m->workspace.present) { - std::string targetMember; - - if (!overrides.package_filter.empty()) { - // -p : find matching member by directory basename or path - for (auto& mp : m->workspace.members) { - auto basename = std::filesystem::path(mp).filename().string(); - if (basename == overrides.package_filter || mp == overrides.package_filter) { - targetMember = mp; - break; - } - } - if (targetMember.empty()) { - return std::unexpected(std::format( - "workspace member '{}' not found in [workspace].members", - overrides.package_filter)); - } - } else if (m->package.name.empty()) { - // Virtual workspace: find a member with a program target ("is - // this the program", #622 A3's `is_program()`, so a member whose - // only target is `kind = "app"` is picked exactly as one whose - // target is `bin` is), or use last member. - for (auto& mp : m->workspace.members) { - auto memberDir = *root / mp; - auto mm = mcpp::manifest::load(memberDir / "mcpp.toml", - {.insideWorkspace = true}); - if (!mm) continue; - for (auto& t : mm->targets) { - if (t.is_program()) { - targetMember = mp; - break; - } - } - if (!targetMember.empty()) break; - } - if (targetMember.empty() && !m->workspace.members.empty()) { - targetMember = m->workspace.members.back(); - } - } - // else: rooted workspace with [package] — build root normally. - - if (!targetMember.empty()) { - auto memberDir = *root / targetMember; - if (!std::filesystem::exists(memberDir / "mcpp.toml")) { - return std::unexpected(std::format( - "workspace member '{}' has no mcpp.toml", targetMember)); - } - runtimeWorkspaceRoot = *root; - wsManifest = std::move(*m); // preserve workspace manifest - auto memberManifest = mcpp::manifest::load(memberDir / "mcpp.toml", - {.insideWorkspace = true}); - if (!memberManifest) return std::unexpected(std::format( - "workspace member '{}': {}", targetMember, - memberManifest.error().format())); - m = std::move(*memberManifest); - - // ONE call, not a hand-copied list. `*root` is still the WORKSPACE - // root here (the `root = memberDir` reassignment below has not - // happened yet), which is what a relative `[indices].path` or - // `[workspace.dependencies] path` was written against (#224). - mcpp::project::inherit_workspace_config(*m, *wsManifest, *root); - if (auto bad = mcpp::project::workspace_inheritance_error(*m, memberDir)) - return std::unexpected(*bad); - - mcpp::ui::status("Workspace", std::format("building member '{}'", targetMember)); - root = memberDir; - } - } else { - // Not at workspace root: inside a member, the loader above has - // already inherited (#224 anchoring included). Only the workspace is - // recorded here, for the membership test of this member's own `path` - // dependencies. - if (effective && effective->member) { - runtimeWorkspaceRoot = effective->workspaceRoot; - wsManifest = std::move(*effective->workspace); - } else if (overrides.preloaded_manifest) { - auto wsRoot = mcpp::project::find_workspace_root(*root); - if (!wsRoot.empty()) { - if (auto wsm = mcpp::manifest::load(wsRoot / "mcpp.toml"); - wsm && wsm->workspace.present) { - runtimeWorkspaceRoot = wsRoot; - wsManifest = std::move(*wsm); - } - } - } - } - - mcpp::xlings::runtime::RuntimeSelection runtimeSelection; - if (overrides.inherited_runtime_selection) { - runtimeSelection = *overrides.inherited_runtime_selection; - } else { - std::optional> wsRef; - if (wsManifest) wsRef = std::cref(*wsManifest); - auto selected = mcpp::xlings::runtime::select_runtime( - *m, wsRef, *root, runtimeWorkspaceRoot); - if (!selected) return std::unexpected(selected.error()); - runtimeSelection = std::move(*selected); - } - - // Where mcpp WRITES — derived here because `root` is only final now: the - // workspace block above may have moved it to the selected member. Defaults - // to the project root, so every existing invocation is byte-for-byte - // unchanged; the tool-provisioning pass points it at the tool store - // instead (BuildOverrides::work_dir). - const std::filesystem::path workRoot = - overrides.work_dir.empty() ? *root : overrides.work_dir; - { - std::error_code wdEc; - std::filesystem::create_directories(workRoot, wdEc); - } - std::vector planNotes; - - if (m->package.sourceProvenance.empty()) { - m->package.sourceProvenance = - "path+" + root->lexically_normal().generic_string(); - } - - // A `compat`-form (Form B) package's sources live under a wrap directory - // inside the version dir, which is why its descriptor writes globs like - // `*/src/foo.cc` — the `*` stands for the tarball's top-level folder, - // whose name the descriptor cannot know. `[build] sources` has always - // expanded those; `targets..main` did NOT, so a bin target in such a - // package handed ninja a literal `*` and died with - // `missing and no known rule to make it`. - // - // Nothing could reach that path before #355 (a dependency's bin targets - // were never built), which is why it went unnoticed. Resolve it here, once - // the manifest is final and before anything reads `t.main`. - for (auto& t : m->targets) { - if (t.main.empty() || t.main.find('*') == std::string::npos) continue; - auto hits = mcpp::modgraph::expand_glob(*root, t.main); - if (hits.size() == 1) { - t.main = std::filesystem::relative(hits.front(), *root).generic_string(); - } else { - return std::unexpected(std::format( - "target '{}': `main = \"{}\"` matched {} files; it must name " - "exactly one entry source", - t.name, t.main, hits.size())); - } - } - - // Inject synthetic targets (e.g. test binaries from `mcpp test`). - for (auto& t : extraTargets) m->targets.push_back(t); - - // #540: a cfg() predicate mcpp cannot evaluate must say so. - // - // A PREDICATE THAT ANSWERS FALSE AND A PREDICATE THAT WAS NEVER - // UNDERSTOOD USED TO READ THE SAME. `cfgpred` returns false for an unknown - // key and for an unknown bareword, and a `[target..build]` section - // whose predicate is false is dropped without a word — so a typo, and every - // `cfg(c-abi = …)` section docs/14 documented before this release, produced - // a successful build configured as if the section had not been written. - // - // Reported here rather than in the manifest parser because the vocabulary - // lives with the evaluator, and a second copy of it in `toml.cppm` is the - // exact defect this release is fixing four other instances of. - // - // Scoped to the root manifest by where it sits, which matches the existing - // policy for every other schema warning: a dependency may adopt a predicate - // a consumer's older mcpp does not know, and its build stays quiet. - for (auto const& cc : m->conditionalConfigs) { - auto unknown = cfgpred::unknown_tokens(cc.predicate); - if (!unknown.empty()) { - std::string names; - for (auto const& u : unknown) { - if (!names.empty()) names += ", "; - names += '\'' + u + '\''; - } - m->schemaWarnings.push_back(std::format( - "[target.'{}'] names {} in its cfg() predicate, which mcpp does " - "not know, so the section never applies (ignored). {}", - cc.predicate, names, cfgpred::vocabulary_sentence())); - } - // A RESOLVED layer is answered AFTER dependency resolution, so a - // dependency selected by one would form a cycle with the resolution - // that produces the answer — docs/14 states this. The section's build - // inputs are honoured by the second pass; its dependencies cannot be, - // and saying so is the difference between a documented limit and a - // silent drop. - // - // `accelerator` is not one of these (see kCfgEarlyLayerKeys), so - // `[target.'cfg(accelerator = "cuda")'.dependencies]` is honoured and - // never reaches this warning: nothing about it is circular, because the - // accel is an input to the build rather than an answer from the graph. - if (cfgpred::uses_layer(cc.predicate) - && !(cc.dependencies.empty() && cc.devDependencies.empty() - && cc.buildDependencies.empty() && cc.featureDeps.empty())) { - m->schemaWarnings.push_back(std::format( - "[target.'{}'] conditions dependencies on a target-side layer " - "(ignored). A layer is resolved from the dependency graph, so a " - "dependency chosen by one would decide the answer it is asking " - "for. Build inputs under this predicate DO apply; move the " - "dependency to an unconditional [dependencies] entry, or " - "condition it on the triple instead.", - cc.predicate)); - } - // The same reason holds for a row's library form: whether a package - // is linked shared is decided while the graph is resolved, before a - // layer has an answer. - if (cfgpred::uses_layer(cc.predicate) && !cc.targetKinds.empty()) { - m->schemaWarnings.push_back(std::format( - "[target.'{}'] conditions a target's kind or linkage on a " - "target-side layer (ignored). A layer is resolved from the " - "dependency graph, and a library's form is decided while that " - "graph is resolved; condition the statement on the triple " - "instead.", - cc.predicate)); - } - } - - // Surface non-fatal manifest schema warnings (e.g. unsupported [targets.*] - // keys). Under --strict they become errors — same policy as the - // feature/platform schema checks below. - for (auto const& w : m->schemaWarnings) { - if (overrides.strict) return std::unexpected(w); - mcpp::diag::warning("manifest/schema", w); - } - - // Load mcpp.lock once, up front: it is a resolution input for git deps - // (#329), which decide the commit to build long before anything is - // fetched. Keyed by package name — the same key the writer at the end of - // this function emits, both taken from the root manifest's [dependencies]. - std::map gitLockAnchors; - std::map packageIdentityLockAnchors; - { - // Read where the project keeps it. A planning pass that writes - // elsewhere (plan_only) still resolves against the project's lock. - auto lockPath = (overrides.plan_only ? *root : workRoot) / "mcpp.lock"; - if (std::filesystem::exists(lockPath)) { - if (auto lock = mcpp::pm::load(lockPath); lock) { - for (auto const& p : lock->packages) { - if (!p.namespace_.empty()) - packageIdentityLockAnchors.emplace( - p.name, p.namespace_); - if (auto parsed = mcpp::pm::parse_git_source(p.source); parsed) - gitLockAnchors.emplace(p.name, std::move(*parsed)); - } - } else { - // Degraded, not a plain warning: the engine silently does less - // than asked — every git branch dep falls back to `ls-remote` - // and may advance past the commit the lock recorded. - mcpp::diag::degraded("lockfile", - std::format("mcpp.lock could not be read: {}", - lock.error().message), - "git branch dependencies are re-resolved over the network " - "and may move onto a newer commit than the one recorded", - "delete mcpp.lock and rebuild to regenerate it"); - } - } - } - - // Global-cache mode: --cache > MCPP_BUILD_CACHE > [build] cache > global. - // An unparseable value is a warning (error under --strict) and falls - // through to the next source rather than silently meaning "global" — a typo - // that quietly re-enabled the cache would be the hardest kind of surprise - // to attribute. - // Selection lives in resolve_cache_mode (above) so the fast paths settle it - // identically. This block only adds the diagnostics, which the fast paths - // have no business emitting: an unparseable value must be reported once, by - // the invocation that actually resolves the build. - const CacheMode cacheMode = resolve_cache_mode(*m, overrides.cache_mode); - { - const char* envMode = std::getenv("MCPP_BUILD_CACHE"); - for (auto [value, origin] : std::initializer_list< - std::pair>{ - {overrides.cache_mode, "--cache"}, - {envMode ? envMode : "", "MCPP_BUILD_CACHE"}, - {m->buildConfig.cacheMode, "[build] cache"}}) { - if (value.empty() || parse_cache_mode(value)) continue; - auto msg = std::format( - "{} has unknown cache mode '{}' (expected: global | local | off)", - origin, value); - if (overrides.strict) return std::unexpected(msg); - mcpp::diag::warning("build/cache-mode", msg); - } - } - - // ─── Toolchain resolution (docs/21) ──────────────────────────────── - // - // THE WHOLE CHAIN, in the order it is applied. It was documented twice, as - // "3 steps" here and "4 steps" further down, and neither list had been - // true for a long time — between them they named five of the nine inputs - // below and disagreed about two. A comment that undercounts the inputs to - // a decision is worse than none: it tells the next reader they have seen - // the whole thing. - // - // The WHAT (which spec) is settled first, then the HOW (which binary). - // Anything that WRITES `tcSpec` also writes `tcOrigin`, and that is the - // invariant this table rests on — the enumerator names below are real, so - // this comment cannot quietly stop matching the code. - // - // WHICH SPEC tcOrigin - // 1. mcpp.toml [toolchain]. / .default ManifestToolchain - // 2. global config.toml [toolchain] default GlobalDefault - // 3. mcpp.toml [target.].toolchain TargetSection - // (--target / [build] target / config default - // select the section; 3 outranks 1 and 2) - // 4. the target vocabulary's pin (triple.cppm) TargetPin - // — a convention, and it stands down when a - // REMEMBERED target would overrule a spec the - // user wrote down - // 5. the platform's first-run default, installed FirstRun - // and persisted by this very invocation - // - // WHICH BINARY, from the spec settled above - // 6. `msvc@system` → probe the machine (no xim package exists) - // 7. `@` → xim payload; msvc resolves through - // resolve_managed_msvc, everything else through - // the bin/-shaped frontend lookup - // 8. bare `system` → the PATH compiler. A deliberate escape hatch, and - // the ONLY host-compiler route: there is no - // `gcc@system` (see parse_toolchain_spec) - // 9. offline / MCPP_NO_AUTO_INSTALL → hard error rather than a silent - // ~800 MB download - // - // AND ONE REVISION, after 1-9 have produced a toolchain: a spec targeting - // the MSVC ABI on a machine with no usable MSVC is switched to MinGW-w64 - // — but only when `tc_origin_is_user_explicit` says mcpp chose it itself. - std::filesystem::path explicit_compiler; - std::optional cfg_opt; - bool bootstrap_checked = false; - auto get_cfg = [&](bool requireBootstrap = true) -> std::expected { - if (!cfg_opt) { - auto c = mcpp::config::load_or_init(/*quiet=*/false, - mcpp::fetcher::make_bootstrap_progress_callback()); - if (!c) return std::unexpected(c.error().message); - cfg_opt = std::move(*c); - } - // Commands that need bootstrap tools (build, run, toolchain install) - // pass requireBootstrap=true to get an early, clear error. - if (requireBootstrap && !bootstrap_checked) { - bootstrap_checked = true; - auto problem = mcpp::config::check_base_init(*cfg_opt); - if (!problem.empty()) { - return std::unexpected(std::format( - "{}\n hint: run `mcpp self init --force` to reset and re-initialize", - problem)); - } - } - return &*cfg_opt; - }; - - // Resolve one exact runtime contract before resolving/fixing a toolchain. - // The fixup is itself a consumer of RuntimeBinding: doing it first would - // recreate #392 by letting directory order choose a libc and only later - // discovering what the project selected. - mcpp::platform::runtime::RuntimeBinding runtimeBindingSnapshot; - if (overrides.inherited_runtime_binding) { - runtimeBindingSnapshot = *overrides.inherited_runtime_binding; - } else { - auto cfgRuntime = get_cfg(); - if (!cfgRuntime) return std::unexpected(cfgRuntime.error()); - auto resolved = mcpp::platform::runtime::resolve_runtime_binding( - runtimeSelection, {}, (**cfgRuntime).xlingsHome()); - if (!resolved) return std::unexpected(resolved.error()); - runtimeBindingSnapshot = std::move(*resolved); - // A degradation that nobody prints is indistinguishable from no - // degradation, which is the failure this whole area keeps paying for. - // A note is not a warning: nothing is wrong with the build, some facts - // are simply unavailable — so it is reported once, at info level. - if (!runtimeBindingSnapshot.note.empty()) - mcpp::ui::info("Runtime", runtimeBindingSnapshot.note); - } - // THE `bin` THIS PROJECT'S BUILD PROGRAMS SEE FIRST — derived ONCE, - // here, from the selection that has just been resolved. - // - // Empty unless the manifest declared `[xlings].subos`. That is deliberate: - // prepending the SHARED `subos/default/bin` would make what a build sees - // depend on what else has been installed on this machine, so a project - // that has not asked for an environment of its own gets the `PATH` mcpp - // was started with, byte for byte. - // - // NOT RE-DERIVED AT THE TWO DELIVERY SITES BELOW, AND NOT FROM - // `[xlings] deps`. `mcpp::xlings::runtime` is the sole runtime-selection - // policy and `RuntimeBinding::subosDir` is its resolved answer; a second - // derivation is how a build ends up with two subos and no way to say which - // one it used. The per-package payload paths a program may also need are - // already answered, separately, by `MCPP_XPKG_*_DIR`. - const std::string projectSubosBin = [&]() -> std::string { - using Mode = mcpp::xlings::runtime::RuntimeSelection::Mode; - if (runtimeBindingSnapshot.selection.mode != Mode::NamedSubos) - return {}; - auto bin = runtimeBindingSnapshot.subosDir / "bin"; - std::error_code ec; - if (!std::filesystem::is_directory(bin, ec)) return {}; - return bin.string(); - }(); - - auto runtimePayload = runtimeBindingSnapshot.libc; - auto runtimeLibDir = runtimeBindingSnapshot.libraryDirs.empty() - ? std::filesystem::path{} : runtimeBindingSnapshot.libraryDirs.front(); - - // THE DECLARED RUNTIME PAYLOAD IS PROVIDED BEFORE THE FIRST FIXUP THAT - // CONSUMES IT (mcpp#660), and not earlier: a build whose toolchain needs - // no C runtime payload must not download one. At most once per build. An - // inherited binding is not exempt, because the parent build may have used - // a toolchain that needed no payload. The two values derived above are - // refreshed with the binding, because detection and the fingerprint read - // them after the fixups. - bool runtimePayloadProvided = false; - auto provide_runtime_payload = [&](const mcpp::toolchain::XimToolchainPackage& pkg) { - if (runtimePayloadProvided) return; - if (mcpp::toolchain::post_install_fixup_kind(pkg).empty()) return; - runtimePayloadProvided = true; - auto cfgP = get_cfg(); - if (!cfgP) return; - if (!mcpp::toolchain::ensure_declared_runtime(**cfgP, runtimeBindingSnapshot)) - return; - runtimePayload = runtimeBindingSnapshot.libc; - runtimeLibDir = runtimeBindingSnapshot.libraryDirs.empty() - ? std::filesystem::path{} : runtimeBindingSnapshot.libraryDirs.front(); - }; - - // mcpp#427: a toolchain fixup that could not run is a DEGRADATION, not a - // failure — the build continues without it. But it has to be said, or the - // eventual `stdlib.h: No such file or directory` arrives with no way to - // connect it to its cause. - // - // Deduplicated by payload: `ensure_post_install_fixup` is called from up - // to four seams in one build (manifest toolchain, default toolchain, - // MinGW first-run, build.mcpp host toolchain) and they routinely resolve - // the SAME payload. Saying it once is the rule mcpp#417 already paid for. - auto fixupNoticed = std::make_shared>(); - auto report_fixup = [fixupNoticed]( - const mcpp::toolchain::FixupOutcome& outcome, - const std::filesystem::path& payloadRoot) { - if (outcome.skippedReason.empty()) return; - if (!fixupNoticed->insert(payloadRoot.generic_string()).second) return; - // Only the fact this line ADDS. The `Runtime` note above already gave - // the cause and the remedy for the same absence; repeating them here - // would be the second copy of one message, which is the habit mcpp#417 - // exists to break. - mcpp::ui::info("Toolchain", std::format( - "used as installed — not patched against a C runtime ({})", - outcome.skippedReason)); - }; - - constexpr std::string_view kCurrentPlatform = mcpp::platform::name; - - // Toolchain resolution priority: see the table at the top of this - // function. Stated once, where `tcOrigin` is introduced — this used to be - // a second, shorter and differently-wrong list of the same thing. - // - // Resolve the build profile, overlaid by any [profile.] from the - // manifest → buildConfig. `effectiveProfile` outlives the block: the - // build.mcpp env contract exposes it as MCPP_PROFILE. - std::string effectiveProfile; - { - auto& pname = effectiveProfile; - // Precedence lives in resolve_profile_name (above) so execute.cppm's - // fast paths settle it identically without running prepare_build. - // Release is opt-in via --release / --profile release; a project that - // wants its plain `mcpp build` optimized sets - // [build].default-profile = "release" (mcpp's own mcpp.toml does this, - // so the released binary stays -O2). - pname = resolve_profile_name(*m, overrides.profile, overrides.profile_fallback); - mcpp::manifest::Profile pr; - if (pname == "dev" || pname == "debug") { pr.optLevel = "0"; pr.debug = true; } - else if (pname == "dist") { pr.optLevel = "3"; pr.strip = true; } - // (built-in dist intentionally leaves lto off: several packaged gcc - // payloads ship without the LTO plugin; enable via [profile.dist].) - else { pr.optLevel = "2"; } // release - if (auto it = m->profiles.find(pname); it != m->profiles.end()) pr = it->second; - // #519 — a profile may override the whole-graph form. OPTIONAL, so a - // profile that does not mention it leaves `[build]` standing; a plain - // value would reset it, because the block above REPLACES `pr` wholesale - // with the declared profile. - if (pr.dependencyLinkageDeclared) - m->buildConfig.dependencyLinkage = pr.dependencyLinkage; - m->buildConfig.optLevel = pr.optLevel; - m->buildConfig.debug = pr.debug; - m->buildConfig.lto = pr.lto; - m->buildConfig.strip = pr.strip; - m->buildConfig.cflags.insert(m->buildConfig.cflags.end(), - pr.cflags.begin(), pr.cflags.end()); - m->buildConfig.cxxflags.insert(m->buildConfig.cxxflags.end(), - pr.cxxflags.begin(), pr.cxxflags.end()); - m->buildConfig.ldflags.insert(m->buildConfig.ldflags.end(), - pr.ldflags.begin(), pr.ldflags.end()); - } - - // Every directory a package payload may legitimately have been INSTALLED - // into: the global registry, plus the two project-local data roots a custom - // git index installs into. Defined HERE, above its first use, because three - // separate questions now depend on the same answer — where a dependency's - // cache address is anchored, whether its sources came from a store at all, - // and whether a `standard` declaration in its manifest was written by an - // author or by a descriptor generator. One definition, three uses; deriving - // the same fact twice is how two of them start disagreeing. - const auto storeRoots = [&]() -> std::vector { - std::vector roots; - if (auto c = get_cfg()) roots.push_back((*c)->xlingsHome() / "data" / "xpkgs"); - for (auto& d : mcpp::config::project_xlings_data_roots(workRoot)) - roots.push_back(d / "xpkgs"); - return roots; - }(); - - // [package] platforms — fixed vocabulary owned by mcpp (it owns the - // target/triple system): the platform name of every row it has - // (`platform_name`, beside `artifact_naming`). Unknown values: warning, or - // error under --strict. - for (auto& pf : m->package.platforms) { - if (!mcpp::toolchain::triple::is_platform_name(pf)) { - auto msg = std::format( - "[package] platforms contains unknown platform '{}' " - "(expected: {})", pf, - mcpp::toolchain::triple::platform_names_joined()); - if (overrides.strict) return std::unexpected(msg); - mcpp::diag::warning("manifest/platforms", msg); - } - } - - auto tcSpec = m->toolchain.for_platform(kCurrentPlatform); - // Where the spec came from decides whether mcpp may later revise it. - // See TcOrigin: mcpp can rewrite a default it chose itself, but must not - // silently overrule one the user wrote down. - auto tcOrigin = tcSpec.has_value() ? TcOrigin::ManifestToolchain - : TcOrigin::None; - // `--toolchain` shares `ManifestToolchain`'s precedence and not its - // spelling: the messages that refuse a spec name where it was written, and - // 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; - auto tcSpecSource = [&]() -> std::string { - if (tcOrigin == TcOrigin::ManifestToolchain && tcFromCommandLine) - return "--toolchain"; - switch (tcOrigin) { - case TcOrigin::ManifestToolchain: - return std::format("[toolchain].{}", kCurrentPlatform); - case TcOrigin::TargetSection: - return std::format("[target.{}].toolchain", overrides.target_triple); - case TcOrigin::GlobalDefault: - return "the default toolchain (`mcpp toolchain default`)"; - default: - return std::format("the toolchain mcpp chose ({})", - tc_origin_name(tcOrigin)); - } - }; - // `--toolchain` (arriving as MCPP_TOOLCHAIN, the same side channel - // `--offline` and `--jobs` use) beats everything, including the manifest. - // - // This is the usable form of "which compiler". On this repository the - // choice is worth 2.48x — gcc@16.1.0 builds mcpp in 79.9s, llvm@22.1.8 in - // 32.2s — but CHANGING THE DEFAULT is an ecosystem decision, not a - // performance one: it invalidates every published package's fingerprint and - // the three platforms do not yet ship the same llvm. Selecting per build - // costs nobody anything and needs no coordination. - // - // It counts as user-explicit, so mcpp will not quietly revise it. - if (const char* tcEnv = std::getenv("MCPP_TOOLCHAIN"); tcEnv && *tcEnv) { - tcSpec = std::string(tcEnv); - tcOrigin = TcOrigin::ManifestToolchain; - tcFromCommandLine = true; - } - if (!tcSpec.has_value()) { - auto cfg = get_cfg(); - if (cfg && !(*cfg)->defaultToolchain.empty()) { - tcSpec = (*cfg)->defaultToolchain; - tcOrigin = TcOrigin::GlobalDefault; - } - } - - // ─── Windows first run without Visual Studio ──────────────────────── - // The host triple on Windows is MSVC-ABI, so the historical default - // (llvm) resolves to clang targeting MSVC — which uses the MSVC STL and - // the Windows SDK. Neither ships with Windows; both arrive only with - // Visual Studio's "Desktop development with C++" workload. On a bare box - // that default installs fine and then fails at compile time with no - // actionable message. - // - // Seed only the TARGET axis and let the block right below derive the - // rest: the vocabulary table already maps x86_64-windows-gnu to its pin - // (winlibs GCC) and to static linkage, so the toolchain answer stays a - // single derivation instead of being spelled out a second time here. - // "Is MSVC usable here" — either origin. Asking `has_usable_msvc()` (which - // probes the machine) would answer "no" on a box that has a pinned - // msvc@ payload and no Visual Studio, and every decision below - // would then divert a perfectly good toolchain to mingw. - auto msvc_usable_either_origin = [&]() -> bool { - auto c = get_cfg(); - if (!c) return mcpp::toolchain::msvc::has_usable_msvc(); - return mcpp::toolchain::msvc::msvc_available_here( - (*c)->xlingsHome() / "data" / "xpkgs"); - }; - - // THE PLATFORM'S CANONICAL NATIVE DEFAULT — a spec string only; no - // install, no persistence. Two places need "what would a native - // `mcpp build` pick here, with no --target": the first-run installer - // further below (which goes on to install and persist it), and - // `host_tc_for_build_program`'s cross branch (which needs a genuine HOST - // compiler when nothing was ever recorded as one — see its own comment - // for why #622 happened). One derivation, called from both, so they - // cannot drift the way a hand-copied second copy would. - auto native_first_run_spec = [&]() -> std::string { - namespace pins = mcpp::toolchain::triple::pins; - if constexpr (mcpp::platform::is_macos) { - return std::string(pins::kFirstRunMac); - } else if constexpr (mcpp::platform::is_windows) { - // A machine with no usable MSVC gets the GNU pin, not an - // MSVC-ABI clang it cannot use — mirrors the windows-gnu seed - // below, which this function's other caller runs after. - return std::string(msvc_usable_either_origin() - ? pins::kFirstRunWinMsvc : pins::kFirstRunWinGnu); - } else if (mcpp::platform::host_arch == std::string_view("x86_64")) { - return std::string(pins::kFirstRunLinuxX86_64); - } else { - return std::string(pins::kFirstRunLinuxOther); - } - }; - - bool windowsGnuFirstRun = false; - if constexpr (mcpp::platform::is_windows) { - if (!tcSpec.has_value() && overrides.target_triple.empty() - && m->buildConfig.target.empty() - && !msvc_usable_either_origin()) { - auto cfgW = get_cfg(); - if (!cfgW || (*cfgW)->defaultTarget.empty()) { - overrides.target_triple = - std::string(mcpp::toolchain::triple::pins::kFirstRunWinGnuTarget); - windowsGnuFirstRun = true; - } - } - } - - // ─── --target / --static overrides ────────────────────────────────── - // Target-axis default resolution when no --target flag was passed: - // [build] target (project default, ≙ cargo build.target) > - // [toolchain] default_target (global config) > host. - if (overrides.target_triple.empty() && !m->buildConfig.target.empty()) - overrides.target_triple = m->buildConfig.target; - // Remembered, not requested: this one came out of the global config, so - // it must not outrank anything the user wrote down (see the pin below). - bool targetFromGlobalDefault = false; - if (overrides.target_triple.empty()) { - if (auto cfg = get_cfg(); cfg && !(*cfg)->defaultTarget.empty()) { - overrides.target_triple = (*cfg)->defaultTarget; - targetFromGlobalDefault = true; - } - } - // Normalize the triple (alias spellings → canonical), validate against - // the known-target vocabulary, then apply the manifest [target.] - // override and the vocabulary-table convention (pin + default linkage). - if (!overrides.target_triple.empty()) { - namespace triple = mcpp::toolchain::triple; - // THE SPELLING THE PROJECT WROTE, KEPT FOR EVERY DIAGNOSTIC BELOW. - // `overrides.target_triple` is canonicalised further down, and until - // this variable existed the refusals quoted the canonical form: - // `--target aarch64-linux` produced "target 'aarch64-linux-gnu' is - // registered but not yet supported", a string the reader never typed - // and cannot find in their own command. - const std::string requestedSpelling = overrides.target_triple; - auto parsed = triple::parse(overrides.target_triple); - - // THE REQUEST IS COMPLETED FROM THE VOCABULARY BEFORE ANYTHING - // READS IT, AND THE ORDER RELATIVE TO THE `[target.X]` LOOKUP IS PART - // OF THE CONTRACT. - // - // `parse` fills a missing env segment lexically so the identity stays - // total — `x86_64-linux` IS `x86_64-linux-gnu`, and a unit test says so. - // Every gate below then asked about the filled value instead of about - // the request. See `triple::resolve_request` for the two measurements. - // - // The lookup that follows keys on `parsed->str()`, so completing after - // it would match sections against a triple this build is not going to - // use. A project wanting the `planned` row keeps its escape hatch by - // WRITING the segment: `--target aarch64-linux-gnu` skips completion - // entirely, because a written segment is a request rather than a gap. - triple::RequestResolution req; - if (parsed) { - req = triple::resolve_request(*parsed); - parsed = req.triple; - } - - // [target.X] lookup is spelling-independent: a section keyed - // `x86_64-w64-mingw32` matches `--target x86_64-windows-gnu` and - // vice versa. Unparseable keys/inputs compare exactly (escape hatch). - auto it = m->targetOverrides.find(overrides.target_triple); - if (it == m->targetOverrides.end() && parsed) { - for (auto o = m->targetOverrides.begin(); - o != m->targetOverrides.end(); ++o) { - if (auto k = triple::parse(o->first); - k && k->str() == parsed->str()) { it = o; break; } - } - } - bool hasExplicitSection = it != m->targetOverrides.end(); - bool hasToolchainOverride = hasExplicitSection - && !it->second.toolchain.empty(); - - const triple::TargetInfo* known = - parsed ? triple::find_known_target(*parsed) : nullptr; - - // Validation: a typo must never silently fall through to the host - // toolchain (the worst failure mode — you think you cross-compiled). - // An explicit [target.X] section is the escape hatch for custom - // triples outside the vocabulary. - // Several rows serve this (arch, os) and the lexical default names none - // of them, so there is nothing to complete the request WITH. Refusing - // and listing them is the only honest answer; picking one would be an - // invented convention. No group has this shape today — the rule is here - // so the first one that does gets a diagnosis rather than a guess. - if (parsed && req.ambiguous && !hasExplicitSection) { - std::string opts; - for (auto s : req.supported) { - if (!opts.empty()) opts += ", "; - opts += std::string(s); - } - refusal::record(refusal::Code::AmbiguousRequest); - return std::unexpected(std::format( - "target '{}' does not say which C library, and several are " - "supported here.\n" - " candidates: {}\n" - " Name one of them.", - requestedSpelling, opts)); - } - if (!known && !hasExplicitSection) { - // "UNKNOWN" IS A CLAIM ABOUT THE VOCABULARY, AND IT WAS FALSE FOR - // A WHOLE arch+os FAMILY. - // - // Measured on 2026.8.26.1: `--target riscv64-linux` reported - // `unknown target 'riscv64-linux'` while `riscv64-linux-musl` was - // sitting in `kKnownTargets` as `planned`. The lexical fill had - // produced `riscv64-linux-gnu` — a row that genuinely does not - // exist — and the gate reported on the fill. - // - // A non-empty sibling group means the family IS registered, so this - // is the planned refusal wearing the wrong word. It names the row - // that exists, which is also the one the reader would have to write - // to opt in. - if (!req.siblings.empty()) { - std::string rows; - for (auto s : req.siblings) { - if (!rows.empty()) rows += ", "; - rows += std::string(s); - } - refusal::record(refusal::Code::TierPlanned); - return std::unexpected(std::format( - "target '{}' is registered but not yet supported (planned) — " - "no toolchain is published for it yet.\n" - " registered rows for this system: {}\n" - " An explicit [target.] toolchain override can " - "opt in early.", - requestedSpelling, rows)); - } - auto sug = triple::did_you_mean(requestedSpelling); - refusal::record(refusal::Code::UnknownTarget); - return std::unexpected(std::format( - "unknown target '{}'{}\n" - " known targets: `mcpp toolchain list`; a custom triple needs an\n" - " explicit [target.{}] section in mcpp.toml", - requestedSpelling, - sug ? std::format(" — did you mean '{}'?", *sug) : "", - requestedSpelling)); - } - if (known && known->tier == "planned" && !hasToolchainOverride) { - refusal::record(refusal::Code::TierPlanned); - // The subject is what the user wrote. When completion filled a - // segment, both are shown — otherwise the sentence is about a - // string that appears nowhere in their command. - const std::string subject = - requestedSpelling == parsed->str() - ? std::format("'{}'", requestedSpelling) - : std::format("'{}' (which resolves to '{}')", - requestedSpelling, parsed->str()); - return std::unexpected(std::format( - "target {} is registered but not yet supported (planned) — " - "no toolchain is published for it yet.\n" - " An explicit [target.{}] toolchain override can opt in early.", - subject, parsed->str())); - } - // AN APPLE SDK IS LOCATED, SO ITS ABSENCE IS KNOWN NOW. - // - // REFUSED HERE AND NOT WITH THE TOOLCHAIN, which is a decision about - // WHEN rather than about the message. The iOS rows need the machine's - // iPhoneOS or iPhoneSimulator SDK, and that is knowable before any - // payload is resolved -- so a machine without Xcode used to download - // a 700 MB compiler and then be told the thing it was missing was not - // the compiler. - // - // AND UNLIKE `host_can_serve` BELOW, THIS IS NOT DEFERRED. That - // refusal waits for the dependency graph because a package can supply - // a target's C library and platform interface. An Apple SDK is not - // redistributable, so no package supplies it: there is nothing a later - // line could learn that would change this answer. - // - // The escape hatch that opens the tier gate does NOT open this one. - // Declaring a toolchain says which compiler; it says nothing about - // where the headers and stub libraries are, and every compiler needs - // them. - if (parsed && parsed->is_ios()) { - const auto which = parsed->is_ios_simulator() - ? mcpp::platform::macos::sdk_iphonesim - : mcpp::platform::macos::sdk_iphoneos; - appleSdkLocated = mcpp::platform::macos::sdk_path(which); - // AN UNSET FLOOR IS THE LOCATED SDK'S VERSION, READ RATHER THAN - // LEFT TO THE DRIVER. `docs/20` promised that an unversioned - // triple meant the SDK's own default; measured on macos-15 with - // Xcode 16.4, clang given `arm64-apple-ios` with no version - // refused thread-local storage for the target, which libc++abi - // uses, so the default it chose was older than any SDK on the - // machine. The version `xcrun` reports for the located SDK is the - // one the SDK was made for, and it enters the manifest here so - // that the fingerprint slot, the effective triple and every - // report read one value. - if (appleSdkLocated && m->buildConfig.iosDeploymentTarget.empty()) { - if (auto v = mcpp::platform::macos::sdk_version(which)) { - m->buildConfig.iosDeploymentTarget = *v; - iosFloorFromSdk = true; - } - } - if (!appleSdkLocated) { - // A CODE, BECAUSE THE MATRIX COMPARES REASONS AND NOT ONLY - // OUTCOMES. A refusal with no code is recorded as `other`, - // which `check_matrix_reasons.sh` refuses on the ground that - // it freezes an unnamed branch into the expected table. - refusal::record(refusal::Code::AppleSdkAbsent); - return std::unexpected(std::format( - "target {} needs the {} SDK, which this machine does not " - "provide.\n" - " It is not redistributable, so mcpp LOCATES it " - "rather than installing it: `xcrun --sdk {} " - "--show-sdk-path` must answer, which needs Xcode on macOS " - "(not the Command Line Tools alone -- those ship the " - "macOS SDK only).\n" - " Check `xcode-select -p`, and note that the " - "compiler is not what is missing: these rows pin " - "`xim:llvm`, which every other Apple row also uses.", - parsed->str(), which, which)); - } - } - // A `shared` TARGET NAMES A LINK CONTRACT THIS ENGINE DOES NOT RENDER. - // - // `-sSIDE_MODULE` is a different Emscripten link mode from the - // ordinary one (one static image, `artifact_naming`'s `.js`+`.wasm` - // pair) and mcpp emits no flag for it. Falling through to the - // ordinary link would still WRITE a `.so`-shaped file — the fallback - // naming's `sharedLibExt` is empty, so the linker would be asked for - // an empty-named output — so this is caught here, by NAME, rather - // than reached as an obscure link failure. - // - // REFUSED HERE AND NOT AT PLAN TIME, same reasoning as the Apple SDK - // check above: `parsed` and the manifest's own target list are both - // already known, resolving neither an emsdk payload nor any other - // toolchain, so an offline build (no emsdk installed) gets this - // sentence instead of downloading the SDK first. - if (parsed && parsed->object_format() - == triple::ObjectFormat::Wasm) { - for (auto const& t : m->targets) { - if (t.kind != mcpp::manifest::Target::SharedLibrary) continue; - return std::unexpected(std::format( - "[targets.{}] kind = \"shared\" is not supported on " - "wasm32-emscripten: a side module needs -sSIDE_MODULE, " - "which mcpp does not render", - t.name)); - } - } - // Known, supported — and IMPOSSIBLE ON THIS HOST. - // - // Without this the target falls through to the host toolchain and the - // build SUCCEEDS, which is the failure the check above calls the worst - // one, arriving through a different door. Measured on Linux: - // - // $ mcpp build --target x86_64-windows-msvc - // Resolved gcc@16.1.0 → x86_64-windows-msvc → …/xim-x-gcc/bin/g++ - // Finished dev [unoptimized + debuginfo] in 0.07s - // $ ls target/ - // x86_64-linux-gnu/ ← an ELF, reported as a Windows build - // - // The vocabulary tier says "mcpp supports this target"; it never said - // "this machine can produce it". `host_can_serve` is the answer to the - // second question and lives beside the payload resolution it has to - // agree with. - // - // The escape hatch stays open on purpose: an explicit `[target.X]` - // toolchain override means the author is supplying the cross toolchain - // themselves, and mcpp's payload matrix has no standing to refuse it. - // DIAGNOSED HERE, REPORTED LATER, AND THE DIFFERENCE IS THE POINT. - // - // Whether a payload on this machine produces this target is knowable - // now. Whether anything ELSE produces it is not: a dependency can - // supply the target's platform interface and C library, and the - // dependency graph does not exist yet at this line. Refusing here - // therefore answered a narrower question than the one it claimed — - // measured, a project that only had to add a dependency was told its - // machine could not build the target at all. - // - // The refusal is kept in full, because it is right whenever nothing - // supplies the target side, which remains the ordinary case. It is - // carried to where the graph is known and released there. Nothing - // between here and there consumes the answer: what follows is toolchain - // and dependency resolution, and a target no payload serves resolves to - // a driver that simply will not be asked to emit anything. - // - // The escape hatch stays open on purpose: an explicit `[target.X]` - // toolchain override means the author is supplying the cross toolchain - // themselves, and mcpp's payload matrix has no standing to refuse it. - if (known && known->tier != "planned" && !hasToolchainOverride - && parsed - && !mcpp::toolchain::host_can_serve(*parsed)) { - std::string servable; - for (auto const& info : triple::known_targets()) { - auto t = triple::parse(info.canonical); - if (!t || info.tier == "planned") continue; - if (!mcpp::toolchain::host_can_serve(*t)) continue; - if (!servable.empty()) servable += ", "; - servable += t->str(); - } - unservedTargetDiagnosis = std::format( - "target '{}' cannot be built on this host.\n" - " No toolchain payload here produces it, and nothing in " - "the dependency graph\n" - " supplies its system side.\n" - " this host can build with the payload alone: {}\n" - " To build it anyway, depend on a package that implements " - "the target's system\n" - " (its kernel interface and C library), or supply your own " - "cross toolchain with\n" - " an explicit [target.{}] toolchain = \"…\" section.", - parsed->str(), - servable.empty() ? "(nothing — `mcpp toolchain list`)" : servable, - parsed->str()); - } - // CAPTURED BEFORE CANONICALISATION, BECAUSE CANONICALISATION IS - // EXACTLY WHAT DESTROYS IT. - // - // `str()` renders the filled-in identity, so `x86_64-linux` becomes - // `x86_64-linux-gnu` here and every later `parse` of that string reports - // an env segment the project never wrote. The request has to be taken - // from the ONLY triple that still knows the difference: this one. - if (parsed && parsed->envExplicit) requestedCAbi = parsed->env; - // AND THE SPELLING THE PROJECT USED, FOR THE REPORT ONLY. - // - // The canonical form is the identity — the output directory, the cache - // key, the subject of a `cfg()` — and it must stay filled. The REPORT is - // a different thing: it says what was asked for and what resolved, and - // heading it `x86_64-linux-gnu` above a line reading `c-abi musl` states - // a contradiction the build does not actually contain. A project that - // declined to name a C library is shown as having declined. - if (parsed && !parsed->envExplicit && !parsed->env.empty()) { - auto asWritten = *parsed; - asWritten.env.clear(); - targetDisplayName = asWritten.str(); - } - - // Canonical from here on: cfg evaluation, spec attachment and the - // 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; - } - // 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 - // mapping, not here) and its default linkage. GCC 16 pin rationale: - // GCC 15 drops module template instantiations at link (remediation - // doc A2; packages shipped 2026-07-08/09, GitHub+GitCode). - // A convention, not an instruction: on the Windows-GNU first-run path - // this is what turns the seeded target into `gcc@16.1.0`. - // - // It must not fire when it would overrule a toolchain the user wrote - // down. The pin is mcpp's own default for a target row — `gcc@16.1.0` - // for Windows-GNU, because the mingw payload is what supplies that - // target's headers and C library — and an explicit `[toolchain]` line - // is not a default. This is the promise the no-Visual-Studio fallback - // is built on: mcpp revises its own defaults, never yours. - // - // HOW THE TARGET WAS NAMED IS NOT PART OF THE QUESTION, and it used to - // be. The guard read `targetFromGlobalDefault && user_explicit`, so a - // target given on the command line disabled it — and then the row's pin - // replaced a toolchain the project had stated. Measured 2026-08-23: - // `--target x86_64-windows-gnu` with an explicit `llvm@22.1.8` resolved - // `x86_64-w64-mingw32-g++`, and gcc cannot compile libc++'s std module. - // - // A project that means to use a different compiler for a pinned target - // is stating something about its own build, and a project whose target - // side comes from its dependency graph is the ordinary reason to do so: - // the payload the row names supplies headers and a C library that such - // a project does not use. The narrower reading of this guard was - // patched with an openkal-specific exception; stating the rule - // correctly removes the need for one. - // RECORDED, NOT APPLIED. The convention answers "which payload - // supplies this target's C library", and whether it is needed depends on - // whether the dependency graph supplies one instead. That is knowable - // only after resolution, so the decision waits for - // `resolve_target_toolchain` and only the candidate is kept here. - if (known && !known->pin.empty() && parsed - && !parsed->pin_is_capability()) { - targetRowPin = std::string(known->pin); - targetRowName = parsed->str(); - } - if (known && !hasToolchainOverride && !known->pin.empty() - && !tc_origin_is_user_explicit(tcOrigin)) { - targetPinCandidate = std::string(known->pin); - targetPinIsCapability = parsed && parsed->pin_is_capability(); - } - // A USER'S EXPLICIT TOOLCHAIN OVERRIDES A CONVENTION, NOT A - // CAPABILITY — AND UNTIL THIS LINE IT OVERRODE BOTH. - // - // The block above deliberately steps aside for an explicit - // `[toolchain] default`: a hosted row's pin says "this payload supplies - // the target's C library", and an author who names their own compiler - // has said they will supply it instead. A bare-metal row's pin says - // something the author cannot override — the table's own words: "the - // pin is llvm on every host because clang/lld are cross-compilers by - // construction". A host g++ does not emit riscv64 whatever anyone - // declares. - // - // Measured 2026-08-26: - // - // [toolchain] default = "gcc@16.1.0" - // $ mcpp build --target riscv64-none-elf - // g++: error: unrecognized argument in option '-mabi=lp64d' - // g++: note: valid arguments to '-mabi=' are: ms sysv - // - // — a message about an option, for a decision made here. Refusing at - // the decision costs one line; the alternative is a compiler complaining - // about flags the reader never wrote. - if (known && parsed && parsed->pin_is_capability() - && tc_origin_is_user_explicit(tcOrigin) && tcSpec.has_value()) { - auto declared = mcpp::toolchain::parse_toolchain_spec(*tcSpec); - // WHICH DECLARATIONS THE ROW ACCEPTS IS THE ROW'S PIN, NOT A FIXED - // FAMILY. - // - // This asked `family != Llvm`, which was right while every - // capability-pinned row pinned llvm. `wasm32-emscripten` pins - // `emsdk@6.0.9`, and emsdk NORMALISES to the llvm family -- `em++` - // is clang -- so a declared `llvm@22.1.8` passed this gate, was - // never refused, and resolved the generic llvm payload for a target - // it cannot emit. The condition is now the pin's own family, which - // is the question the row was always answering. - const auto pinFamily = [&]() -> std::optional { - if (known->pin.empty()) return mcpp::toolchain::Family::Llvm; - if (auto ps = mcpp::toolchain::parse_toolchain_spec( - std::string(known->pin))) - return ps->family; - return std::nullopt; - }(); - const bool declaredMatchesPin = - declared && pinFamily && declared->family == *pinFamily - // An emsdk row is llvm-family, so the family alone cannot - // separate `emsdk@6.0.9` from `llvm@22.1.8`. The pin's own - // spelling is what does. - && (known->pin.empty() - || tcSpec->find(known->pin.substr(0, known->pin.find('@'))) - != std::string::npos); - if (declared && !declaredMatchesPin) { - // THE REASON TRAVELS WITH THE ROW. The rows refuse for the - // same rule and NOT for the same reason, and one sentence - // covering all of them would be wrong about the others: a - // PE+musl target is not bare metal, a wasm target is neither, - // and a reader told the wrong one stops reading. - // - // Measured before the third arm existed: `--target - // wasm32-emscripten` with a declared gcc was refused correctly - // and explained with "No gcc payload emits a PE with a musl C - // library", which is a true sentence about a different row. - // - // IT HAPPENED AGAIN, AND ADDING AN ARM IS ONLY HALF THE FIX. - // Android became a capability row and this chain still had - // three arms, so a declared `llvm@22.1.8` against - // `aarch64-linux-android` was refused correctly and explained - // with the PE+musl sentence -- the identical wrong answer the - // paragraph above records for wasm, reached the same way: by a - // fourth case falling into a final `else` that was written as - // the third case's answer. - // - // So the last arm now NAMES ITS OWN ROW and the fallthrough is - // generic. A capability added later gets a sentence that is - // merely unspecific instead of one that is false, and the - // refusal still names the pin either way. - std::string_view why = parsed->is_freestanding() - ? "A freestanding target has no per-host cross payload: " - "clang and lld are\n" - " cross-compilers by construction and gcc is not." - : parsed->is_wasm() - ? "Nothing but Emscripten emits WebAssembly: `em++` is a " - "clang whose target,\n" - " sysroot and JavaScript glue all come from its own " - "payload." - : parsed->is_android() - ? "An Android target needs bionic, not just an aarch64 or " - "x86_64 back end:\n" - " its headers, its per-API-level stubs and its " - "loader path are inside the\n" - " NDK, and no package adds them to another compiler." - : (parsed->is_pe() && parsed->is_musl()) - ? "No gcc payload emits a PE with a musl C library — the " - "mingw payload emits\n" - " PE with the MinGW CRT, which is the separate " - "`-gnu` row." - : "This row's toolchain is the only one that can emit the " - "target at all."; - refusal::record(refusal::Code::CapabilityPin); - return std::unexpected(std::format( - "target '{}' cannot be emitted by '{}'.\n" - " {}\n" - " The row names `{}` as a capability rather than as a " - "preference, so\n" - " this one line is not a convention you can override.\n" - " remove the `[toolchain]` line for this target, or set " - "it to `{}`.", - parsed->str(), *tcSpec, why, - known->pin.empty() ? std::string_view("llvm") : known->pin, - known->pin.empty() ? std::string_view("llvm") : known->pin)); - } - } - if (known && known->defaultStatic && m->buildConfig.linkage.empty()) - m->buildConfig.linkage = "static"; - } - if (overrides.force_static) m->buildConfig.linkage = "static"; - - // #254: everything compiled INTO this build is resolved for the TARGET — - // an xpkg descriptor's per-OS sections (sources, flags, deps) and its xpm - // asset/version table all describe code that will run on the target, not - // on the machine building it. Previously a compile-time host constant, - // which is invisible natively (host == target) and picks the wrong leg - // under --target. - // - // Computed HERE, not earlier: `overrides.target_triple` is only complete - // above — it is filled from `[build] target` and the config default, then - // canonicalized. Reading it before that point would silently fall back to - // the host for any project that sets its target in the manifest rather - // than on the command line. - // ── The device axis, resolved ONCE ──────────────────────────────────── - // - // `--accel` / `--no-accel` over `[build] accel`. `--no-accel` arrives as the - // sentinel "(none)", which parse_accel reads as nothing, and printing the - // parsed form back normalises the spelling -- so every reader below sees - // one string, and a build program sees the same one in MCPP_ACCEL. Read - // at call time rather than captured: a `[target.'cfg(...)'.build]` section - // may set `accel`, and the merge that applies it runs a few lines down. - // - // "NO ACCELERATOR" IS THE EMPTY STRING HERE, NOT `accel_str`'s "(none)". - // - // `accel_str` is a DISPLAY function: it prints `(none)` for an empty set so - // an ABI tag reads as a sentence. Handing that spelling on as a value made - // two readers wrong at once. A build program saw `MCPP_ACCEL=(none)` while - // the manual promised an empty string, so a rule package asking "is there - // an accelerator" got a yes and a backend named `(none)`; and the - // fingerprint's own guard, `if (!accel.empty())`, was true for every - // project on earth, appending `#accel=(none)` to builds that had asked for - // nothing. Measured 2026-09-05 with a build program that wrote the value to - // a file, which is the only way to see it -- a program's stdout is shown - // only when it fails. - auto resolvedAccel = [&]() -> std::string { - const auto sets = mcpp::pack::parse_accel( - overrides.accel.empty() ? m->buildConfig.accel : overrides.accel); - return sets.empty() ? std::string{} : mcpp::pack::accel_str(sets); - }; - // The cfg context, with the accelerator layer filled from the resolved - // accel's backend names. `cfg(accelerator = "cuda")` is a membership test - // over these (prepare_inputs::Ctx::layer_matches); before this the field - // was declared, documented, and never written, so the key matched nothing. - auto cfgCtx = [&]() { - auto c = cfgpred::context_for(overrides.target_triple); - for (auto const& set : mcpp::pack::parse_accel(resolvedAccel())) - c.accelerators.push_back(set.backend); - return c; - }; - const auto targetPlatform = mcpp::platform::TargetPlatform::for_os(cfgCtx().os); - - // ── L1: merge conditional [target.'cfg(...)'] sections ─────────────────── - // Evaluated now (target resolved) against the resolved target — the - // --target triple for a cross build, else the host. - // - // #229: merge_conditional_config MUST run here — before - // `packages[0] = makePackageRoot(*root, *m)` snapshots `m->buildConfig` - // into `packages[0].privateBuild`/`.manifest` — because that snapshot, - // not `*m`, is what the modgraph scan and per-TU compile-flag assembly - // actually read afterward. Every dependency (path/git/version alike) gets - // the SAME treatment, at the mirror-image point in its own load path - // (right before ITS `makePackageRoot`/`propagateLinkFlags`) — see the - // dependency-manifest-acquisition block below. That makes this the root - // package's half of the one funnel, not a special case: every package is - // merged exactly once, immediately before it is captured into `packages[]`. - if (!m->conditionalConfigs.empty()) { - merge_conditional_config(*m, cfgCtx()); - } - // `[target..abi] threads` -- the ROOT's statement, rendered once, - // into channels that already reach the whole artefact: the graph-global - // dialect flag set (every C++ translation unit, the std module's own - // commands, the scan, every dependency's cache key), the C flags of every - // package (the root here, each dependency where it is loaded), and the link. - // - // For hosted targets that are not PE. On PE the MSVC runtime is always - // multithreaded and mingw-w64's threading model belongs to its payload; a - // freestanding target has no thread library to select. - const bool abiThreadsRendered = [&] { - if (!m->buildConfig.abiThreads) return false; - const auto abiTriple = mcpp::toolchain::triple::parse( - overrides.target_triple.empty() - ? mcpp::toolchain::triple::host_triple().str() - : overrides.target_triple); - return abiTriple && !abiTriple->is_pe() && !abiTriple->is_freestanding(); - }(); - auto add_once = [](std::vector& v, std::string_view flag) { - if (std::ranges::find(v, flag) == v.end()) v.emplace_back(flag); - }; - if (abiThreadsRendered) { - add_once(m->buildConfig.dialectCxxflags, "-pthread"); - add_once(m->buildConfig.cflags, "-pthread"); - add_once(m->buildConfig.ldflags, "-pthread"); - } - // `[target..abi] exceptions` -- design 2026-09-12 (the UI - // framework record), section 2.1, A1: the second `abi` member, the - // ROOT's statement, rendered once. Reaches the dialect flag set (every - // C++ translation unit, the std module's own commands, the scan, every - // dependency's cache key) and the link -- NOT the C flags, unlike - // `threads`: `-fexceptions` has no C-language meaning worth carrying to - // a `.c` translation unit. - // - // Rendered only where the target's default is OFF: Emscripten's native - // toolchain builds without exceptions unless asked. gcc, clang and MSVC - // already link with exceptions on, so a host build with the member - // declared is byte-identical to one without -- the same property - // `threads` has on PE. - const bool abiExceptionsRendered = m->buildConfig.abiExceptions - && cfgCtx().os == "emscripten"; - if (abiExceptionsRendered) { - add_once(m->buildConfig.dialectCxxflags, "-fexceptions"); - add_once(m->buildConfig.ldflags, "-fexceptions"); - } - // `[build].defines` must reach the scanner (P1689) and the compile edge, - // and must participate in the fingerprint. Fold before dependency - // resolution / fingerprinting. - report_flag_words_changes(*m); - fold_build_defines_into_flags(m->buildConfig); - - // ORIGIN, RESOLVED ONCE. - // - // The spec used to be parsed TWICE from the same string a dozen lines - // apart — once to ask "is this msvc@system", once to get the package — - // and each call site drew its own conclusions from the result. Two parses - // of one string is two places for the answer to differ, which is the shape - // §1 of the three-axes design is about: a platform special case whose cost - // is paid at every site that has to know about it. - // - // `Origin::SystemMsvc` is located on the machine and never resolved - // through an xim package — mcpp does not install the machine's Visual - // Studio. `Origin::Managed` is everything else, including a VERSIONED - // msvc spec, and that is the point: what the manifest says is what gets - // used, on every machine, instead of whatever this one happens to have. - // RESOLVED HERE, RUN AFTER THE DEPENDENCY GRAPH — AND THE SPLIT IS THE - // WHOLE POINT. - // - // A target row's convention does not name a preferred compiler. It names - // the payload that supplies THAT TARGET'S C library. Whether the user's own - // toolchain can serve the target instead depends on whether something ELSE - // supplies the target side — and that is knowable only once the graph is - // resolved, which is after this point in the function. - // - // Deciding early was measured to be wrong in both directions. Applying the - // convention unconditionally replaced a toolchain the user had set with - // `mcpp toolchain default`, for a payload their project never used. NOT - // applying it turned a working zero-dependency cross build into a failing - // one, because clang alone carries no C runtime for `x86_64-windows-gnu` - // while the payload the row names does. - // - // The body does not MOVE; only its execution does. Everything between - // here and the call site was measured to read `tc` exactly once, and that - // one read wanted the target triple rather than the compiler. - std::optional tc; - // `std::function` AND NOT `auto`, BECAUSE THE FIRST-RUN BRANCH INSIDE - // CALLS BACK INTO IT. That branch installs a host default and then has to - // resolve THAT default for the requested target — which is what the top of - // this same function does. Recursing reuses it; writing it a second time - // there would be a second answer to one question. Depth is one: the second - // pass takes the `tcSpec.has_value()` branch that the first-run path just - // made true. - bool firstRunNeedsTargetPass = false; - // Guards the one recursive call below. Set before the call so the second - // pass cannot reach it, whatever else changed in between. - bool targetPassDone = false; - std::function()> resolve_target_toolchain; - resolve_target_toolchain = [&]() -> std::expected { - std::optional parsedSpec; - auto tcOriginAxis = mcpp::toolchain::Origin::Managed; - if (tcSpec.has_value() && *tcSpec != "system") { - // A parse FAILURE is not the same as an unparseable spec being - // absent: `gcc@system` now fails here by name (see - // parse_toolchain_spec), and swallowing that would put the error back - // where it used to happen — somewhere else, saying something else. - auto s = mcpp::toolchain::parse_toolchain_spec(*tcSpec); - if (!s) return std::unexpected(std::format( - "{} = '{}': {}", tcSpecSource(), *tcSpec, s.error())); - parsedSpec = std::move(*s); - tcOriginAxis = mcpp::toolchain::origin_of(*parsedSpec); - } - // ASSIGNED, NOT DECLARED. `host_tc_for_build_program` reads it and is - // defined outside this lambda, so the declaration lives in the enclosing - // scope; the value is still decided here, where the spec is parsed. - tcSpecIsMsvc = - parsedSpec && tcOriginAxis == mcpp::toolchain::Origin::SystemMsvc; - - // A PINNED TOOLSET THIS MACHINE ALREADY HAS IS USED WHERE IT IS. - // - // `msvc@14.44.35207` names one Microsoft build, and the ecosystem package - // of that version unpacks the same installer payloads Visual Studio does, - // so an installed copy is the same toolset without a download. `xim:` - // opts out: it asks for the package, whose SDK is pinned with it. - std::optional installedPin; - std::vector installedPinNotes; - if constexpr (mcpp::platform::is_windows) { - if (parsedSpec && !tcSpecIsMsvc - && parsedSpec->family == mcpp::toolchain::Family::Msvc - && !parsedSpec->ecosystemOnly && !parsedSpec->version.empty()) - installedPin = mcpp::toolchain::msvc::system_installation_matching( - parsedSpec->version, mcpp::toolchain::msvc::ToolsetNeeds{}, - &installedPinNotes); - } - - if (installedPin) { - for (auto const& n : installedPinNotes) mcpp::ui::info("note", n); - explicit_compiler = installedPin->clPath; - mcpp::ui::info("Resolved", std::format( - "{} → msvc {} (installed: {})", parsedSpec->display(), - installedPin->display_version(), installedPin->clPath.string())); - } else if (tcSpecIsMsvc) { - if (!mcpp::platform::is_windows) { - return std::unexpected(std::format( - "toolchain '{}' is only available on Windows hosts", *tcSpec)); - } - auto inst = mcpp::toolchain::msvc::detect_installation(); - if (!inst) { - return std::unexpected(mcpp::toolchain::msvc::install_guidance()); - } - explicit_compiler = inst->clPath; - mcpp::ui::info("Resolved", std::format( - "msvc@system → msvc {} ({})", - inst->display_version(), inst->clPath.string())); - } else if (parsedSpec) { - auto spec = parsedSpec; - if (spec->version.empty()) { - return std::unexpected(std::format( - "{} = '{}' is invalid; expected '@'", - tcSpecSource(), *tcSpec)); - } - // A `--target ` build carries the (already canonical) triple - // into the spec's target axis: the payload mapping then resolves the - // right package/frontend (e.g. aarch64-linux-musl-g++ for a cross - // musl build, never the host g++). Escape-hatch triples outside the - // language don't parse and leave the spec on the host target. - if (!overrides.target_triple.empty()) { - if (auto t = mcpp::toolchain::triple::parse(overrides.target_triple)) - spec->target = *t; - } - auto pkg = mcpp::toolchain::to_xim_package(*spec); - - // AND NOT INSTALLED WHEN NO PAYLOAD HERE COULD SERVE THE TARGET. - // - // `unservedTargetDiagnosis` is decided a thousand lines above and - // released a thousand lines below — deliberately, because whether the - // dependency GRAPH supplies the target's system is not knowable until - // it is resolved. This install sits between the two, and it does not - // need to wait: if no payload here serves the target, then either the - // graph supplies the system (and this payload is not wanted) or the - // build refuses later (and it is not wanted then either). - // - // Measured on ubuntu-24.04-arm, `--target x86_64-linux-musl`: - // - // error: toolchain 'gcc@16.1.0': xlings install of - // 'xim:x86_64-linux-musl-gcc@16.1.0' failed … - // - // — the cross-musl packages are published per host arch and that one is - // x86_64-only. The refusal that names this correctly never ran, because - // the install failed first and failed hard. - // - // Skipping leaves BOTH later paths intact; attempting cannot help - // either of them. - const bool targetPayloadUnservable = - !unservedTargetDiagnosis.empty() && !spec->target.empty(); - - auto cfg = get_cfg(); - if (!cfg) return std::unexpected(cfg.error()); - mcpp::fetcher::Fetcher fetcher(**cfg); - - mcpp::ui::info("Resolving", "toolchain"); - mcpp::fetcher::InstallProgressHandler progress; - auto payload = fetcher.resolve_xpkg_path( - pkg.target(), /*autoInstall=*/!targetPayloadUnservable, &progress); - if (!payload && targetPayloadUnservable) { - // The held diagnosis is already the right words for this; releasing - // it here rather than at its usual site keeps one sentence per cause. - refusal::record(refusal::Code::HostCannotServe); - return std::unexpected(unservedTargetDiagnosis); - } - if (!payload) { - // `windows = "msvc@19.44"` in a manifest is the retired - // cl-version spelling; saying "no such xim package" would send - // the reader looking for a toolset that cannot exist. - if (spec->family == mcpp::toolchain::Family::Msvc) { - if (auto hint = mcpp::toolchain::msvc::cl_version_spelling_hint( - spec->version)) - return std::unexpected(*hint); - } - return std::unexpected(std::format( - "toolchain '{}': {}", *tcSpec, payload.error().message)); - } - - // A pinned MSVC toolset: the payload root IS a VS-shaped root and the - // package version IS the toolset directory name, so cl.exe is - // derived, not searched for. Nothing here can silently pick a - // different toolset — which is the defect this path exists to close. - // - // It also skips the two steps below: the bin/-shaped frontend lookup - // (cl.exe is four levels deeper) and the ELF post-install fixup - // (there is nothing to patchelf on a PE toolchain). - if (spec->family == mcpp::toolchain::Family::Msvc) { - // One rule, one place: where a managed toolset lives and why the - // fetcher's `root` must not be used for it (mcpp.toolchain. - // registry). Install and build asked the same question and each - // answered it in its own words. - auto inst = mcpp::toolchain::resolve_managed_msvc( - mcpp::config::make_xlings_env(**cfg), pkg); - if (!inst) return std::unexpected(inst.error()); - explicit_compiler = inst->clPath; - mcpp::ui::info("Resolved", std::format( - "{} → msvc {} ({})", spec->display(), - inst->display_version(), inst->clPath.string())); - } else { - auto frontendR = mcpp::toolchain::payload_frontend(payload->root, pkg); - // A payload that describes itself and describes itself wrongly is - // refused by name -- not reported as a missing frontend, which is - // a different repair. - if (!frontendR) return std::unexpected(frontendR.error()); - explicit_compiler = *frontendR; - if (!std::filesystem::exists(explicit_compiler)) { - return std::unexpected(std::format( - "toolchain payload '{}' has no known C++ frontend in {}", - pkg.target(), - mcpp::toolchain::payload_frontend_dir(payload->root, pkg).string())); - } - // Same post-install fixup as `mcpp toolchain install` — this - // manifest [toolchain] path previously ran none, so a freshly - // auto-installed payload kept its stale install-time cfg / - // unpatched runtime libs. - provide_runtime_payload(pkg); - if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( - **cfg, payload->root, pkg, - runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) - return std::unexpected(std::format( - "toolchain post-install fixup: {}", fixed.error())); - else report_fixup(*fixed, payload->root); - // Canonical rendering, whatever spelling the manifest/config used: - // "Resolved gcc@16.1.0 → x86_64-linux-musl → ". - // - // AND IT SAYS SO WHEN MCPP CHOSE. A toolchain the user wrote down - // needs no explanation — they can read their own manifest. One this - // engine selected from a target row is a decision the user did not - // make, and a status line that reports the outcome without the - // reason leaves them to discover the rule by experiment. - std::string chosenBy; - // A COMPILER THE GRAPH ASKED FOR IS ANNOUNCED WITH THE PACKAGE - // THAT ASKED. Without the name this reads as mcpp ignoring the - // user's default; with it, it reads as the dependency it is. - // The second line appears only when something was displaced — - // "replacing nothing" is not worth a line. - if (!graphCompilerRequiredBy.empty()) - chosenBy = std::format( - "\n required by {} (`requires = " - "[\"mcpp:compiler={}\"]`){}", - graphCompilerRequiredBy, graphCompilerFamily, - graphCompilerReplaced.empty() - ? std::string{} - : std::format(", not your {} — this project only", - graphCompilerReplaced)); - else if (!pinReplacedDefault.empty()) - chosenBy = std::format( - "\n target default for {}, replacing your " - "{} — override with `[target.{}] toolchain`", - overrides.target_triple, pinReplacedDefault, - overrides.target_triple); - else if (tcOrigin == TcOrigin::TargetPin - || tcOrigin == TcOrigin::FirstRun) - chosenBy = std::format(" ({})", tc_origin_name(tcOrigin)); - mcpp::ui::info("Resolved", - std::format("{} → {}{}", spec->display(), - mcpp::ui::shorten_path(explicit_compiler, - mcpp::fetcher::make_path_ctx(&**get_cfg(), *root)), - chosenBy)); - } - } else if (tcSpec.has_value() && *tcSpec == "system") { - // REFUSED. THE COMPILER IS THE ONE AXIS THAT IS NOT THE PROJECT'S TO - // TAKE FROM THE HOST. - // - // mcpp's host-dependence policy is not uniform across axes, and the - // split is the point rather than an inconsistency: - // - // LIBRARIES are the program's business. A project may link a host - // library or its own `.so`; mcpp says what that costs and what the - // supported route is, and does not refuse as long as the result - // builds and runs. The developer owns the artifact and guarantees it. - // - // THE TOOLCHAIN is mcpp's own contract. Everything mcpp promises — - // that `import std` is available, that the runtime closure is - // computable, that two machines and CI produce the same build — is a - // statement about a compiler mcpp resolved and can identify. A - // compiler picked off `PATH` makes every one of those promises - // unverifiable, and a build tool that cannot state what it built with - // is answering in the wrong version (see - // `.agents/docs/…a-build-must-be-able-to-state-its-own-version`). - // - // So this is refused rather than warned about, and it is refused HERE, - // before any resolution work, so the message is the first thing the - // user sees rather than a consequence three layers down. - // - // `msvc@system` is a different spelling and stays supported: it names a - // FAMILY whose installation mcpp locates and identifies, on the one - // platform where the compiler cannot be redistributed. - return std::unexpected(std::format( - "[toolchain] {} = \"system\" is not supported: mcpp builds only " - "with toolchains it manages.\n" - " A compiler taken from PATH cannot be identified or " - "reproduced, so `import std` availability, the runtime closure and " - "\"the same build on another machine\" all stop being things mcpp " - "can promise.\n" - " Name one instead — mcpp installs it on first use:\n" - "\n" - " [toolchain]\n" - " {} = \"gcc@16.1.0\"\n" - "\n" - " or set a machine default with `mcpp toolchain default " - "gcc@16.1.0`, and see `mcpp toolchain list` for what is available.\n" - " (On Windows, `msvc@system` is different and remains " - "supported: it names a family whose installation mcpp locates.)\n" - " Host LIBRARIES are a separate question and are not refused " - "— a project may link them and owns the result.", - kCurrentPlatform, kCurrentPlatform)); - } else if (mcpp::platform::env::offline_mode() - || mcpp::platform::env::no_auto_install()) { - // CI / offline / test opt-out: hard-error instead of silently - // pulling ~800 MB of toolchain. Preserves the original M5.5 - // contract for environments that need it. - // - // `--offline` / MCPP_OFFLINE subsumes MCPP_NO_AUTO_INSTALL: the older - // name only ever covered this one gate, which made "don't use the - // network" three separate concepts with three spellings. The old var is - // kept working (it predates offline mode and CI still exports it). - namespace pins = mcpp::toolchain::triple::pins; - // Name the knob that actually fired, not a fixed one: telling a user - // who passed `--offline` to unset MCPP_NO_AUTO_INSTALL sends them - // looking for a variable they never set. - std::string_view release = mcpp::platform::env::offline_mode() - ? "or drop --offline / unset MCPP_OFFLINE to let mcpp auto-install." - : "or unset MCPP_NO_AUTO_INSTALL to let mcpp auto-install."; - // Windows without a usable MSVC must not be told to install llvm: - // that default resolves to clang targeting the MSVC ABI, which is - // exactly what this machine cannot build. Name the toolchain that - // will actually work there instead. - if (mcpp::platform::is_windows - && !msvc_usable_either_origin()) { - refusal::record(refusal::Code::OfflineDownloadRequired); - return std::unexpected(std::format( - "no toolchain configured (and no Visual Studio found).\n" - " run one of:\n" - " mcpp toolchain install {} --target {}\n" - " mcpp toolchain default {} --target {}\n" - " {}", - pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget, - pins::kFirstRunWinGnu, pins::kFirstRunWinGnuTarget, release)); - } - if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { - refusal::record(refusal::Code::OfflineDownloadRequired); - return std::unexpected(std::format( - "no toolchain configured.\n" - " run one of:\n" - " mcpp toolchain install {}\n" - " mcpp toolchain default {}\n" - " {}", - pins::kSuggestLlvm, pins::kFirstRunMac, release)); - } else { - refusal::record(refusal::Code::OfflineDownloadRequired); - return std::unexpected(std::format( - "no toolchain configured.\n" - " run one of:\n" - " mcpp toolchain install {}\n" - " mcpp toolchain default {}\n" - " {}", - pins::kSuggestGccMusl, pins::kFirstRunLinuxOther, release)); - } - } else { - // First-run UX: no project-level [toolchain], no global default, - // and the user just ran `mcpp build` (or similar). Auto-install - // the platform's canonical default so the user gets a working - // binary out of the box without any config. We pin it as the - // global default so the next invocation is silent. - // Users can switch any time via `mcpp toolchain default `. - // - // macOS: LLVM/Clang — Apple doesn't ship GCC; upstream LLVM with - // bundled libc++ is the self-contained choice. - // Linux: glibc gcc — the platform-native ABI. A musl-static default - // cannot link the glibc world (X11/GL/system libs), so it - // breaks GUI/native packages out of the box. musl-static stays - // opt-in via `mcpp build --target x86_64-linux-musl` for users - // who explicitly want portable static binaries. - // Linux default is arch-aware: - // x86_64 → glibc gcc (native ABI; the glibc toolchain is published - // for x86_64). musl-static stays opt-in via --target. - // other arches (aarch64, ...) → musl-static gcc: it's what's - // published for them, is self-contained, and yields portable - // static binaries (ideal for aarch64 / Termux, no bionic dep). - // glibc-world linking (X11/GL) needs an explicit glibc - // toolchain, addable later for native-ABI aarch64 builds. - // `native_first_run_spec()` (declared above) is this exact selection - // — on Windows it re-checks `msvc_usable_either_origin()`, which here - // is redundant (the seed above already diverted the unusable case - // onto the windows-gnu target before this block runs) but harmless. - std::string defaultSpec = native_first_run_spec(); - auto defaultParsed = mcpp::toolchain::parse_toolchain_spec(defaultSpec); - // The legacy "-musl" spelling normalizes to (gcc, -linux-musl), - // so the resolver finds the `-linux-musl-g++` frontend - // without any manual triple seeding. - bool muslDefault = defaultParsed->target.is_musl(); - auto defaultPkg = mcpp::toolchain::to_xim_package(*defaultParsed); - - if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { - mcpp::ui::info("First run", - std::format("no toolchain configured — installing {} (LLVM/Clang) as default", - defaultSpec)); - } else { - mcpp::ui::info("First run", - std::format("no toolchain configured — installing {} ({}) as default", - defaultSpec, muslDefault ? "musl, static" : "glibc, native ABI")); - } - - auto cfg = get_cfg(); - if (!cfg) return std::unexpected(cfg.error()); - mcpp::fetcher::Fetcher fetcher(**cfg); - - mcpp::fetcher::InstallProgressHandler progress; - auto payload = fetcher.resolve_xpkg_path(defaultPkg.target(), - /*autoInstall=*/true, &progress); - if (!payload) { - return std::unexpected(std::format( - "auto-installing default toolchain {} failed: {}\n" - " you can install it manually with:\n" - " mcpp toolchain install {}", - defaultSpec, payload.error().message, defaultSpec)); - } - auto defaultFrontendR = - mcpp::toolchain::payload_frontend(payload->root, defaultPkg); - if (!defaultFrontendR) return std::unexpected(defaultFrontendR.error()); - explicit_compiler = *defaultFrontendR; - if (!std::filesystem::exists(explicit_compiler)) { - return std::unexpected(std::format( - "default toolchain payload {} has no known C++ frontend in {}", - defaultPkg.target(), - mcpp::toolchain::payload_frontend_dir(payload->root, defaultPkg).string())); - } - - // The freshly-installed toolchain needs the SAME post-install fixup - // (patchelf / specs / cfg wiring against the sandbox glibc) that - // `mcpp toolchain install` performs — without it a fresh sandbox - // gcc cannot find the C library (stdlib.h: No such file or - // directory) and a fresh llvm keeps its stale install-time cfg. - provide_runtime_payload(defaultPkg); - if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( - **cfg, payload->root, defaultPkg, - runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) - return std::unexpected(std::format( - "default toolchain post-install fixup: {}", fixed.error())); - else report_fixup(*fixed, payload->root); - - // Persist the default so we don't ask again next time. - if (auto wr = mcpp::config::write_default_toolchain(**cfg, defaultSpec); wr) { - (*cfg)->defaultToolchain = defaultSpec; - mcpp::ui::status("Default", std::format("set to {}", defaultSpec)); - } // best-effort: a failed config write only loses the persistence, - // not the running build. - tcSpec = defaultSpec; - tcOrigin = TcOrigin::FirstRun; - - // AND IF A TARGET WAS ASKED FOR, RESOLVE FOR IT — THIS BRANCH JUST - // INSTALLED A HOST COMPILER AND WAS ABOUT TO BUILD WITH IT. - // - // Everything above answers "this machine has no toolchain, give it - // one", and the answer is a HOST payload. `--target` was never read - // here, so on a machine that had never built anything, - // `mcpp build --target x86_64-windows-gnu` installed a native gcc and - // compiled Windows sources with it. Measured in CI 2026-08-25: - // - // First run no toolchain configured — installing gcc@16.1.0 … - // Resolved gcc@16.1.0 → …/xim-x-gcc/16.1.0/bin/g++ - // ↑ no target in the path - // - // against the same command on a machine that already had one: - // - // Resolved gcc@16.1.0 → x86_64-windows-gnu → …/mingw-cross-gcc/… - // - // REUSES THE PATH THAT ALREADY KNOWS HOW, rather than repeating what - // it does. `resolve_target_toolchain` maps a spec plus a target onto a - // payload and installs it; the default just chosen is the spec. A - // second implementation here would be a second answer to one question, - // which is the shape this release exists to remove. - // RECORDED HERE, ACTED ON BELOW — the Windows first-run block that - // follows SETS `overrides.target_triple` itself, and returning from - // here would skip it. Its own comment says why that matters: it - // persists BOTH axes, and persisting only the target leaves - // `mcpp toolchain list` disagreeing with what the build used. - firstRunNeedsTargetPass = !overrides.target_triple.empty(); - } - - // Windows first run that got diverted to winlibs GCC: announce it and - // persist BOTH axes, so the next invocation is silent and - // `mcpp toolchain list` shows the same pair the build actually used. - // Persisting only the target would leave the toolchain axis implicit - // (derived from the vocabulary pin) and the two views would disagree. - // - // NOT WHEN THE DEPENDENCY GRAPH SUPPLIED THE ANSWER. This branch's - // condition is `tcSpec.has_value()`, and since 2026.8.26.2 a package's - // `requires = ["mcpp:compiler=…"]` can be what made it true — so a bare - // Windows box building ONE project with an llvm-requiring dependency - // would have persisted llvm as the MACHINE's default, and the next - // project, which asked for nothing, would inherit it. - // - // A requirement is a property of the package that states it. It decides - // this build and nothing else; the first-run answer for the machine is - // still the one this branch was written for. - if (windowsGnuFirstRun && tcSpec.has_value() - && tc_origin_may_persist(tcOrigin)) { - mcpp::ui::info("First run", - std::format("no toolchain configured and no Visual Studio found — " - "using {} for {} (MinGW-w64, self-contained)", - *tcSpec, overrides.target_triple)); - if (auto cfgW = get_cfg(); cfgW) { - if (mcpp::config::write_default_toolchain(**cfgW, *tcSpec)) - (*cfgW)->defaultToolchain = *tcSpec; - if (mcpp::config::write_default_target(**cfgW, overrides.target_triple)) - (*cfgW)->defaultTarget = overrides.target_triple; - mcpp::ui::status("Default", - std::format("set to {} → {}", *tcSpec, overrides.target_triple)); - } - tcOrigin = TcOrigin::FirstRun; - } - - // AND NOW RESOLVE FOR THE TARGET, IF ONE WAS ASKED FOR. - // - // The first-run branch above answers "this machine has no toolchain, give - // it one", and the answer is a HOST payload; `--target` was never read - // there. On a machine that had never built anything, - // `mcpp build --target x86_64-windows-gnu` therefore installed a native - // gcc and compiled Windows sources with it — measured in CI 2026-08-25: - // - // First run no toolchain configured — installing gcc@16.1.0 … - // Resolved gcc@16.1.0 → …/xim-x-gcc/16.1.0/bin/g++ - // ↑ no target in the path - // - // against the same command where one already existed: - // - // Resolved gcc@16.1.0 → x86_64-windows-gnu → …/mingw-cross-gcc/… - // - // REUSES THE PATH THAT ALREADY KNOWS HOW rather than repeating it. The - // default just chosen is the spec; mapping a spec plus a target onto a - // payload (installing it if absent — `autoInstall` was always true there) - // is what the top of this function does. Depth is one: the second pass - // takes the `tcSpec.has_value()` branch the first run just made true. - // ONE-SHOT, AND THE FLAG IS SET BEFORE THE CALL, NOT AFTER. - // - // This line sits OUTSIDE the first-run branch — it has to, because the - // Windows block just above sets the target itself — so it is evaluated on - // every pass. The first version relied on `firstRunNeedsTargetPass` being - // false on the second pass; it is a captured variable that nothing - // resets, so every pass recursed again. Measured in a consumer's CI as - // the same `Resolved` line four times and then - // - // ##[error]Process completed with exit code 139 - // - // — SIGSEGV, a stack that ran out. A recursion whose termination depends - // on state the recursive call does not change is not a depth-one - // recursion, however its comment reads. - if (!targetPassDone - && (firstRunNeedsTargetPass - || (windowsGnuFirstRun && tcSpec.has_value()))) { - targetPassDone = true; - return resolve_target_toolchain(); - } - - auto detected = mcpp::toolchain::detect( - explicit_compiler, runtimePayload, runtimeBindingSnapshot.contractHash); - if (!detected) return std::unexpected(detected.error().message); - tc = std::move(*detected); - - // Something about the resolution the user has to be told, but which is - // not a failure. Today's only producer is the Windows SDK axis: a managed - // toolset binds the SDK it was installed with, so a `WindowsSdkDir` in - // the environment does not apply — and an override that is ignored - // SILENTLY is indistinguishable from one that was never set. - if (!tc->resolutionNote.empty()) - mcpp::ui::info("note", tc->resolutionNote); - - // ── A retargetable driver has to be TOLD what it is targeting ──────── - // - // `tc.targetTriple` comes from `-dumpmachine`, and for every cross target - // that worked before this it was right for a reason that does not - // generalise: those targets use a DISTINCT compiler binary - // (`x86_64-w64-mingw32-g++`, `aarch64-linux-musl-g++`), whose own - // -dumpmachine reports the cross triple. Clang is ONE binary that emits - // every target it was built with, so -dumpmachine always answers with the - // host — and nothing downstream ever learns otherwise. - // - // Measured before this line existed: - // - // $ mcpp build --target riscv64-none-elf - // Resolved llvm@22.1.8 → riscv64-none-elf → …/bin/clang++ - // Finished dev [unoptimized + debuginfo] in 0.47s - // $ ls target/ - // x86_64-linux-gnu/ ← an ELF for the host, reported as riscv64 - // - // That is E1: success reported, host artifact produced. The output - // directory, the fingerprint, the cache key and the flag layer all read - // `tc.targetTriple`, so correcting it here corrects all of them at once — - // which is the point of there being one field rather than five answers. - // - // THIS USED TO BE SCOPED TO FREESTANDING, WITH THIS REASON: - // - // The hosted cross targets already resolve a per-target binary, and - // overwriting their probed triple would replace a measured fact with - // an assumed one for no gain. - // - // That was true while every hosted cross was served by a payload. It - // stops being true when the TARGET SIDE comes from the dependency graph: - // the C library, the C++ runtime and the platform's own implementation are - // then packages built from source, and the compiler is an ordinary clang — - // whose `-dumpmachine` answers the host, exactly as the paragraph above - // describes for freestanding. - // - // Measured 2026-08-23, with an explicit `[target.aarch64-macos] - // toolchain = "llvm@…"`. The manifest's cfg evaluation used the REQUESTED - // target, so the C library's aarch64 headers were on the command line; the - // toolchain's own triple was still the host's, so code generation was - // x86_64. Two answers to one question, in one command: - // - // okm_float_assert.c: the C library and the compiler disagree about - // LDBL_DIG ('33 == 18') 33 = aarch64 binary128, 18 = x87 - // - // ⇒ The condition is now the property the first paragraph of this comment - // already names: a RETARGETABLE driver has to be told. gcc is not one — a - // gcc payload IS its target — so the mingw and musl-gcc crosses keep - // answering from `-dumpmachine`, which for them remains a measured fact. - if (!overrides.target_triple.empty()) { - if (auto want = mcpp::toolchain::triple::parse(overrides.target_triple); - want && (want->is_freestanding() - || tc->compiler == mcpp::toolchain::CompilerId::Clang)) - { - tc->targetTriple = want->str(); - - // AND THE GATE THAT ALREADY EXISTS FOR THIS, APPLIED WHERE THE - // ANSWER IS KNOWN. - // - // `discover_link_runtime_dirs` refuses to report these - // directories for a target that carries its own sysroot, and the - // refusal never fired: that function runs during DETECTION, - // before this line, when `targetTriple` is still the HOST's. The - // gate read a host triple and answered correctly about it. - // - // The artefact is what showed it. An Android link line carried - // - // -L /toolchains/llvm/prebuilt/linux-x86_64/lib/ - // x86_64-unknown-linux-gnu - // - // whose last component is this machine's triple, produced by - // `root / "lib" / targetTriple` -- so the string names the - // question that was asked. Those are the compiler's own host - // runtime directories; an Android artefact must resolve libc++, - // the crt objects and the loader from the NDK's sysroot, and the - // hermetic check reported exactly that failure with six host - // objects. - // - // Cleared rather than re-derived. Re-running the discovery with - // the final triple would also change what every OTHER clang cross - // target gets, and those are measured as they stand; the claim - // being made here is only the one the gate already states. - if (want->has_own_sysroot()) tc->linkRuntimeDirs.clear(); - - // And the flag that says it to the driver — for a HOSTED target - // only. Freestanding already emits its own `--target`, together - // with the ISA flags that must accompany it - // (freestanding/target.cppm); a second one here would be the same - // decision in two places. - if (!want->is_freestanding() - && tc->compiler == mcpp::toolchain::CompilerId::Clang) { - tc->crossTargetFlag = - "--target=" + want->llvm_triple( - min_platform_version(*m, *want, tc->binaryPath)); - - // AND THE SAME FLAG ON THE std MODULE'S OWN COMMANDS, FOR A - // PAYLOAD THAT SERVES MORE THAN ONE TARGET. - // - // The std module is built by its own command assembly - // (clang.cppm), not by the compile flags, so a decision made - // only here reaches every translation unit and not that. For - // most toolchains the omission cannot be seen: a payload - // whose compiler IS its target finds its own headers, and a - // package-provided module carries the target inside - // `stdModuleFlags`. - // - // ONE NDK SERVES BOTH ANDROID ARCHES, which is the property - // that makes this necessary and is stated in the row's own - // pin: `android-ndk@` names no arch, so `--target` is the - // only thing that says which. Without it the precompile - // resolved libc++'s `#include <__config>` against the - // building machine and stopped there. - // - // NOT `has_own_sysroot()`, though both rows that answer true - // to it are SDKs with their own sysroot. Emscripten's `em++` - // serves exactly one target and needs no flag -- the verified - // wasm loop is measured without it -- so widening the gate to - // the predicate would add a flag to a command that does not - // want one. The property here is "one payload, several - // targets", and Android is the only row that has it; a future - // row brings its own measurement. - if (want->is_android()) { - tc->stdModuleTargetFlags = " " + tc->crossTargetFlag; - // BIONIC'S ctype HEADER AND A MODULE'S EXPORT RULES. - // - // bionic declares `isalnum` and its neighbours - // `static inline`, and libc++'s module surface exports - // them with `using std::isalnum`. A using-declaration - // cannot export a name with internal linkage, so the - // precompile fails on 14 names at once. Defining the - // macro empty makes those declarations extern, which is - // what every other C library this engine compiles - // against already does. - // - // Scoped to the std module and not to every unit: the - // rule being satisfied is about exporting from a module, - // and a translation unit that includes - // directly is entitled to bionic's inline definitions. - // `xim:android-ndk`'s own install-time self-test reaches - // the identical conclusion from the other direction. - // - // AND THE PAYLOAD MAY SAY SO ITSELF. The recipe applies - // this same define in that self-test, so it is a fact - // the payload already holds; `std_module_defines` in - // `.mcpp-toolchain.json` is the channel for it, and the - // define below is what a payload that ships no - // descriptor still gets. The two are not added - // together: a descriptor that names defines is the - // payload's complete answer for this channel, and - // appending to it would mean a payload could not - // withdraw a define this engine once needed. - auto stdDefines = [&]() -> std::vector { - auto desc = - mcpp::toolchain::payload_descriptor_for_compiler( - tc->binaryPath); - if (desc && *desc && !(*desc)->stdModuleDefines.empty()) - return (*desc)->stdModuleDefines; - return { "__BIONIC_CTYPE_INLINE=" }; - }(); - for (auto const& def : stdDefines) - tc->stdModuleTargetFlags += " -D" + def; - } - - // ── iOS: THE COMPILER IS OURS, THE SDK IS THE MACHINE'S ── - // - // The three iOS rows pin `llvm@22.1.8` -- any sufficiently - // new clang emits arm64 Mach-O for an iOS deployment target - // -- and take their headers and stub libraries from the - // machine's Xcode, which is where the whole item shrinks to - // a located sysroot. `aarch64-macos` is verified on exactly - // this split and is the precedent. - // - // LOCATED HERE, ONCE. Three later sites need the answer (the - // compile flags, the link line, and the std module's own - // command), and a function that probes the machine is the - // wrong thing to call three times: `xcrun` shells out, and - // three answers can differ if the developer directory - // changes mid-build. - // - // AND ITS ABSENCE IS A REFUSAL THAT NAMES THE SDK. The - // recorded host-surface rule is that a host dependency must - // be minimal, named, and never a fallthrough; the iOS SDK - // and `simctl` are the two this platform adds, both in the - // "proprietary runtime that exists only on its own OS" - // category. A build that continued without the SDK would - // fail in the driver's header search, naming a file rather - // than the thing that is missing. - if (want->is_ios()) { - // READ, NOT RE-DERIVED. The refusal above located it - // before any payload was resolved, and that is the one - // `xcrun` call this build makes. - // - // An empty answer here cannot happen through the - // `--target` path, and a line that prints when it does - // is cheaper than a branch that pretends it cannot: the - // row could be reached one day by a route that skipped - // the gate, and an iOS build with no `-isysroot` is a - // macOS artefact with an iOS triple on it. - if (!appleSdkLocated) { - return std::unexpected(std::format( - "internal: target {} reached toolchain " - "resolution without its SDK being located; the " - "gate that locates it did not run for this " - "request", want->str())); - } - tc->appleSdkRoot = *appleSdkLocated; - auto sdk = appleSdkLocated; - // AND THE std MODULE'S OWN COMMAND, WHICH IS A SEPARATE - // CHANNEL. Same reason the Android rows set it: the - // module is precompiled by `clang.cppm`'s own assembly - // rather than by the compile flags, so a decision made - // only in the flag builder reaches every translation - // unit and not the module they all import. Without the - // SDK here the precompile resolves libc++'s - // `#include <__config>` against the macOS SDK and the - // module is built for the wrong platform. - // - // QUOTED, as every path this string carries is (see the - // package-provided producer, which uses `shq` for each - // `-isystem`). The string is spliced into a shell - // command, and an Xcode installed as `Xcode 16.app` is - // a path with a space in it. - tc->stdModuleTargetFlags = - " " + tc->crossTargetFlag - + " -isysroot " + mcpp::xlings::shq(sdk->string()); - } - } - } - if (auto want = mcpp::toolchain::triple::parse(overrides.target_triple); - want && want->is_freestanding()) - { - // `import std` is structurally hosted, and turning it off is the - // SAME fact as the line above, not a second policy: libc++'s - // std.cppm is one module over the whole library, including the - // parts that are threads, filesystem and iostreams. There is no - // subset of it to precompile. - // - // Left on, the failure is neither early nor legible — measured: - // - // error: std module precompile failed (rc=1): - // .../include/c++/v1/__config:13:10: fatal error: - // '__config_site' file not found - // - // which reads as a broken toolchain payload and says nothing about - // the target. The freestanding std subset a user actually wants is - // an ordinary package (`mcpplibs.std.freestanding`), so mcpp's job - // here is to stop pretending the hosted one exists and to say - // where the other one is. - tc->hasImportStd = false; - tc->stdModuleSource.clear(); - tc->stdCompatSource.clear(); - - // ── The target's C library, resolved like its compiler ───────── - // - // The row in kKnownTargets names it, exactly as it names the - // toolchain pin, and it is installed through the same channel a - // project's `[xlings] deps` use (see the materialization above). - // Resolved HERE because the config is already open; the flag - // builder only reads the result. - // - // Absent is not an error at this point: the install happens - // earlier in this function and may legitimately not have run yet - // on a first pass. What follows would then simply not add the - // paths, and the link fails naming the missing libc — which is the - // truthful message either way. - if (const std::string want_sysroot = - mcpp::toolchain::triple::effective_sysroot( - *want, sysroot_override(*m, *want)); - !want_sysroot.empty()) { - if (auto cfg3 = get_cfg(); cfg3) { - auto ref = mcpp::xlings::paths::parse_xpkg_ref(want_sysroot); - auto xl = mcpp::config::make_xlings_env(**cfg3); - // INSTALLED, NOT MERELY LOOKED UP — THE SAME CHANNEL - // THE ROW'S TOOLCHAIN PIN GOES THROUGH. - // - // The row names two things and only one of them used to - // be made to exist: `pin` went through - // `resolve_xpkg_path(…, autoInstall=true, …)` while - // `sysroot` was a pure lookup that returned nullopt and - // let the whole block below be skipped without a word. - // - // Measured 2026-08-26 in a clean environment (an empty - // home, so mcpp's registry starts fresh): - // - // Target riscv64-none-elf - // c-abi picolibc-riscv (…, prebuilt) - // error: 'stdio.h' file not found - // - // The report named the C library and the build could not - // find its headers. mcpp's own bare-metal CI installs it - // by hand, which is why no test ever saw this — every - // bare-metal e2e runs on a machine where the gap has - // already been papered over. - // - // OFFLINE AND `MCPP_NO_AUTO_INSTALL` ARE THE FETCHER'S - // DECISION, not re-derived here. One question, one place - // that answers it — asking it twice is the shape this - // whole release exists to remove. - mcpp::fetcher::Fetcher srFetcher(**cfg3); - mcpp::fetcher::InstallProgressHandler srProgress; - std::optional dir; - if (auto p = srFetcher.resolve_xpkg_path( - want_sysroot, /*autoInstall=*/true, &srProgress)) - dir = p->root; - else - dir = mcpp::xlings::paths::xpkg_payload(xl, ref); - if (dir) { - if (auto spec = mcpp::freestanding::resolve(*want)) { - const auto inc = - *dir / "include" / std::string(spec->libdir); - const auto lib = - *dir / "lib" / std::string(spec->libdir); - std::error_code ec2; - tc->targetSysrootRoot = *dir; - tc->targetSysrootPkg = ref.name; - if (std::filesystem::is_directory(inc, ec2)) - tc->targetSysrootInclude = inc; - if (std::filesystem::is_directory(lib, ec2)) - tc->targetSysrootLib = lib; - } - } - } - } - } - } - - // THE MSVC TOOLSET OF THE CLANG ROW, chosen once and recorded before the - // runtime identity below reads its SDK version. See bind_msvc_sysroot. - if (tc->compiler == mcpp::toolchain::CompilerId::Clang - && mcpp::toolchain::is_msvc_target(*tc)) { - auto bound = bind_msvc_sysroot(*tc, *m, [&] { return get_cfg(); }); - if (!bound) return std::unexpected(bound.error()); - } else if (tc->compiler == mcpp::toolchain::CompilerId::MSVC) { - if (auto ok = check_cl_row_sysroot(*tc, *m); !ok) - return std::unexpected(ok.error()); - } - - // The Windows runtime identity, flowing BACK into the contract. - // - // Everything else about the runtime is known before a toolchain is - // resolved, and deliberately so (see the RuntimeBinding block above). The - // Windows SDK is the exception: it is a property of the toolchain, and - // until it reached the contract hash the version axis simply did not - // exist one layer below the compiler — two SDKs produced one cache key. - // - // `ucrt@` is a COMPATIBILITY FLOOR, not a payload binding like - // `glibc@`: ucrtbase.dll is an OS component and mcpp ships no - // redistributable for it. See mcpp.runtime.binding. - if (!tc->windowsSdkVersion.empty()) { - mcpp::platform::runtime::bind_windows_ucrt( - runtimeBindingSnapshot, tc->windowsSdkVersion); - tc->runtimeContractHash = runtimeBindingSnapshot.contractHash; - } - - // ── Targeting the MSVC ABI without a usable MSVC ───────────────────── - // - // One judgement, one place. This used to be two separate concerns and - // only one of them was implemented: `msvc@system` with no Windows SDK - // was caught here, while clang-targeting-MSVC on a machine with no - // Visual Studio at all — the default on every bare Windows box — fell - // straight through to clang's own "'vector' file not found", from which - // no user could infer that a working alternative was one flag away. - // Deriving the same judgement in two places is how the second case went - // unnoticed, so they are now one condition with two outcomes. - const bool targetsMsvcAbi = - tc->compiler == mcpp::toolchain::CompilerId::MSVC - || mcpp::toolchain::is_msvc_target(*tc); - if (targetsMsvcAbi && !msvc_usable_either_origin()) { - // Native cl.exe is ALWAYS a deliberate choice: mcpp never selects - // msvc@system on its own — it cannot install one — so the only way it - // reaches config.toml is a user typing `mcpp toolchain default msvc`. - // Without this, that user (who evidently wants MSVC and is probably - // just missing the SDK component) would be silently moved to MinGW - // instead of being told which component to install. - // - // The residual imprecision is deliberate and bounded: a *global* - // default of llvm@20.1.7 is indistinguishable from the one mcpp used - // to write itself, so an explicitly-typed one gets repaired too. The - // value is identical either way and the machine cannot build with it; - // a user who wants that failure can pin it in mcpp.toml, which is - // honoured exactly. - const bool userChoseMsvcItself = - tc->compiler == mcpp::toolchain::CompilerId::MSVC; - // AND NOT A COMPILER THE GRAPH REQUIRED. The repair below rewrites - // the machine's default to winlibs GCC, which is right when mcpp's - // own default cannot work here. A family a package REQUIRED is not - // mcpp's default to revise: switching to gcc would satisfy nothing — - // `check_requirements` refuses the build three thousand lines later — - // while having changed the user's configuration on the way there. - // Refusing at the decision is what the rest of this release is about. - const bool mayRepair = - !tc_origin_is_user_explicit(tcOrigin) - && tc_origin_may_persist(tcOrigin) - && !userChoseMsvcItself - && !mcpp::platform::env::offline_mode() - && !mcpp::platform::env::no_auto_install() - && mcpp::platform::is_windows; - if (!mayRepair) { - return std::unexpected(msvc_unavailable_guidance(*tc)); - } - // mcpp chose this default itself and it cannot work on this machine. - // Revise it — including for users who already have `llvm@20.1.7` - // persisted by an older mcpp: the first-run branch never fires again - // for them, so this gate (which runs on EVERY build) is what repairs - // them without a single manual command. - namespace pins = mcpp::toolchain::triple::pins; - mcpp::ui::info("Toolchain", - std::format("{} targets the MSVC ABI but no Visual Studio " - "(MSVC STL + Windows SDK) was found — switching to {} → {}", - tcSpec.value_or("the configured default"), - pins::kFirstRunWinGnu, pins::kFirstRunWinGnuTarget)); - - overrides.target_triple = std::string(pins::kFirstRunWinGnuTarget); - // The x86_64-windows-gnu row is defaultStatic; the target block that - // normally applies that already ran, so mirror just this one field. - if (m->buildConfig.linkage.empty()) m->buildConfig.linkage = "static"; - - auto gnuSpec = mcpp::toolchain::parse_toolchain_spec( - std::string(pins::kFirstRunWinGnu)); - if (!gnuSpec) return std::unexpected(gnuSpec.error()); - if (auto t = mcpp::toolchain::triple::parse(overrides.target_triple)) - gnuSpec->target = *t; - auto gnuPkg = mcpp::toolchain::to_xim_package(*gnuSpec); - - auto cfgR = get_cfg(); - if (!cfgR) return std::unexpected(cfgR.error()); - mcpp::fetcher::Fetcher fetcherR(**cfgR); - mcpp::fetcher::InstallProgressHandler progressR; - auto payloadR = fetcherR.resolve_xpkg_path(gnuPkg.target(), - /*autoInstall=*/true, &progressR); - if (!payloadR) { - return std::unexpected(std::format( - "switching to the MinGW-w64 toolchain ({}) failed: {}\n" - " install it manually with:\n" - " mcpp toolchain install {} --target {}", - pins::kFirstRunWinGnu, payloadR.error().message, - pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget)); - } - auto gnuFrontendR = - mcpp::toolchain::payload_frontend(payloadR->root, gnuPkg); - if (!gnuFrontendR) return std::unexpected(gnuFrontendR.error()); - explicit_compiler = *gnuFrontendR; - if (!std::filesystem::exists(explicit_compiler)) { - return std::unexpected(std::format( - "MinGW-w64 payload {} has no known C++ frontend in {}", - gnuPkg.target(), - mcpp::toolchain::payload_frontend_dir(payloadR->root, gnuPkg).string())); - } - provide_runtime_payload(gnuPkg); - if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( - **cfgR, payloadR->root, gnuPkg, - runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) - return std::unexpected(std::format( - "MinGW toolchain post-install fixup: {}", fixed.error())); - else report_fixup(*fixed, payloadR->root); - - // Persist both axes so the repair happens once, not on every build. - if (mcpp::config::write_default_toolchain(**cfgR, pins::kFirstRunWinGnu)) - (*cfgR)->defaultToolchain = std::string(pins::kFirstRunWinGnu); - if (mcpp::config::write_default_target(**cfgR, overrides.target_triple)) - (*cfgR)->defaultTarget = overrides.target_triple; - - tcSpec = std::string(pins::kFirstRunWinGnu); - tcOrigin = TcOrigin::FirstRun; - auto redetected = mcpp::toolchain::detect( - explicit_compiler, runtimePayload, - runtimeBindingSnapshot.contractHash); - if (!redetected) return std::unexpected(redetected.error().message); - tc = std::move(*redetected); - } - - // For musl-gcc the toolchain is fully self-contained - // (`/x86_64-linux-musl/{include,lib}` is its own sysroot). - // musl-gcc's `-dumpmachine` reports `x86_64-linux-musl`. - bool isMuslTc = mcpp::toolchain::is_musl_target(*tc); - - // A musl toolchain only really makes sense with static linkage — - // dynamic-musl binaries depend on a system /lib/ld-musl-x86_64.so.1 - // that most distros don't ship. Default linkage to "static" when - // the resolved toolchain is musl, unless the user has already opted - // out via `--static` or [target.].linkage. (There is no - // [build].linkage — the parser only reads it under a target section.) - if (isMuslTc && m->buildConfig.linkage.empty()) { - m->buildConfig.linkage = "static"; - } - return {}; - }; - - // Sysroot comes from the toolchain payload itself (GCC -print-sysroot, - // Clang clang++.cfg). mcpp does not override it — the payload is - // self-describing. See docs: 2026-05-21-linux-sysroot-missing-kernel-headers.md - - // ── L3: project-local `build.mcpp` imperative build program ───────────── - // The ROOT program is compiled with the HOST toolchain and run AFTER - // dependency resolution + feature activation (so it receives - // MCPP_DEP__DIR like a dependency's does — design §3.1 item 4) and - // BEFORE the modgraph scan (so its `generated=`/`source=` sources are - // picked up) — see the call site further below, after the dep build.mcpp - // loop. Its stdout directives augment buildConfig; a declared-input cache - // re-runs it only when its source/inputs/env/contract change. It cannot - // gate the top-level dependency graph (leaf-only rule). Under a cross - // --target it runs with a host-resolved toolchain and sees MCPP_TARGET = - // the cross triple (G3). - // See .agents/docs/2026-06-30-l3-build-mcpp-implementation-design.md, - // 2026-07-17-asm-sources-and-general-build-capabilities-design.md §2.4 and - // 2026-07-19-large-source-pkg-platform-fixes-and-buildmcpp-generation-design.md. - // Root [generated_files]: materialize before build.mcpp and the modgraph - // scan so synthesized sources are globbed like any on-disk file — and - // BEFORE dependency resolution, since generated_files may produce - // build.mcpp itself. (The per-dependency call sits in the dep resolution - // loop below; the root manifest needs its own.) - if (!m->buildConfig.generatedFiles.empty()) { - std::vector staleGenerated; - if (auto r = materialize_generated_files( - *root, *m, overrides.plan_only ? &staleGenerated : nullptr); !r) { - return std::unexpected(r.error()); - } - for (auto const& path : staleGenerated) - planNotes.push_back({"MCPP_GENERATED_FILE_NOT_MATERIALIZED", - std::format("'{}' is declared in [build] generated_files and its " - "content on disk differs from the declaration; this " - "command does not write the project, and `mcpp build` " - "writes it", path.string())}); - } - - // Canonical rendering of the resolved target (for the env contract). - std::string resolvedTargetCanonical; - if (!overrides.target_triple.empty()) { - auto tt = mcpp::toolchain::triple::parse(overrides.target_triple); - resolvedTargetCanonical = tt ? tt->str() : overrides.target_triple; - } - - // Host toolchain for build.mcpp (G3): under a cross --target the resolved - // `tc` is the cross toolchain, whose products cannot run here — resolve a - // host-target toolchain from the same spec vocabulary (the spec WITHOUT - // the --target axis), lazily and only when a build.mcpp actually exists - // (root or dependency). - std::optional> hostTcCache; - auto host_tc_for_build_program = [&]() -> std::expected< - std::pair, std::string> { - // A HOST TOOLCHAIN'S C LIBRARY IS THE PAYLOAD'S, WHATEVER THE - // PROJECT'S TARGET SIDE IS. - // - // `build.mcpp` is compiled AND RUN on the machine doing the build. Its - // C library therefore comes from the compiler payload — even for a - // project whose TARGET takes its C library from the dependency graph. - // The two are different machines and this function's whole job is to - // keep them apart. - // - // AND THE NATIVE BRANCH BELOW RETURNS THE MAIN `tc`, WHICH CARRIES - // THE OTHER ANSWER. `build_program.cppm`'s own header states the - // invariant — "`tc` is always a HOST-targeting toolchain" — and for - // every field but this one the native branch satisfied it, because on a - // native build the compiler IS the host compiler. `cAbiPrebuilt` is the - // first field where "same compiler" and "same target side" come apart. - // - // AN INVARIANT, NOT A BUG FIX FOR ANY MEASURED FAILURE. It was - // written while chasing a `features.h: No such file` on openkal-musl's - // CI and it is NOT that failure's cause: measured on `origin/main` and - // on this branch, the gcc std module carries zero `-isystem`/ - // `-idirafter` rows either way — that toolchain reaches its C library - // through the specs the post-install fixup rewrites, and the real - // defect was in resolving WHICH glibc payload those specs name. - // - // Kept because the invariant is worth being true: a helper compiled and - // run on the build machine must not inherit the target's C-library - // origin, and the next field that comes apart would find no rule here. - // - // ⇒ Stated once, so every consumer (the std module build, - // `host_base_flags`) gets it without asking. - auto as_host = [](mcpp::toolchain::Toolchain t) { - t.cAbiPrebuilt = true; - return t; - }; - // `explicit_compiler` IS EMPTY FOR ONE RESOLUTION PATH, AND THIS IS - // THE ONLY CALLER THAT NOTICED BY CRASHING (#527). - // - // Every branch that resolves a toolchain from the index assigns - // `explicit_compiler`; the `[toolchain] system` branch does not, because - // it has nothing to assign yet — `detect` finds the PATH compiler a few - // hundred lines below and stores the resolved ABSOLUTE path in - // `tc->binaryPath`. The main build reads the compiler from `tc` and is - // fine; this closure returned the local variable and handed "" to - // `posix_spawnp`, which is `exit 127: posix_spawnp('') failed`. - // - // AND THE FIX IS NOT "SUPPORT THE HOST". `tc->binaryPath` is the - // compiler this build is ALREADY using for every other translation - // unit; build.mcpp is compiled with the project's toolchain by - // definition (see this lambda's header). Reading it from the place it - // was resolved makes the two paths agree — it grants no capability the - // project did not already have, and the host-dependence warning at the - // `system` branch is what states the cost. - // - // The CROSS branch below is a different question and deliberately - // unchanged: there `explicit_compiler` is empty because NO host - // toolchain was resolved at all, and its classified refusal is correct. - if (overrides.target_triple.empty()) - return std::pair{ - explicit_compiler.empty() ? tc->binaryPath : explicit_compiler, - as_host(*tc)}; - if (hostTcCache) - return std::pair{hostTcCache->first, as_host(hostTcCache->second)}; - if (!tcSpec || *tcSpec == "system" || tcSpecIsMsvc) { - // A READABLE REFUSAL THAT HAD NO CODE, so the target matrix - // recorded four identical `other` cells for it. The sentence was - // right; the classification was missing. Measured on windows-2022 - // with `msvc@system` declared and any cross target. - refusal::record(refusal::Code::HostToolToolchain); - return std::unexpected(std::string( - "build.mcpp under a cross --target needs a resolvable host " - "toolchain — set one via [toolchain] or `mcpp toolchain default`")); - } - // THE ROW'S CONVENTION IS NOT THE HOST'S COMPILER. When the target - // row's pin replaced a spec the user or the machine had chosen, the - // build program resolves the replaced one: it is what a native build - // on this machine would use, and it is what the user wrote. - // - // A PIN THAT REPLACED NOTHING IS NOT "RESOLVED AS BEFORE" ANY MORE - // (#622). "Before" meant falling through to `*tcSpec`, which at this - // point (`tcOrigin == TargetPin`) IS the row's own pin — a TARGET - // answer. For a row whose payload can only ever emit its target - // (`emscripten@…` → em++, WebAssembly under every invocation) that - // resolved a cross compiler as the HOST toolchain for build.mcpp, - // which is compiled AND RUN on this machine: the compile itself - // "succeeds" (clang accepts the syntax) and the failure surfaces one - // step later, inside the payload's own driver, trying to produce a - // program this machine can execute (measured: emcc.py's - // `phase_compile_inputs` hits `assert os.path.exists(output_file)` - // and raises, on the very first `mcpp build --target - // wasm32-emscripten` in a fresh $HOME, before any [toolchain] default - // has ever been resolved or persisted). A row whose payload happens - // to double as a host compiler (an NDK clang) hid the same defect by - // accident. - // - // "Nothing to fall back on" must mean "resolve the platform's native - // default now", exactly as a plain `mcpp build` would on a virgin - // machine — not "reuse the target's answer". `native_first_run_spec()` - // 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); - auto spec = mcpp::toolchain::parse_toolchain_spec(hostSpecText); - if (!spec || spec->version.empty()) { - return std::unexpected(std::format( - "toolchain spec '{}' is invalid for the build.mcpp host resolve", hostSpecText)); - } - // Deliberately NO target injection: the spec resolves for the host. - auto pkg = mcpp::toolchain::to_xim_package(*spec); - auto cfgH = get_cfg(); - if (!cfgH) return std::unexpected(cfgH.error()); - mcpp::fetcher::Fetcher fetcher(**cfgH); - mcpp::fetcher::InstallProgressHandler progress; - auto payload = fetcher.resolve_xpkg_path(pkg.target(), /*autoInstall=*/true, &progress); - if (!payload) { - return std::unexpected(std::format( - "host toolchain for build.mcpp ('{}'): {}", hostSpecText, - payload.error().message)); - } - auto frontendR = mcpp::toolchain::payload_frontend(payload->root, pkg); - if (!frontendR) return std::unexpected(frontendR.error()); - auto frontend = *frontendR; - if (!std::filesystem::exists(frontend)) { - return std::unexpected(std::format( - "host toolchain payload '{}' has no known C++ frontend in {}", - pkg.target(), - mcpp::toolchain::payload_frontend_dir(payload->root, pkg).string())); - } - provide_runtime_payload(pkg); - if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( - **cfgH, payload->root, pkg, - runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) - return std::unexpected(std::format( - "host toolchain post-install fixup: {}", fixed.error())); - else report_fixup(*fixed, payload->root); - // SAME THREE ARGUMENTS THE NATIVE CALL USES (line ~3550), not the - // one-argument form. `detect()` probes `payloadPaths` — the - // fine-grained glibc/linux-headers xpkg directories `resolve_link_model` - // attaches as explicit `-isystem` rows — from the SECOND argument, and - // does so only when it is given; passing only `frontend` leaves - // `tc.payloadPaths` unset, so `host_base_flags`/`host_compile_tokens` - // fell back to `tc.sysroot` alone (from the payload's own - // `*sysroot_spec: --sysroot=%R`, `%R` being wherever the fixup pointed - // it — nothing, on a sandbox with no leaked subos sysroot to fill it - // in by accident). - // - // Measured in the xlings sandbox against the released 2026.9.12.3, on - // a fresh registry (a real, non-symlinked gcc@16.1.0 payload, no - // ambient /usr/include, no subos state to leak): "Resolved host - // toolchain for build.mcpp: gcc 16.1.0 (x86_64-linux-gnu)" — the right - // FAMILY, since #622's first fix already keeps the pre-row spec — and - // then the `mcpp` module compile failed with `features.h: No such - // file or directory`, because that gcc's specs alone name no C - // library. `echo | g++ -x c++ -E -v -` there lists only the payload's - // own `c++/16.1.0`, `include`, `include-fixed` — no glibc directory. - // On a development machine the same probe happens to pass, but for a - // reason that has nothing to do with this code path: the shared-store - // gcc's search list there ends with a SUBOS's `usr/include`, leaked - // into `%R` by machine state the payload never declared (the same - // shape as "host /usr/include silently completes a payload - // toolchain") — which is exactly the kind of thing a fresh sandbox - // does not have lying around to hide the gap. - // - // `runtimePayload` and `runtimeBindingSnapshot` (declared once, near - // the top of this function) are the HOST's C-library identity — never - // re-derived from `--target`, see their own declarations — so passing - // them here is not a parallel derivation; it is the one this function - // already had in scope and the native call already trusts. - auto htc = mcpp::toolchain::detect( - frontend, runtimePayload, runtimeBindingSnapshot.contractHash); - if (!htc) return std::unexpected(htc.error().message); - mcpp::ui::info("Resolved", std::format( - "host toolchain for build.mcpp: {}", htc->label())); - hostTcCache = std::pair{frontend, *htc}; - return std::pair{hostTcCache->first, as_host(hostTcCache->second)}; - }; - - // Resolve dependencies: walk the **transitive** graph from the main - // manifest, BFS-style. Each unique `(namespace, shortName)` is fetched - // once, its `[build].include_dirs` are propagated to the main - // manifest, and its own `[dependencies]` are queued for processing - // (its `[dev-dependencies]` are NOT — those are private to the dep's - // own test runs). - // - // Conflict policy: C++ modules require globally-unique module names - // and ODR-respecting symbols, so the same `(ns, name)` resolved to - // two different exact versions is an error — mcpp prints both - // requesting parents and asks the user to align them. - - // Refresh the builtin package index only when a dependency cannot be - // resolved from the local copy (#315). - // - // This used to fire whenever the refresh marker was older than an hour, - // whether or not anything was actually missing — so every build with a - // registry dependency paid a multi-repo network sync once an hour, which is - // minutes on a slow or blocked network for data it already had. The policy - // now lives in mcpp.pm.index_refresh and is shared with `mcpp add` and the - // xim install gate, which had each derived their own (and disagreed). - // - // Nothing here decides anything itself — in particular the "a miss proves - // nothing for this namespace" rule must not be re-derived; see that module. - if (!m->dependencies.empty()) { - if (auto cfg2 = get_cfg()) { - auto xlEnv = mcpp::config::make_xlings_env(**cfg2); - auto policy = mcpp::pm::policy_for(**cfg2); - // Same routing the dependency walk below uses (the `index_route` - // lambda is declared further down; this is the identical value). - mcpp::pm::IndexRoute route{ &m->indices, *root, *cfg2 }; - for (auto& [depName, spec] : m->dependencies) { - auto decision = mcpp::pm::decide_for_dependency( - route, depName, spec, xlEnv, targetPlatform, policy); - if (!decision.shouldRefresh) { - mcpp::log::verbose("index", std::format( - "{}: {}", decision.subject, - mcpp::pm::reason_text(decision.reason))); - continue; - } - // A failed refresh is not a failed build: the dependency walk - // below may still resolve everything from what is on disk, and - // if it cannot, it reports the actual missing package with the - // index's age attached. Failing here instead would turn a - // transient network blip into a hard stop for a build that - // needed no network at all. - if (auto r = mcpp::pm::apply(decision, xlEnv); !r) - mcpp::ui::warning(r.error()); - break; // one sync covers every dependency - } - } - } - - // Set up project-level .mcpp/ directory for custom indices and/or the - // [xlings] build environment (L-1). This creates .mcpp/.xlings.json with - // custom non-builtin index entries (so xlings can clone them) plus the - // [xlings] deps/workspace/subos/envs materialized verbatim. - const auto& runtimeOwnerManifest = wsManifest ? *wsManifest : *m; - // The TARGET's C library, if this target has one. Resolved here and not by - // any package, for the same reason the compiler pin is: it is a property - // of the target. - // - // It rides the SAME channel as `[xlings] deps` rather than getting an - // install path of its own — one materialization, one place that can be - // wrong. What it must NOT do is depend on the project having an `[xlings]` - // section: a bare-metal project written to the template has none, and the - // whole point is that it never mentions a libc. - // FROM THE REQUESTED TRIPLE, NOT FROM THE TOOLCHAIN — AND THE TWO WERE - // THE SAME VALUE ALL ALONG. - // - // This read of `tc->targetTriple` was the ONLY thing tying the compiler's - // resolution to a point before dependency resolution, and it never wanted - // the compiler: `tc->targetTriple` is corrected to the requested triple a - // few lines after the toolchain is detected, so the value here is the one - // `--target` named. Taking it from the request instead lets the toolchain - // be resolved where the information it needs actually exists. - std::string targetSysroot; - { - auto tt = overrides.target_triple.empty() - ? std::optional{mcpp::toolchain::triple::host_triple()} - : mcpp::toolchain::triple::parse(overrides.target_triple); - // Not on an MSVC-ABI row: there the key names an MSVC toolset, which - // `bind_msvc_sysroot` locates or installs itself -- an installed - // toolset of the pinned version must win over a download, and - // `msvc@system` is not a package at all. - if (tt && !tt->is_msvc_env()) - targetSysroot = mcpp::toolchain::triple::effective_sysroot( - *tt, sysroot_override(*m, *tt)); - } - const bool materializeRootRuntime = - !overrides.inherited_runtime_binding - && (!runtimeOwnerManifest.xlings.empty() || !targetSysroot.empty()); - if (!m->indices.empty() || materializeRootRuntime) { - auto cfg2 = get_cfg(); - if (cfg2) { - mcpp::xlings::ProjectEnv penv; - if (materializeRootRuntime) { - penv.deps = runtimeOwnerManifest.xlings.deps; - // Appended, never substituted: a project may legitimately - // declare other xim packages, and a target sysroot is one more - // entry rather than a replacement for the list. Deduplicated - // because a manifest written before this axis existed still - // names it, and declaring it twice is not an error the author - // should have to hear about. - if (!targetSysroot.empty() - && std::ranges::find(penv.deps, targetSysroot) == penv.deps.end()) - penv.deps.push_back(targetSysroot); - penv.subos = runtimeOwnerManifest.xlings.subos; - for (auto const& [k, v] : runtimeOwnerManifest.xlings.workspace) - penv.workspace.emplace_back(k, v); - // `[feature-xlings.]` becomes part of the project's - // environment only while `` is active. It is written into - // the same two fields, because from xlings' side there is no - // such thing as a feature: the file states what this project - // uses, and the feature decided that. - for (auto const& f : - feature_closure(runtimeOwnerManifest, - parse_feature_request(overrides.features))) - if (auto it = runtimeOwnerManifest.xlings.featureDeps.find(f); - it != runtimeOwnerManifest.xlings.featureDeps.end()) - for (auto const& address : it->second) { - if (std::ranges::find(penv.deps, address) == penv.deps.end()) - penv.deps.push_back(address); - const auto entry = - mcpp::manifest::parse_address(address); - if (std::ranges::none_of(penv.workspace, - [&](auto const& kv) { return kv.first == entry.target; })) - penv.workspace.emplace_back(entry.target, entry.pin()); - } - } - if (runtimeSelection.ownerRoot == workRoot) { - mcpp::config::ensure_project_index_dir( - **cfg2, workRoot, m->indices, penv); - } else { - if (!m->indices.empty()) - mcpp::config::ensure_project_index_dir( - **cfg2, workRoot, m->indices, {}); - if (materializeRootRuntime) - mcpp::config::ensure_project_index_dir( - **cfg2, runtimeSelection.ownerRoot, {}, penv); - } - - // `[xlings] deps` are DECLARED above and, until now, nothing - // installed them (mcpp-index #281 §9). - // - // `ensure_project_index_dir` writes them into `.mcpp/.xlings.json` - // verbatim and stops there, so a manifest saying - // `deps = ["xim:mesa"]` produced a file naming mesa, no project - // SubOS, and `fatal error: gbm.h: No such file or directory`. The - // declaration looked accepted and did nothing — which is the worst - // shape a config key can have. - // - // This is the same "declare it and mcpp provisions it on first use" - // contract `[toolchain]` has had all along; that path is a few - // hundred lines up ("First run — no toolchain configured … - // installing … as default"). A build environment should not have - // two grades of declaration. - // - // ORDER IS LOAD-BEARING: this must run BEFORE the runtime binding - // resolves, because a named `[xlings] subos` that does not exist - // yet is a hard error ("selected SubOS '…' does not exist; - // create/bootstrap that environment"), and provisioning is what - // creates it. Placed here, next to the index sync below, both - // first-use provisioning steps sit in one place. - // - // `install_packages` rather than `fetcher.install`: the install - // DESTINATION is chosen by package scope (project vs global), and - // the project scope is what materializes the project SubOS. It also - // carries the live progress UI and captured child errors, matching - // the toolchain and custom-index paths. - // Only what the MANIFEST declared, deliberately not `penv.deps`. - // - // A cross-compilation target sysroot is APPENDED to that list a few - // lines up, and provisioning it here would change behaviour for - // projects that never asked for it: a name that does not resolve - // would turn a build that used to proceed into a hard failure. The - // contract being added is "what you declared gets installed", and - // the sysroot entry is mcpp's own inference rather than the - // author's declaration. - // Only the tiers this verb needs, and only what the ROOT - // declared. The graph's own declarations are provisioned after - // resolution, which is the first moment they are known — see the - // second pass near `xlingsDepBinDirs`. - // ONE PACKAGE, ONE VERSION, INSIDE ONE MANIFEST TOO. The - // conditional merge already unified the two tool AXES by package; - // what it cannot see is `[xlings.workspace]` and - // `[feature-xlings.]` naming one package at two versions, which - // reaches here as two addresses and used to install both. - std::vector rootClaims; - for (auto const& spec : applicable_xlings_addresses( - runtimeOwnerManifest, - feature_closure(runtimeOwnerManifest, - parse_feature_request(overrides.features)), - toolPurpose, /*isRoot=*/true)) - rootClaims.push_back({spec, "this project", 0}); - auto rootUnified = mcpp::xlings::addrset::unify(rootClaims); - if (!rootUnified) { - refusal::record(refusal::Code::ToolVersionConflict); - return std::unexpected(rootUnified.error()); - } - for (auto const& note : rootUnified->overrides) - mcpp::diag::warning("xlings/version-override", note); - std::vector declaredDeps; - for (auto const& w : rootUnified->winners) - declaredDeps.push_back(w.address); - if (materializeRootRuntime && !declaredDeps.empty()) { - if (auto pv = provision_xlings_addresses( - **cfg2, declaredDeps, runtimeSelection.ownerRoot, - "[xlings.workspace] entries"); - !pv) return std::unexpected(pv.error()); - } - - // On first build, the project index data root may be empty because - // ensure_project_index_dir only writes .xlings.json but does not - // trigger clone/link creation. Local path indices are read directly; - // remote custom indices are synced quietly before dependency resolution. - bool hasCustomIndices = false; - for (auto& [idxName, spec] : m->indices) { - if (!spec.is_builtin()) { - hasCustomIndices = true; - break; - } - } - if (hasCustomIndices) { - bool needsClone = !mcpp::config::project_index_data_initialized(*root); - if (needsClone) { - bool needsRemoteUpdate = false; - for (auto& [idxName, spec] : m->indices) { - if (spec.is_builtin() || spec.is_local()) continue; - needsRemoteUpdate = true; - break; - } - // A first sync is a refresh of an index that has no local - // copy yet, and `[index] auto_refresh = false` means that no - // refresh happens implicitly (docs/05). The opt-outs are the - // policy's (#648 A5); offline, the sync is a no-op as before - // and resolution reports what is missing. - // - // WHY THIS ONE DOES NOT GO THROUGH `decide_for_miss`/`apply`. - // Those answer "may this run refresh the index that would - // resolve a dependency", and their debounce and one-sync-per- - // process guard are about that one index. This sync creates a - // local copy that does not exist yet, of a DIFFERENT set of - // repositories, and nothing else will create it: taking the - // guard would let a refresh of the builtin index earlier in - // the same run suppress a clone the build cannot proceed - // without. Only the opt-outs are shared, and they are read - // from the same `policy_for`. - const auto refreshPolicy = mcpp::pm::policy_for(**cfg2); - if (needsRemoteUpdate && !refreshPolicy.offline && !refreshPolicy.autoRefresh) { - return std::unexpected(std::string( - "the project's custom index repositories have never been synced, " - "and [index] auto_refresh = false forbids syncing them implicitly\n" - " run `mcpp index update` once, then build again")); - } - if (needsRemoteUpdate && !refreshPolicy.offline) { - mcpp::ui::status("Fetching", "custom index repos (first use)"); - auto projEnv = mcpp::config::make_project_xlings_env(**cfg2, *root); - int rc = mcpp::xlings::update_index(projEnv, /*quiet=*/true); - if (rc != 0) { - return std::unexpected( - "project custom index update failed; run `mcpp index update` for details"); - } - } - } - } - } - } - - std::vector packages; - // The features each package ends up built with, index-aligned with - // `packages`. Recorded at activation because the passes that run after it - // — `[feature-xlings]` provisioning among them — otherwise have no way to - // ask, and re-deriving it there would be a second copy of the aggregation - // rule. - std::vector> activeFeaturesByPackage; - - // WHICH VERSION OF EACH TOOL PACKAGE THIS BUILD USES, decided once. - // - // Keyed by `(namespace, name)` — the identity, with the version treated as - // a constraint on it. Filled by the first `graph_xlings_split()` below and - // read by `fillXpkgDirs`, because those two answer the same question from - // different ends: one decides what is installed, the other tells a build - // program where it landed. They used to derive it separately, and the - // failure that produced is the quiet one — installed A, answered B. - std::map xlingsWinner; - - // The split is computed HERE and reused by the late pass, so the two - // cannot disagree about what "the graph declared" means. - // - // ONE PACKAGE, ONE VERSION. This used to compare whole address strings, so - // `xim:cuda-nvcc@13.3.33` from the project and `xim:cuda-nvcc@>=12.9.86` - // from a rule package were two packages: both installed, gigabytes each, - // and `xpkg_dir` answered one of them. The unification below adjudicates - // (the declaration nearer the artifact wins) and validates (the winner must - // satisfy every requirement that lost) — see mcpp.xlings.address_set. - auto graph_xlings_split = [&]() -> std::expected< - std::pair, std::vector>, - std::string> { - namespace addrset = mcpp::xlings::addrset; - auto describe = [&](std::size_t i) { - auto const& pkg = packages[i].manifest.package; - return pkg.namespace_.empty() ? pkg.name - : pkg.namespace_ + ":" + pkg.name; - }; - std::vector claims; - for (auto const& spec : applicable_xlings_addresses( - runtimeOwnerManifest, activeFeaturesByPackage.empty() - ? std::vector{} : activeFeaturesByPackage[0], - toolPurpose, /*isRoot=*/true)) - claims.push_back({spec, "this project", 0}); - // THE BUCKET IS DECIDED BY WHERE THE WINNING CLAIM SITS IN THIS LIST, - // not by its distance. The root's own pass provisions exactly the - // addresses collected above; anything else has to reach the graph pass - // or nothing installs it. Those two lists are the same one whenever the - // project is its own runtime owner, and differ under a workspace. - const std::size_t rootClaims = claims.size(); - for (std::size_t i = 0; i < packages.size(); ++i) { - const auto& man = packages[i].manifest; - const auto feats = i < activeFeaturesByPackage.size() - ? activeFeaturesByPackage[i] : std::vector{}; - for (auto const& spec : applicable_xlings_addresses( - man, feats, toolPurpose, /*isRoot=*/i == 0)) - claims.push_back({spec, describe(i), i == 0 ? 0 : 1}); - } - auto unified = addrset::unify(claims); - if (!unified) return std::unexpected(unified.error()); - std::vector rootSpecs, fromGraph; - for (auto const& w : unified->winners) { - (w.claim < rootClaims ? rootSpecs : fromGraph).push_back(w.address); - xlingsWinner[addrset::package_key(w.address)] = w.address; - } - // REPORTED, NOT INFERRED. An override that is only visible as "two - // versions were declared and one directory exists" is a fact the reader - // has to reconstruct from the filesystem. - // - // This lambda runs twice per build (the early pass and the late one), - // and the reader sees each note ONCE: `mcpp::diag` deduplicates by the - // whole payload, which is a designed property rather than an accident - // of where these two calls sit. - for (auto const& note : unified->overrides) - mcpp::diag::warning("xlings/version-override", note); - return std::pair{std::move(rootSpecs), std::move(fromGraph)}; - }; - packages.push_back({*root, *m}); - - // dep_manifests is kept around purely so the build plan can move it - // out at the end (PackageRoot stores a `Manifest` by value, so the - // unique_ptr is not load-bearing for liveness — it's a leftover from - // an earlier design and harmless). - std::vector> dep_manifests; - auto cache_index_name = [](std::string_view ns) { - if (ns.empty()) return std::string(mcpp::pm::kDefaultNamespace); - return std::string(ns); - }; - struct DepCacheIdentity { - std::string indexName; - std::string packageName; - std::string version; - // "version" | "path" | "git". Only "version" is cacheable: an index - // package's payload lives in the immutable xpkgs store under a - // version-keyed directory, so name@version identifies its sources. - // Path and git checkouts can change under an unchanged identity. - std::string sourceKind; - // What identifies the SOURCES when the version does not: the resolved - // commit for `git`, the package root for `path`, empty for an index - // package. Read by the tool store, whose key must hold everything - // that can change a built tool's bytes (#630, item 6). - std::string sourceRef; - }; - std::vector dep_cache_identities; - struct GitLockIdentity { - std::string source; - std::string hash; - }; - std::map root_git_lock_identities; - - struct ResolvedKey { - std::string ns; - std::string shortName; - auto operator<=>(const ResolvedKey&) const = default; - }; - struct ResolvedRecord { - std::string version; // empty for path/git deps - std::string constraint; // AND-combined original constraints (version src only) - std::string requestedBy; // human-readable for error messages - std::string source; // "version" | "path" | "git" — for type-clash check - // The declaration's identity beyond `source`, so a SECOND declaration - // of the same (ns, name) can be compared for "the same reference" - // rather than merely "the same kind". `git`: "#=", - // from the DECLARED ref (never the resolved commit — comparing two - // branch names must not need a network round trip to decide whether - // they conflict). `path`: the canonical absolute directory. `version`: - // the original constraint string ("*" for none). See the - // `dependency/source-override` decision at the resolve hit (2026-09-13 - // #630 record, §2.2). - std::string sourceRef; - // True when this record's declaration came from the root manifest's - // own [dependencies]/[dev-dependencies]/[build-dependencies] - // (`item.consumerDepIndex == kMainConsumer` at the time the record - // was created). Bounds the root's privilege to override a - // conflicting declaration of the SAME identity the way - // `DependencySpec::linkage` is honoured only on the root's own - // edges — see dep_spec.cppm. - bool fromRoot = false; - // Reached ONLY through [dev-dependencies]. mcpp.lock excludes these: - // dev-deps are resolved under `mcpp test` and not under `mcpp build`, so - // recording them makes a VCS-committed file depend on which command ran - // last and ping-pong between the two. The lock must be a function of the - // MANIFEST, not of the command. Cleared the moment a non-dev consumer - // asks for the same package. - bool devOnly = false; - std::size_t depIndex = 0; // index into dep_manifests/packages-1 (for in-place re-fetch) - std::vector linkFlagsAdded; // entries appended to m->buildConfig.ldflags by this dep - }; - std::map resolved; - - // Sentinel for "the consumer is the main package" (no dep_manifests entry). - constexpr std::size_t kMainConsumer = static_cast(-1); - - struct WorkItem { - std::string name; // dep map key as written - mcpp::manifest::DependencySpec spec; // copy (we may mutate version) - std::string requestedBy; // who asked for it - std::string originalConstraint; // spec.version BEFORE pinning (for SemVer merge) - std::size_t consumerDepIndex; // dep_manifests slot of who pushed this child; kMainConsumer for main - std::filesystem::path resolveRoot; // base dir for relative path deps (empty = use project root) - bool devOnly = false; // seeded from [dev-dependencies]; inherited by children - // Seeded from `[build-dependencies]`, and inherited by children the - // same way `devOnly` is. It answers "does this serve the build or the - // target", which is a different question from "which build-time - // product do I want" — that one is answered per edge by `tools` and - // `host-module`, and the two are orthogonal. A package linked into the - // target that also provides a tool is written once, in - // `[dependencies]`, with a `tools` request on it. - bool buildOnly = false; - }; - std::deque worklist; - - // #634, A2. A `path` or `git` dependency's identity is the one its manifest - // declares (SPEC-001 §1.2), and the key a consumer wrote is one way of - // reaching it. `identityBySource` maps a canonical source (the directory, - // or the repository and reference) to the identity resolved from it, so a - // second key over the same source finds that record without loading the - // manifest again. `declaringManifest` holds the manifest each such - // identity came from, and records whether that manifest named its - // namespace: one that does not takes the key's, so two keys over it would - // be two identities over one source. - struct DeclaringManifest { - std::string path; - bool namespaceDeclared = false; - }; - std::map identityBySource; - std::map declaringManifest; - std::set> adoptionsReported; - // A GIT DEPENDENCY NAMES A REPOSITORY, AND THE KEY NAMES WHICH PACKAGE OF - // IT (#649 E7). The root manifest's package is one; each `[workspace] - // members` entry of that manifest is another. Before this a git source - // always yielded the root package, so a repository holding a framework and - // its tools could be pinned by revision for the framework only. The clone - // of every git source resolved so far is kept with the reference it was - // resolved from, so a later key over the same source, and a member's - // `path` edge that stays inside the clone, find it. - struct GitClone { - std::filesystem::path root; - std::string url, refKind, ref; - }; - std::map gitCloneBySource; - // The member of the repository at `cloneRoot` whose manifest declares - // `want`, when the root manifest's package is not `want` itself. - auto gitMemberDeclaring = [&](const std::filesystem::path& cloneRoot, - const ResolvedKey& want) - -> std::optional { - auto declares = [&](const mcpp::manifest::Manifest& mm) { - auto rn = mcpp::pm::compat::resolve_package_name( - mm.package.name, mm.package.namespace_); - // A manifest that names no namespace takes the key's, as a path - // dependency's does (#634 A2), so only the short name is compared. - const bool nsDeclared = !mm.package.namespace_.empty() || rn.usedLegacySplit; - return rn.shortName == want.shortName - && (!nsDeclared || rn.namespace_ == want.ns); - }; - std::error_code ec; - if (!std::filesystem::exists(cloneRoot / "mcpp.toml", ec)) return std::nullopt; - auto rootManifest = mcpp::manifest::load(cloneRoot / "mcpp.toml"); - if (!rootManifest || declares(*rootManifest) - || !rootManifest->workspace.present) - return std::nullopt; - for (auto const& member : rootManifest->workspace.members) { - const auto path = cloneRoot / member / "mcpp.toml"; - if (!std::filesystem::exists(path, ec)) continue; - auto mm = mcpp::manifest::load(path, {.insideWorkspace = true}); - if (mm && declares(*mm)) - return std::filesystem::path(member).lexically_normal().generic_string(); - } - return std::nullopt; - }; - auto qualifiedKey = [](const ResolvedKey& k) { - return k.ns.empty() ? k.shortName : std::format("{}.{}", k.ns, k.shortName); - }; - // A root edge that adopted an identity states it on the root's own - // declaration too, which is what every later reader of the root manifest - // (the build banner, the resolution record) sees. - auto stateAdoptedIdentity = [&](const WorkItem& item, const ResolvedKey& declared) { - if (item.consumerDepIndex != kMainConsumer) return; - if (auto it = m->dependencies.find(item.name); it != m->dependencies.end()) { - it->second.namespace_ = declared.ns; - it->second.shortName = declared.shortName; - } - }; - // One warning per declaring edge: each names a line someone can correct. - auto reportAdoption = [&](const std::string& requestedBy, const std::string& written, - const ResolvedKey& normalised, const ResolvedKey& declared, - const std::string& manifestPath) { - if (!adoptionsReported.emplace(requestedBy, written).second) return; - mcpp::diag::warning("dependency/identity", std::format( - "'{}' declares the dependency '{}', which names {}; the manifest " - "'{}' declares {}, and that identity is used.", - requestedBy, written, qualifiedKey(normalised), manifestPath, - qualifiedKey(declared)), - std::format("write '{}' in '{}' to state the identity the " - "manifest declares.", - qualifiedKey(declared), requestedBy)); - }; - - - // Index routing — WHICH index answers for a namespace and how its - // descriptors are read — lives in mcpp.pm.index_route, shared with the - // `mcpp add` existence gate so the two cannot disagree about which - // packages are real (#305/#307). `cfg` is filled in per call: the route is - // rebuilt on demand because `root` moves when a workspace member is - // selected above. - auto index_route = [&](mcpp::config::GlobalConfig* cfg = nullptr) { - return mcpp::pm::IndexRoute{ &m->indices, *root, cfg }; - }; - auto findIndexForNs = [&](const std::string& ns) - -> const mcpp::pm::IndexSpec* - { - return index_route().find_for_ns(ns); - }; - - // SemVer constraint resolver, shared across the worklist so transitive - // deps with caret/range constraints (`^1.0`) also get pinned to a - // concrete version before fetch. - auto resolveSemver = [&](mcpp::manifest::DependencySpec& s, - const std::string& depName) - -> std::expected - { - if (s.isPath() || s.isGit()) return {}; - if (!mcpp::pm::is_version_constraint(s.version)) return {}; - auto cfg = get_cfg(); - if (!cfg) return std::unexpected(cfg.error()); - // 0.0.10+: use structured namespace from DependencySpec. The route (not - // a bare Fetcher) is what reaches a descriptor served by a project - // `[indices]` entry — see #308. - auto resolved = mcpp::pm::resolve_semver( - s.namespace_, s.shortName.empty() ? depName : s.shortName, - s.version, index_route(*cfg), targetPlatform); - if (!resolved) return std::unexpected(resolved.error()); - mcpp::ui::info("Resolved", - std::format("{} {} → v{}", depName, s.version, *resolved)); - s.version = std::move(*resolved); - return {}; - }; - - // Acquire a version-source dep at a specific pinned version. Used both - // by the first-time walk and by the SemVer merger when a re-fetch at a - // different version is needed. Returns the dep's effective root (where - // mcpp.toml lives) and a fully loaded manifest. - using LoadedDep = std::pair; - // Identity-first candidate probe. A candidate is DISAMBIGUATED by the - // DECLARED (namespace, name) of whatever descriptor the index holds — never - // by whether a canonically-named file `..lua` happens to exist on - // disk. It routes through the same identity-verified readers the load path - // uses (`read_xpkg_lua*`, which gate every hit on the descriptor's declared - // identity and already cover non-canonical filenames), so candidate selection - // and loading can never disagree about what a candidate resolves to. - // - // SCOPE (#278, do not over-read the paragraph above): identity governs which - // hits are ACCEPTED, not which files are REACHED. Discovery is still bounded - // by the candidate-filename list from `compat::xpkg_lua_candidates` — there - // is no index-wide scan of `pkgs/*/*.lua` anywhere in mcpp, so a descriptor - // whose filename matches none of the candidates is simply not found. The - // `IdentityIndex` that would lift that bound was deferred with §5 of the - // 2026-06-26 design and is deliberately NOT being added: see - // .agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md - // §3.2/§4.2 for why bare-name discovery across arbitrary namespaces is a - // reproducibility hazard rather than a convenience. - // - // Before this, selection probed the canonical filename only, so a descriptor - // filed under a non-canonical name (e.g. `aimol.tensorvia-cpu` declared in the - // mcpplibs index as bare `pkgs/t/tensorvia-cpu.lua`) was invisible to its own - // peer-root candidate `(aimol, tensorvia-cpu)`, leaving the request pinned to - // the wrong front candidate `(mcpplibs.aimol, …)`. See - // .agents/docs/2026-06-26-identity-first-resolution-no-filename.md. - auto readStrictLuaForCandidate = - [&](const mcpp::pm::DependencyCoordinate& coord) - -> std::optional - { - auto cfg = get_cfg(); - if (!cfg) return std::nullopt; - return index_route(*cfg).read(coord); - }; - - auto xpkgLuaMatchesCandidate = - [&](const mcpp::pm::DependencyCoordinate& coord, - std::string_view luaContent, - bool allowLegacyBareDefault) { - // Single source of truth: the descriptor identity gate lives in - // mcpp.manifest and is shared with the read_xpkg_lua family. A - // descriptor served by a declared project index inherits that - // index's namespace when package.namespace is omitted; preserve - // the same owner context during this second, stricter check. - const auto route = index_route(); - const auto* owner = route.find_for_ns(coord.namespace_); - const std::string_view ownerNs = owner - ? std::string_view{owner->name} : std::string_view{}; - return mcpp::manifest::xpkg_lua_identity_matches( - luaContent, coord.namespace_, coord.shortName, - allowLegacyBareDefault, ownerNs); - }; - - auto dependencyCoordinates = - [](const mcpp::manifest::DependencySpec& spec, - const std::string& depName) { - if (!spec.candidates.empty()) return spec.candidates; - std::vector out; - out.push_back({ - .namespace_ = spec.namespace_.empty() - ? std::string(mcpp::pm::kDefaultNamespace) - : spec.namespace_, - .shortName = spec.shortName.empty() ? depName : spec.shortName, - }); - return out; - }; - - std::set selectorMigrationWarnings; - - auto selectDependencyCandidate = - [&](mcpp::manifest::DependencySpec& spec, - const std::string& depName) -> std::expected - { - auto candidates = dependencyCoordinates(spec, depName); - if (candidates.empty()) { - return std::unexpected( - with_index_cause(std::format( - "dependency '{}' has no lookup candidates", depName))); - } - - // One release train of migration support for the former dotted - // candidate search. A lockfile records the identity an existing - // project already selected, so keep that identity stable until the - // user rewrites the selector explicitly. Without a lock anchor, never - // fall back: only diagnose a valid old-primary package and continue - // with the new exact coordinate. - if (spec.legacyCandidateSearch) { - const auto exact = candidates.front(); - bool lockExpressesIntent = false; - if (auto locked = packageIdentityLockAnchors.find(depName); - locked != packageIdentityLockAnchors.end()) { - lockExpressesIntent = true; - if (locked->second != exact.namespace_) { - mcpp::pm::DependencyCoordinate lockedCoordinate{ - .namespace_ = locked->second, - .shortName = exact.shortName, - }; - if (selectorMigrationWarnings.insert(depName).second) { - mcpp::ui::warning(std::format( - "dependency selector '{}' now means exact package " - "'{}', but mcpp.lock records '{}'; keeping the " - "locked identity for this migration release. " - "Write '{}' to keep it explicitly, or remove the " - "lock and keep '{}' to migrate", - depName, - mcpp::pm::format_package_selector(exact), - mcpp::pm::format_package_selector(lockedCoordinate), - mcpp::pm::format_package_selector(lockedCoordinate), - mcpp::pm::format_package_selector(exact))); - } - candidates.assign(1, std::move(lockedCoordinate)); - } - } - - if (!lockExpressesIntent && spec.isVersion()) { - if (auto old = mcpp::pm::legacy_prefixed_coordinate(exact)) { - auto oldLua = readStrictLuaForCandidate(*old); - if (oldLua && xpkgLuaMatchesCandidate( - *old, *oldLua, - /*allowLegacyBareDefault=*/false) - && selectorMigrationWarnings.insert(depName).second) { - mcpp::ui::warning(std::format( - "dependency selector '{}' now resolves exactly to " - "'{}'; an older mcpp would select the existing " - "package '{}'. Write '{}' to keep the old identity " - "or keep '{}' for the new exact identity", - depName, - mcpp::pm::format_package_selector(exact), - mcpp::pm::format_package_selector(*old), - mcpp::pm::format_package_selector(*old), - mcpp::pm::format_package_selector(exact))); - } - } - } - } - - auto selected = candidates.front(); - bool matched = false; - if (spec.isVersion()) { - for (auto& candidate : candidates) { - auto lua = readStrictLuaForCandidate(candidate); - if (!lua) continue; - if (auto violation = mcpp::manifest:: - xpkg_name_form_violation_from_lua(*lua)) { - return std::unexpected(std::format( - "dependency '{}': {}", depName, *violation)); - } - if (!xpkgLuaMatchesCandidate( - candidate, *lua, /*allowLegacyBareDefault=*/false)) { - continue; - } - - // INV-RESOLVE (#278) — the discovery rung `(∅, name)` is the - // "upstream package that declares no namespace" rung, NOT a - // cross-namespace wildcard. The identity gate is intentionally - // permissive here (`ns.empty() → name match is enough`, because - // `mcpp new --template X` legitimately discovers by short name), - // so the narrowing lives at THIS call site rather than in the - // gate — tightening the gate would break template discovery. - // - // Rejecting the hit keeps a third-party-namespaced package from - // being reachable by a bare name: resolution must not depend on - // which indices happen to be present, or adding an index could - // silently retarget an existing dependency (design §3.2). - auto declaredNs = - mcpp::manifest::extract_xpkg_namespace(*lua); - if (candidate.namespace_.empty() && !declaredNs.empty()) { - continue; - } - - // P3 (#278) — resolve the discovery rung to a REAL identity - // before anything downstream sees it. `selected.namespace_` - // used to be the CANDIDATE's namespace, so a discovery hit - // wrote an empty namespace into the spec and on into the - // lockfile and install layer. Read the DECLARED one instead. - // - // An empty `declaredNs` is a legal identity here, not a hole to - // fill: an upstream package with no `namespace` (xim `opencv`, - // `musl-gcc`) is keyed by its bare name, and the derived - // fqname == shortName is exactly right for it. Attributing such - // a descriptor to its owning index (`xim-pkgindex → xim`) is - // §4.1 of the 2026-06-26 design and is still unimplemented. - selected = candidate; - if (selected.namespace_.empty()) selected.namespace_ = declaredNs; - matched = true; - break; - } - - // One-release bare-name migration. Namespace omission means exactly - // `mcpplibs`, but every published `compat.*` package and every user - // manifest written before this release spells the dependency bare — - // `gtest = "1.15.2"`, `ftxui = "6.1.9"`. Making that an immediate - // hard error means an mcpp upgrade breaks builds against data that - // is already published and cannot be edited retroactively; the - // symmetric rule ("published data must not break the program") is - // why the index floor degrades instead of bricking. - // - // The defect #278 removed was the SILENCE, not the reach: mcpp used - // to continue with a namespace the user never wrote and never say - // so. A hit here is announced, is recorded downstream under its - // canonical identity, and names the exact edit that removes the - // warning. Only a selector whose namespace was OMITTED is eligible — - // `mcpplibs.gtest` states an identity and must still miss. - if (!matched && spec.isVersion() && spec.namespaceOmitted) { - for (auto& legacy : - mcpp::pm::legacy_bare_candidates(candidates.front())) { - auto lua = readStrictLuaForCandidate(legacy); - if (!lua) continue; - if (mcpp::manifest::xpkg_name_form_violation_from_lua(*lua)) - continue; - if (!xpkgLuaMatchesCandidate( - legacy, *lua, /*allowLegacyBareDefault=*/false)) - continue; - auto declaredNs = - mcpp::manifest::extract_xpkg_namespace(*lua); - // Same narrowing as the exact loop: the namespace-less rung - // is "upstream package that declares no namespace", not a - // cross-namespace wildcard. - if (legacy.namespace_.empty() && !declaredNs.empty()) - continue; - - selected = legacy; - if (selected.namespace_.empty()) - selected.namespace_ = declaredNs; - matched = true; - // Downstream — lock, install, cache label — must see the - // canonical identity, so the ambiguous spelling survives in - // exactly one place: the user's manifest, until they edit it. - candidates.assign(1, selected); - - if (selectorMigrationWarnings.insert(depName).second) { - mcpp::ui::warning(std::format( - "dependency '{}' resolved to '{}' through the " - "deprecated bare-name search; namespace omission " - "means `{}` only. Write the exact package:" - "\n [dependencies.{}]" - "\n {} = \"{}\"" - "\n (or run `mcpp add {}@{}`). This fallback is " - "removed in {}.", - depName, - mcpp::pm::format_package_selector(selected), - mcpp::pm::kDefaultNamespace, - selected.namespace_, selected.shortName, - spec.version, - mcpp::pm::format_package_selector(selected), - spec.version, - mcpp::pm::kBareNameFallbackRemovedIn)); - } - break; - } - } - - // A custom GIT index is cloned lazily by xlings during install, so - // at selection time its descriptors may legitimately not be on disk - // yet. "Not found" is therefore not conclusive for those namespaces - // — keep the historical fall-through rather than hard-failing on a - // package that would have materialized a moment later. Local path - // indices and the builtin index are both readable here, so they stay - // under the strict rule below. - bool anyLazyGitIndex = std::ranges::any_of(candidates, - [&](const mcpp::pm::DependencyCoordinate& c) { - return index_route().lazy_git(c.namespace_); - }); - - // An exact coordinate that a readable index cannot serve fails at - // resolution. Never carry it into install-time compatibility - // retries, which would reintroduce cross-namespace guessing. - if (!matched && !anyLazyGitIndex) { - std::string tried; - for (auto& c : candidates) { - if (!tried.empty()) tried += ", "; - tried += c.namespace_.empty() - ? std::format("(no namespace, {})", c.shortName) - : std::format("({}, {})", c.namespace_, c.shortName); - } - - // T12 — did-you-mean. DIAGNOSTIC ONLY: the scan runs solely on - // this already-failed path and its result never leaves the - // error string (see Fetcher::scan_short_name_matches). - std::string hint; - if (auto cfg = get_cfg()) { - auto suggestions = mcpp::pm::cross_namespace_suggestions( - index_route(*cfg), candidates.front().shortName); - if (!suggestions.empty()) { - hint += "\n a package with this name exists under " - "another namespace:"; - for (auto& suggestion : suggestions) - hint += "\n " + suggestion.fqn - + suggestion.versions_label(); - if (auto suggested = mcpp::pm::parse_package_selector( - suggestions.front().fqn); suggested - && suggested->namespace_) { - hint += std::format( - "\n namespace omission means `{}`. write the " - "exact package:" - "\n [dependencies.{}]" - "\n {} = \"{}\"", - mcpp::pm::kDefaultNamespace, - *suggested->namespace_, suggested->name, - spec.version.empty() ? "" - : spec.version); - } - } - } - - // Advisory, never a gate (#315): now that a build only refreshes - // the index on a miss, "not found" and "your copy of the index - // is from last month" are easy to confuse. State which index - // answered and how old it is, so the next step is obvious - // instead of guessed at. - if (auto cfgA = get_cfg()) { - hint += std::format("\n index: {}\n hint: `mcpp index update` " - "if it was published recently", - mcpp::pm::staleness_note( - mcpp::config::make_xlings_env(**cfgA))); - } - // Offline with no local copy of the index at all, the miss says - // nothing about the package: nothing has been downloaded to look - // in. That is a download the run needs, not a wrong selector. - if (mcpp::platform::env::offline_mode()) { - if (auto cfgO = get_cfg(); - cfgO && !mcpp::xlings::default_index_status( - mcpp::config::make_xlings_env(**cfgO), 0).present) { - hint += "\n offline: the package index has never been fetched; " - "run `mcpp index update` without --offline"; - refusal::record(refusal::Code::OfflineDownloadRequired); - } - } - return std::unexpected(with_index_cause(std::format( - "dependency '{}': no package found for exact selector" - "\n tried: {}{}", - depName, tried, hint))); - } - } - - spec.namespace_ = std::move(selected.namespace_); - spec.shortName = std::move(selected.shortName); - spec.candidates = std::move(candidates); - return {}; - }; - - // 0.0.10+: loadVersionDep accepts structured (ns, shortName) for - // namespace-aware lookup. depName is the map key (qualified or bare), - // kept for install() target formatting and error messages. - std::set preinstallStack; - std::set preinstallDone; - - std::function( - const std::string&, - const std::string&, - const std::string&, - const std::string&)> loadVersionDep; - - loadVersionDep = [&](const std::string& depName, - const std::string& ns, - const std::string& shortName, - const std::string& version) - -> std::expected - { - auto cfg = get_cfg(); - if (!cfg) return std::unexpected(cfg.error()); - mcpp::fetcher::Fetcher fetcher(**cfg); - - // ─── Routing: check if this dep's namespace maps to a custom index ── - auto* idxSpec = findIndexForNs(ns); - - const bool useProjectEnv = idxSpec && !idxSpec->is_builtin(); - - auto readLuaContent = [&]() -> std::optional { - if (idxSpec && idxSpec->is_local()) { - auto indexPath = mcpp::config::resolve_project_index_path(*root, *idxSpec); - return mcpp::fetcher::Fetcher::read_xpkg_lua_from_path( - indexPath, ns, shortName); - } - if (idxSpec && !idxSpec->is_builtin()) { - return mcpp::fetcher::Fetcher::read_xpkg_lua_from_project_data( - *root, ns, shortName); - } - return fetcher.read_xpkg_lua(ns, shortName); - }; - - auto luaContent = readLuaContent(); - if (idxSpec && idxSpec->is_local() && !luaContent) { - auto indexPath = mcpp::config::resolve_project_index_path(*root, *idxSpec); - return std::unexpected(with_index_cause(std::format( - "dependency '{}': not found in local index at '{}'", - depName, indexPath.string()))); - } - - auto findRawInstalled = [&]() -> std::optional { - if (useProjectEnv) { - if (auto p = mcpp::fetcher::Fetcher::install_path_from_project_data( - *root, ns, shortName, version)) { - return p; - } - } - return fetcher.install_path(ns, shortName, version); - }; - - auto installedLayoutMatchesIndex = [&](const std::filesystem::path& verRoot) -> bool { - if (!luaContent) return false; - - auto field = mcpp::manifest::extract_mcpp_field(*luaContent); - if (field.kind == mcpp::manifest::McppField::StringPath) { - return !mcpp::modgraph::expand_glob(verRoot, field.value).empty(); - } - if (field.kind == mcpp::manifest::McppField::TableBody) { - auto dm = mcpp::manifest::synthesize_from_xpkg_lua( - *luaContent, shortName, version, targetPlatform); - if (!dm) return false; - for (auto const& [generatedPath, _] : dm->buildConfig.generatedFiles) { - if (!generatedPath.empty()) return true; - } - for (auto const& glob : dm->modules.sources) { - if (!glob.empty() && glob.front() == '!') continue; - if (!mcpp::modgraph::expand_glob(verRoot, glob).empty()) { - return true; - } - } - return false; - } - - for (auto pat : { "mcpp.toml", "*/mcpp.toml" }) { - if (!mcpp::modgraph::expand_glob(verRoot, pat).empty()) { - return true; - } - } - return false; - }; - - auto findCompleteInstalled = [&]() -> std::optional { - auto p = findRawInstalled(); - if (!p) return std::nullopt; - if (mcpp::fallback::is_install_complete(*p)) return p; - if (installedLayoutMatchesIndex(*p)) { - mcpp::fallback::mark_install_complete(*p); - return p; - } - mcpp::fallback::clean_incomplete_install(*p); - return std::nullopt; - }; - - auto markInstalled = [&](const std::filesystem::path& p) { - mcpp::fallback::mark_install_complete(p); - }; - - // For custom indices, try project-level xlings data roots first. - // Existing directories without the mcpp completion marker are treated - // as stale/incomplete on this active resolve path and reinstalled. - std::optional installed = findCompleteInstalled(); - - // #278 masking guard. The hard INV-NAME check lives on the install path - // below, so a machine that already has the package from an older index - // snapshot keeps building. That asymmetry is exactly the trap the issue - // names — local green, clean CI red — so make it visible here instead of - // letting it stay silent. - if (installed && luaContent) { - if (auto violation = mcpp::manifest:: - xpkg_name_form_violation_from_lua(*luaContent)) { - mcpp::ui::warning(std::format( - "dependency '{}': {}\n" - " resolving from the already-installed copy; a clean " - "environment (CI) will fail here", - depName, *violation)); - } - } - - if (!installed) { - if (luaContent) { - auto field = mcpp::manifest::extract_mcpp_field(*luaContent); - if (field.kind == mcpp::manifest::McppField::TableBody) { - auto depManifest = mcpp::manifest::synthesize_from_xpkg_lua( - *luaContent, shortName, version, targetPlatform); - if (!depManifest) { - return std::unexpected(std::format( - "dependency '{}': {}", depName, depManifest.error().format())); - } - warn_unknown_xpkg_keys(*depManifest, depName); - - auto preinstallKey = std::format("{}:{}@{}", ns, shortName, version); - if (preinstallStack.contains(preinstallKey)) { - return std::unexpected(std::format( - "dependency '{}': cyclic mcpp.deps while preparing install hooks", - depName)); - } - - if (!preinstallDone.contains(preinstallKey)) { - preinstallStack.insert(preinstallKey); - for (auto [childName, childSpec] : depManifest->dependencies) { - mcpp::pm::compat::normalize_nested_namespace( - childSpec.namespace_, - childSpec.shortName, - childSpec.legacyDottedKey); - - if (auto r = selectDependencyCandidate( - childSpec, childName); !r) { - preinstallStack.erase(preinstallKey); - return std::unexpected(r.error()); - } - - if (auto r = resolveSemver(childSpec, childName); !r) { - preinstallStack.erase(preinstallKey); - return std::unexpected(r.error()); - } - - if (!childSpec.isVersion()) continue; - - ResolvedKey childKey{ - childSpec.namespace_, - childSpec.shortName.empty() ? childName : childSpec.shortName, - }; - if (auto child = loadVersionDep( - childName, - childKey.ns, - childKey.shortName, - childSpec.version); !child) { - preinstallStack.erase(preinstallKey); - return std::unexpected(child.error()); - } - } - preinstallStack.erase(preinstallKey); - preinstallDone.insert(preinstallKey); - } - } - } - - // The address xlings is asked for is `:` (SPEC-001 §6), and BOTH halves come from the - // descriptor the identity gate accepted — see - // `mcpp::manifest::xpkg_wire_address` for why splitting the two - // sources is the bug it is. - auto wireAddr = mcpp::manifest::xpkg_wire_address( - luaContent ? std::string_view(*luaContent) : std::string_view{}, - ns, shortName); - if (luaContent) { - if (auto violation = mcpp::manifest:: - xpkg_name_form_violation_from_lua(*luaContent)) { - return std::unexpected(std::format( - "dependency '{}': {}", depName, *violation)); - } - } - // Human-facing name stays the resolved identity `.` — - // that is what the user wrote in [dependencies], so it is what the - // progress line and errors should echo back. - auto displayName = ns.empty() ? shortName - : std::format("{}.{}", ns, shortName); - - // Offline (#315). Checked HERE, at the point of download, and not - // any earlier: everything above this line — reading descriptors, - // resolving versions, reusing an already-installed package — is - // local, and an offline build that has its dependencies must - // succeed. Only the download itself is refused, and it names the - // package rather than surfacing a socket error from three layers - // down. (The toolchain payload path has its own gate; this is the - // dependency path, which does not go through resolve_xpkg_path.) - if (mcpp::platform::env::offline_mode()) { - refusal::record(refusal::Code::OfflineDownloadRequired); - return std::unexpected(std::format( - "offline mode: dependency '{}' v{} is not installed and " - "cannot be downloaded\n" - " run without --offline (or unset MCPP_OFFLINE) to fetch it", - displayName, version)); - } - mcpp::ui::info("Downloading", std::format("{} v{}", displayName, version)); - - // #238: retain whatever error/warn text the child DID emit so we - // can fold it into a diagnostic if install_packages exits non-zero. - std::string capturedChildError; - // xlings' own error lines, after its structured summary (#614). - auto append_xlings_stderr = [](std::string& into, - const mcpp::xlings::CallResult& r) { - if (r.exitCode == 0) return; - for (auto const& line : r.stderrTail) - into += (into.empty() ? "" : "\n ") + std::string("xlings: ") + line; - }; - auto install_one = [&](std::string target) -> std::expected { - if (useProjectEnv) { - // Project/custom-index deps install into the project-local - // xlings data root (so a package's install hook can find - // sibling packages from the same index). The NDJSON - // interface honors this: in the pinned xlings the - // `install_packages` capability and the `install` CLI share - // `xim::cmd_install`, and the install destination is chosen - // by package *scope* (project vs global), not by transport. - // Using the interface (rather than the silenced direct CLI) - // restores the live `Downloading … [bar] X/Y Z/s` UI here, - // matching the toolchain and builtin-index paths. - auto projEnv = mcpp::config::make_project_xlings_env(**cfg, *root); - auto argsJson = std::format( - R"({{"targets":["{}"],"yes":true}})", target); - mcpp::fetcher::InstallProgressHandler progress; - auto r = mcpp::xlings::call( - projEnv, "install_packages", argsJson, &progress); - capturedChildError = progress.captured_error(); - if (!r) return std::unexpected(mcpp::pm::CallError{r.error()}); - append_xlings_stderr(capturedChildError, *r); - return *r; - } - std::vector targets{ std::move(target) }; - mcpp::fetcher::InstallProgressHandler progress; - auto r = fetcher.install(targets, &progress); - capturedChildError = progress.captured_error(); - if (r) append_xlings_stderr(capturedChildError, *r); - return r; - }; - // Target = `:@` (SPEC-001 §6). - // - // The colon prefix is xlings' *effective namespace*, matched against - // the descriptor's own `package.namespace` (xlings issue-381 design - // §2.2) — NOT the index name. mcpp's `[indices] = {...}` keys - // ARE namespaces, so the two coincide for a qualified request; for a - // bare one they do NOT, which is exactly why the namespace has to be - // read off the descriptor rather than off `ns`. - // - // A namespace-less upstream package (xim `opencv`) is addressed by - // its bare literal name, with no prefix. - auto target = std::format("{}@{}", wireAddr.target, version); - // Keep every address we actually put on the wire. Diagnosing the - // 2026-07-25 breakage needed MCPP_VERBOSE=1 to discover that mcpp - // had asked for `mcpplibs:gtest` — the error itself only named the - // dependency, which is the one thing nobody doubts. - std::vector attempted{ target }; - // #613: THE INSTALL HOOK'S ENVIRONMENT, under the names and the rule - // a build program gets: always emitted, empty when not applicable, - // so a hook never reads a value inherited from a parent process. - // Computed by `install_hook_env`, the function the build-program - // environment takes the same six values from. - // - // THE TOOLCHAIN VALUES ARE EMPTY HERE ON THE ORDINARY PATH. `tc` is - // resolved after the dependency graph (see its declaration: a - // package in the graph may supply a target-side layer), so when a - // dependency installs there is no resolved compiler or standard - // library to state, and a guessed one would be worse than none. - // Measured with tests/e2e/648. The target names are decided, and a - // package states the standard library it was built for with - // `requires = ["mcpp:c++-abi=..."]`, checked once `tc` exists. A - // hook must not build a variant into a store directory that does - // not name the variant, because the store is keyed by package and - // version. Scoped: restored when this dependency's install returns, - // compat retries below included. - mcpp::build::BuildProgramEnv hookEnv; - fill_target_build_env(hookEnv, *m, tc ? &*tc : nullptr, cfg_opt ? &*cfg_opt : nullptr); - hookEnv.targetTriple = overrides.target_triple; - // Six names, fixed by install_hook_env; one guard each. - const auto hookVars = mcpp::build::install_hook_env(hookEnv); - mcpp::platform::env::ScopedEnv hookVar0(hookVars.at(0).first, hookVars.at(0).second); - mcpp::platform::env::ScopedEnv hookVar1(hookVars.at(1).first, hookVars.at(1).second); - mcpp::platform::env::ScopedEnv hookVar2(hookVars.at(2).first, hookVars.at(2).second); - mcpp::platform::env::ScopedEnv hookVar3(hookVars.at(3).first, hookVars.at(3).second); - mcpp::platform::env::ScopedEnv hookVar4(hookVars.at(4).first, hookVars.at(4).second); - mcpp::platform::env::ScopedEnv hookVar5(hookVars.at(5).first, hookVars.at(5).second); - auto r = install_one(target); - if (r && r->exitCode != 0 && - (ns.empty() || ns == mcpp::pm::kDefaultNamespace)) { - // Compat retry for a bare/default-namespace request whose - // descriptor could not be read (no `wireAddr` to trust): the - // package may still be a `compat` one. Try BOTH spellings — a - // SPEC-001 index keys it `compat:`, a pre-SPEC-001 index - // keys it by the literal `compat.`. Sending only the - // latter is what left the retry pointing at a name the migrated - // index no longer has. - for (auto&& compatTarget : { - std::format("compat:{}@{}", shortName, version), - std::format("compat.{}@{}", shortName, version) }) { - if (compatTarget == target) continue; - mcpp::ui::info("Downloading", std::format("{} v{}", - compatTarget.substr(0, compatTarget.rfind('@')), version)); - attempted.push_back(compatTarget); - r = install_one(compatTarget); - if (!r || r->exitCode == 0) break; - } - } - if (!r) return std::unexpected(std::format( - "fetch '{}@{}': {}", depName, version, r.error().message)); - if (r->exitCode != 0) { - // #238: the opaque `fetch failed (exit 1)` hid the actionable - // context mcpp actually has. Reconstruct it: the target, the - // configured index repos (read back from the seeded - // .xlings.json — project scope when useProjectEnv, else the - // global xlings home), any child error text we captured, plus - // a hint about the known ≥2-repo xlings resolution gap. The - // real fix lives in openxlings/xlings; this only surfaces WHY. - auto xlingsJson = (useProjectEnv - ? (workRoot / ".mcpp") - : (*cfg)->xlingsHome()) - / ".xlings.json"; - auto indexRepos = mcpp::pm::read_seeded_index_repos(xlingsJson); - std::string childErr = capturedChildError; - if (r->error) { - if (!childErr.empty()) childErr += "; "; - childErr += r->error->message; - } - auto target = std::format("{}@{}", depName, version); - auto diag = mcpp::pm::format_install_failure_diagnostic( - target, r->exitCode, indexRepos, childErr); - std::string tried; - for (auto& a : attempted) { - if (!tried.empty()) tried += ", "; - tried += a; - } - diag += std::format("\n wire address{} tried: {}", - attempted.size() == 1 ? "" : "es", tried); - return std::unexpected(std::move(diag)); - } - // After install, check project data first for custom index packages. - installed = findRawInstalled(); - if (!installed) return std::unexpected(std::format( - "package '{}@{}' install path missing after fetch", depName, version)); - markInstalled(*installed); - } - std::filesystem::path verRoot = *installed; - - // Route xpkg.lua reading through the appropriate index. - if (!luaContent) { - luaContent = readLuaContent(); - } - if (!luaContent) return std::unexpected(with_index_cause(std::format( - "dependency '{}': index entry not found in local clone", depName))); - auto field = mcpp::manifest::extract_mcpp_field(*luaContent); - - // 0.0.6+: read explicit namespace from xpkg lua if present. - auto luaNs = mcpp::manifest::extract_xpkg_namespace(*luaContent); - - std::optional manifest; - std::filesystem::path effRoot = verRoot; - auto loadFrom = [&](const std::filesystem::path& mcppToml) - -> std::expected - { - // A manifest that is a member of a workspace inside the archive - // receives that workspace's inheritance, as it does from a git - // clone of the same commit (#690). - auto repoWorkspace = workspace_listing(mcppToml.parent_path(), verRoot); - auto dm = mcpp::manifest::load( - mcppToml, {.insideWorkspace = repoWorkspace.has_value()}); - if (!dm) return std::unexpected(std::format( - "dependency '{}' (at '{}'): {}", - depName, mcppToml.string(), dm.error().format())); - if (repoWorkspace) { - if (auto bad = inherit_as_workspace_member( - *dm, repoWorkspace->first, repoWorkspace->second, - mcppToml.parent_path())) - return std::unexpected(std::format( - "dependency '{}': {}", depName, *bad)); - } - manifest = std::move(*dm); - effRoot = mcppToml.parent_path(); - return {}; - }; - if (field.kind == mcpp::manifest::McppField::StringPath) { - auto matches = mcpp::modgraph::expand_glob(verRoot, field.value); - if (matches.empty()) return std::unexpected(std::format( - "dependency '{}': mcpp pointer '{}' did not match any " - "file under '{}'", depName, field.value, verRoot.string())); - if (matches.size() > 1) return std::unexpected(std::format( - "dependency '{}': mcpp pointer '{}' matched {} files " - "(expected exactly one)", depName, field.value, matches.size())); - if (auto r = loadFrom(matches.front()); !r) return std::unexpected(r.error()); - } else if (field.kind == mcpp::manifest::McppField::TableBody) { - auto dm = mcpp::manifest::synthesize_from_xpkg_lua( - *luaContent, shortName, version, targetPlatform); - if (!dm) return std::unexpected(std::format( - "dependency '{}': {}", depName, dm.error().format())); - warn_unknown_xpkg_keys(*dm, depName); - manifest = std::move(*dm); - // effRoot stays as verRoot - } else { - std::vector matches; - for (auto pat : { "mcpp.toml", "*/mcpp.toml" }) { - matches = mcpp::modgraph::expand_glob(verRoot, pat); - if (!matches.empty()) break; - } - // Name the directory actually searched. `` was a literal - // placeholder, so the message could not distinguish "the package - // is Form B and you forgot the mcpp field" from "the verdir mcpp - // resolved is not this package's at all" — the second is what a - // cross-namespace install_path hit produces, and it sent this - // investigation down the wrong path for a while. - if (matches.empty()) return std::unexpected(std::format( - "dependency '{}': index entry has no `mcpp = ...` field, " - "and no mcpp.toml was found at '{}/mcpp.toml' or " - "'{}/*/mcpp.toml' — add an explicit `mcpp = \"\"` " - "or `mcpp = {{ ... }}` block to the .lua descriptor. " - "(If that directory belongs to a DIFFERENT package, the " - "install step resolved the wrong verdir.)", - depName, verRoot.string(), verRoot.string())); - if (matches.size() > 1) return std::unexpected(std::format( - "dependency '{}': default mcpp.toml lookup matched {} " - "files; pin one with explicit `mcpp = \"\"`.", - depName, matches.size())); - if (auto r = loadFrom(matches.front()); !r) return std::unexpected(r.error()); - } - // Propagate lua-level namespace into the loaded manifest when - // the manifest itself doesn't carry one (Form A descriptors - // whose upstream mcpp.toml predates the namespace field). - // Guard: if the manifest's name already starts with luaNs+"." - // (e.g. name="mcpplibs.tinyhttps" with luaNs="mcpplibs"), - // the namespace is already embedded in the name — don't inject - // it again or the scanner will produce a double-prefixed - // qualified name like "mcpplibs.mcpplibs.tinyhttps". - if (manifest->package.namespace_.empty() && !luaNs.empty()) { - auto prefix = luaNs + "."; - if (!manifest->package.name.starts_with(prefix)) { - manifest->package.namespace_ = luaNs; - } - } - - if (auto r = materialize_generated_files(effRoot, *manifest); !r) { - return std::unexpected(std::format( - "dependency '{}': {}", depName, r.error())); - } - - // Dependency-side L1 cfg merge (flags + sources): a descriptor's - // `target_cfg` / a dep mcpp.toml's [target.'cfg(...)'.build] must - // evaluate here too — before its globs expand. This is the version/ - // registry-dep half of the #229 funnel: every loadVersionDep() caller - // (the main per-dependency loop, the multi-version mangling - // secondary, and the SemVer-merge re-fetch) shares this one call site, - // so a version dep is merged exactly once regardless of which of the - // three paths loaded it. The path/git-dep half is the mirror-image - // call right after ITS manifest load (dependency-manifest-acquisition - // block below) — same function, same one-merge-per-package guarantee, - // just keyed off a different loading branch since path/git deps never - // pass through loadVersionDep. - if (!manifest->conditionalConfigs.empty()) { - merge_conditional_config(*manifest, - cfgCtx()); - } - report_flag_words_changes(*manifest); - fold_build_defines_into_flags(manifest->buildConfig); - // The root's `abi.threads` reaches a dependency's C translation units - // here; its C++ units already receive it through the dialect flag set. - if (abiThreadsRendered) add_once(manifest->buildConfig.cflags, "-pthread"); - - return std::pair{effRoot, std::move(*manifest)}; - }; - - struct DependencyEdge { - std::size_t consumerPackageIndex = 0; - std::size_t dependencyPackageIndex = 0; - mcpp::modgraph::DependencyVisibility visibility = - mcpp::modgraph::DependencyVisibility::Public; - // #242/#243: the per-edge feature request that THIS consumer made of - // THIS dependency. Feature activation must consume these off the edge - // graph (union over all incoming edges) rather than re-scanning only - // the root manifest's direct deps — otherwise a transitive dep's - // requested features and its consumer's `default-features = false` are - // silently dropped (resolution honors them per-edge; activation did not). - std::vector requestedFeatures; - bool defaultFeatures = true; - // #355: HOST tools this consumer asked the dependency for. Aggregated - // off the edge graph exactly like requestedFeatures — a transitive - // consumer's request must not be silently dropped, which is the - // #242/#243 failure shape. - std::vector requestedTools; - // #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? - bool hostModule = false; - bool reexport = false; - // Did this edge come from `[build-dependencies]`? Such an edge serves - // the BUILD and never the target, and the property is inherited by - // everything reachable through it. It is a property of the edge and - // not of the package: the same package may be an ordinary dependency - // of someone else in the same build, and then it does reach the - // target. - bool buildOnly = false; - }; - std::vector dependencyEdges; - // #634, X: every request that reached a package, as the requester wrote - // it, for the `graph` section of resolution.json. Kept apart from - // `dependencyEdges`, which merges two requests of one consumer for one - // dependency into one edge; the record has to keep both keys, because two - // keys over one identity (A2) and the table a declaration came from (A1) - // are what it exists to show. - struct GraphRequest { - std::size_t consumerPackageIndex = 0; - std::size_t dependencyPackageIndex = 0; - std::string key; // the dependency key as the requester wrote it - std::string table; // `DependencySpec::declaredIn` - }; - std::vector graphRequests; - // The link form each dependency takes and the facts it was decided from, - // by package index. COMPUTED ONCE, before the root build program runs, so - // that program can read the answer (#642 E2); APPLIED after the scan, where - // it always was. Every reader below reads this, never a second resolution. - struct DependencyLinkForm { - mcpp::build::linkage_form::PackageFacts facts; - mcpp::build::linkage_form::Resolution answer; - // The package has a library form to report: a package of programs or - // rules has none, and is neither recorded nor offered to a program. - bool recorded = false; - }; - std::map dependencyLinkForms; - namespace dg = mcpp::build::dep_graph; - // #355: consumer package index → (env var, absolute path) for each host - // tool that consumer requested. Filled by the provisioning pass below; - // read by BOTH build.mcpp call sites (the dependency loop and the root), - // which is why it lives out here rather than inside the resolution block. - std::map>> - toolEnvByConsumer; - // #355 step 5: consumer package index → (logical module name, interface - // path) for each dependency that offers HOST build rules. Same fan-out - // shape as toolEnvByConsumer, and read by the same two call sites. - std::map> - hostModulesByConsumer; - // The same providers by INDEX, and the reason they are needed twice. - // - // A rule's code runs inside its CONSUMER's build program, so - // `mcpp::xpkg_dir("cuda-nvcc")` is asked there -- while the payload that - // answers it was declared by the RULE, under `[feature-xlings.]`, which - // is where it belongs: which packages a device compiler needs is the - // rule's knowledge and no project should have to rediscover it. - // - // The graph pass already INSTALLS what a dependency declares. Only the - // answer was missing: `fillXpkgDirs` read one manifest, so the address was - // fetched, unpacked, and then unreachable from the only code that wanted - // it -- a failure that reads as "the toolkit is not installed" while it - // sits on disk. - // - // The set is the host-module providers rather than every dependency: the - // code that can call `xpkg_dir` in this build program is the consumer's - // own `build.mcpp` plus exactly the rule modules compiled into it. - std::map> hostModuleProvidersByConsumer; - // #359: who can see which build-time provision. Computed once by the - // provisioning pass below (a fixpoint over `dependencyEdges`, the same - // shape as computeUsageRequirements) and read by every consumer of the - // three env channels above. Declared here because `fillDepDirs` closes - // over it and is defined long before the pass runs; every call site is - // after it. - namespace prov = mcpp::build::provisions; - prov::Propagation provisionGraph; - // The spellings a given consumer may address a provider by. The qualified - // name always works; the bare tail only when the namespace ladder binds it - // to exactly this package FOR THIS CONSUMER. Scoped per consumer rather - // than globally because two packages sharing a tail only collide inside an - // environment that contains both. - auto bareBindingsFor = [&](std::size_t consumer) { - std::vector fqns; - if (consumer < provisionGraph.visible.size()) - for (auto const& pr : provisionGraph.visible[consumer]) { - if (pr.provider >= packages.size()) continue; - auto const& n = packages[pr.provider].manifest.package.name; - if (std::find(fqns.begin(), fqns.end(), n) == fqns.end()) - fqns.push_back(n); - } - return prov::bind_bare_names(fqns); - }; - // THE NAMES UNDER WHICH ONE PROVIDER IS PUBLISHED TO ONE CONSUMER, derived - // once for every channel (#647 E4.3). The manifest's `name`, the qualified - // `namespace.name` when the manifest writes the two apart, and the bare - // tail where the namespace ladder binds it to this provider for this - // consumer. `dep_dir`/`dep_linkage` and `dep_bin` used to derive this list - // separately; #642 added the qualified spelling to the first and the second - // kept publishing `MCPP_DEP_INSTALLER_BIN_*` alone for a package written - // `namespace = "spike"`, `name = "installer"`, so - // `dep_bin("spike.installer", ...)` read nothing. - auto publishedNamesFor = - [&](std::size_t provider, - const std::map& bind) { - std::vector out; - auto const& manifest = packages[provider].manifest; - auto const& canon = manifest.package.name; - out.push_back(canon); - if (auto qualified = mcpp::build::qualified_package_name(manifest); - qualified != canon) - out.push_back(std::move(qualified)); - if (auto tail = prov::tail_of(canon); tail != canon) { - auto it = bind.find(tail); - if (it != bind.end() && it->second.owner == canon) - out.push_back(std::move(tail)); - } - return out; - }; - - // A package whose DECLARED targets are all programs (#649 E6). See the - // worklist, where such a package is not walked into a consumer's graph. - auto isProgramOnlyPackage = [](const mcpp::manifest::Manifest& pm) { - if (pm.targetsInferred || pm.targets.empty()) return false; - return std::ranges::none_of(pm.targets, [](const mcpp::manifest::Target& t) { - return t.kind == mcpp::manifest::Target::Library - || t.kind == mcpp::manifest::Target::SharedLibrary; - }); - }; - auto parseVisibility = [](std::string_view visibility) { - if (visibility == "private") - return mcpp::modgraph::DependencyVisibility::Private; - if (visibility == "interface") - return mcpp::modgraph::DependencyVisibility::Interface; - return mcpp::modgraph::DependencyVisibility::Public; - }; - - auto packageIndexForConsumer = [&](std::size_t consumerDepIndex) { - if (consumerDepIndex == kMainConsumer) return std::size_t{0}; - return consumerDepIndex + 1; - }; - - auto appendUniquePath = - [](std::vector& dirs, - const std::filesystem::path& dir) -> bool - { - if (std::find(dirs.begin(), dirs.end(), dir) != dirs.end()) return false; - dirs.push_back(dir); - return true; - }; - - auto appendUniquePaths = - [&](std::vector& dirs, - const std::vector& additions) -> bool - { - bool changed = false; - for (auto const& dir : additions) { - changed = appendUniquePath(dirs, dir) || changed; - } - return changed; - }; - - // "Which compile-visible channels a build.mcpp directive lands in" is a - // property of the DIRECTIVE TABLE, not of this call site, so both the mark - // and the fold now live with the table in mcpp.build.directives. This pair - // used to be defined here and was already incomplete — the comment it - // replaced admitted that link/source residues stayed at the call sites, - // which is the #242 two-derivations shape. - // - // The fold is PRIVATE by design (Cargo discipline — a build-time program - // must not widen the package's public interface): privateBuild only, never - // publicUsage. The after-dirs ride the typed #249 channel, which owns the - // per-dialect degradations (cl.exe /I, NASM -I). - using DirectiveMark = mcpp::build::directives::Mark; - auto markDirectiveTail = [](const mcpp::manifest::Manifest& mm) { - return mcpp::build::directives::mark(mm); - }; - auto foldDirectiveTailIntoPrivateBuild = - [](auto& pkg, const mcpp::manifest::Manifest& ran, - const DirectiveMark& t) - { - mcpp::build::directives::fold_private_tail(pkg.privateBuild, ran, t); - }; - - // mcpp#241: the (name → dir) pairs a package's build.mcpp receives as - // MCPP_DEP__DIR. ONE owner: the dependency loop and the root call - // site had drifted into two near-identical copies of this, and #355 was - // about to add a third. Each dependency is emitted under BOTH its - // canonical name and its namespace-stripped tail, so - // `mcpp::dep_dir("compat.zlib")` and `mcpp::dep_dir("zlib")` both resolve - // regardless of which spelling the author used in `deps`. - // - // #359: the set is now the consumer's VISIBLE provisions rather than its - // direct edges, so a re-exported dependency's directory reaches it too. - // That is what makes a rule package able to find data files belonging to a - // dependency the user never declared — protoc's well-known .proto files - // are exactly such a directory, and `grpcgen` reads them through dep_dir. - // - // The bare tail is emitted only when the namespace ladder binds it here. - // Emitting it unconditionally was safe while only the root's own - // declarations reached build.mcpp; with re-export, two packages that never - // heard of each other can share a tail and the later emplace_back would - // silently win. - // The xlings half of fillDepDirs. Same question ("where did my declared - // dependency's payload land"), different namespace and store layout, so it - // cannot ride the mcpp dependency channel — but it must be an INTERFACE on - // the build.mcpp side for the same reason that one is: a program that - // reconstructs the store path is coupled to internals mcpp is free to - // change. See mcpp::build::hostprogram::xpkg_dir. - // Which dependency supplied the runner, for the exactly-one-provider - // error below. A name rather than a bool: the message has to name both. - std::string runnerProvider; - // ONE PROVIDER PER RUNNER NAME. `runner` has had this rule since #544; - // a NAMED runner inherits it per name, because a board may legitimately - // supply `flash` while a different package supplies `monitor`. - std::map namedRunnerProvider; - - auto fillXpkgDirs = [&](mcpp::build::BuildProgramEnv& e, - const mcpp::manifest::Manifest& owner, - std::size_t consumer) { - // `[feature-xlings.]` is provisioned when `` is active, so it has - // to be answerable here too. Before this, a tool a feature declared was - // downloaded and installed and then `mcpp::xpkg_dir` returned "" for it - // — the build program was told to declare a package it had already - // declared, which is a diagnostic pointing at the wrong file. - // - // The set is taken from the SAME env the caller already computed, so - // "which features are on" is answered once. Installation stays the - // filter below: a declared address whose payload is absent answers "", - // which is what a `when = "dev"` entry looks like to a consumer. - std::vector declared = owner.xlings.deps; - for (auto const& f : e.features) - if (auto it = owner.xlings.featureDeps.find(f); - it != owner.xlings.featureDeps.end()) - for (auto const& address : it->second) - if (std::ranges::find(declared, address) == declared.end()) - declared.push_back(address); - // …and what the rule packages compiled INTO this build program - // declared. Their own active features, not the consumer's: the - // consumer asked for `features = ["rules-cuda"]` on the edge, and that - // is what decides which of the rule's `[feature-xlings]` tables apply. - if (auto pit = hostModuleProvidersByConsumer.find(consumer); - pit != hostModuleProvidersByConsumer.end()) { - for (auto q : pit->second) { - if (q >= packages.size()) continue; - auto const& pm = packages[q].manifest; - auto want = [&](const std::string& address) { - if (std::ranges::find(declared, address) == declared.end()) - declared.push_back(address); - }; - for (auto const& address : pm.xlings.deps) want(address); - const auto& pf = q < activeFeaturesByPackage.size() - ? activeFeaturesByPackage[q] : std::vector{}; - for (auto const& f : pf) - if (auto it = pm.xlings.featureDeps.find(f); - it != pm.xlings.featureDeps.end()) - for (auto const& address : it->second) want(address); - } - } - if (declared.empty()) return; - auto cfg = get_cfg(); - if (!cfg) return; - auto xlEnv = mcpp::config::make_xlings_env(**cfg); - std::set answered; - for (auto const& raw : declared) { - // THE VERSION THIS BUILD INSTALLED, NOT THE ONE THIS MANIFEST - // WROTE. Both statements are about one package, and only one - // version of it exists on disk; answering from the local spelling - // is how a rule package could declare `>=8.5.0`, have the project's - // exact pin installed instead, and then be told nothing is there. - // `xlingsWinner` is empty only before the split has run, and every - // caller of this lambda runs after it — the fallback keeps that a - // fact about ordering rather than a crash. - const auto key = mcpp::xlings::addrset::package_key(raw); - if (!answered.insert(key).second) continue; - auto wit = xlingsWinner.find(key); - const std::string spec = wit == xlingsWinner.end() ? raw : wit->second; - auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec); - auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref); - if (!dir) continue; // declared but not installed: "" is the answer - // Namespaced first — it is the exact spelling, and the bare form - // below must not shadow it (the receiver keeps the first value it - // is given for a name). - e.xpkgDirs.emplace_back( - mcpp::build::xpkg_env_var(ref.ns, ref.name), dir->string()); - e.xpkgDirs.emplace_back( - mcpp::build::xpkg_env_var("", ref.name), dir->string()); - } - }; - - // `linkForms` (#642 E2): when given, each dependency that has a resolved - // library form is also offered under exactly the names its directory is, - // so `dep_linkage(n)` answers for every `n` that `dep_dir(n)` answers for. - // Only the root's program passes it; see the root call site for why. - auto fillDepDirs = [&](mcpp::build::BuildProgramEnv& e, std::size_t consumer, - const std::map* linkForms = nullptr) { - if (consumer >= provisionGraph.visible.size()) return; - auto bind = bareBindingsFor(consumer); - for (auto const& [tail, b] : bind) { - if (auto note = prov::contest_note(tail, b); !note.empty()) - mcpp::diag::warning("provisions/ambiguous", note); - } - for (auto const& pr : provisionGraph.visible[consumer]) { - if (pr.kind != prov::Kind::DepDir) continue; - if (pr.provider >= packages.size()) continue; - auto const& depPkg = packages[pr.provider]; - auto const& canon = depPkg.manifest.package.name; - const std::string* form = nullptr; - if (linkForms) - if (auto f = linkForms->find(pr.provider); f != linkForms->end()) - form = &f->second; - // Every spelling of `publishedNamesFor`: the manifest's name, the - // qualified name a manifest writing `namespace = "ns"` and - // `name = "fw"` is addressed by (#642: the framework's rule asks - // `dep_linkage("huxerui.huxerui")`), and the bound tail. - for (auto const& n : publishedNamesFor(pr.provider, bind)) { - e.depDirs.emplace_back(n, depPkg.root); - if (form) e.depLinkages.emplace_back(n, *form); - } - } - }; - - // A declared build-graph node's Source outputs must be visible to the - // scan, so they are materialized as placeholders and joined to the source - // set here — the same two lists `generated=` feeds, for the same reason - // (the scanner walks the legacy modules.sources mirror). ninja overwrites - // the placeholder before the compile edge runs, because that compile - // depends on the action's output. - auto adoptActionOutputs = [](mcpp::manifest::Manifest& mm, - const std::filesystem::path& pkgRoot, - std::size_t firstNewAction) { - if (firstNewAction >= mm.buildConfig.actions.size()) return; - std::vector fresh( - mm.buildConfig.actions.begin() - + static_cast(firstNewAction), - mm.buildConfig.actions.end()); - // The package that DECLARED the outputs classifies them: a dependency - // generating a `.ixx` asks its own manifest, not the root project's. - // Built once per package, not once per output — and BEFORE - // `prepare_actions`, which needs the same table to decide which - // outputs get a placeholder (a header does not; see mcpp#534). - const auto pkgExtTable = - mcpp::extension_table_for(mm.buildConfig.moduleExtensions, - mm.buildConfig.deviceExtensions); - mcpp::build::directives::prepare_actions(fresh, pkgRoot, pkgExtTable); - std::copy(fresh.begin(), fresh.end(), - mm.buildConfig.actions.begin() - + static_cast(firstNewAction)); - for (auto const& a : fresh) { - if (a.role != mcpp::manifest::BuildAction::Role::Source) continue; - for (auto const& o : a.outputs) { - if (o.find("${mcpp.") != std::string::npos) continue; - // Companion outputs (protoc's .pb.h next to its .pb.cc) are - // produced by the edge but are NOT translation units. - if (!mcpp::build::directives::is_compilable_output(o, pkgExtTable)) - continue; - mm.buildConfig.sources.push_back(o); - mm.modules.sources.push_back(o); - } - } - }; - - - auto appendUniqueFlags = - [](std::vector& flags, - const std::vector& additions) -> bool - { - bool changed = false; - for (auto const& f : additions) { - if (std::find(flags.begin(), flags.end(), f) != flags.end()) continue; - flags.push_back(f); - changed = true; - } - return changed; - }; - - auto expandIncludeDirs = - [&](const std::filesystem::path& packageRoot, - const mcpp::manifest::Manifest& manifest) - { - std::vector dirs; - for (auto const& inc : manifest.buildConfig.includeDirs) { - if (inc.is_absolute()) { - // Native spelling: a TOML `C:/SDL2/include` stays mixed on - // MSVC and leaks into the CDB's -I otherwise. Direct - // make_preferred — no generic_string round trip, which can - // throw for names the ANSI codepage cannot spell (mcpp#230). - auto n = inc; - n.make_preferred(); - appendUniquePath(dirs, std::move(n)); - continue; - } - for (auto& dir : mcpp::modgraph::expand_dir_glob( - packageRoot, inc.generic_string())) { - appendUniquePath(dirs, dir); - } - } - return dirs; - }; - - // #249: same glob expansion for `include_dirs_after` (the -idirafter - // channel — searched after the toolchain's system dirs). - auto expandIncludeDirsAfter = - [&](const std::filesystem::path& packageRoot, - const mcpp::manifest::Manifest& manifest) - { - std::vector dirs; - for (auto const& inc : manifest.buildConfig.includeDirsAfter) { - if (inc.is_absolute()) { - auto n = inc; - n.make_preferred(); - appendUniquePath(dirs, std::move(n)); - continue; - } - for (auto& dir : mcpp::modgraph::expand_dir_glob( - packageRoot, inc.generic_string())) { - appendUniquePath(dirs, dir); - } - } - return dirs; - }; - - // The same expansion for `private_include_dirs`, so a private entry may be - // a glob and still name exactly the directories it expands to. - auto expandPrivateIncludeDirs = - [&](const std::filesystem::path& packageRoot, - const mcpp::manifest::Manifest& manifest) - { - std::vector dirs; - for (auto const& inc : manifest.buildConfig.privateIncludeDirs) { - if (inc.is_absolute()) { - auto n = inc; - n.make_preferred(); - appendUniquePath(dirs, std::move(n)); - continue; - } - for (auto& dir : mcpp::modgraph::expand_dir_glob( - packageRoot, inc.generic_string())) { - appendUniquePath(dirs, dir); - } - } - return dirs; - }; - - auto makePackageRoot = - [&](const std::filesystem::path& packageRoot, - const mcpp::manifest::Manifest& manifest) - -> std::expected - { - // THE SNAPSHOT READS A NORMALISED MANIFEST; IT DOES NOT NORMALISE ONE. - // - // Every merge that feeds a package's build inputs (workspace - // inheritance, the conditional `[target..build]` sections) runs - // at the package's LOAD site, and `fold_build_defines_into_flags` runs - // after all of them. This lambda only captures the result. - // - // `[workspace.build]` inheritance used to run here (#539). The root - // had already inherited at load time, so it received the workspace - // entries twice; a member reached as a sibling's `path` dependency - // inherited after its `defines` had been folded, so the workspace - // `defines` never reached its compile lines (#690). Both follow from - // performing a merge at the snapshot, and both are removed by - // performing it at the load site, where the root already did. - // - // The post-condition below is what keeps it removed: a merge placed - // after the fold leaves `defines` non-empty here, and the build stops - // with an internal error instead of dropping the macros in silence. - if (auto unfolded = unfolded_defines_error(manifest)) - return std::unexpected(*unfolded); - - mcpp::modgraph::PackageRoot pkg; - pkg.root = packageRoot; - pkg.manifest = manifest; - pkg.usageResolved = true; - - pkg.privateBuild.includeDirs = expandIncludeDirs(packageRoot, manifest); - pkg.privateBuild.includeDirsAfter = expandIncludeDirsAfter(packageRoot, manifest); - pkg.privateBuild.cflags = manifest.buildConfig.cflags; - pkg.privateBuild.cxxflags = manifest.buildConfig.cxxflags; - // NOT `= privateBuild` ANY MORE — a package may now say which of - // its include directories stop at its own boundary. - // - // This line took the whole set for as long as the two were the same - // set, which they are for almost every package. The one shape where - // they are not is a package that vendors a library with an internal - // header overlay: musl's `src/include` adds `hidden`, `weak` and - // `weak_alias` for musl's own sources, and publishing it hands those - // names to every consumer. See BuildInputs::privateIncludeDirs. - // - // THE FILTER IS APPLIED AFTER GLOB EXPANSION, so a private entry may - // itself be a glob and still name exactly the directories it expands - // to. Comparing the unexpanded spellings would let `musl/src/*` be - // published because it is not literally equal to `musl/src/include`. - { - const auto privateExpanded = - expandPrivateIncludeDirs(packageRoot, manifest); - for (auto const& d : pkg.privateBuild.includeDirs) - if (std::ranges::find(privateExpanded, d) == privateExpanded.end()) - pkg.publicUsage.includeDirs.push_back(d); - - // AN ENTRY THAT WITHHOLDS NOTHING IS REPORTED, because the way - // it fails is the very defect this key exists to prevent: a - // directory the author believes is private stays published, and - // nothing about the build looks different until a consumer trips - // over a name months later. - // - // A WARNING AND NOT AN ERROR, for consistency with `include_dirs` - // itself: that key silently ignores a glob matching nothing, and a - // conditional manifest can legitimately name a directory that - // exists on one platform only. Refusing here would be stricter - // than the list this one filters. - for (auto const& want : privateExpanded) { - if (std::ranges::find(pkg.privateBuild.includeDirs, want) - != pkg.privateBuild.includeDirs.end()) - continue; - mcpp::diag::warning("manifest", std::format( - "package '{}': `private_include_dirs` names '{}', which is " - "not among this package's `include_dirs`.\n" - " It withholds nothing — `private_include_dirs` says " - "which entries OF `include_dirs`\n" - " stop at this package's boundary, and an entry that " - "is not one of them is published\n" - " exactly as before.", - manifest.package.name, want.generic_string())); - } - } - pkg.publicUsage.includeDirsAfter = pkg.privateBuild.includeDirsAfter; - pkg.linkUsage.ldflags = manifest.buildConfig.ldflags; - return pkg; - }; - - { - auto rootPackage = makePackageRoot(*root, *m); - if (!rootPackage) return std::unexpected(rootPackage.error()); - packages[0] = std::move(*rootPackage); - } - - auto recordDependencyEdge = - [&](std::size_t consumerDepIndex, - std::size_t dependencyPackageIndex, - const mcpp::manifest::DependencySpec& spec, - bool buildOnly, - const std::string& writtenKey) - { - const auto consumerPackageIndex = packageIndexForConsumer(consumerDepIndex); - if (consumerPackageIndex >= packages.size() - || dependencyPackageIndex >= packages.size()) { - return; - } - if (std::ranges::none_of(graphRequests, [&](const GraphRequest& r) { - return r.consumerPackageIndex == consumerPackageIndex - && r.dependencyPackageIndex == dependencyPackageIndex - && r.key == writtenKey && r.table == spec.declaredIn; - })) - graphRequests.push_back(GraphRequest{ - .consumerPackageIndex = consumerPackageIndex, - .dependencyPackageIndex = dependencyPackageIndex, - .key = writtenKey, - .table = spec.declaredIn, - }); - const auto visibility = parseVisibility(spec.visibility); - auto same = [&](const DependencyEdge& edge) { - return edge.consumerPackageIndex == consumerPackageIndex - && edge.dependencyPackageIndex == dependencyPackageIndex - && edge.visibility == visibility; - }; - auto it = std::find_if(dependencyEdges.begin(), dependencyEdges.end(), same); - if (it != dependencyEdges.end()) { - // One consumer naming one dependency in BOTH tables. The ordinary - // declaration wins, because the build-time path never subtracts - // from what the project asked to link — stating the rule the other - // way round would let a `[build-dependencies]` line quietly drop a - // library the target needs. - if (!buildOnly) it->buildOnly = false; - // AND THE SECOND DECLARATION'S REQUESTS ARE KEPT (#649 E7). Both - // declarations name one edge, so what each asks of the dependency - // is asked of that edge: this used to return here and lose the - // second one's `tools`, `features`, `host-module` and `reexport` - // without a word, under `--strict` too. The rule is the one - // `mergeActiveFeatureDeps` already applies to a feature's - // restatement: additive fields union, `default-features` stays on - // unless every declaration opts out. - for (auto const& t : spec.tools) - if (std::ranges::find(it->requestedTools, t) == it->requestedTools.end()) - it->requestedTools.push_back(t); - for (auto const& f : spec.features) - if (std::ranges::find(it->requestedFeatures, f) - == it->requestedFeatures.end()) - it->requestedFeatures.push_back(f); - it->defaultFeatures = it->defaultFeatures || spec.defaultFeatures; - if (spec.hostModule) it->hostModule = true; - else if (dependencyPackageIndex < packages.size()) - for (auto const& f : spec.features) - if (packages[dependencyPackageIndex].manifest.featureRuleModule.contains(f)) { - it->hostModule = true; - break; - } - it->reexport = it->reexport || spec.reexport; - return; - } - // A REQUESTED FEATURE THAT IS A BUILD RULE IMPLIES `host-module`. - // - // `host-module = true` says "compile this dependency's interface unit - // for the host so my build program can import it", and a feature - // declaring `rule_module` has already said that is the only way to use - // it. Requiring both was a second spelling of one fact, and the failure - // when only the feature was written landed in the consumer's build as - // an unresolved import rather than in the line that was incomplete. - bool hostModule = spec.hostModule; - if (!hostModule && dependencyPackageIndex < packages.size()) { - auto const& depManifest = packages[dependencyPackageIndex].manifest; - for (auto const& f : spec.features) - if (depManifest.featureRuleModule.contains(f)) { hostModule = true; break; } - } - dependencyEdges.push_back(DependencyEdge{ - .consumerPackageIndex = consumerPackageIndex, - .dependencyPackageIndex = dependencyPackageIndex, - .visibility = visibility, - .requestedFeatures = spec.features, - .defaultFeatures = spec.defaultFeatures, - .requestedTools = spec.tools, - .hostModule = hostModule, - .reexport = spec.reexport, - .buildOnly = buildOnly, - }); - }; - - auto computeUsageRequirements = [&] { - bool changed = true; - while (changed) { - changed = false; - for (auto const& edge : dependencyEdges) { - if (edge.consumerPackageIndex >= packages.size() - || edge.dependencyPackageIndex >= packages.size()) { - continue; - } - auto& consumer = packages[edge.consumerPackageIndex]; - auto const& dependency = packages[edge.dependencyPackageIndex]; - // A package of programs publishes no usage requirements to its - // consumers (#649 E6): nothing of it is compiled or linked here. - if (edge.dependencyPackageIndex > 0 - && isProgramOnlyPackage(dependency.manifest)) continue; - - if (edge.visibility == mcpp::modgraph::DependencyVisibility::Private - || edge.visibility == mcpp::modgraph::DependencyVisibility::Public) { - changed = appendUniquePaths(consumer.privateBuild.includeDirs, - dependency.publicUsage.includeDirs) - || changed; - // #249: after-dirs ride the same edges but keep their - // after-ness — consumers receive them as -idirafter, - // never upgraded to -I. - changed = appendUniquePaths(consumer.privateBuild.includeDirsAfter, - dependency.publicUsage.includeDirsAfter) - || changed; - // Interface defines (a dependency's active-feature `defines`) - // ride the same edges as include dirs: they must reach the - // consumer's own TUs so header-only switches like - // EIGEN_USE_BLAS take effect where the headers are used. - changed = appendUniqueFlags(consumer.privateBuild.cflags, - dependency.publicUsage.cflags) - || changed; - changed = appendUniqueFlags(consumer.privateBuild.cxxflags, - dependency.publicUsage.cxxflags) - || changed; - } - if (edge.visibility == mcpp::modgraph::DependencyVisibility::Public - || edge.visibility == mcpp::modgraph::DependencyVisibility::Interface) { - changed = appendUniquePaths(consumer.publicUsage.includeDirs, - dependency.publicUsage.includeDirs) - || changed; - changed = appendUniquePaths(consumer.publicUsage.includeDirsAfter, - dependency.publicUsage.includeDirsAfter) - || changed; - changed = appendUniqueFlags(consumer.publicUsage.cflags, - dependency.publicUsage.cflags) - || changed; - changed = appendUniqueFlags(consumer.publicUsage.cxxflags, - dependency.publicUsage.cxxflags) - || changed; - } - } - } - }; - - auto normalizeDepLdflag = [](const std::filesystem::path& depRoot, - const std::string& flag) { - auto absolute_path = [&](std::string_view raw) { - std::filesystem::path p{std::string(raw)}; - // A loader token stays as written; see the predicate. - if (p.is_absolute() || mcpp::build::is_loader_relative_search_path(raw)) - return p; - return depRoot / p; - }; - - if (flag.starts_with("-L") && flag.size() > 2) { - return "-L" + absolute_path(std::string_view(flag).substr(2)).string(); - } - - constexpr std::string_view rpathPrefix = "-Wl,-rpath,"; - if (flag.starts_with(rpathPrefix) && flag.size() > rpathPrefix.size()) { - return std::string(rpathPrefix) - + absolute_path(std::string_view(flag).substr(rpathPrefix.size())).string(); - } - - return flag; - }; - - auto propagateLinkFlags = [&](const std::filesystem::path& depRoot, - const mcpp::manifest::Manifest& depManifest) - -> std::vector - { - // Word by word (SPEC-004 §8, #703): a search path is made absolute - // per word, and each word is written back as an element that reads as - // exactly that word, so the consumer's renderer reads the dependency's - // flags with the same reading its own flags receive, and an element - // that packs several tokens is several words on both sides. - std::vector added; - for (auto const& word : mcpp::manifest::flag_words(depManifest.buildConfig.ldflags)) { - auto normalized = mcpp::manifest::flag_element(normalizeDepLdflag(depRoot, word)); - m->buildConfig.ldflags.push_back(normalized); - added.push_back(std::move(normalized)); - } - return added; - }; - - auto removeLinkFlags = [&](const std::vector& flags) { - auto& ldflags = m->buildConfig.ldflags; - for (auto const& flag : flags) { - auto pos = std::find(ldflags.begin(), ldflags.end(), flag); - if (pos != ldflags.end()) ldflags.erase(pos); - } - }; - - auto package_source_files = []( - const std::filesystem::path& srcRoot, - const mcpp::manifest::Manifest& depManifest) - -> std::expected, std::string> - { - // Resolve the source globs against the original root, falling - // back to the convention default if the manifest didn't set any. - std::vector globs = depManifest.modules.sources; - if (globs.empty()) { - // Was a fourth hand-written copy of the convention default, and it - // had already drifted: all three assembly extensions were missing, - // so staging a dependency with .S/.s/.asm silently dropped them. - globs = mcpp::default_source_globs( - mcpp::extension_table_for(depManifest.buildConfig.moduleExtensions, - depManifest.buildConfig.deviceExtensions)); - } - // Glob exclusion (same as scan_one_into): `!` prefix removes. - std::set sourceFiles; - std::set excluded; - for (auto const& g : globs) { - if (!g.empty() && g[0] == '!') { - for (auto& p : mcpp::modgraph::expand_glob(srcRoot, g.substr(1))) - excluded.insert(p); - } else { - for (auto& p : mcpp::modgraph::expand_glob(srcRoot, g)) - sourceFiles.insert(p); - } - } - for (auto& p : excluded) sourceFiles.erase(p); - if (sourceFiles.empty()) { - return std::unexpected(std::format( - "stage: no source files found under '{}' (globs={})", - srcRoot.string(), globs.size())); - } - return sourceFiles; - }; - - // Stage a dep's source files into a fresh directory, rewriting their - // module / import declarations against `rename`. Used by the multi- - // version mangling fallback (Level 1) so two cross-major copies of - // the same package can coexist with distinct module names. - // - // Headers reached through `[build].include_dirs` are NOT staged — those - // keep pointing at the original install dir via absolutized include paths. - // - // HEADERS BESIDE A SOURCE ARE A DIFFERENT CASE, AND THEY ARE STAGED. - // - // `#include "detail.h"` is resolved relative to the directory of the file - // holding the directive, so moving the source moves the search. No - // `include_dirs` entry is involved and absolutizing one cannot help: the - // package never declared a path because it never needed one. Measured - // before this, on a package whose `src/time.cpp` includes `src/sbi.h`: - // - // target/.mangled/openkal-opensbi/__self__/src/time.cpp:44:10: - // fatal error: 'sbi.h' file not found - // - // THE DIAGNOSIS THIS PRODUCES POINTS AT THE WRONG THING. The path in it - // is a staging directory the author never wrote, for a header sitting - // exactly where the source expects it, and the build that triggered it - // asked for nothing unusual — two majors of one dependency is a supported - // arrangement, and this is its most ordinary consequence. - // - // What is copied is every file in a directory that contains a staged - // source and is not itself staged, verbatim: rewriting applies to module - // declarations, and a header has none. Directories with no staged source - // are not visited, so this stays proportional to what is being staged. - auto stage_with_rewrite = [&](const std::filesystem::path& srcRoot, - const std::filesystem::path& dstRoot, - const mcpp::manifest::Manifest& depManifest, - const std::map& rename) - -> std::expected - { - std::error_code ec; - std::filesystem::create_directories(dstRoot, ec); - if (ec) return std::unexpected(std::format( - "stage: cannot create '{}': {}", dstRoot.string(), ec.message())); - - auto sources = package_source_files(srcRoot, depManifest); - if (!sources) return std::unexpected(sources.error()); - - for (auto const& f : *sources) { - auto rel = std::filesystem::relative(f, srcRoot, ec); - if (ec) return std::unexpected(std::format( - "stage: cannot relativize '{}': {}", f.string(), ec.message())); - auto dst = dstRoot / rel; - std::filesystem::create_directories(dst.parent_path(), ec); - - std::ifstream is(f); - if (!is) return std::unexpected(std::format( - "stage: cannot read '{}'", f.string())); - std::stringstream buf; buf << is.rdbuf(); - std::string content = buf.str(); - - std::string out = mcpp::pm::rewrite_module_decls(content, rename); - std::ofstream os(dst); - if (!os) return std::unexpected(std::format( - "stage: cannot write '{}'", dst.string())); - os << out; - } - - // The files beside those sources, carried across unchanged so a quoted - // include still finds what it named. - std::set sourceDirs; - for (auto const& f : *sources) sourceDirs.insert(f.parent_path()); - for (auto const& dir : sourceDirs) { - for (auto const& entry : std::filesystem::directory_iterator(dir, ec)) { - if (ec) break; - if (!entry.is_regular_file()) continue; - if (sources->contains(entry.path())) continue; - auto rel = std::filesystem::relative(entry.path(), srcRoot, ec); - if (ec) continue; - auto dst = dstRoot / rel; - std::filesystem::create_directories(dst.parent_path(), ec); - std::filesystem::copy_file( - entry.path(), dst, - std::filesystem::copy_options::overwrite_existing, ec); - if (ec) return std::unexpected(std::format( - "stage: cannot copy '{}': {}", - entry.path().string(), ec.message())); - } - ec.clear(); - } - return {}; - }; - - auto declared_modules_for = [&](const std::filesystem::path& srcRoot, - const mcpp::manifest::Manifest& depManifest) - -> std::expected, std::string> - { - auto sources = package_source_files(srcRoot, depManifest); - if (!sources) return std::unexpected(sources.error()); - std::vector modules; - for (auto const& file : *sources) { - std::ifstream is(file); - if (!is) return std::unexpected(std::format( - "mangle: cannot read '{}'", file.string())); - std::stringstream buf; buf << is.rdbuf(); - for (auto& name : mcpp::pm::declared_module_roots(buf.str())) { - if (std::ranges::find(modules, name) == modules.end()) - modules.push_back(std::move(name)); - } - } - if (modules.empty()) return std::unexpected(std::format( - "mangle: package '{}' declares no named C++ module to rewrite", - depManifest.package.name)); - return modules; - }; - - // Stage 2a — feature-activated optional dependencies. Defined as local - // lambdas (NOT file-scope functions): keeping their std::map instantiations - // inside this implementation unit avoids polluting the exported module BMI, - // which otherwise trips a GCC-16 modules bug ("failed to load pendings for - // __normal_iterator") when other modules import std. - auto activateFeatures = [](const mcpp::manifest::Manifest& pm, - const std::vector& requested, - bool seedDefault = true) { - return feature_closure(pm, requested, seedDefault); // single shared implementation - }; - // Merge a manifest's active feature-deps into its `dependencies` map so the - // worklist below pulls them like any normal dep. A top-level dep of the same - // key is never overwritten; deps declared only under a feature appear only - // when that feature is active. `seedDefault` carries consumer-side - // `default-features = false` (#242): when a consumer opts out of this dep's - // default set, feature-deps behind the default pseudo-feature stay dormant. - // - // A RESTATEMENT NAMES THE SAME SOURCE OR IS REFUSED (#647 E4.2). The grammar - // asks a `[feature-deps]` entry to restate its dependency's source, and the - // merge below takes only the additive fields from it, so a restatement - // that names another path, repository or version was dropped without a - // word, under `--strict` too: the tool came from the declaration in effect - // while the manifest said it came from somewhere else. The comparison runs - // against `dependencies` after the conditional fold, so a row's replacement - // (#634 A1) is the declaration a restatement is held to. - // - // TWO SPELLINGS OF ONE SOURCE ARE ONE SOURCE. The comparison below decides - // whether a restatement names something else, so it has to be made on what - // the two declarations MEAN, not on their bytes: a path is normalised, and - // a version constraint is compared with its whitespace removed, because - // `">= 1.2.0"` and `">=1.2.0"` are one constraint and the manifest that - // spells them differently built on 2026.9.15.2. A gate added for #647 E4.2 - // must refuse a restatement that names another source, and nothing else. - auto dependencySourceOf = [](const mcpp::manifest::DependencySpec& s) { - if (s.inheritWorkspace) return std::string("workspace = true"); - if (s.isPath()) { - auto norm = std::filesystem::path(s.path).lexically_normal().generic_string(); - while (norm.size() > 1 && norm.back() == '/') norm.pop_back(); - return std::format("path = \"{}\"", norm); - } - if (s.isGit()) - return std::format("git = \"{}\", {} = \"{}\"", s.git, - s.gitRefKind.empty() ? "rev" : s.gitRefKind, s.gitRev); - return std::format("version = \"{}\"", s.version); - }; - // What the comparison is made on. The message shows the declaration as it - // was written; the judgement drops the whitespace inside a constraint, so - // the two declarations are compared on what they mean. - auto dependencySourceKey = [&](const mcpp::manifest::DependencySpec& s) { - auto spelled = dependencySourceOf(s); - if (!s.inheritWorkspace && !s.isPath() && !s.isGit()) - std::erase_if(spelled, [](char c) { return c == ' ' || c == '\t'; }); - return spelled; - }; - auto mergeActiveFeatureDeps = [&](mcpp::manifest::Manifest& pm, - const std::vector& requested, - bool seedDefault = true) - -> std::expected { - if (pm.featureDeps.empty()) return {}; - for (auto& f : activateFeatures(pm, requested, seedDefault)) { - auto it = pm.featureDeps.find(f); - if (it == pm.featureDeps.end()) continue; - for (auto& [k, spec] : it->second) { - auto [pos, fresh] = pm.dependencies.try_emplace(k, spec); - if (fresh) continue; - if (!pos->second.inheritWorkspace && !spec.inheritWorkspace) { - const auto inEffect = dependencySourceOf(pos->second); - const auto restated = dependencySourceOf(spec); - if (dependencySourceKey(pos->second) != dependencySourceKey(spec)) - return std::unexpected(std::format( - "[feature-deps.{}] of '{}' restates the dependency '{}' " - "with {}, while the declaration in effect on this row " - "names {}.\n" - " One dependency has one source, so the " - "restatement would be ignored.\n" - " fix: restate the same source ({}), or declare " - "'{}' only under the feature.", - f, pm.package.name, k, restated, inEffect, inEffect, k)); - } - // #359: the key already exists unconditionally, and dropping - // the feature's spec here loses REQUESTS the feature exists to - // make. gRPC is the shape: it depends on compat.protobuf - // always, and its `codegen` feature has to add - // `tools = ["protoc"], reexport = true` to that same edge — - // which is precisely what must NOT be paid for by a consumer - // who did not ask for codegen, so moving it to the - // unconditional entry is not an option either. - // - // Additive fields merge; identity fields (version/path/git) do - // not, keeping "a conditional section never silently - // overrides an unconditional one" intact. Same rule the - // per-edge feature request already follows. - auto& dst = pos->second; - for (auto const& t : spec.tools) - if (std::find(dst.tools.begin(), dst.tools.end(), t) - == dst.tools.end()) - dst.tools.push_back(t); - for (auto const& f2 : spec.features) - if (std::find(dst.features.begin(), dst.features.end(), f2) - == dst.features.end()) - dst.features.push_back(f2); - dst.hostModule = dst.hostModule || spec.hostModule; - dst.reexport = dst.reexport || spec.reexport; - } - } - return {}; - }; - - // #243: dep/feat forwarding. When a resolved package's feature F is active, - // it may forward features to its dependencies (Cargo `[features] F = - // ["dep/feat"]`). Injecting the forwarded feature into the child's request - // BEFORE the child is pushed onto the worklist makes BOTH consumption points - // observe it: resolution (mergeActiveFeatureDeps reads the child's - // spec.features) and activation (recordDependencyEdge stores spec.features on - // the P->D edge, which aggregatedRequest unions and apply() activates). - // Transitive forwarding rides the BFS forward edge (root -> mid -> leaf). - auto injectForwards = [](const mcpp::manifest::Manifest& parent, - const std::vector& parentActive, - const std::string& childKey, - mcpp::manifest::DependencySpec& childSpec) { - if (parent.featureForwards.empty()) return; - for (auto const& f : parentActive) { - auto it = parent.featureForwards.find(f); - if (it == parent.featureForwards.end()) continue; - for (auto const& [depKey, depFeat] : it->second) { - if (depKey != childKey) continue; - if (std::find(childSpec.features.begin(), childSpec.features.end(), - depFeat) == childSpec.features.end()) - childSpec.features.push_back(depFeat); - } - } - }; - // #243: a forward whose active feature targets a dependency that is not - // declared is a manifest bug — name it instead of silently dropping. Only - // active features' forwards are checked (lazy, like the - // unknown-requested-feature gate at ~2875). - // - // THE VALIDATOR ASKS WHAT THE FORWARD LANGUAGE DEFINES: IS THE KEY DECLARED - // IN ANY DEPENDENCY TABLE OF THIS MANIFEST, ON ANY ROW, UNDER ANY FEATURE - // (#647 E4.1). It used to look in `dependencies` and `devDependencies` - // only, while `injectForwards` applies a forward to the build-dependency - // edge as well, so a forward along `[build-dependencies]` was applied and - // reported as undeclared in the same run, and `--strict` refused a build - // whose forward had worked. A key declared only for another row, or only - // under an inactive feature, is declared: on this row the forward reaches - // no edge and does nothing, which is what a portable manifest means by it. - auto declaresDependencyKey = [](const mcpp::manifest::Manifest& pm, - const std::string& key) { - auto inFeatureDeps = [&](const auto& byFeature) { - for (auto const& [f, deps] : byFeature) - if (deps.contains(key)) return true; - return false; - }; - if (pm.dependencies.contains(key) || pm.devDependencies.contains(key) - || pm.buildDependencies.contains(key) || inFeatureDeps(pm.featureDeps)) - return true; - for (auto const& cc : pm.conditionalConfigs) - if (cc.dependencies.contains(key) || cc.devDependencies.contains(key) - || cc.buildDependencies.contains(key) - || inFeatureDeps(cc.featureDeps)) - return true; - return false; - }; - auto validateForwards = [&](const mcpp::manifest::Manifest& parent, - const std::vector& parentActive, - std::string_view parentName) - -> std::expected { - for (auto const& f : parentActive) { - auto it = parent.featureForwards.find(f); - if (it == parent.featureForwards.end()) continue; - for (auto const& [depKey, depFeat] : it->second) { - if (declaresDependencyKey(parent, depKey)) continue; - auto msg = std::format( - "feature '{}' of '{}' forwards to dependency '{}' (as " - "'{}/{}') which no dependency table declares ([dependencies], " - "[build-dependencies], [dev-dependencies] or [feature-deps], " - "on any row)", f, parentName, depKey, depKey, depFeat); - if (overrides.strict) return std::unexpected(msg); - mcpp::diag::warning("features/forwarding", msg); - } - } - return {}; - }; - - // Pull the root package's active feature-deps into its dependency set before - // seeding, so `mcpp build --features X` resolves X's optional deps. - std::vector rootReq = parse_feature_request(overrides.features); - if (auto fm = mergeActiveFeatureDeps(*m, rootReq); !fm) - return std::unexpected(fm.error()); - // #243: the root's active features may forward features to its direct deps. - std::vector rootActive = feature_closure(*m, rootReq, true); - if (auto fe = validateForwards(*m, rootActive, m->package.name); !fe) - return std::unexpected(fe.error()); - activeFeaturesByPackage.assign(1, rootActive); - - // `--features /` (#649 E8): a forward of the root, - // applied to the edges exactly as a `[features]` forward is and checked - // against the same tables. Named whether or not the root declares - // `[features]`: the token cannot be a macro of the root, so there is no - // "pure macro usage" to preserve for it. - std::vector> cliForwards; - for (auto const& tok : feature_forward_request_tokens(overrides.features)) { - auto fwd = mcpp::pm::split_feature_forward_token(tok); - std::string msg; - if (!fwd) - msg = std::format("--features requests '{}', which names neither a " - "feature nor `/`", tok); - else if (!declaresDependencyKey(*m, fwd->first)) - msg = std::format("--features requests '{}', and no dependency table " - "of '{}' declares '{}'", tok, m->package.name, - fwd->first); - if (!msg.empty()) { - if (overrides.strict) return std::unexpected(msg); - mcpp::diag::warning("features/request", msg); - continue; - } - cliForwards.push_back(std::move(*fwd)); - } - auto injectCliForwards = [&](const std::string& childKey, - mcpp::manifest::DependencySpec& childSpec) { - for (auto const& [depKey, depFeat] : cliForwards) - if (depKey == childKey - && std::ranges::find(childSpec.features, depFeat) - == childSpec.features.end()) - childSpec.features.push_back(depFeat); - }; - - // Seed the worklist from the main manifest. Dev-deps only when the - // caller wants them; they're never propagated transitively. - const std::string mainPkgLabel = m->package.name; - for (auto& [n, s] : m->dependencies) { - auto req = s; - injectForwards(*m, rootActive, n, req); - injectCliForwards(n, req); - worklist.push_back({n, req, mainPkgLabel, req.version, kMainConsumer, {}}); - } - if (includeDevDeps) { - for (auto& [n, s] : m->devDependencies) { - auto req = s; - injectForwards(*m, rootActive, n, req); - injectCliForwards(n, req); - worklist.push_back({n, req, mainPkgLabel + " (dev-dep)", - req.version, kMainConsumer, {}, /*devOnly=*/true}); - } - } - // `[build-dependencies]`. Parsed since 0.0.x, merged across workspace - // members, conditionalised by target predicate — and until now read by - // nothing that made a decision, so writing it produced a manifest that - // loaded, no diagnostic, and no effect. Seeded here, and unlike dev-deps - // it IS walked transitively: a build dependency's own dependencies are - // what make it work, and they inherit its build-only nature. - for (auto& [n, s] : m->buildDependencies) { - auto req = s; - injectForwards(*m, rootActive, n, req); - injectCliForwards(n, req); - worklist.push_back({n, req, mainPkgLabel + " (build-dep)", - req.version, kMainConsumer, {}, /*devOnly=*/false, - /*buildOnly=*/true}); - } - - // `ResolvedRecord::sourceRef` for a given declaration — see the field's - // comment. Computed from what was AUTHORED, not from a network round - // trip: a `branch` reference is compared by name here, and the two - // clones it may eventually resolve to are a question `resolveSemver`-style - // ANSWERING code, not this IDENTITY code, would have to ask. - auto sourceRefOf = [&](const std::string& kind, - const mcpp::manifest::DependencySpec& s, - const std::filesystem::path& resolveRoot, - const std::string& originalConstraint) -> std::string { - if (kind == "git") { - return std::format("{}#{}={}", s.git, s.gitRefKind, s.gitRev); - } - if (kind == "path") { - std::filesystem::path p = s.path; - auto base = resolveRoot.empty() ? *root : resolveRoot; - if (p.is_relative()) p = base / p; - std::error_code ec; - auto canon = std::filesystem::weakly_canonical(p, ec); - return (ec ? p : canon).lexically_normal().generic_string(); - } - // "version": the constraint as authored; empty means unconstrained, - // matching `addrset::unify`'s treatment of a bare-name claim. - return originalConstraint.empty() ? std::string("*") : originalConstraint; - }; - - while (!worklist.empty()) { - auto item = std::move(worklist.front()); - worklist.pop_front(); - - const auto& name = item.name; - auto& spec = item.spec; - - mcpp::pm::compat::normalize_nested_namespace( - spec.namespace_, spec.shortName, spec.legacyDottedKey); - if (spec.legacyDottedKey) { - spec.candidates = {{ - .namespace_ = spec.namespace_, - .shortName = spec.shortName, - }}; - } - - if (auto r = selectDependencyCandidate(spec, name); !r) { - return std::unexpected(r.error()); - } - if (item.consumerDepIndex == kMainConsumer) { - if (auto it = m->dependencies.find(name); it != m->dependencies.end()) { - it->second.namespace_ = spec.namespace_; - it->second.shortName = spec.shortName; - it->second.candidates = spec.candidates; - } - } - - // A `path` edge that stays inside a git clone this graph already - // resolved names the same git source at the same commit (#649 E7): - // a member's `spike.fw = { path = ".." }` reaches the repository the - // application pinned by revision, and is that package rather than a - // second, path-sourced declaration of it. Only the clone root itself - // and its `[workspace] members` are mapped; any other directory keeps - // being an ordinary path. - if (spec.isPath() && !gitCloneBySource.empty()) { - std::filesystem::path p = spec.path; - auto base = item.resolveRoot.empty() ? *root : item.resolveRoot; - if (p.is_relative()) p = base / p; - std::error_code ec; - auto canon = std::filesystem::weakly_canonical(p, ec); - if (ec) canon = p.lexically_normal(); - for (auto const& [src, clone] : gitCloneBySource) { - auto rel = canon.lexically_relative(clone.root).generic_string(); - if (rel.empty() || rel.starts_with("..")) continue; - bool mapped = rel == "."; - if (!mapped) { - if (auto rm = mcpp::manifest::load(clone.root / "mcpp.toml"); - rm && rm->workspace.present) - for (auto const& member : rm->workspace.members) - if (std::filesystem::path(member).lexically_normal() - .generic_string() == rel) - mapped = true; - } - if (!mapped) continue; - spec.path.clear(); - spec.git = clone.url; - spec.gitRefKind = clone.refKind; - spec.gitRev = clone.ref; - break; - } - } - - // Pin SemVer constraint before dedup/fetch. - if (auto r = resolveSemver(spec, name); !r) { - return std::unexpected(r.error()); - } - - ResolvedKey key{ - spec.namespace_, - spec.shortName.empty() ? name : spec.shortName, - }; - const std::string sourceKind = - spec.isPath() ? "path" - : spec.isGit() ? "git" - : "version"; - // The commit a `git` dependency resolved to, carried out of the clone - // branch below for the cache identity. - std::string sourceCommit; - // The repository member a `git` dependency selected; empty for the - // repository's root package (#649 E7). - std::string gitMember; - std::filesystem::path gitMemberCloneRoot; - - // A second key over a source that is already resolved takes the - // identity resolved there; its manifest is not loaded again. - if (sourceKind != "version") { - const auto source = sourceRefOf(sourceKind, spec, item.resolveRoot, - item.originalConstraint); - // A key naming another package of the same repository is that - // member, not a second key over the root's identity (#649 E7). - bool namesMember = false; - if (sourceKind == "git") - if (auto clone = gitCloneBySource.find(source); - clone != gitCloneBySource.end()) - namesMember = gitMemberDeclaring(clone->second.root, key).has_value(); - if (auto bySource = identityBySource.find(source); - !namesMember && bySource != identityBySource.end() - && !(bySource->second == key)) { - const auto& existing = resolved.at(bySource->second); - const auto& declaring = declaringManifest.at(bySource->second); - if (!declaring.namespaceDeclared) { - return std::unexpected(std::format( - "one source is reached as two packages: '{}' names it {} " - "and '{}' names it {}, and its manifest '{}' declares no " - "namespace, so each key gives it its own identity and " - "its modules would be compiled twice.\n" - " fix: declare `namespace` in '{}', or write the " - "same key in both places.", - existing.requestedBy, qualifiedKey(bySource->second), - item.requestedBy, qualifiedKey(key), - declaring.path, declaring.path)); - } - reportAdoption(item.requestedBy, name, key, bySource->second, - declaring.path); - key = bySource->second; - stateAdoptedIdentity(item, key); - } - } - - if (auto it = resolved.find(key); it != resolved.end()) { - // A package is dev-only until some non-dev consumer wants it. Order - // of arrival must not decide, so this is an AND over every request. - it->second.devOnly = it->second.devOnly && item.devOnly; - // Conflict detection: a KIND clash (`path`/`git`/`version` differ). - // Rows 4 and 5 of the decision table in the 2026-09-13-630 record - // §2.2. Two non-root requesters keep the outright refusal (row - // 5); when the root is a party, its declaration wins instead - // (row 4) — a whole-graph choice of WHICH checkout an identity - // resolves to is exactly the kind of decision - // `DependencySpec::linkage` already reserves to the root's own - // edges (dep_spec.cppm). - if (it->second.source != sourceKind) { - const bool existingIsRoot = it->second.fromRoot; - const bool incomingIsRoot = item.consumerDepIndex == kMainConsumer; - - if (!existingIsRoot && !incomingIsRoot) { - return std::unexpected(std::format( - "dependency '{}{}{}' is requested as both a {} dep " - "(by '{}') and a {} dep (by '{}'). Pick one.\n" - " declare '{}{}{}' in the root to settle it.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.source, it->second.requestedBy, - sourceKind, item.requestedBy, - key.ns, key.ns.empty() ? "" : ".", key.shortName)); - } - if (incomingIsRoot && !existingIsRoot) { - // FIFO SEEDING MAKES THIS UNREACHABLE. Every root-declared - // identity is pushed onto `worklist` before this loop - // starts; a transitive dependency's request is pushed - // onto the BACK of the same deque while the loop runs. - // The root's own entry for any identity is therefore - // always dequeued — and resolved — before any - // dependency's request for that identity can arrive. If - // this branch is ever reached, the invariant broke - // upstream (the seed reordered, or a new seed source was - // added after the loop starts): refusing and naming the - // invariant is safer than silently letting whichever side - // arrived first win, which is the accident #630 reports. - return std::unexpected(std::format( - "internal: dependency '{}{}{}': the root's " - "declaration arrived after '{}' had already resolved " - "it. This is unreachable under first-in-first-out " - "worklist seeding; please report this as an mcpp " - "engine defect.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.requestedBy)); - } - - // The root already holds this identity (existingIsRoot); the - // incoming, non-root declaration is overridden. When the - // OVERRIDDEN declaration is a version requirement, it is - // still a promise about the graph and is checked against - // what the root's checkout actually is — the same - // Holds/Violated test `addrset::unify` runs for a tool pin - // (address_set.cppm). - if (sourceKind == "version") { - const std::string winnerVersion = it->second.source == "version" - ? it->second.version - : (it->second.depIndex < dep_manifests.size() - ? dep_manifests[it->second.depIndex]->package.version - : std::string{}); - auto req = mcpp::version_req::parse_req(item.originalConstraint); - auto ver = mcpp::version_req::parse_version(winnerVersion); - // An unparseable requirement or checkout version is - // reported as an override below rather than refused: a - // refusal manufactured from ignorance is worse than the - // silent override it would be preventing (the same - // reasoning `addrset::check` states for an unparseable - // spelling). - if (req && ver && !mcpp::version_req::matches(*req, *ver)) { - return std::unexpected(std::format( - "'{}{}{}' is pinned to {} (version {}) by '{}', " - "and '{}' requires {}.\n" - " One checkout of a package is used, so the " - "two cannot both hold.\n" - " fix: relax the requirement, or point the " - "root's pin at a checkout satisfying it.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.sourceRef, winnerVersion, - it->second.requestedBy, - item.requestedBy, item.originalConstraint)); - } - } - - mcpp::diag::warning("dependency/source-override", std::format( - "'{}{}{}' is declared as a {} dep (by '{}', {}) and as a " - "{} dep (by '{}', {}); the root's declaration wins.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.source, it->second.requestedBy, it->second.sourceRef, - sourceKind, item.requestedBy, - sourceKind == "version" ? item.originalConstraint - : sourceRefOf(sourceKind, spec, - item.resolveRoot, - item.originalConstraint)), - std::format("declare '{}{}{}' in the root to choose the other.", - key.ns, key.ns.empty() ? "" : ".", key.shortName)); - - if (it->second.depIndex + 1 < packages.size()) { - recordDependencyEdge(item.consumerDepIndex, - it->second.depIndex + 1, - spec, item.buildOnly, name); - } - continue; - } - if (sourceKind == "version" && it->second.version != spec.version) { - // SemVer merge attempt: AND-combine the two original - // constraint strings and ask the index for a single version - // satisfying both. Same-major caret/tilde/exact pairs that - // overlap converge here; cross-major or otherwise - // unsatisfiable pairs fall through to a hard error (a future - // PR adds multi-version mangling as a Level-1 fallback). - auto cfg = get_cfg(); - if (!cfg) return std::unexpected(cfg.error()); - - auto merged = mcpp::pm::try_merge_semver( - key.ns, key.shortName, - it->second.constraint, - item.originalConstraint, - index_route(*cfg), targetPlatform); - if (!merged) { - // Level 1 fallback: multi-version mangling. Two - // versions can't be reconciled by SemVer, but they - // can coexist in the same build if we mangle the - // secondary copy's module name and rewrite the one - // consumer that asked for it. The primary keeps its - // authored module name so consumers that don't care - // about the secondary see no churn. - // - // MVP scope (these limits surface as clear errors): - // * The conflicting consumer must be a dep, not - // the main package — main-package mangling - // would mean rewriting user-authored sources, - // which is too surprising for a fallback path. - // * The secondary version must be a leaf (no own - // transitive deps) — recursive mangling is - // deferred to a follow-up. - if (item.consumerDepIndex == kMainConsumer) { - return std::unexpected(std::format( - "dependency '{}{}{}' has irreconcilable versions:\n" - " '{}' (constraint '{}') requested by '{}'\n" - " '{}' (constraint '{}') requested by '{}'\n" - "SemVer merge: {}\n" - "Multi-version mangling can't help here — the conflict " - "involves the main package directly. Pin one version " - "explicitly in your mcpp.toml.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.version, it->second.constraint, it->second.requestedBy, - spec.version, item.originalConstraint, item.requestedBy, - merged.error())); - } - - auto loaded = loadVersionDep(name, key.ns, key.shortName, spec.version); - if (!loaded) return std::unexpected(loaded.error()); - auto& [secondaryRoot, secondaryManifest] = *loaded; - - if (!secondaryManifest.dependencies.empty()) { - return std::unexpected(std::format( - "dependency '{}{}{}' has irreconcilable versions:\n" - " '{}' requested by '{}'\n" - " '{}' requested by '{}'\n" - "Multi-version mangling fallback only handles leaf " - "secondaries in 0.0.3 — but the secondary v{} declares " - "its own dependencies, which would need recursive " - "mangling. Pin one version explicitly, or wait for " - "the recursive-mangling extension.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.version, it->second.requestedBy, - spec.version, item.requestedBy, - spec.version)); - } - - // Module names are authored API and are not required to - // mirror package identity. Discover every provided module - // root from the secondary's source text, then rewrite the - // same map in both the secondary and its consumer. - auto moduleNames = declared_modules_for( - secondaryRoot, secondaryManifest); - // The two branches above name both versions and who asked - // for them; this one used to report only that the package - // declares no named C++ module, which is a true statement - // about a package the reader never asked to be staged. A - // C package -- compat.vulkan-runtime is one -- reaches - // here whenever a manifest pins one version of it and - // another dependency asks for a second, and the message - // has to say that before it says anything about modules. - if (!moduleNames) return std::unexpected(std::format( - "dependency '{}{}{}' has irreconcilable versions:\n" - " '{}' requested by '{}'\n" - " '{}' requested by '{}'\n" - "Multi-version mangling cannot separate them: {}.\n" - "A package with no named C++ module has nothing to " - "rewrite, so the two requests must agree. Align the " - "pin in your mcpp.toml with the version the other " - "dependency asks for.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.version, it->second.requestedBy, - spec.version, item.requestedBy, - moduleNames.error())); - std::map rename; - for (auto const& module : *moduleNames) { - rename.emplace(module, - mcpp::pm::mangle_name(module, spec.version)); - } - const auto& moduleName = moduleNames->front(); - const auto& mangledModule = rename.at(moduleName); - const std::string mangledPackage = mcpp::pm::mangle_name( - key.shortName, spec.version); - - // Stage layout: - // /target/.mangled//__/ ← rewritten secondary source - // /target/.mangled//__self__/ ← rewritten consumer source - auto& consumerManifest = *dep_manifests[item.consumerDepIndex]; - auto consumerRoot = packages[item.consumerDepIndex + 1].root; - // Under the write root, not the source root: the stage - // is build output, and BuildOverrides::work_dir promises - // that everything the build writes moves with it. - auto stageBase = workRoot / "target" / ".mangled" - / consumerManifest.package.name; - auto secStage = stageBase - / std::format("{}__{}", key.shortName, spec.version); - auto consumerStage = stageBase / "__self__"; - - if (auto r = stage_with_rewrite(secondaryRoot, secStage, - secondaryManifest, rename); !r) - return std::unexpected(r.error()); - if (auto r = stage_with_rewrite(consumerRoot, consumerStage, - consumerManifest, rename); !r) - return std::unexpected(r.error()); - - // Re-anchor the consumer's PackageRoot at its staged copy - // so the modgraph scanner picks up the rewritten imports. - packages[item.consumerDepIndex + 1].root = consumerStage; - - // Record the staged secondary as a brand-new dep entry - // under its mangled name, so future encounters of this - // exact (ns, mangled) pair dedup cleanly. The original - // primary entry (it->second) is untouched. - auto stagedManifest = secondaryManifest; - // Give the staged package a distinct atomic identity too; - // authored module names remain independent and are carried - // exclusively by the rename map above. - stagedManifest.package.name = mangledPackage; - if (stagedManifest.package.namespace_.empty()) { - stagedManifest.package.namespace_ = key.ns.empty() - ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; - } - stagedManifest.package.sourceProvenance = std::format( - "index+{}@{}", cache_index_name(key.ns), spec.version); - // Absolutize secondary's include_dirs against its original - // install root so the staged copy still finds headers. - for (auto& inc : stagedManifest.buildConfig.includeDirs) { - if (inc.is_relative()) inc = secondaryRoot / inc; - } - for (auto& inc : stagedManifest.buildConfig.includeDirsAfter) { - if (inc.is_relative()) inc = secondaryRoot / inc; - } - - dep_manifests.push_back( - std::make_unique(std::move(stagedManifest))); - dep_cache_identities.push_back({ - .indexName = cache_index_name(key.ns), - .packageName = mangledPackage, - .version = spec.version, - .sourceKind = "version", - }); - const auto depPackageIndex = packages.size(); - auto secPackage = makePackageRoot(secStage, *dep_manifests.back()); - if (!secPackage) return std::unexpected(secPackage.error()); - packages.push_back(std::move(*secPackage)); - recordDependencyEdge(item.consumerDepIndex, depPackageIndex, - spec, item.buildOnly, name); - auto linkFlagsAdded = propagateLinkFlags(secStage, *dep_manifests.back()); - - ResolvedKey mangledKey{key.ns, mangledPackage}; - resolved[mangledKey] = ResolvedRecord{ - .version = spec.version, - .constraint = item.originalConstraint, - .requestedBy = item.requestedBy, - .source = "version", - .sourceRef = item.originalConstraint.empty() - ? std::string("*") : item.originalConstraint, - // The mangling fallback refuses a main-package - // participant earlier (see the branch's comment - // above), so this record's requester is always a - // dependency. - .fromRoot = false, - .devOnly = item.devOnly, - .depIndex = dep_manifests.size() - 1, - .linkFlagsAdded = std::move(linkFlagsAdded), - }; - - mcpp::ui::info("Mangled", - std::format("{} v{} ↔ v{} → {} (cross-major fallback)", - moduleName, it->second.version, spec.version, - mangledModule)); - continue; - } - - // Combine the constraint strings so future merges AND with - // both. Empty originalConstraint means "any" — use "*". - const std::string& addCstr = - item.originalConstraint.empty() ? std::string("*") - : item.originalConstraint; - if (it->second.constraint.empty()) - it->second.constraint = addCstr; - else - it->second.constraint += "," + addCstr; - - if (*merged == it->second.version) { - // The existing pin already satisfies the new constraint — - // no re-fetch needed; just record this consumer edge. - recordDependencyEdge(item.consumerDepIndex, - it->second.depIndex + 1, - spec, item.buildOnly, name); - continue; - } - - // Merged version differs from the previously-pinned one. - // Re-fetch the dep at the merged version and replace the - // earlier slot in dep_manifests / packages so the build plan - // sees only one version. Old include_dir entries are evicted - // and the new manifest's entries are appended. - mcpp::ui::info("Merged", - std::format("{}{}{} {} ⨯ {} → v{}", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.version, spec.version, *merged)); - auto reloaded = loadVersionDep(name, key.ns, key.shortName, *merged); - if (!reloaded) return std::unexpected(reloaded.error()); - auto& [newRoot, newManifest] = *reloaded; - - // Name match against the re-loaded manifest. - { - const std::string& expectedShort = - spec.shortName.empty() ? name : spec.shortName; - // Also accept the fully-qualified form (ns.short) since - // synthesize_from_xpkg_lua may set package.name to the - // composite name for backward compat. - auto expectedComposite = spec.namespace_.empty() - ? std::string{} - : std::format("{}.{}", spec.namespace_, expectedShort); - const bool nameOk = - newManifest.package.name == expectedShort - || newManifest.package.name == name - || (!expectedComposite.empty() - && newManifest.package.name == expectedComposite); - if (!nameOk) { - return std::unexpected(std::format( - "dependency '{}' (merged to v{}) resolved to " - "package '{}' (mismatch with declared name '{}')", - name, *merged, newManifest.package.name, - expectedShort)); - } - } - if (newManifest.package.namespace_.empty()) { - newManifest.package.namespace_ = key.ns.empty() - ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; - } - newManifest.package.sourceProvenance = std::format( - "index+{}@{}", cache_index_name(key.ns), *merged); - - removeLinkFlags(it->second.linkFlagsAdded); - auto linkFlagsAdded = propagateLinkFlags(newRoot, newManifest); - - // Replace in dep_manifests + packages. depIndex is the slot - // in dep_manifests; packages = [main, dep_0, dep_1, …], so - // packages[depIndex+1] is the same dep. - *dep_manifests[it->second.depIndex] = std::move(newManifest); - auto mergedPackage = - makePackageRoot(newRoot, *dep_manifests[it->second.depIndex]); - if (!mergedPackage) return std::unexpected(mergedPackage.error()); - packages[it->second.depIndex + 1] = std::move(*mergedPackage); - recordDependencyEdge(item.consumerDepIndex, - it->second.depIndex + 1, - spec, item.buildOnly, name); - - it->second.version = *merged; - it->second.linkFlagsAdded = std::move(linkFlagsAdded); - if (it->second.depIndex < dep_cache_identities.size()) - dep_cache_identities[it->second.depIndex].version = *merged; - - // Walk the *new* manifest's deps so their constraints feed - // future merges. Already-resolved children dedup via the - // resolved map. - const std::string newLabel = std::format("{}{}{}@{}", - key.ns, key.ns.empty() ? "" : ".", - key.shortName, *merged); - for (auto& [child_name, child_spec] : - dep_manifests[it->second.depIndex]->dependencies) { - worklist.push_back({child_name, child_spec, newLabel, - child_spec.version, - it->second.depIndex, {}, item.devOnly}); - } - continue; - } - // SAME kind, possibly DIFFERENT reference: two `git` declarations - // of different rev/tag/branch, or two `path` declarations of - // different directories. Row 3 of the decision table (`version` - // vs `version` is handled above and never reaches here). Before - // this comparison existed, the second declaration's reference was - // never even read — the record kept no `path`/`gitRev`, so there - // was nothing to compare, and the winner was whichever request - // happened to be dequeued first (the #630 "accident of queue - // order"). - if (sourceKind != "version") { - const std::string incomingRef = - sourceRefOf(sourceKind, spec, item.resolveRoot, item.originalConstraint); - if (incomingRef != it->second.sourceRef) { - const bool existingIsRoot = it->second.fromRoot; - const bool incomingIsRoot = item.consumerDepIndex == kMainConsumer; - if (incomingIsRoot && !existingIsRoot) { - // See the identical comment in the kind-clash branch - // above: unreachable under FIFO seeding, and refused - // by name rather than silently swapped in. - return std::unexpected(std::format( - "internal: dependency '{}{}{}': the root's " - "declaration arrived after '{}' had already " - "resolved it. This is unreachable under " - "first-in-first-out worklist seeding; please " - "report this as an mcpp engine defect.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.requestedBy)); - } - // The already-resolved record wins either way: it is the - // root's (existingIsRoot) or it is simply the first one - // dequeued (neither party is the root). Both are "the - // first requester" in the sense row 3 states — the root - // is dequeued before any transitive request under FIFO - // seeding, so "the root wins" and "the first dequeued - // wins" never disagree about WHICH record already sits in - // `resolved`. - mcpp::diag::warning("dependency/source-override", std::format( - "'{}{}{}' is declared as {} '{}' (by '{}') and as {} " - "'{}' (by '{}'); {} wins.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - sourceKind, it->second.sourceRef, it->second.requestedBy, - sourceKind, incomingRef, item.requestedBy, - existingIsRoot ? "the root's declaration" - : std::format("'{}', declared first", - it->second.requestedBy)), - std::format("declare '{}{}{}' in the root to choose " - "the other.", - key.ns, key.ns.empty() ? "" : ".", key.shortName)); - } - } - // Same key, same version (or compatible path/git) — already - // processed; still record the dependency edge before skipping. - // Usage propagation is per edge, not per unique package: two - // consumers can need the same dep's public surface even though - // the dep itself is fetched/scanned once. - if (it->second.depIndex + 1 < packages.size()) { - recordDependencyEdge(item.consumerDepIndex, - it->second.depIndex + 1, - spec, item.buildOnly, name); - } - continue; - } - - std::filesystem::path dep_root; - - if (spec.isPath()) { - // Path-based: resolve relative to the consumer's root dir. - // For top-level deps this is the project root; for transitive - // deps it's the parent dep's directory (stored in resolveRoot). - dep_root = spec.path; - auto base = item.resolveRoot.empty() ? *root : item.resolveRoot; - if (dep_root.is_relative()) dep_root = base / dep_root; - dep_root = std::filesystem::weakly_canonical(dep_root); - } else if (spec.isGit()) { - // Git-based (M4 #5): clone into ~/.mcpp/git// and treat - // as a path dep from there. - // - // Two independent questions, each answered by at most one network - // operation and therefore guarded by exactly one --offline gate: - // - // 1. WHICH COMMIT? `tag`/`rev` name one outright. A `branch` is - // floating: mcpp.lock answers it, else `git ls-remote` does. - // 2. IS IT ON DISK? The commit selects the cache directory, so a - // miss — or a clone parked on the wrong commit — is a clone. - // - // mcpp.lock is authoritative for (1), not a hint that (2) has to - // confirm: a recorded commit is used whether or not the clone - // survived, so evicting ~/.mcpp/git can never quietly move a build - // onto a newer branch tip. `mcpp update ` drops the entry and - // stays the one way a branch advances. - auto mcppHome = mcpp::home::root(); // single resolver (#311) - - const bool remoteIsLocal = is_local_git_remote(spec.git); - auto refuse_offline = [&](std::string_view need, - std::string_view why, - std::string_view verb) { - refusal::record(refusal::Code::OfflineDownloadRequired); - return std::unexpected(std::format( - "offline mode: git dependency '{}' needs {} of '{}'\n" - " {}\n" - " run without --offline (or unset MCPP_OFFLINE) to {} it", - name, need, spec.git, why, verb)); - }; - const bool offline = - !remoteIsLocal && mcpp::platform::env::offline_mode(); - - // ── 1. which commit ── - std::string resolvedGitRev = spec.gitRev; - bool fromLock = false; - if (spec.gitRefKind == "branch") { - auto it = gitLockAnchors.find(name); - if (it != gitLockAnchors.end() - && it->second.url == spec.git - && it->second.refKind == spec.gitRefKind - && it->second.ref == spec.gitRev - && it->second.resolvedCommit) { - resolvedGitRev = *it->second.resolvedCommit; - fromLock = true; - } else { - if (offline) - return refuse_offline("`git ls-remote`", - std::format("mcpp.lock records no commit for branch " - "'{}'", spec.gitRev), - "resolve"); - // The FIRST network step of a git dependency, and therefore - // the one a transient fault is most likely to meet. - auto r = run_with_network_retry(std::format( - "git ls-remote {} {} 2>&1", - mcpp::platform::shell::quote(spec.git), - mcpp::platform::shell::quote( - std::format("refs/heads/{}", spec.gitRev)))); - if (r.exit_code != 0) - return std::unexpected(std::format( - "git ls-remote of '{}' failed:\n{}", - spec.git, r.output)); - // Cleared first: `operator>>` leaves the target untouched - // when the stream is already at EOF, which would otherwise - // let the declared branch name pass the emptiness check. - resolvedGitRev.clear(); - std::istringstream is(r.output); - is >> resolvedGitRev; - if (resolvedGitRev.empty()) - return std::unexpected(std::format( - "git branch '{}' not found in '{}'", - spec.gitRev, spec.git)); - } - } - - // ── 2. is it on disk ── - // Cache key: hash(url + refkind + declared ref + resolved commit). - // For fixed rev/tag deps the declared ref is also the resolved ref. - // Deterministic across hosts: `std::hash` is not (see the note on - // mcpp::pm::index_package_digest). This key names the git cache - // directory AND the lock hash below, so a host-dependent hash made - // both the cache directory and mcpp.lock differ by platform. - auto H = [](std::string_view s) -> std::string { - return mcpp::toolchain::hash_string(s); - }; - auto gitRoot = mcppHome / "git" / H(spec.git + "|" + spec.gitRefKind - + "|" + spec.gitRev + "|" + resolvedGitRev); - std::error_code ec; - std::filesystem::create_directories(gitRoot.parent_path(), ec); - - // A branch's resolved rev is always a sha by now, so the clone can - // be checked against it — catching one killed between `git clone` - // and `git checkout`, which would otherwise serve the wrong commit - // from a correctly-named directory forever. tag/rev keep their ref - // name as the identity, so there is nothing to compare. - bool cachePresent = std::filesystem::exists(gitRoot / ".git"); - if (cachePresent && spec.gitRefKind == "branch" - && git_cache_head(gitRoot) != resolvedGitRev) { - std::filesystem::remove_all(gitRoot, ec); - cachePresent = false; - } - - // Reported before the clone, not instead of it: when the cache is - // gone this line is the whole explanation for why the build is on - // an older commit than the branch now points at. - if (fromLock) - mcpp::ui::info("Resolved", - std::format("{} (branch = {}) from mcpp.lock", - spec.git, spec.gitRev)); - - if (!cachePresent) { - if (offline) - return refuse_offline("a clone", - std::format("no cached clone at {}", gitRoot.string()), - "fetch"); - mcpp::ui::info("Cloning", - std::format("{} ({} = {})", spec.git, spec.gitRefKind, spec.gitRev)); - // A commit taken from the lock may sit behind the branch tip, - // and a tag/rev may sit anywhere in history — both need full - // history before the checkout. Only a tip just read from - // ls-remote is guaranteed present in a depth-1 clone. - // - // `git -C` rather than `cd &&`: on Windows `cd` does not - // change drive without /d, and the cache root routinely lives - // on a different one than the project. - auto cloneCmd = (spec.gitRefKind == "branch" && !fromLock) - ? std::format( - "git clone --depth 1 --branch {} {} {} && " - "git -C {} checkout --quiet {} 2>&1", - mcpp::platform::shell::quote(spec.gitRev), - mcpp::platform::shell::quote(spec.git), - mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(resolvedGitRev)) - : std::format( - "git clone {} {} && git -C {} checkout --quiet {} 2>&1", - mcpp::platform::shell::quote(spec.git), - mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(resolvedGitRev)); - // See `run_with_network_retry` for why, and for what the - // callback is removing between attempts. - auto r = run_with_network_retry(cloneCmd, [&] { - std::filesystem::remove_all(gitRoot, ec); - }); - if (r.exit_code != 0) { - std::filesystem::remove_all(gitRoot, ec); - return std::unexpected(std::format( - "git clone of '{}' failed:\n{}", spec.git, r.output)); - } - } - if (item.consumerDepIndex == kMainConsumer) { - // Only root deps are locked: the writer below walks the root - // manifest's [dependencies], so a transitive git branch dep - // has no anchor and still resolves over the network. - auto source = std::format("git+{}#{}={}", - spec.git, spec.gitRefKind, spec.gitRev); - if (spec.gitRefKind == "branch") source += "@" + resolvedGitRev; - root_git_lock_identities[name] = GitLockIdentity{ - .source = std::move(source), - .hash = "fnv1a:" + H(spec.git + "|" - + spec.gitRefKind + "|" + spec.gitRev + "|" - + resolvedGitRev), - }; - } - sourceCommit = resolvedGitRev; - dep_root = gitRoot; - gitCloneBySource.try_emplace( - sourceRefOf("git", spec, item.resolveRoot, item.originalConstraint), - GitClone{ gitRoot, spec.git, spec.gitRefKind, spec.gitRev }); - if (auto member = gitMemberDeclaring(gitRoot, key)) { - gitMember = *member; - gitMemberCloneRoot = gitRoot; - dep_root = gitRoot / *member; - } - } - // (version-source: dep_root + manifest are loaded together via - // loadVersionDep below since the index entry drives both.) - - // Manifest acquisition. - // - Path/git dep: dep_root is the source tree, mcpp.toml at root. - // - Version dep: delegate to loadVersionDep — the index entry's - // `mcpp` field decides where mcpp.toml lives (StringPath / - // TableBody / default lookup). - std::optional dep_manifest; - if (spec.isPath() || spec.isGit()) { - if (!std::filesystem::exists(dep_root / "mcpp.toml")) { - return std::unexpected(std::format( - "{} dependency '{}' (at '{}') has no mcpp.toml", - spec.isGit() ? "git" : "path", name, dep_root.string())); - } - // A MEMBER IS A MEMBER HOWEVER IT IS REACHED. - // - // A workspace member that omits `package.version` because - // `[workspace.package]` supplies it is legal — and it is reached - // here as a sibling's `path` dependency, which is the ordinary - // shape rather than an exotic one. Loading it as an anonymous path - // dependency would refuse it for a field the workspace does - // provide, and the message would name the member's manifest rather - // than the table that answers. - // - // `is_workspace_member` asks the workspace's own `members` list, so - // a vendored copy or an example living inside the tree is still - // refused for a missing version, exactly as before. - const bool depIsMember = - wsManifest && !runtimeWorkspaceRoot.empty() - && mcpp::project::is_workspace_member( - *wsManifest, runtimeWorkspaceRoot, dep_root); - auto dm = mcpp::manifest::load( - dep_root / "mcpp.toml", - {.insideWorkspace = depIsMember || !gitMember.empty()}); - if (!dm) { - return std::unexpected(std::format( - "dependency '{}' (at '{}'): {}", - name, dep_root.string(), dm.error().format())); - } - dep_manifest = std::move(*dm); - // A member reached as a dependency inherits here, at its load - // site; see `inherit_as_workspace_member`. A member of a - // git-hosted workspace inherits from ITS repository, anchored at - // the clone. - auto inheritAsMember = [&](const mcpp::manifest::Manifest& ws, - const std::filesystem::path& wsRoot) { - return inherit_as_workspace_member(*dep_manifest, ws, wsRoot, dep_root); - }; - if (depIsMember) { - if (auto bad = inheritAsMember(*wsManifest, runtimeWorkspaceRoot)) - return std::unexpected(*bad); - } else if (!gitMember.empty()) { - if (auto rm = mcpp::manifest::load(gitMemberCloneRoot / "mcpp.toml")) { - if (auto bad = inheritAsMember(*rm, gitMemberCloneRoot)) - return std::unexpected(*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 - // at all: their `[target.'cfg(...)'.build] sources` were parsed - // into `conditionalConfigs` but never folded into - // `buildConfig.sources` / `modules.sources`, so the modgraph scan - // never saw the file — link-time `undefined reference`. Must run - // BEFORE `propagateLinkFlags`/`makePackageRoot` below, which - // snapshot this manifest's flags/sources into `packages[]`. - if (!dep_manifest->conditionalConfigs.empty()) { - merge_conditional_config(*dep_manifest, - cfgCtx()); - } - report_flag_words_changes(*dep_manifest); - fold_build_defines_into_flags(dep_manifest->buildConfig); - // The root's `abi.threads` reaches this dependency's C translation - // units here, as it does for a version dependency. - if (abiThreadsRendered) add_once(dep_manifest->buildConfig.cflags, "-pthread"); - } else { - auto loaded = loadVersionDep(name, key.ns, key.shortName, spec.version); - if (!loaded) return std::unexpected(loaded.error()); - dep_root = std::move(loaded->first); - dep_manifest = std::move(loaded->second); - } - - // Name match via compat::resolve_package_name — handles both - // canonical (explicit namespace field) and legacy (dotted name) - // forms transparently. - { - auto resolved = mcpp::pm::compat::resolve_package_name( - dep_manifest->package.name, dep_manifest->package.namespace_); - const std::string& expectedShort = - spec.shortName.empty() ? name : spec.shortName; - const bool nameOk = - resolved.shortName == expectedShort - || dep_manifest->package.name == expectedShort - || dep_manifest->package.name == - mcpp::pm::compat::qualified_name(spec.namespace_, expectedShort); - if (!nameOk) { - return std::unexpected(std::format( - "dependency '{}' resolved to package '{}' (mismatch with declared name '{}')", - name, dep_manifest->package.name, expectedShort)); - } - } - - // The identity a `path` or `git` manifest declares is the package's, - // whatever key reached it (#634, A2). Before this, only the short name - // was compared, so `fw` reaching a manifest that declares `huxdemo.fw` - // resolved as `mcpplibs.fw` while every reader that builds a name from - // the manifest saw `huxdemo.fw`, and a second edge written - // `huxdemo.fw` put the same sources into the build twice. - const bool namespaceDeclared = !dep_manifest->package.namespace_.empty(); - const std::string manifestPath = sourceKind == "version" - ? std::string{} - : (dep_root / "mcpp.toml").lexically_normal().generic_string(); - if (sourceKind != "version" && namespaceDeclared) { - auto declaredName = mcpp::pm::compat::resolve_package_name( - dep_manifest->package.name, dep_manifest->package.namespace_); - ResolvedKey declared{ dep_manifest->package.namespace_, - declaredName.shortName }; - if (!(declared == key)) { - reportAdoption(item.requestedBy, name, key, declared, manifestPath); - stateAdoptedIdentity(item, declared); - if (resolved.contains(declared)) { - // Another source already resolved the declared identity, - // and the rules for two declarations of one identity - // decide (the #630 decision table, at the resolved-record - // hit above). The edge is queued again stating that - // identity, which sends it there. - item.spec.namespace_ = declared.ns; - item.spec.shortName = declared.shortName; - item.spec.candidates = {{ .namespace_ = declared.ns, - .shortName = declared.shortName }}; - item.spec.namespaceOmitted = false; - item.spec.legacyCandidateSearch = false; - item.spec.legacyDottedKey = false; - worklist.push_front(std::move(item)); - continue; - } - key = declared; - } - } - - // Stamp the identity with the resolver's exact coordinate and source. - // A descriptor that omitted namespace inherits the coordinate that - // answered it; otherwise two indices containing the same short name - // collapse in runtime provenance even though resolution distinguished - // them correctly. - if (dep_manifest->package.namespace_.empty()) { - dep_manifest->package.namespace_ = key.ns.empty() - ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; - } - if (sourceKind == "version") { - dep_manifest->package.sourceProvenance = std::format( - "index+{}@{}", cache_index_name(key.ns), spec.version); - } else if (sourceKind == "git") { - dep_manifest->package.sourceProvenance = std::format( - "git+{}#{}={}", spec.git, spec.gitRefKind, spec.gitRev); - } else { - dep_manifest->package.sourceProvenance = - "path+" + dep_root.lexically_normal().generic_string(); - } - - // Stage 2a: merge this dependency's active feature-deps into its own - // dependency set before its children are pushed, so a dep's feature can - // transitively pull a provider. `spec.features` = features the consumer - // requested for this dep. - if (auto fm = mergeActiveFeatureDeps(*dep_manifest, spec.features, - spec.defaultFeatures); !fm) - return std::unexpected(fm.error()); - - // A PACKAGE OF PROGRAMS HAS NOTHING TO LINK (#649 E6). Its tools are - // built by the tool sub-build, which resolves the package as its own - // root; in this graph it is a provider of tools and of its directory, - // and nothing more. Walking its dependencies here put a tool's own - // library into the application's link (a tool depending on `z` gave the - // 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); - auto linkFlagsAdded = depProgramOnly - ? std::vector{} - : propagateLinkFlags(dep_root, *dep_manifest); - - // Move the manifest into stable storage so we can later look it up - // by depIndex (the SemVer merger needs to overwrite the slot). - dep_manifests.push_back( - std::make_unique(std::move(*dep_manifest))); - dep_cache_identities.push_back({ - .indexName = cache_index_name(key.ns), - .packageName = name, - .version = sourceKind == "version" - ? spec.version - : dep_manifests.back()->package.version, - .sourceKind = sourceKind, - .sourceRef = sourceKind == "git" ? sourceCommit - : sourceKind == "path" ? dep_root.string() - : std::string{}, - }); - const auto depPackageIndex = packages.size(); - auto depPackage = makePackageRoot(dep_root, *dep_manifests.back()); - if (!depPackage) return std::unexpected(depPackage.error()); - packages.push_back(std::move(*depPackage)); - recordDependencyEdge(item.consumerDepIndex, depPackageIndex, spec, - item.buildOnly, name); - - // Record this dep as resolved so future encounters of the same - // (ns, name) hit the fast path (skip / merge / conflict). - if (sourceKind != "version") { - identityBySource.emplace( - sourceRefOf(sourceKind, spec, item.resolveRoot, item.originalConstraint) - + (gitMember.empty() ? std::string{} : "#member=" + gitMember), - key); - declaringManifest[key] = DeclaringManifest{ manifestPath, namespaceDeclared }; - } - resolved[key] = ResolvedRecord{ - .version = sourceKind == "version" ? spec.version : "", - .constraint = sourceKind == "version" ? item.originalConstraint : "", - .requestedBy = item.requestedBy, - .source = sourceKind, - .sourceRef = sourceRefOf(sourceKind, spec, item.resolveRoot, - item.originalConstraint), - .fromRoot = item.consumerDepIndex == kMainConsumer, - .devOnly = item.devOnly, - .depIndex = dep_manifests.size() - 1, - .linkFlagsAdded = std::move(linkFlagsAdded), - }; - - // Recurse: the dep's own [dependencies] become new worklist items. - // dev-dependencies are intentionally NOT walked — those are - // private to the dep's test runs, not part of its public ABI. - // A package of programs is not walked at all; see `depProgramOnly`. - if (depProgramOnly) continue; - const std::string thisDepLabel = std::format( - "{}{}{}@{}", - key.ns, - key.ns.empty() ? "" : ".", - key.shortName, - sourceKind == "version" ? spec.version : sourceKind); - const std::size_t selfIdx = dep_manifests.size() - 1; - // #243: forward this dep's active features to ITS children before they - // are pushed (transitive dep->dep forwarding rides the BFS forward - // edge). Uses the SAME closure inputs as mergeActiveFeatureDeps above - // (this edge's spec.features, already carrying any forward injected by - // this dep's own consumer, + defaultFeatures), so activation agrees - // with resolution. - auto depActive = feature_closure(*dep_manifests.back(), spec.features, - spec.defaultFeatures); - if (auto fe = validateForwards(*dep_manifests.back(), depActive, - dep_manifests.back()->package.name); !fe) - return std::unexpected(fe.error()); - for (auto& [child_name, child_spec] : dep_manifests.back()->dependencies) { - auto childReq = child_spec; - injectForwards(*dep_manifests.back(), depActive, child_name, childReq); - worklist.push_back({child_name, childReq, thisDepLabel, - childReq.version, selfIdx, dep_root, - item.devOnly, item.buildOnly}); - } - // A dependency's own `[build-dependencies]` — the only channel through - // which a package can speak about what IT needs at build time. Both - // live channels (`tools`, `host-module`) are written by the CONSUMER - // on an edge, so before this a build rule could not request anything - // on its own behalf. That, and not a design decision, is why a rule - // was a leaf. - // - // These are build-only regardless of how this package was reached: a - // library's build dependency has no business in its consumer's binary - // either. - for (auto& [child_name, child_spec] : - dep_manifests.back()->buildDependencies) { - auto childReq = child_spec; - injectForwards(*dep_manifests.back(), depActive, child_name, childReq); - worklist.push_back({child_name, childReq, - thisDepLabel + " (build-dep)", - childReq.version, selfIdx, dep_root, - item.devOnly, /*buildOnly=*/true}); - } - } - - // ONE PLACE DETECTS A CYCLE OF PACKAGES, AND IT IS HERE, WHERE THE GRAPH - // IS RESOLVED (#649 E6). The build-cache key walk was the only reader that - // noticed, and it runs for the global cache only, so the same manifest was - // refused by default and built under `--cache=local`. Every edge counts, - // build-only ones included, as the key walk counts them. - { - std::vector state(packages.size(), 0); // 0 new / 1 on stack / 2 done - std::vector stack, cycle; - auto visit = [&](auto&& self, std::size_t u) -> bool { - state[u] = 1; - stack.push_back(u); - for (auto const& e : dependencyEdges) { - if (e.consumerPackageIndex != u) continue; - const auto v = e.dependencyPackageIndex; - if (v >= packages.size()) continue; - if (state[v] == 1) { - cycle.assign(std::ranges::find(stack, v), stack.end()); - cycle.push_back(v); - return true; - } - if (state[v] == 0 && self(self, v)) return true; - } - stack.pop_back(); - state[u] = 2; - return false; - }; - for (std::size_t i = 0; i < packages.size() && cycle.empty(); ++i) - if (state[i] == 0) (void)visit(visit, i); - if (!cycle.empty()) { - std::string path; - for (auto p : cycle) { - if (!path.empty()) path += " -> "; - path += std::format("'{}'", - mcpp::build::qualified_package_name(packages[p].manifest)); - } - refusal::record(refusal::Code::PackageCycle); - return std::unexpected(std::format( - "dependency cycle: {}.\n" - " A package cannot reach itself through its own dependencies.\n" - " fix: remove one of these edges. A program that depends on the " - "package requesting it builds without a cycle when its package " - "declares only `kind = \"bin\"` targets: it is then built by its " - "own tool sub-build.", path)); - } - } - - computeUsageRequirements(); - - // ─── The toolchain, resolved now that the graph exists ────────────────── - // - // THE TARGET AND THE COMPILER ARE NOT BOUND TOGETHER, AND THE ROW'S - // CONVENTION IS A FALLBACK RATHER THAN A RULE. - // - // `x86_64-linux-musl → gcc@16.1.0` does not say "prefer gcc". It says "the - // musl-gcc payload is what supplies this target's C library". A project - // whose C library comes from its dependency graph does not use that payload, - // and for it the convention is not a default but a substitution — measured, - // it replaced a toolchain the user had set with `mcpp toolchain default` and - // said nothing. - // - // The discriminator is whether anything in the graph supplies the system, - // which is what these few lines ask. It is the same question - // `mcpp.targetside` answers in full further down; asked here it needs only - // the answer's shape, so it reads the manifests rather than resolving them. - { - bool graphSuppliesSystem = false; - // `requires` IS READ HERE TOO, AND UNTIL THIS LOOP IT WAS ONLY EVER - // CHECKED — A THOUSAND LINES LATER, AGAINST A DECISION THIS BLOCK HAD - // ALREADY MADE WITHOUT IT. - // - // `provides` and `requires` are the two halves of one vocabulary and - // they were read at opposite ends of the function: this block consulted - // the first to decide the compiler, and `check_requirements` used the - // second only to reject the outcome. Measured on 2026.8.26.1, one - // three-line manifest, `llvm@22.1.8` already installed: - // - // [dependencies] - // openkal-llvm-runtime = "0.1.3" # requires mcpp:compiler=llvm - // - // $ mcpp build # global default gcc@16.1.0 - // error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. - // Select that compiler … mcpp toolchain default llvm - // $ MCPP_TOOLCHAIN=llvm@22.1.8 mcpp build - // Finished dev [unoptimized + debuginfo] in 1.02s - // - // Nothing was missing. The engine knew which compiler was wanted, the - // payload was on the machine, and the remedy it printed was to change - // the default for EVERY project on the box because ONE project's - // dependency asked. - // - // AND THIS IS THE PLACE, NOT MERELY *A* PLACE. `resolve_target_toolchain` - // has exactly two call sites — its own one-shot recursion, and the one - // at the bottom of this block — so every branch inside it, INCLUDING the - // first-run install-and-persist path and all three - // `write_default_toolchain` calls, is downstream of this line. Setting - // `tcSpec` here therefore selects the compiler without writing anything: - // on a machine with no toolchain at all the first-run branch is not even - // reached, because its condition is `!tcSpec.has_value()`. - // - // That is the whole design. "Do not touch the user's configuration" is - // not a rule anyone has to remember here — the writes live on a branch - // this no longer enters. - std::string reqCompiler, reqCompilerBy; - - // A FAMILY NAME BECOMES A CONCRETE SPEC THE SAME WAY IT DOES FOR - // `mcpp toolchain default `, AND FOR THE SAME REASON. - // - // `requires = ["mcpp:compiler=llvm"]` names a family; the build path - // needs `@` and refuses anything else - // (`expected '@'`). There are two honest sources for the - // missing half and they are tried in this order: - // - // 1. what is already installed — highest version wins, nothing is - // downloaded, and it is literally the same two functions - // `toolchain_set_default` calls; - // 2. the vocabulary's own pins — the version this ecosystem ships for - // that family's payload, already written down once per row - // (`pinned_versions_for`). Deriving it from - // there rather than from a fresh constant means the answer moves - // when the ecosystem moves, with nobody having to remember a - // second place. - // - // NOT `pins::kFirstRun*`. Those are per-HOST first-run defaults — - // `llvm@20.1.7` on macOS, `gcc@16.1.0` on Linux x86_64 — so reading them - // would make the version a package requires depend on which machine - // built it. A requirement is a property of the package. - auto resolve_required_family = - [&](const std::string& family) - -> std::expected { - auto spec = mcpp::toolchain::parse_toolchain_spec(family); - if (!spec) { - refusal::record(refusal::Code::CompilerRequirementConflict); - return std::unexpected(std::format( - "`{}` requires the compiler to be `{}`, and mcpp has no " - "compiler family by that name.\n" - " known families: gcc, llvm, msvc, emsdk, android-ndk.", - reqCompilerBy, family)); - } - - if (auto cfg = get_cfg(); cfg) { - auto pkg = mcpp::toolchain::to_xim_package(*spec); - if (auto picked = mcpp::toolchain::resolve_version_match( - "", mcpp::toolchain::list_installed_versions( - (*cfg)->xlingsHome() / "data" / "xpkgs", - pkg.ximName))) - return std::format("{}@{}", family, *picked); - } - - // Matched by the payload a pin names, not by its family: the NDK - // and emsdk pins are llvm-family pins of other payloads (#641). - if (auto picked = mcpp::toolchain::resolve_version_match( - "", mcpp::toolchain::pinned_versions_for(*spec))) - return std::format("{}@{}", family, *picked); - - // Neither source has one. Saying which family and which two places - // were consulted is the difference between an actionable message - // and "something went wrong". - // RECORDED, like every other refusal in this function. An - // unnamed branch reports `other`, and this release exists partly - // because one of those had a perfectly good name. - refusal::record(refusal::Code::CompilerRequirementConflict); - return std::unexpected(std::format( - "`{}` requires the compiler to be `{}`, and mcpp has no version " - "of it to use.\n" - " none is installed, and no target row pins one.\n" - " install one — `mcpp toolchain install {} ` " - "(`mcpp toolchain list --available {}`).", - reqCompilerBy, family, family, family)); - }; - - for (auto const& pkg : packages) { - for (auto const& entry : pkg.manifest.provides) { - auto cap = mcpp::targetside::parse_capability(entry); - if (!cap || !*cap) continue; - if ((*cap)->layer == mcpp::targetside::CapLayer::KernelAbi - || (*cap)->layer == mcpp::targetside::CapLayer::CAbi) { - graphSuppliesSystem = true; - } - } - for (auto const& entry : pkg.manifest.requires_) { - auto cap = mcpp::targetside::parse_capability(entry); - if (!cap || !*cap) continue; - if ((*cap)->layer != mcpp::targetside::CapLayer::Compiler) continue; - // A bare `mcpp:compiler` asks only that one exist, which it - // always does. Only a named family selects anything. - if ((*cap)->interfaceName.empty()) continue; - const auto pkgId = pkg.manifest.package.version.empty() - ? pkg.manifest.package.name - : std::format("{}@{}", pkg.manifest.package.name, - pkg.manifest.package.version); - // TWO DIFFERENT FAMILIES IS AN ERROR RATHER THAN A PICK, the - // same rule `provides` already follows one screen down. Choosing - // by graph-traversal order would make the answer depend on an - // order the author neither writes nor can predict — and unlike a - // conflicting `provides`, this one would silently satisfy one - // package's requirement and fail the other's inside a header. - if (!reqCompiler.empty() && reqCompiler != (*cap)->interfaceName) { - refusal::record(refusal::Code::CompilerRequirementConflict); - return std::unexpected(std::format( - "two packages require different compilers, and a build " - "has only one.\n" - " {:<28} requires `{}`\n" - " {:<28} requires `{}`\n" - " Both cannot hold. Drop one of them, or take a " - "version of one that is\n" - " configured for the other's compiler.", - reqCompilerBy, reqCompiler, pkgId, - (*cap)->interfaceName)); - } - if (reqCompiler.empty()) { - reqCompiler = (*cap)->interfaceName; - reqCompilerBy = pkgId; - } - } - } - // AND A FREESTANDING PIN SURVIVES IT. `graphSuppliesSystem` spans - // kernel-abi and c-abi, and it correctly cancels a HOSTED row's - // convention — that row names the payload the graph is replacing. - // A bare-metal row names the only compiler that emits the target. - // - // Measured 2026-08-25, on a three-line manifest: - // - // provides = ["mcpp:kernel-abi=openkal"] - // $ mcpp build --target riscv64-none-elf - // Resolved gcc@16.1.0 → riscv64-none-elf → …/bin/g++ - // g++: error: unrecognized argument in option '-mabi=lp64d' - // g++: error: unrecognized command-line option - // '--target=riscv64-none-elf' - // - // A package saying which layer it supplies made the host compiler be - // chosen for a target it cannot produce. Same shape as the four - // defects 2026.8.25.1 fixed: a predicate spanning two layers deciding - // something that does not depend on either of them. - if (!targetPinCandidate.empty() - && (!graphSuppliesSystem || targetPinIsCapability)) { - if (tcOrigin == TcOrigin::GlobalDefault && tcSpec.has_value() - && *tcSpec != targetPinCandidate) - pinReplacedDefault = *tcSpec; - // Kept for the build program's host resolution; see the - // declaration. Taken from every origin, not only the global - // default, because a `[toolchain]` the manifest named is just as - // much the host's compiler as a remembered default is. - if (tcSpec.has_value() && *tcSpec != targetPinCandidate) - hostSpecBeforeRowPin = *tcSpec; - tcSpec = targetPinCandidate; - tcOrigin = TcOrigin::TargetPin; - } - - // THE GRAPH'S REQUIREMENT, TAKEN AS AN INSTRUCTION RATHER THAN AS A - // TEST TO FAIL LATER. - // - // Everything above this line decides the compiler from what mcpp knows - // about the TARGET. A package saying `requires = ["mcpp:compiler=llvm"]` - // is saying something about ITSELF — its C++ runtime was configured for - // one family and its headers record that configuration — and it is the - // most specific statement in the build. Below the user's own word, above - // every default mcpp keeps. - // - // THE RANK IS NOT NEW. `TcOrigin` already sorts these, and - // `tc_origin_is_user_explicit` already answers "may mcpp revise this". - // The defect was never that the answer was wrong; it was that nobody - // asked. `GlobalDefault` is deliberately not user-explicit — see the - // note on that function — so a remembered default is exactly the kind of - // value this may replace. - // `system` IS LEFT ALONE, AND IT IS THE ONE VALUE HERE THAT IS AN - // ESCAPE HATCH RATHER THAN AN ANSWER. - // - // It means "the PATH compiler, whatever it is" — a deliberate opt-out - // of the payload model. Substituting a payload for it would defeat - // exactly what the user asked for, and mcpp cannot even tell whether - // the requirement is already satisfied: the family of a PATH compiler - // is not knowable from the spec. `check_requirements` reports the - // mismatch further down against what the driver actually turned out to - // be, which is the only place that answer exists. - const bool tcIsSystemEscapeHatch = - tcSpec.has_value() && *tcSpec == "system"; - if (!reqCompiler.empty() && !tcIsSystemEscapeHatch) { - std::string haveFamily; - if (tcSpec.has_value()) - if (auto s = mcpp::toolchain::parse_toolchain_spec(*tcSpec); s) - haveFamily = - std::string(mcpp::toolchain::family_name(s->family)); - - if (haveFamily != reqCompiler) { - // THE PROJECT'S OWN WORD IS NOT REVISED, AND THIS IS THE ONLY - // CASE THAT STILL REFUSES. `[toolchain]`, `[target.X].toolchain` - // and `MCPP_TOOLCHAIN` are statements about THIS build; the - // graph disagreeing with one of them is a real contradiction and - // `check_requirements` reports it further down with both names. - // Nothing to do here but leave the value alone. - if (tc_origin_is_user_explicit(tcOrigin)) { - // fall through to check_requirements - } - // A ROW'S PIN THAT SURVIVED TO HERE CANNOT BE OVERRIDDEN BY - // A REQUIREMENT, AND THE REASON IS THE SAME ONE THE PIN EXISTS - // FOR. - // - // The block above applied it only when the graph does NOT supply - // the system, or when the row names a capability. In the first - // case the row's payload is what carries this target's headers - // and C library, and a different compiler brings none — measured - // as `crtbeginT.o (bare name)` and as a host `crtbegin.o`, both - // accurate about the symptom and silent about the decision. In - // the second the row names the only compiler that emits the - // target at all. - // - // Either way the requirement cannot be honoured, and saying so - // here — where both halves are known — beats a compiler - // complaining about a file the reader never named. - else if (tcOrigin == TcOrigin::TargetPin) { - // THE TWO ROWS REFUSE UNDER ONE RULE AND FOR TWO - // REASONS, AND ONE REMEDY DOES NOT SERVE BOTH. - // - // A CONVENTION pin is cancelled by a graph that supplies the - // target's system — that is `graphSuppliesSystem`, one - // screen up — so "depend on a package that supplies it" is - // exactly the way out. - // - // A CAPABILITY pin is not: `targetPinIsCapability` keeps it - // applied no matter what the graph supplies, because no - // other family emits the target at all. Offering the same - // remedy there prints an instruction that the sentence - // directly above it has already ruled out — the failure - // this release removes from `check_requirements`, reproduced - // three screens away. - std::string_view why = targetPinIsCapability - ? "The row names its compiler as a capability: no other " - "family emits this target." - : "The row's payload is what supplies this target's " - "headers and C library,\n and nothing in the " - "dependency graph supplies them instead."; - std::string remedy = targetPinIsCapability - ? std::format( - " Drop the package that requires `{}`, or " - "take a version of it built\n" - " for `{}`.", - reqCompiler, targetPinCandidate) - : std::format( - " Depend on a package that supplies this " - "target's system (its kernel\n" - " interface and C library) so the row's " - "payload is not needed, or drop\n" - " the package that requires `{}`.", - reqCompiler); - refusal::record(refusal::Code::CompilerRequirementConflict); - return std::unexpected(std::format( - "`{}` requires the compiler to be `{}`, and target '{}' " - "cannot be built with it here.\n" - " target row {:<14} ({})\n" - " required {:<14} (required by {})\n" - " {}\n{}", - reqCompilerBy, reqCompiler, - targetRowName.empty() ? overrides.target_triple - : targetRowName, - targetPinCandidate, - targetPinIsCapability ? "capability" : "convention", - reqCompiler, reqCompilerBy, - why, remedy)); - } - // Free to take it. `tcSpec` is either absent (nothing configured - // anywhere) or one of mcpp's own remembered answers. - else { - auto pickedSpec = resolve_required_family(reqCompiler); - if (!pickedSpec) - return std::unexpected(pickedSpec.error()); - graphCompilerReplaced = tcSpec.value_or(""); - graphCompilerRequiredBy = reqCompilerBy; - graphCompilerFamily = reqCompiler; - tcSpec = *pickedSpec; - tcOrigin = TcOrigin::GraphRequirement; - } - } - } - // OVERRIDING THE CONVENTION IS ALLOWED; OVERRIDING IT AND SUPPLYING - // NOTHING IN ITS PLACE IS NOT, AND UNTIL THIS BLOCK IT LOOKED THE SAME. - // - // A hosted row's pin names the payload that supplies the target's C - // library. A project may name a different compiler — that is the escape - // hatch the whole convention/capability distinction exists to protect — - // and the ordinary reason to do so is that its dependency graph supplies - // the C library instead. `examples/06-openkal-cross` is exactly that: - // `llvm@22.1.8` plus `openkal-llvm-runtime`, and `graphSuppliesSystem` - // is true there. - // - // WITH NEITHER, THE BUILD USED TO RUN ANYWAY AND FAIL SOMEWHERE ELSE. - // Measured 2026-08-26 on Linux, `[toolchain] default = "llvm@22.1.8"` - // and no dependencies: - // - // --target x86_64-linux-musl - // hermetic link check failed … crtbeginT.o (bare name) - // --target x86_64-windows-gnu - // hermetic link check failed … - // /usr/lib/gcc/x86_64-w64-mingw32/13-win32/crtbegin.o (outside) - // - // Both are accurate about the symptom and silent about the decision: - // clang is retargetable and brings no C library, so it reached for a - // gcc installation — one that does not exist under the payload prefix - // in the first case, and that belongs to the HOST in the second. There - // is no llvm payload supplying either target's C library today. - // - // THE REFUSAL IS DECIDED HERE BECAUSE ONLY HERE ARE BOTH HALVES - // KNOWN. The row is read a thousand lines earlier and the graph does - // not exist then; `host_can_serve` is family-agnostic and would answer - // "yes, some payload here produces it" — the same shape as the family - // this release is about, a predicate answering a question narrower than - // the one it is asked. - if (!targetRowPin.empty() && !graphSuppliesSystem - && tc_origin_is_user_explicit(tcOrigin) && tcSpec.has_value()) { - auto declared = mcpp::toolchain::parse_toolchain_spec(*tcSpec); - auto rowTc = mcpp::toolchain::parse_toolchain_spec(targetRowPin); - if (declared && rowTc && declared->family != rowTc->family) { - refusal::record(refusal::Code::ConventionUnreplaced); - return std::unexpected(std::format( - "target '{}' takes its C library from the '{}' payload, and " - "'{}' has none here.\n" - " The row's toolchain is a convention, so naming your " - "own compiler overrides it —\n" - " but the convention is what supplies this target's " - "headers and C library, and\n" - " nothing in the dependency graph supplies them " - "instead.\n" - " depend on a package that implements the target's C " - "library (openkal-musl and\n" - " openkal-llvm-runtime are the ones in the index), or " - "remove the `[toolchain]`\n" - " line so `{}` is used for this target.", - targetRowName, targetRowPin, *tcSpec, targetRowPin)); - } - } - if (auto r = resolve_target_toolchain(); !r) - return std::unexpected(r.error()); - } - - // ─── Feature activation (Cargo-style, additive) ──────────────────── - // activated(pkg) = pkg.[features].default ∪ features requested for it - // (root: --features; deps: the root dep spec's `features = [...]`). - // Implied features expand transitively. Each active feature becomes - // -DMCPP_FEATURE_ on that package's compile flags. - // (Transitive dep→dep feature requests are not yet propagated.) - // Also captured here: the root package's active feature set, reused below - // for the [targets.*] required_features gate. - std::set activeRootFeatures; - // Capability accumulation (Stage 3): which packages provide each capability, - // and which (capability, requiring-package) pairs need binding. Filled by - // apply() as each package's features activate; bound after the loops below. - std::map> capProviders; - std::vector> capRequires; - // `requires_abi`: (what, requirer). See Manifest::requiresAbiThreads. - std::vector> abiRequires; - // Same shape, for the second `abi` member (A1/A6). Two vectors rather - // than one tagged one, because every reader below already asks "threads - // or exceptions" as two separate questions. - std::vector> abiRequiresExceptions; - // Who claimed sole provision of what. Separate from capProviders because - // the question it answers is different: capProviders asks "can this - // requirement be satisfied", this asks "can these two coexist at all". - std::map> capExclusive; - // Callable twice: once here, for what the manifests and the - // dependencies' build programs declared, and once more after the - // root's build program has run -- a rule package it imports states - // its facts and floors from there (`mcpp::fact` / `mcpp::floor`), - // and a check that ran only before it would never see them. - // package name -> device-kind sources of its effective source set, filled - // by the narrowing pass after feature application and read at both - // build-program run sites (MCPP_DEVICE_SOURCES). - // Keyed by the package's ROOT DIRECTORY, not by its name. Two packages in - // one graph may share a bare name and differ only by namespace — that is - // what namespaces are for — and a name key would hand one package's - // device sources to the other's build program with nothing reporting it. - std::map> deviceSourcesByPackage; - auto checkVersionFloors = [&]() -> std::optional { - std::map> facts; // name -> (version, who) - // #634, A9: THE TARGET'S PLATFORM FLOOR IS A FACT THE ENGINE STATES, - // in the platform's own words. A dependency that needs Android API 23 - // writes `android.api-level >= 23` as an ordinary `version-floor` - // requirement and is refused before compiling when the application - // targets less. The floor is not raised for it: the value is already - // inside the compiler's `--target` by now, and which devices an - // application installs on is the application's decision. A row that - // states no such fact (a desktop Linux build) leaves the requirement - // silent, so a requirement needs no selector. The engine's value is - // entered first, so a package stating the same name cannot replace it. - std::map platformFactOrigin; // name -> the key that sets it - if (tc) { - if (auto t = mcpp::toolchain::triple::parse(tc->targetTriple); - t && (t->is_android() || t->is_apple())) { - const auto value = min_platform_version(*m, *t, tc->binaryPath); - std::string name, origin; - if (t->is_android()) { - auto row = m->targetOverrides.find(t->str()); - name = "android.api-level"; - origin = row != m->targetOverrides.end() && row->second.minApiLevel > 0 - ? std::format("[target.{}] min_api_level", t->str()) - : std::format("the toolchain's lowest supported level, because " - "[target.{}] min_api_level is not set", t->str()); - } else if (t->is_ios()) { - name = "ios.deployment-target"; - origin = iosFloorFromSdk - ? std::string("the located SDK's version, because [build] " - "ios_deployment_target is not set") - : std::string("[build] ios_deployment_target"); - } else { - name = "macos.deployment-target"; - origin = m->buildConfig.macosDeploymentTarget.empty() - ? std::string("mcpp's default for macOS, because [build] " - "macos_deployment_target is not set") - : std::string("[build] macos_deployment_target"); - } - if (!value.empty()) { - facts.emplace(name, std::pair{value, std::string{}}); - platformFactOrigin.emplace(name, std::move(origin)); - } - } - } - for (std::size_t pi = 0; pi < packages.size(); ++pi) { - // The root's claims live in *m: its build program mutates - // *m, and packages[0] is a snapshot taken before it ran. - const auto& mf = pi == 0 ? *m : packages[pi].manifest; - const auto who = mf.package.name; - for (auto const& entry : mf.runtimeConfig.provides) { - auto fact = mcpp::build::parse_version_fact(entry); - if (fact.valid()) facts.emplace(fact.name, std::pair{fact.version, who}); - } - } - for (std::size_t pi = 0; pi < packages.size(); ++pi) { - // The root's claims live in *m: its build program mutates - // *m, and packages[0] is a snapshot taken before it ran. - const auto& mf = pi == 0 ? *m : packages[pi].manifest; - const auto who = mf.package.name; - for (auto const& req : mf.runtimeConfig.requirements) { - if (req.kind != "version-floor") continue; - auto floor = mcpp::build::parse_version_floor(req.value); - if (!floor.valid()) { - return std::format( - "`{}` declares a version-floor requirement mcpp " - "cannot read: '{}'.\n" - " The shape is ` >= `, e.g. " - "`cuda.driver >= 12.0`.", who, req.value); - } - auto it = facts.find(floor.name); - if (it == facts.end()) continue; // nobody stated it - auto met = mcpp::build::version_at_least(it->second.first, - floor.version); - if (!met || *met) continue; - refusal::record(refusal::Code::VersionFloorUnmet); - if (auto origin = platformFactOrigin.find(floor.name); - origin != platformFactOrigin.end()) - return std::format( - "`{}` requires {} >= {}, and this build targets {}.\n" - " set by: {}\n" - " This is checked before anything is compiled " - "because the failure it prevents is not:\n" - " a library that needs a newer platform links " - "cleanly and fails on the device that lacks it.", - who, floor.name, floor.version, it->second.first, - origin->second); - return std::format( - "`{}` requires {} >= {}, and {} is stated as {}.\n" - " stated by: {}\n" - " This is checked before anything is compiled " - "because the failure it prevents is not:\n" - " a build against too-new a runtime links " - "cleanly and fails at first use.", - who, floor.name, floor.version, floor.name, - it->second.first, it->second.second); - } - } - return std::nullopt; - }; - { - auto sanitize = [](std::string f) { - for (auto& c : f) - c = std::isalnum(static_cast(c)) - ? static_cast(std::toupper(static_cast(c))) : '_'; - return f; - }; - auto activate = [](const mcpp::manifest::Manifest& pm, - const std::vector& requested, - bool seedDefault = true) { - return feature_closure(pm, requested, seedDefault); // single shared implementation - }; - auto apply = [&](mcpp::modgraph::PackageRoot& pkg, - const std::vector& requested, - bool seedDefault = true) { - auto active = activate(pkg.manifest, requested, seedDefault); - // Capability accumulation: package-level provides always count; - // feature-scoped provides/requires count only when the feature is - // active. Requirements are bound after all packages are processed. - const auto& pcap = pkg.manifest.package.name; - for (auto& cap : pkg.manifest.provides) capProviders[cap].push_back(pcap); - for (auto& cap : pkg.manifest.exclusive) capExclusive[cap].push_back(pcap); - for (auto& f : active) { - if (auto it = pkg.manifest.featureProvides.find(f); - it != pkg.manifest.featureProvides.end()) - for (auto& cap : it->second) capProviders[cap].push_back(pcap); - if (auto it = pkg.manifest.featureRequires.find(f); - it != pkg.manifest.featureRequires.end()) - for (auto& cap : it->second) capRequires.emplace_back(cap, pcap); - if (auto it = pkg.manifest.featureRequiresAbiThreads.find(f); - it != pkg.manifest.featureRequiresAbiThreads.end() && it->second) - abiRequires.emplace_back(std::format("feature `{}`", f), pcap); - if (auto it = pkg.manifest.featureRequiresAbiExceptions.find(f); - it != pkg.manifest.featureRequiresAbiExceptions.end() && it->second) - abiRequiresExceptions.emplace_back(std::format("feature `{}`", f), pcap); - // The TARGET-AXIS per-feature form (A6): - // `[target..feature-requires-abi] `, already reduced - // by merge_conditional_config to the selectors that matched - // and asked. Named by the selector, as written, not "feature - // `f`" -- the feature only decided whether the section counts; - // the selector is what asked for the switch. - if (auto it = pkg.manifest.targetFeatureRequiresAbiThreads.find(f); - it != pkg.manifest.targetFeatureRequiresAbiThreads.end() && !it->second.empty()) - abiRequires.emplace_back( - std::format("[target.'{}']", it->second.front()), pcap); - if (auto it = pkg.manifest.targetFeatureRequiresAbiExceptions.find(f); - it != pkg.manifest.targetFeatureRequiresAbiExceptions.end() && !it->second.empty()) - abiRequiresExceptions.emplace_back( - std::format("[target.'{}']", it->second.front()), pcap); - } - if (pkg.manifest.requiresAbiThreads) - abiRequires.emplace_back("the package", pcap); - if (pkg.manifest.requiresAbiExceptions) - abiRequiresExceptions.emplace_back("the package", pcap); - // `[target.] requires_abi` (A6): the package-wide form of the - // same target-axis requirement, one entry per matching selector - // that asked. - for (auto const& sel : pkg.manifest.targetRequiresAbiThreads) - abiRequires.emplace_back(std::format("[target.'{}']", sel), pcap); - for (auto const& sel : pkg.manifest.targetRequiresAbiExceptions) - abiRequiresExceptions.emplace_back(std::format("[target.'{}']", sel), pcap); - // A DEPENDENCY'S OWN `[target..abi]` DOES NOT CHANGE THE - // BUILD. The switch belongs to the artefact, which the root decides; - // a table written in a dependency is reported rather than silently - // ignored, and points at the key a dependency does have. Covers - // BOTH members: a dependency that declares only `exceptions` must - // be reported exactly as one that declares only `threads`. - if (pcap != m->package.name - && (pkg.manifest.buildConfig.abiThreadsDeclared - || pkg.manifest.buildConfig.abiExceptionsDeclared)) - mcpp::diag::warning("abi/dependency-table", std::format( - "`{}` declares [target..abi], which only the root " - "manifest decides; a dependency states what it needs with " - "`requires_abi = {{ threads = true }}` or " - "`requires_abi = {{ exceptions = true }}`", pcap)); - // `[targets.*] required_features` on a DEPENDENCY. - // - // THIS GATE EXISTED ONLY FOR THE ROOT. The root's targets are - // filtered further down against the root's own active features; - // a dependency's were never filtered at all, so a descriptor that - // wrote `required_features` on a target got the opposite of what - // it asked for: the target was built for EVERY consumer, whether - // or not the feature was active. For a `kind = "shared"` target - // that is not a cosmetic difference — its mere presence changes - // how the whole package is linked into every consumer. - // - // Gated against THIS package's active set, not the root's. A - // feature name is package-scoped, so the root's set is a different - // vocabulary that happens to share a type. - // - // LIBRARY TARGETS ONLY, and the exclusion is load-bearing. - // - // A target requested as a HOST TOOL is what was ASKED FOR, so its - // `required_features` become that sub-build's INPUTS instead of a - // gate — docs/05 §2.2 says so in as many words. An earlier - // revision of this gate erased every kind, with a comment claiming - // the tool path "re-enters prepare_build with the dependency as - // the ROOT, so it never reaches this code". That was written from - // memory rather than read: the tool LOOKUP runs several hundred - // lines BELOW this point, against this very manifest, and it found - // an empty target list. `187_dep_host_tool.sh` caught it. - // - // Restricting the gate to libraries is not a workaround, it is the - // rule: a dependency's `bin` target produces no link unit in this - // build (make_plan only walks the ROOT's targets), so leaving it in - // place costs nothing. What the gate exists for is the shape where - // a target's mere presence changes how the package is linked into - // every consumer — and that is exactly a `shared` or `lib` target. - std::erase_if(pkg.manifest.targets, - [&](const mcpp::manifest::Target& t) { - if (t.kind != mcpp::manifest::Target::Library - && t.kind != mcpp::manifest::Target::SharedLibrary) - return false; - for (auto const& rf : t.requiredFeatures) - if (std::find(active.begin(), active.end(), rf) == active.end()) - return true; - return false; - }); - - for (auto& f : active) { - auto def = "-DMCPP_FEATURE_" + sanitize(f); - pkg.manifest.buildConfig.cflags.push_back(def); - pkg.manifest.buildConfig.cxxflags.push_back(def); - pkg.privateBuild.cflags.push_back(def); - pkg.privateBuild.cxxflags.push_back(def); - // Feature System v2 Stage 1: package-owned `defines` declared on - // this feature ride alongside the automatic MCPP_FEATURE_ macro. - // Bare names desugar to -D, matching [targets.*] `defines`. - if (auto it = pkg.manifest.buildConfig.featureDefines.find(f); - it != pkg.manifest.buildConfig.featureDefines.end()) - for (auto& d : it->second) { - auto fdef = mcpp::manifest::flag_element("-D" + d); - pkg.manifest.buildConfig.cflags.push_back(fdef); - pkg.manifest.buildConfig.cxxflags.push_back(fdef); - pkg.privateBuild.cflags.push_back(fdef); - pkg.privateBuild.cxxflags.push_back(fdef); - // Interface-propagate the user-declared feature define: - // a header-only dependency's switch (e.g. EIGEN_USE_BLAS) - // only takes effect in the TU that includes its headers, - // so consumers that enable the feature must see it too. - // computeUsageRequirements() flows publicUsage flags into - // each consumer's privateBuild along Public/Interface - // edges, mirroring include_dirs. The automatic - // MCPP_FEATURE_ macro stays private to the owning - // package (it is a build signal, not a public contract). - pkg.publicUsage.cflags.push_back(fdef); - pkg.publicUsage.cxxflags.push_back(fdef); - } - } - // Feature-gated sources (e.g. gtest's gtest_main.cc behind "main"): - // drop EVERY feature-listed glob from the default build, then add - // back only the ones whose feature is active. Runs even when no - // feature is active, so a gated source is excluded by default. - // - // The DROP is build-mode only (!includeDevDeps). `mcpp test` - // (includeDevDeps) keeps the full surface so the dev-dependency - // track's per-test main detection (run_tests / make_plan) still sees - // gtest_main.cc and prunes it per test — the two tracks stay - // decoupled; gtest's descriptor keeps gtest_main.cc in base `sources` - // too, so skipping the drop leaves it visible. - // - // The ADD runs in BOTH modes. A descriptor may list a glob ONLY under - // `features` and never in base `sources` (xpkg's `features.X.sources` - // lands in featureSources alone — compat.spdlog's `compiled`, - // compat.cjson's `utils`, compat.eigen's `eigen_blas`). Gating the add - // on !includeDevDeps meant those sources were never compiled under - // `mcpp test` → link-time `undefined reference` (the eigen_blas - // `dgemm_` failure, long misread as a linking follow-up: it was - // source-set resolution, not linking). Add is dedup'd so gtest's - // doubly-listed gtest_main.cc cannot land twice. - auto& bc = pkg.manifest.buildConfig; - if (!bc.featureSources.empty()) { - // WHETHER A FEATURE *GATES* A SOURCE OR *PROVIDES* IT, AND - // THE ANSWER IS WRITTEN IN THE MANIFEST ALREADY. - // - // Two families of package reach this code and they want - // opposite things under `mcpp test`: - // - // gtest lists `*/googletest/src/gtest_main.cc` in - // base `sources` AND under `features.main`. - // The package provides the file unconditionally; - // the feature is a gate over it. The - // dev-dependency track's per-test main detection - // must still SEE it in order to prune it per - // test, so an inactive gate must not make it - // vanish. - // - // riscv-virt-rt names `src/kal/**` under `features.openkal` - // and nowhere else. The package does not provide - // those files at all without the feature — the - // headers they include arrive through that - // feature's `[feature-deps]` — so compiling them - // fails on `'openkal/abort.h' file not found`. - // - // The discriminator is membership in base `sources`, evaluated - // BEFORE the drop below removes it. A glob in both places is a - // gate; a glob in one place is a provider. - // - // THIS IS THE FOURTH ATTEMPT, AND THE THIRD WAS ABANDONED ON - // A MISTAKEN READING. It was recorded as failing because - // "gtest's base entry is a glob that MATCHES the file rather - // than the same string". Measured against the descriptor the - // index actually carries, the two entries are byte-identical - // (`compat.gtest.lua` lines 71 and 90). The criterion was - // sound; what it was applied to was not — the earlier attempt - // compared against `bc.sources` AFTER `drop()` had already - // removed the entry, so the membership test could only ever be - // false. - std::set baseGlobs(bc.sources.begin(), bc.sources.end()); - baseGlobs.insert(pkg.manifest.modules.sources.begin(), - pkg.manifest.modules.sources.end()); - if (!includeDevDeps) { - // glob → owned by at least one ACTIVE feature? - std::set activeNow(active.begin(), active.end()); - std::map gated; - for (auto& [f, globs] : bc.featureSources) - for (auto& g : globs) - gated[g] = gated[g] || activeNow.contains(f); - auto drop = [&](std::vector& v) { - std::erase_if(v, [&](const std::string& s) { return gated.contains(s); }); - }; - drop(bc.sources); - drop(pkg.manifest.modules.sources); - // Dropping the glob STRING is not enough: files it matches - // may still be covered by a broader base glob (the default - // src/** — the mcpp.toml G5 case). An inactive gate becomes - // a `!` exclusion so the gate actually gates; active gates - // are re-added below. - for (auto& [g, isActive] : gated) { - if (isActive || g.starts_with("!")) continue; - bc.sources.push_back("!" + g); - pkg.manifest.modules.sources.push_back("!" + g); - } - } - else { - // `mcpp test`. The gate that build mode applies wholesale is - // applied here only to the globs the package provides - // NOWHERE ELSE, which leaves gtest's doubly-listed source - // visible and stops riscv-virt-rt's feature-only sources - // from being compiled without their feature. - // - // THE `!` EXCLUSION IS THE WHOLE MECHANISM, NOT THE GLOB - // REMOVAL. `src/kal/**` is never IN `bc.sources` — the - // package declares no `sources` at all and its files are - // matched by the inferred `src/**`. Erasing the string - // erases nothing; only an exclusion gates. - std::set activeNow(active.begin(), active.end()); - std::map gated; - for (auto& [f, globs] : bc.featureSources) - for (auto& g : globs) - gated[g] = gated[g] || activeNow.contains(f); - for (auto& [g, isActive] : gated) { - if (isActive || g.starts_with("!")) continue; - if (baseGlobs.contains(g)) continue; // a gate, not a provider - bc.sources.push_back("!" + g); - pkg.manifest.modules.sources.push_back("!" + g); - } - } - std::set activeSet(active.begin(), active.end()); - auto add = [](std::vector& v, const std::string& g) { - if (std::ranges::find(v, g) == v.end()) v.push_back(g); - }; - for (auto& [f, globs] : bc.featureSources) { - if (!activeSet.contains(f)) continue; - for (auto& g : globs) { - add(bc.sources, g); - add(pkg.manifest.modules.sources, g); - } - } - } - // #253: per-feature per-glob flags — fold each ACTIVE feature's - // entries into the base globFlags funnel. Everything downstream - // (scanner glob match, per-TU flag landing, zero-hit warning, - // fingerprint serialization) consumes the ONE vector unchanged. - // Appended AFTER base entries, features in map (= name) order, so - // application order is deterministic and a feature rule wins over - // a broader base rule via "last flag wins". An inactive feature - // contributes nothing — its dead globs no longer exist to warn - // about. Deliberately OUTSIDE any includeDevDeps gate: like the - // sources ADD above, `mcpp build` and `mcpp test` must agree - // (0.0.94 dual-path invariant). featureOrigin tags the entry so - // the scanner's zero-hit warning can name the owning feature. - // - // Routed through the SAME append(BuildInputs&) the cfg axis uses - // (#258): both axes are contributing additive build inputs, so - // "how does a contribution combine with the base" must have one - // answer. Only the flags half of the feature axis is expressible - // that way — feature `sources` above carry DROP-then-ADD - // semantics, and feature `defines` are interface contributions - // that propagate along Public edges, so neither is a plain - // append and neither belongs in BuildInputs. - for (auto& [f, entries] : bc.featureFlags) { - if (std::ranges::find(active, f) == active.end()) continue; - mcpp::manifest::BuildInputs contribution; - for (auto const& gf : entries) { - auto tagged = gf; - tagged.featureOrigin = f; - contribution.globFlags.push_back(std::move(tagged)); - } - mcpp::manifest::append(bc, contribution); - } - }; - if (!packages.empty()) { - auto rootReq = parse_feature_request(overrides.features); - // Strict schema check: a requested feature must exist in the - // target package's [features] table when one is declared (a - // package with no [features] accepts any request — pure-define - // usage). Covers backend= sugar (feature backend-) too. - auto unknown_requested = [](const mcpp::manifest::Manifest& pm, - const std::vector& requested) - -> std::optional { - if (pm.featuresMap.empty()) return std::nullopt; - for (auto& f : requested) - if (!pm.featuresMap.contains(f)) return f; - return std::nullopt; - }; - if (auto bad = unknown_requested(packages[0].manifest, rootReq)) { - auto msg = std::format( - "--features requests '{}' which [features] does not declare", *bad); - if (overrides.strict) return std::unexpected(msg); - mcpp::diag::warning("features/request", msg); - } - apply(packages[0], rootReq); - for (auto& f : activate(*m, rootReq)) activeRootFeatures.insert(f); - } - // #242/#243: the feature request for a dependency PACKAGE, aggregated - // over ALL its incoming edges (a package may be depended on by several - // consumers — diamond — or reached only transitively). Cargo semantics: - // requested features UNION; default-features stays on unless EVERY - // consumer opted out. Sourcing this from the authoritative edge graph — - // rather than scanning only the root manifest's direct deps — makes - // activation AGREE with resolution (mergeActiveFeatureDeps, which reads - // the true per-edge spec): a transitive dep's requested features and its - // consumer's `default-features = false` are no longer silently dropped. - auto aggregatedRequest = [&](std::size_t depPkgIndex) - -> std::pair, bool> { - std::vector feats; - bool anyEdge = false, anyDefault = false; - for (auto const& edge : dependencyEdges) { - if (edge.dependencyPackageIndex != depPkgIndex) continue; - anyEdge = true; - if (edge.defaultFeatures) anyDefault = true; - for (auto const& f : edge.requestedFeatures) - if (std::find(feats.begin(), feats.end(), f) == feats.end()) - feats.push_back(f); - } - return { std::move(feats), anyEdge ? anyDefault : true }; - }; - for (std::size_t i = 1; i < packages.size(); ++i) { - auto& pname = packages[i].manifest.package.name; - auto [req, depDefaultFeatures] = aggregatedRequest(i); - if (!req.empty() && !packages[i].manifest.featuresMap.empty()) { - for (auto& f : req) { - if (packages[i].manifest.featuresMap.contains(f)) continue; - auto msg = std::format( - "dependency '{}' does not declare requested feature '{}' " - "in its [features] table", pname, f); - if (overrides.strict) return std::unexpected(msg); - mcpp::diag::warning("features/request", msg); - } - } - // Always apply: even with no requested/default feature, a dep with - // feature-gated sources must have those sources dropped by default. - // depDefaultFeatures carries the consumer's `default-features = false` - // (#242): when opted out, the dep's [features].default is not seeded. - apply(packages[i], req, depDefaultFeatures); - if (activeFeaturesByPackage.size() <= i) - activeFeaturesByPackage.resize(i + 1); - activeFeaturesByPackage[i] = - feature_closure(packages[i].manifest, req, depDefaultFeatures); - } - - // ─── Device extensions a rule dependency declared ────────────────── - // - // A rule package states which device extensions it compiles, on the - // feature that provides the rule. Collected here, after features are - // activated, because only an ACTIVE feature's declaration applies: a - // collection carrying a CUDA rule and a shader rule must not make `.cu` - // a device source in a project that asked for the shader rule alone. - // - // Written into the CONSUMER's `[build]` so every site that already - // builds an extension table for a package picks it up without a second - // plumbing route. - // - // THE POSITION IS LOAD-BEARING. It sits after feature activation and - // before the extension table that narrows the constrained globs, which - // is the first reader. Placed after that table instead, the declared - // extensions arrive too late to classify anything: the device source - // list comes out empty, the rule is handed nothing, it generates no - // module, and the failure surfaces three edges away as `failed to read - // compiled module` on the interface the consumer imported. Measured. - // - // It is what makes a new device language cost no engine change. Adding - // `.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. - for (std::size_t ci = 0; ci < packages.size(); ++ci) { - std::vector collected; - std::vector ruleModules; - for (auto const& edge : dependencyEdges) { - if (edge.consumerPackageIndex != ci) continue; - if (edge.dependencyPackageIndex >= packages.size()) continue; - auto const& dep = packages[edge.dependencyPackageIndex]; - const auto& depFeatures = - edge.dependencyPackageIndex < activeFeaturesByPackage.size() - ? activeFeaturesByPackage[edge.dependencyPackageIndex] - : edge.requestedFeatures; - for (auto const& f : depFeatures) { - auto it = dep.manifest.featureDeviceExtensions.find(f); - if (it == dep.manifest.featureDeviceExtensions.end()) continue; - for (auto const& e : it->second) - if (std::ranges::find(collected, e) == collected.end()) - collected.push_back(e); - // The module a synthesised build program imports for this - // rule. Declared by the feature rather than scanned out of - // its source, because the program has to be WRITTEN before - // anything is compiled and a build that scanned a - // dependency to decide what to write would order the two - // the wrong way round. - if (auto mit = dep.manifest.featureRuleModule.find(f); - 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)); - } - } - } - if (!collected.empty()) { - if (ci == 0) m->buildConfig.deviceExtensions = collected; - packages[ci].manifest.buildConfig.deviceExtensions = std::move(collected); - } - if (!ruleModules.empty()) { - // THE ROOT'S MANIFEST IS TWO OBJECTS. `packages[0]` holds a COPY - // made by `makePackageRoot`, and the build-program environment for the - // root reads `*m`. Writing only the copy left the synthesis with - // an empty list and the shaders uncompiled, with a refusal that - // named the missing build program rather than the missing write. - if (ci == 0) m->buildConfig.ruleModules = ruleModules; - packages[ci].manifest.buildConfig.ruleModules = std::move(ruleModules); - } - } - - // ── Constrained source globs: narrow to what this build targets ──── - // - // A `{ glob = "...", accel = "..." }` entry in `[build] sources` says - // what its files are FOR. Three outcomes, all decided here and none in - // the scanner, which keeps reading a plain list of globs: - // - // - the glob matches nothing: refused, naming the glob. An empty - // match is a typo or a moved directory, not a no-op, and the - // failure it would otherwise become is a kernel that is never - // compiled and a link that resolves nothing. - // - the build asks for no accelerator: the glob is EXCLUDED, with the - // same `!` mechanism feature gates use -- removing the string is not - // enough when a broader glob (the default `src/**`) covers the same - // files. This is how `--no-accel` yields the CPU-only variant. - // - the build asks for one: the constraint must lie within it, or the - // build is refused naming both. A file compiled for sm_89 under a - // build that targets sm_80 is not a variant, it is a mismatch. - // - // Device-kind files the effective set still matches are collected per - // package for the build program (MCPP_DEVICE_SOURCES); the engine has - // no compile rule for them and never will. - { - const auto buildAccel = mcpp::pack::parse_accel(resolvedAccel()); - for (std::size_t i = 0; i < packages.size(); ++i) { - auto& pkg = packages[i]; - auto& bc = pkg.manifest.buildConfig; - std::set excludedGlobs; - for (auto const& sc : bc.sourceConstraints) { - const auto hits = mcpp::modgraph::expand_glob(pkg.root, sc.glob); - if (hits.empty()) { - return std::unexpected(std::format( - "`{}`: [build] sources entry '{}' (accel = \"{}\") matches no file.\n" - " A constrained glob names the files a device build needs; an\n" - " empty match would leave nothing to compile for that device\n" - " and say so only at the link, or never.", - pkg.manifest.package.name, sc.glob, sc.accel)); - } - // A backend the package never declared. Checked BEFORE - // the build's own accel is consulted, because it is a - // property of the manifest alone and because the exclusion - // below would otherwise turn `accel = "cude12.9"` into a - // glob that is quietly never built. Only when the package - // states its backends -- `[package] accelerators` is - // optional, and a package that omits it has said nothing to - // contradict. - if (!pkg.manifest.package.accelerators.empty()) { - for (auto const& w : mcpp::pack::parse_accel(sc.accel)) { - if (std::ranges::find(pkg.manifest.package.accelerators, - w.backend) - != pkg.manifest.package.accelerators.end()) continue; - std::string declared; - for (auto const& a : pkg.manifest.package.accelerators) - declared += (declared.empty() ? "" : ", ") + a; - refusal::record(refusal::Code::AccelBackendUndeclared); - return std::unexpected(std::format( - "`{}`: [build] sources entry '{}' names accelerator " - "backend \"{}\", which this package does not declare.\n" - " [package] accelerators = [{}]\n" - " A constrained glob is left out of builds that do " - "not name its\n" - " backend, so a backend spelled wrong here is a file " - "that is never\n" - " compiled and never mentioned.\n" - " fix: correct the spelling, or add the backend to " - "`[package] accelerators`.", - pkg.manifest.package.name, sc.glob, w.backend, declared)); - } - } - if (buildAccel.empty()) { excludedGlobs.insert(sc.glob); continue; } - const auto want = mcpp::pack::parse_accel(sc.accel); - - // A GLOB WHOSE BACKEND THIS BUILD NEVER NAMED IS NOT A - // MISMATCH, IT IS ABSENT. - // - // The refusal below is about a real disagreement: a file - // written for sm_89 in a build that targets sm_80 is not a - // variant. Across DIFFERENT backends there is no such - // disagreement. A package with a CUDA island and a Vulkan - // one, built with `--accel vulkan1.2`, is asking for the - // Vulkan half; refusing it made a build that names a SUBSET - // of a package's backends impossible, so a package could - // have several device backends only if every build took all - // of them. - // - // The glob is dropped exactly as `--no-accel` drops it, and - // the `cfg(accelerator = ...)` section carrying that - // backend's host half does not activate either, so the two - // halves stay together. - // - // What keeps a TYPO from becoming a silent exclusion is the - // check below, against `[package] accelerators`: a backend - // the package never declared is refused before this point. - bool backendNamed = false; - for (auto const& w : want) - for (auto const& b : buildAccel) - if (b.backend == w.backend) backendNamed = true; - if (!backendNamed) { excludedGlobs.insert(sc.glob); continue; } - - if (!mcpp::pack::accel_accepts(buildAccel, want)) { - refusal::record(refusal::Code::AccelMismatch); - return std::unexpected(std::format( - "`{}`: [build] sources entry '{}' is constrained to accel \"{}\",\n" - " which this build does not cover.\n" - " this build targets: {}\n" - " fix: build with `--accel` covering it, or `--no-accel` to\n" - " leave every constrained glob out (the CPU-only variant).", - pkg.manifest.package.name, sc.glob, - mcpp::pack::accel_str(want), - mcpp::pack::accel_str(buildAccel))); - } - } - for (auto const& g : excludedGlobs) { - bc.sources.push_back("!" + g); - pkg.manifest.modules.sources.push_back("!" + g); - } - // The device-kind files the EFFECTIVE set matches, for the - // build program. Exclusions are honoured the way the scanner - // honours them: positives first, then `!` entries removed. - const auto extTable = mcpp::extension_table_for(bc.moduleExtensions, - bc.deviceExtensions); - std::set matched, dropped; - for (auto const& g : pkg.manifest.modules.sources) { - if (g.empty()) continue; - if (g[0] == '!') { for (auto& f : mcpp::modgraph::expand_glob(pkg.root, g.substr(1))) dropped.insert(f); } - else if (!std::filesystem::path(g).is_absolute()) - for (auto& f : mcpp::modgraph::expand_glob(pkg.root, g)) matched.insert(f); - } - std::vector device; - for (auto const& f : matched) { - if (dropped.contains(f)) continue; - if (mcpp::classify(f, extTable) != mcpp::SourceKind::Device) continue; - device.push_back(f.lexically_relative(pkg.root).generic_string()); - } - deviceSourcesByPackage[pkg.root.string()] = std::move(device); - } - } - activeFeaturesByPackage.resize(packages.size()); - - // ── The GRAPH's `[xlings.workspace]`, provisioned BEFORE build.mcpp ── - // - // Same ordering rule as the host-tool block directly below, and for the - // same reason: a build program consumes what was provisioned, so - // provisioning after it has run is provisioning that did not happen. - // - // MEASURED, on the published `ggml-org:llamacpp@b10069.2`. That package - // declares its shader compiler under the feature that needs it: - // - // [feature-xlings.backend-vulkan] - // "xim:shaderc" = "2026.3" - // - // and its build program asks for it with `xpkg_dir`. As the ROOT it - // works, because the root's pass runs early. As a DEPENDENCY it did - // not: the graph's pass ran ~1700 lines further down, after every - // build.mcpp, so `xpkg_dir` answered "" and the package refused its own - // headline feature with the very declaration it had already made. A - // clean `MCPP_HOME` pulled twenty-four xim payloads for that graph and - // not shaderc. - // - // IT WAS INVISIBLE ON ANY MACHINE THAT HAD BUILT THE PACKAGE ITSELF. - // Once `xim:shaderc` is in the registry for any reason, `xpkg_dir` - // finds it and the ordering stops mattering; only an empty registry can - // see this. The sandbox run is what caught it. - // - { - // ONE CHECK OVER THE WHOLE GRAPH, at the site whose consequence it - // describes. `merge_conditional_config` has three call sites and - // returns void; this loop sees the root and every package that - // reached the graph, and it runs before the first payload is - // fetched, so a refusal costs nothing that has to be undone. - if (auto why = layer_predicated_xlings_refusal(runtimeOwnerManifest)) - return std::unexpected(*why); - for (auto const& pkg : packages) - if (auto why = layer_predicated_xlings_refusal(pkg.manifest)) - return std::unexpected(*why); - auto split = graph_xlings_split(); - if (!split) { - refusal::record(refusal::Code::ToolVersionConflict); - return std::unexpected(split.error()); - } - auto const& fromGraph = split->second; - if (!fromGraph.empty()) { - if (auto cfg = get_cfg()) { - if (auto pv = provision_xlings_addresses( - **cfg, fromGraph, *root, - "[xlings.workspace] entries declared by dependencies"); - !pv) return std::unexpected(pv.error()); - } - } - } - - // ── #355: HOST tool provisioning ──────────────────────────────────── - // - // Runs AFTER feature activation (a tool target's gate is a feature) and - // BEFORE any build.mcpp (which is what consumes the tools). That - // ordering is the whole point: build.mcpp runs inside prepare, so a - // tool produced by the main ninja graph would arrive far too late — - // and under --target it would be the wrong architecture besides. - // - // Each tool is built by re-entering prepare_build with the DEPENDENCY - // as the root and no --target, i.e. for the build machine. That is - // Cargo's [build-dependencies] / Bazel's exec configuration shape. - // It is affordable because an executable has zero ABI contact with the - // main build: the sub-build may use the tool package's own toolchain, - // its own profile, and its own resolution — none of it has to agree - // with the consumer. - { - // Aggregate off the authoritative edge graph, exactly like feature - // activation — a transitive consumer's request must not be - // silently dropped (#242/#243). - std::map> toolRequests; - for (auto const& edge : dependencyEdges) - for (auto const& t : edge.requestedTools) - toolRequests[edge.dependencyPackageIndex].insert(t); - - // #359: one fixpoint decides who SEES what. `toolRequests` above - // still decides what gets BUILT — the two questions are separate, - // and conflating them is what made a re-exported tool impossible: - // the tool was built, but its path was recorded against the library - // that asked for it rather than the project that needs it. - provisionGraph = prov::propagate(dependencyEdges, packages.size()); - - // #355 step 5: dependencies offering HOST build rules. Nothing is - // compiled here — the interface is handed to build_program.cppm, - // which compiles it in the SAME command as build.mcpp so the BMI - // and its consumer agree on standard, dialect and compiler by - // construction rather than by luck. - // - // Driven off the visible set rather than the root manifest, so a - // rule a library re-exports is importable from the consumer's - // build.mcpp without the consumer naming it. The name matching the - // old loop needed is gone with it: the edge already knows which - // package it points at. - // - // The registered name is the one the rule's SOURCE declares, not - // the package's name. See provisions::host_module_name for why the - // two had drifted apart and what that cost on Clang and MSVC. - std::set prefixWarned; - // Providers of host modules that THIS package sees directly. - auto directHostProviders = [&](std::size_t p) { - std::vector out; - if (p >= provisionGraph.visible.size()) return out; - for (auto const& pr : provisionGraph.visible[p]) { - if (pr.kind != prov::Kind::HostModule) continue; - if (pr.provider >= packages.size()) continue; - out.push_back(pr.provider); - } - return out; - }; - auto identity = [&](std::size_t p) { - auto const& pkg = packages[p].manifest.package; - return pkg.namespace_.empty() - ? pkg.name : pkg.namespace_ + "." + pkg.name; - }; - // Every host module one package contributes, the lib root first. - // - // The lib root is what a rule package has always been: one unit, - // compiled alone, registered under the name it declares. A package - // that offers several rules through features (mcpp 2026.9.5.3+) - // lists their sources under `[features.] sources`, and those - // globs have been folded into `buildConfig.sources` by now for - // exactly the features the consumer activated. Every module - // INTERFACE unit among them is therefore a host module of its own, - // under its own declared name, and nothing else in the host-module - // path assumes one unit per package: `build_host_module` is per - // unit and the compile loop accumulates BMIs in list order, so a - // feature unit may import the lib root, which precedes it. - // - // Only sources the manifest LISTS take part. The inferred `src/**` - // of a package with no `sources` is not consulted, so a rule - // package published before this round exposes exactly what it - // exposed then; widening that implicitly would compile units that - // were written to be part of an ordinary library, alone. - auto units = [&](std::size_t p) { - auto const& depPkg = packages[p]; - auto const& pkg = depPkg.manifest.package; - std::vector out; - auto push = [&](std::filesystem::path iface, std::string name) { - prov::HostModule hm; - hm.module = std::move(name); - hm.package = identity(p); - hm.nameSpace = pkg.namespace_; - hm.interface = std::move(iface); - out.push_back(std::move(hm)); - }; - // PROBING form: a host-module dependency whose interface is - // `.ixx` resolves to a `src/.cppm` that does not exist, - // and the consumer's build.mcpp is then handed a path to - // nothing. - auto rel = mcpp::manifest::resolve_lib_root_path( - depPkg.manifest, depPkg.root); - auto iface = depPkg.root / rel; - push(iface, prov::host_module_name(iface, pkg.name)); - // A missing lib root is reported as such by build_host_module, - // and that has to stay the diagnostic. Enumerating the listed - // units first would let one of them collide with the missing - // root's fallback name and report a collision between a file - // and a file that does not exist. - std::error_code ec; - if (!std::filesystem::exists(iface, ec)) return out; - - std::set matched, dropped; - for (auto const& g : depPkg.manifest.buildConfig.sources) { - if (g.empty()) continue; - if (g[0] == '!') { - for (auto& f : mcpp::modgraph::expand_glob(depPkg.root, g.substr(1))) - dropped.insert(f.lexically_normal()); - } else { - for (auto& f : mcpp::modgraph::expand_glob(depPkg.root, g)) - matched.insert(f.lexically_normal()); - } - } - const auto root = iface.lexically_normal(); - // ORDERED BY WHAT THEY IMPORT, NOT BY WHERE THEY SIT. - // - // The compile loop accumulates BMIs in list order, so each - // entry sees only what precedes it. Path order was the previous - // rule and it is not a valid one: `rules/spirv.cppm` sorts - // before `src/surface.cppm`, so a member importing a unit its - // package shares was compiled first and failed with "failed to - // read compiled module ... imports must be built before being - // imported". Reproduced, and reproduced in both directions -- - // renaming the shared unit so its path sorted first made the - // same package build, which is what says the cause is the sort - // and nothing else. - // - // A package that works today is ordered IDENTICALLY: the sort - // below keeps path order wherever no import constrains it, so - // it differs only where the old order was already broken. - struct Unit { - std::filesystem::path path; - std::string name; - std::vector imports; - }; - std::vector pending; - for (auto const& f : matched) { // std::set: sorted - if (dropped.contains(f)) continue; - if (std::filesystem::equivalent(f, root, ec)) continue; - std::ifstream is(f); - if (!is) continue; - std::stringstream buf; - buf << is.rdbuf(); - auto text = buf.str(); - auto name = prov::declared_interface_name(text); - if (name.empty()) continue; - pending.push_back({f, std::move(name), prov::declared_imports(text)}); - } - - // Only names this package itself declares constrain anything. - // `import std;` and the lib root are already ahead of every - // entry here, and a name from another package is ordered by the - // cross-package DFS below rather than by this sort. - std::map byName; - for (std::size_t i = 0; i < pending.size(); ++i) - byName.emplace(pending[i].name, i); - - std::vector state(pending.size(), 0); // 0 new, 1 open, 2 done - std::vector order; - order.reserve(pending.size()); - // Iterative post-order DFS over the path-sorted list: the first - // unit that can be emitted is emitted, which is what preserves - // path order in the unconstrained case. - const auto visit = [&](std::size_t start) { - std::vector> stack{{start, 0}}; - while (!stack.empty()) { - auto& [u, k] = stack.back(); - if (state[u] == 2) { stack.pop_back(); continue; } - state[u] = 1; - if (k < pending[u].imports.size()) { - auto const& want = pending[u].imports[k++]; - auto it = byName.find(want); - // A CYCLE IS LEFT TO THE COMPILER, ON PURPOSE. It - // is ill-formed C++ and the compiler says so with - // the two units named; refusing here would report - // the same fact in a worse place, and getting the - // ordering wrong is no longer possible either way. - if (it != byName.end() && state[it->second] == 0) - stack.push_back({it->second, 0}); - continue; - } - state[u] = 2; - order.push_back(u); - stack.pop_back(); - } - }; - for (std::size_t i = 0; i < pending.size(); ++i) - if (state[i] == 0) visit(i); - - for (auto i : order) push(pending[i].path, std::move(pending[i].name)); - return out; - }; - for (std::size_t c = 0; c < provisionGraph.visible.size(); ++c) { - const auto direct = directHostProviders(c); - if (direct.empty()) continue; - std::set isDirect(direct.begin(), direct.end()); - - // Post-order DFS, so a rule's own host modules are compiled - // BEFORE it. That ordering is the entire mechanism: the - // compile loop in build_program.cppm accumulates the module - // flags as it goes, so each entry sees the BMIs of everything - // ahead of it, and "a rule may import another rule" needs no - // second machinery — only this sort. - std::vector ordered; - std::set done; - std::vector path; // for the cycle diagnostic - auto visit = [&](auto&& self, std::size_t p) -> std::expected { - if (done.contains(p)) return {}; - if (std::ranges::find(path, p) != path.end()) { - // A cycle, reported AS a cycle and naming the packages - // on it. A depth limit would answer a different - // question and would answer it later. - std::string ring; - bool started = false; - for (auto q : path) { - if (q == p) started = true; - if (!started) continue; - ring += identity(q); - ring += " -> "; - } - ring += identity(p); - return std::unexpected(std::format( - "build rules form an import cycle: {}\n" - " A rule's host modules are compiled before " - "it, so a cycle has no order that could satisfy " - "all of them.", ring)); - } - path.push_back(p); - for (auto q : directHostProviders(p)) - if (auto r = self(self, q); !r) return r; - path.pop_back(); - done.insert(p); - for (auto& hm : units(p)) { - hm.importable = isDirect.contains(p); - ordered.push_back(std::move(hm)); - } - return {}; - }; - for (auto p : direct) - if (auto r = visit(visit, p); !r) - return std::unexpected(r.error()); - - // Every provider on this consumer's rule closure, transitive - // ones included -- `done` is exactly that set, and a rule - // imported by another rule declares payloads just as directly. - hostModuleProvidersByConsumer[c].assign(done.begin(), done.end()); - - if (auto clash = prov::host_module_collision(ordered)) - return std::unexpected(*clash); - for (auto const& hm : ordered) { - // Warned once per (package, module), not once per consumer: - // a rule re-exported down a chain is visible to every - // package on it, and repeating one naming remark N times - // reads as N problems. - if (auto w = prov::reserved_prefix_warning( - hm.module, hm.nameSpace, hm.package)) { - if (prefixWarned.insert(hm.package + "\x1e" + hm.module).second) - mcpp::diag::warning("build/rule-namespace", *w); - } - hostModulesByConsumer[c].push_back( - {hm.module, hm.interface, hm.importable}); - } - } - - // A build rule is BUILD-TIME ONLY. Registering the module is not - // enough: the package is still an ordinary node of the consumer's - // graph, so its interface was ALSO compiled as a normal library and - // linked into the target. That is wrong on its own terms — a rule - // has no business in the consumer's binary — and it made the - // feature nearly unusable, because in that second compile the - // bundled `mcpp` module does not exist: any rule that actually used - // the API it exists to wrap died with `fatal error: module 'mcpp' - // not found` (2026.8.5.1). - // - // Emptying the source globs is how a package is removed from the - // compile set here — the same mechanism the feature-gated-sources - // drop above uses. Resolution is untouched: the package still lands - // on disk, which is what `resolve_lib_root_path` just read. - // - // Guarded on EVERY edge into the package being a host-module edge. - // A package can legitimately be both a rule and a library, and - // silently dropping its objects then would surface as an undefined - // reference far from here. (The predicate used to be "no consumer - // other than the root", which said the same thing only while the - // root was the only possible requester.) - // - // Stated as FORWARD REACHABILITY rather than as exclusion, and the - // difference is not cosmetic. - // - // The predicate used to be per-edge: "every in-edge into this - // package is a host-module edge". That is right about the rule - // itself and wrong about everything BEHIND it — a rule's own - // `[dependencies]` are reached by ordinary edges, so they kept - // their globs and were compiled and LINKED INTO THE CONSUMER'S - // BINARY, while the rule could not even import them. Both halves - // were wrong, and the sharper harm was that a rule's dependency - // versions took part in the consumer's real resolution, so a rule - // could create a version conflict in a project that never asked - // for one. - // - // Exclusion would also get the dual-role case backwards. A package - // the project depends on directly must stay in the target even - // when some rule's build dependencies also reach it: the - // build-time path never subtracts from what the project asked to - // link. Asking "can the target reach it" answers both cases with - // one rule and no special case. - { - auto reachable = [&](bool targetEdgesOnly) { - std::vector seen(packages.size(), false); - if (packages.empty()) return seen; - std::vector stack{0}; - seen[0] = true; - while (!stack.empty()) { - auto p = stack.back(); - stack.pop_back(); - for (auto const& e : dependencyEdges) { - if (e.consumerPackageIndex != p) continue; - if (targetEdgesOnly && (e.hostModule || e.buildOnly)) - continue; - auto d = e.dependencyPackageIndex; - if (d >= seen.size() || seen[d]) continue; - seen[d] = true; - stack.push_back(d); - } - } - return seen; - }; - const auto viaTarget = reachable(/*targetEdgesOnly=*/true); - const auto viaAny = reachable(/*targetEdgesOnly=*/false); - for (std::size_t d = 1; d < packages.size(); ++d) { - // `viaAny` is the guard that keeps this from acting on a - // package no edge ever mentioned. Such a package is a - // bookkeeping gap, not a build dependency, and clearing - // its sources would turn that gap into an undefined - // reference a long way from here. - if (viaTarget[d] || !viaAny[d]) continue; - auto& dm = packages[d].manifest; - dm.buildConfig.sources.clear(); - dm.buildConfig.featureSources.clear(); - dm.modules.sources.clear(); - } - } - - if (overrides.tool_depth >= mcpp::build::tool_store::kMaxDepth - && !toolRequests.empty()) { - return std::unexpected(std::format( - "tool provisioning nested more than {} levels deep — this is " - "almost certainly a cycle.\n chain: {}", - mcpp::build::tool_store::kMaxDepth, overrides.tool_chain)); - } - - for (auto const& [depIdx, wanted] : toolRequests) { - auto& depPkg = packages[depIdx]; - const auto& depName = depPkg.manifest.package.name; - std::string depShort = depName; - if (auto dot = depName.rfind('.'); - dot != std::string::npos && dot + 1 < depName.size()) - depShort = depName.substr(dot + 1); - - for (auto const& toolName : wanted) { - // The target must exist and be a binary. Naming the - // alternatives matters: the consumer wrote a string, and a - // typo is the likeliest cause. - // - // #622 A3: deliberately still `Binary`, not `is_program()`. - // A host tool is exec'd directly ON THE BUILD MACHINE - // during THIS build, so it is "literally an executable - // link" — the question this site was already asking — and - // an `app` whose row form happened to be a library (never - // the host row in practice, but the check would be a - // silent trap if the host itself were ever Android) could - // not stand in for it. A build-time tool is declared - // `kind = "bin"`; that is what the word means here. - const mcpp::manifest::Target* tgt = nullptr; - std::string binList; - for (auto const& t : depPkg.manifest.targets) { - if (t.kind != mcpp::manifest::Target::Binary) continue; - if (!binList.empty()) binList += ", "; - binList += t.name; - if (t.name == toolName) tgt = &t; - } - if (!tgt) { - // A package may declare a bin target on some platforms - // only. When the request came from a LIBRARY rather - // than from the user, the user cannot edit it away, so - // point at the knob that library needs (#359 D3a). - return std::unexpected(std::format( - "dependency '{}' has no `kind = \"bin\"` target named " - "'{}' (requested via tools = [...]).\n" - " available bin targets: [{}]\n" - " If the requesting package is a library, it can " - "scope the request per platform with\n" - " [target.'cfg(...)'.feature-deps.].", - depName, toolName, - binList.empty() ? std::string("none") : binList)); - } - - // #359: every consumer that can SEE this tool gets it, not - // just the one whose edge asked for it. The bare spelling - // is emitted only where the namespace ladder binds the tail - // to this package — otherwise two libraries re-exporting a - // same-tailed tool would decide the winner by append order. - // The spellings are `publishedNamesFor`'s, so a tool is - // addressed by exactly the names its directory is. - const prov::Provision want{ prov::Kind::Tool, depIdx, toolName }; - auto record = [&](const std::filesystem::path& p) { - for (std::size_t c = 0; c < provisionGraph.visible.size(); ++c) { - if (!provisionGraph.visible[c].contains(want)) continue; - auto& v = toolEnvByConsumer[c]; - std::vector vars; - for (auto const& n : publishedNamesFor(depIdx, bareBindingsFor(c))) { - auto var = mcpp::build::tool_store::env_var_name(n, toolName); - if (std::ranges::find(vars, var) != vars.end()) continue; - vars.push_back(var); - v.emplace_back(std::move(var), p.string()); - } - } - }; - - // Escape hatch first: it is the cheapest resolution and the - // one a user reaches for precisely when building is not an - // option. Deliberately not part of the store key — see - // tool_store.cppm. - if (auto ovr = mcpp::build::tool_store::find_override( - *m, depName, depShort, toolName)) { - if (!std::filesystem::exists(*ovr)) { - return std::unexpected(std::format( - "tool override for '{}:{}' points at '{}', which " - "does not exist", depName, toolName, ovr->string())); - } - mcpp::ui::info("Tool", std::format( - "{}:{} → {} (override)", depName, toolName, ovr->string())); - record(*ovr); - continue; - } - - // A TOOL WHOSE OWN BUILD REQUESTS IT AGAIN IS REFUSED AT - // THE FIRST REPETITION (#649 E6). The depth bound below - // caught it only after four nested sub-builds, with the - // same prefix repeated four times and no word about which - // edge asked. The edge is the one whose request reached - // this package in THIS graph. - const std::string toolSource = std::format( - "{}|{}", depPkg.root.lexically_normal().generic_string(), toolName); - if (std::ranges::find(overrides.tool_chain_sources, toolSource) - != overrides.tool_chain_sources.end()) { - std::string askedBy; - for (auto const& edge : dependencyEdges) { - if (edge.dependencyPackageIndex != depIdx) continue; - if (std::ranges::find(edge.requestedTools, toolName) - == edge.requestedTools.end()) continue; - if (edge.consumerPackageIndex < packages.size()) { - askedBy = mcpp::build::qualified_package_name( - packages[edge.consumerPackageIndex].manifest); - break; - } - } - return std::unexpected(std::format( - "the host tool '{}:{}' is requested by its own build: " - "{} -> {}:{}.\n" - " The request comes from '{}', which the tool's " - "sub-build resolves with the feature or dependency that " - "asks for the tool.\n" - " fix: the tool's own graph must not activate " - "that request (a feature it does not enable, or a " - "`[target..feature-deps]` row it does not match).", - depName, toolName, - overrides.tool_chain.empty() ? "root" : overrides.tool_chain, - depName, toolName, - askedBy.empty() ? std::string("a package of its graph") : askedBy)); - } - - // Build it. The feature set is the tool package's own - // defaults PLUS the target's required_features — in a tool - // sub-build the target is what was ASKED FOR, so its - // requirements are inputs rather than a gate. (Same field, - // opposite resolution direction; docs/05 says so.) - 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()); - - mcpp::build::tool_store::Key key; - key.indexName = depIdx >= 1 && depIdx - 1 < dep_cache_identities.size() - ? dep_cache_identities[depIdx - 1].indexName - : std::string(mcpp::pm::kDefaultNamespace); - key.packageName = depName; - // THE VERSION IDENTIFIES THE SOURCES ONLY FOR AN INDEX - // PACKAGE. A `git` package is keyed by its commit and a - // `path` package by a stamp of its tree, because both - // change under an unchanged version and the store then - // serves a binary built from sources that no longer exist - // (#630, item 6; measured 2026-09-08 with examples/12). - // The same rule applies to every upstream below. - auto source_keyed_version = [&](std::size_t pkgIdx) { - const auto& man = packages[pkgIdx].manifest.package; - std::string v = man.version; - if (pkgIdx >= 1 && pkgIdx - 1 < dep_cache_identities.size()) { - const auto& id = dep_cache_identities[pkgIdx - 1]; - if (id.sourceKind == "git" && !id.sourceRef.empty()) - v += "+git." + id.sourceRef; - else if (id.sourceKind == "path") - v += "+path." + mcpp::build::tool_store::tree_stamp( - id.sourceRef.empty() ? packages[pkgIdx].root - : std::filesystem::path(id.sourceRef)); - } - return v; - }; - 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.profile = "release"; - key.features = closure; - std::ranges::sort(key.features); - // The tool package's TRANSITIVE dependency closure, not just - // its direct edges. Direct-only would be enough for index - // packages (a frozen version cannot change its own deps), - // but a path dependency can: bump something two levels down - // and the tool's direct list is unchanged, so a stale binary - // stays in the store — a silently wrong artifact. - for (auto up : dg::transitive_dependencies(dependencyEdges, depIdx)) - key.upstreamKeys.push_back(std::format("{}@{}", - packages[up].manifest.package.name, - source_keyed_version(up))); - std::ranges::sort(key.upstreamKeys); - - const auto cacheRoot = mcpp::home::cache_root(); - const auto entry = mcpp::build::tool_store::entry_dir(cacheRoot, key); - const auto exeSuffix = std::string(mcpp::platform::exe_suffix); - const auto binOut = mcpp::build::tool_store::bin_path( - entry, toolName, exeSuffix); - - if (mcpp::build::tool_store::entry_valid(entry, key, toolName, - exeSuffix)) { - record(binOut); - 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}); - record(binOut); - }; - - mcpp::ui::status("Building", std::format( - "host tool {}:{} from {} v{} (once per package source and " - "host toolchain)", depName, toolName, depName, - depPkg.manifest.package.version)); - - BuildOverrides sub; - sub.project_root = depPkg.root; - // Never the package root: it is shared across projects and - // may be read-only. This is the reason work_dir exists. - // - // Scratch is keyed on the CONSUMING project, not shared: - // the store is GLOBAL, so two projects can want the same - // tool at once. A single `/build` would have them - // writing one ninja tree concurrently, and whichever - // finished first would `remove_all` it out from under the - // other. The published binary is what gets shared; the - // scratch is not. - // - // Hashed rather than random so a re-run reuses its own - // scratch (ninja stays incremental if the publish step - // never got to delete it). - // - // Beside the entries rather than inside one: every - // directory name of the entry is repeated in each object - // path the sub-build writes, and on Windows those paths - // crossed the 260-character limit (mcpp#641, item 3). - sub.work_dir = mcpp::build::tool_store::scratch_dir( - cacheRoot, entry, workRoot); - sub.target_triple = ""; // HOST — the whole point - sub.profile = "release"; - sub.cache_mode = overrides.cache_mode; - sub.tool_depth = overrides.tool_depth + 1; - sub.tool_chain_sources = overrides.tool_chain_sources; - sub.tool_chain_sources.push_back(toolSource); - // The PRISTINE manifest the resolver produced for this - // package — `packages[depIdx].manifest` is a copy that - // feature activation has already mutated, and re-activating - // on top of it would fold the same feature sources in - // twice. A `compat` (Form B) package has no mcpp.toml on - // disk at all, so without this the sub-build could not read - // a manifest for it in the first place. - // - // UNMERGED, because the sub-build targets the HOST: the - // resolver merged this manifest's conditional sections for - // the consumer's target, and the sub-build merges them for - // its own (#690, F12). - if (depIdx >= 1 && depIdx - 1 < dep_manifests.size() - && dep_manifests[depIdx - 1]) { - auto const& dep = *dep_manifests[depIdx - 1]; - sub.preloaded_manifest = dep.beforeConditionalMerge - ? dep.beforeConditionalMerge - : std::make_shared(dep); - } - sub.inherited_runtime_selection = std::make_shared< - const mcpp::xlings::runtime::RuntimeSelection>( - runtimeSelection); - sub.inherited_runtime_binding = std::make_shared< - const mcpp::platform::runtime::RuntimeBinding>( - runtimeBindingSnapshot); - sub.tool_chain = overrides.tool_chain.empty() - ? std::format("root → {}:{}", depName, toolName) - : std::format("{} → {}:{}", overrides.tool_chain, depName, - toolName); - for (auto const& f : closure) { - if (!sub.features.empty()) sub.features += ","; - sub.features += f; - } - - // #359 (D3b): a sub-build failure must be attributable and - // REPRODUCIBLE. The Windows tool sub-build has been failing - // on three abseil TUs since #355 and is still unlocated, - // because what reached the log was a one-line summary with - // no scratch path, no chain, and — on the ninja branch below - // — a filtered view of the inner output. Naming the scratch - // directory is what lets a maintainer re-run the exact inner - // build; MCPP_TOOL_BUILD_VERBOSE turns off the filtering. - auto subContext = [&] { - return std::format( - "\n chain: {}\n sub-build scratch: {}\n" - " re-run it directly: mcpp build -p {} --release\n" - " (set MCPP_TOOL_BUILD_VERBOSE=1 for the inner " - "build's unfiltered output)", - sub.tool_chain, sub.work_dir.string(), - depPkg.root.string()); - }; - auto subCtx = prepare_build(/*print_fingerprint=*/false, - /*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())); - } - - // Build ONLY the requested target (#274 gave the backend - // explicit goals) — a tool request must not drag the whole - // package's other artifacts along. - std::filesystem::path goal; - for (auto const& lu : subCtx->plan.linkUnits) { - 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 " - "platform", depName, toolName)); - } - - auto be = mcpp::build::make_ninja_backend(); - mcpp::build::BuildOptions bopt; - bopt.ninjaTargets = { goal.generic_string() }; - // Unfiltered inner output on demand: the filter drops - // ninja's own progress and command echoes, which is right - // for a normal build and wrong when the question is "what - // did the inner build actually do". - if (const char* v = std::getenv("MCPP_TOOL_BUILD_VERBOSE"); - v && *v && std::string_view(v) != "0") - 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 " - "output; re-run with MCPP_TOOL_BUILD_VERBOSE=1)"; - return std::unexpected(std::format( - "building host tool '{}:{}' failed: {}{}\n{}", - depName, toolName, br.error().message, - 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())); - } - - // Publish into the store: build out of place, then move — - // the same discipline mcpp.build.stage follows, so a - // concurrent consumer never observes a half-written entry. - 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())); - } - std::filesystem::create_directories(binOut.parent_path(), cpEc); - auto tmp = binOut; - tmp += ".tmp"; - std::filesystem::remove(tmp, cpEc); - 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())); - } - std::filesystem::permissions(tmp, - std::filesystem::perms::owner_exec - | std::filesystem::perms::group_exec - | std::filesystem::perms::others_exec, - 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())); - } - mcpp::build::tool_store::write_entry(entry, key); - // The sub-build tree is large (protoc is several hundred - // objects) and the key covers every input, so a hit never - // needs it again. Removes only THIS consumer's scratch. - std::filesystem::remove_all(sub.work_dir, cpEc); - record(binOut); - } - } - } - - // ── G2: dependency build.mcpp (Cargo build.rs model) ──────────────── - // Runs AFTER feature activation (the env contract exposes the dep's - // active features) and BEFORE the modgraph scan (generated sources - // must be visible to the glob walk). Scope is Cargo's: flag directives - // land in the dep's own buildConfig (its TUs only); link directives - // ride the dep's ldflags to the final link. Artifacts and generated - // files live in the CONSUMING project's tree — a registry package - // root is shared across projects and may be read-only; it is never - // written to. - for (std::size_t i = 1; i < packages.size(); ++i) { - 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; - std::error_code bpEc; - if (!std::filesystem::exists(pkg.root / "build.mcpp", bpEc) - && pkg.manifest.buildConfig.ruleModules.empty()) continue; - auto host = host_tc_for_build_program(); - if (!host) return std::unexpected(host.error()); - // Same edge-graph aggregation as feature activation above, so a - // dep build.mcpp sees the SAME active feature set the dep is built - // with (incl. transitive requests / default-features opt-out). - auto [req, depDefaultFeatures] = aggregatedRequest(i); - auto dirSafe = [](std::string s) { - for (auto& c : s) if (c == '/' || c == '\\' || c == ':') c = '_'; - return s; - }; - mcpp::build::BuildProgramEnv bpEnv; - bpEnv.targetTriple = resolvedTargetCanonical; - // Everything the engine already knows and a build program would - // otherwise hardcode: the payload ROOT (not the driver), the - // target's C library, which compiler and which C++ standard - // library resolved, and the three answers that keep a board - // package from naming a toolchain. One call, so a new answer - // reaches every build program at once — see fill_target_build_env. - fill_target_build_env(bpEnv, *m, tc ? &*tc : nullptr, cfg_opt ? &*cfg_opt : nullptr); - bpEnv.toolsBin = projectSubosBin; - bpEnv.profile = effectiveProfile; - bpEnv.accel = resolvedAccel(); - // The DECLARING package's setting, not the root project's: a rule - // generating a declaration for this package must match how this - // package is compiled. - fill_package_build_env(bpEnv, pkg.manifest); - bpEnv.packFormat = overrides.pack_format; - bpEnv.packStageDir = overrides.pack_stage_dir; - bpEnv.packStrip = overrides.pack_strip; - bpEnv.packDebugSymbolsDir = overrides.pack_debug_symbols_dir; - bpEnv.languageModules = pkg.manifest.language.modules; - bpEnv.ruleModules = pkg.manifest.buildConfig.ruleModules; - if (auto dit = deviceSourcesByPackage.find(pkg.root.string()); dit != deviceSourcesByPackage.end()) - bpEnv.deviceSources = dit->second; - bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures); - bpEnv.artifactsDir = workRoot / "target" / ".build-mcpp" / "deps" - / (dirSafe(pkg.manifest.package.name) + "@" + pkg.manifest.package.version); - bpEnv.genBase = bpEnv.artifactsDir / "out"; - // mcpp#241: this package's resolved dependencies as - // MCPP_DEP__DIR, from the authoritative edge graph (no - // name-guessing); covers feature-activated deps too - // (mergeActiveFeatureDeps folded them in before the edges were - // recorded). Shared owner — see fillDepDirs. - fillDepDirs(bpEnv, i); - // …and the xlings packages this package itself declared. Its own - // manifest, not the root's: a dependency's `[xlings] deps` is what - // its build.mcpp asks about. - fillXpkgDirs(bpEnv, packages[i].manifest, i); - // #355: the host tools THIS package requested (resolved above). - if (auto tit = toolEnvByConsumer.find(i); tit != toolEnvByConsumer.end()) - bpEnv.toolPaths = tit->second; - bpEnv.hostModules = hostModulesByConsumer.count(i) - ? hostModulesByConsumer.at(i) - : decltype(bpEnv.hostModules){}; - auto& bcDep = pkg.manifest.buildConfig; - const auto mark = markDirectiveTail(pkg.manifest); - const auto ldN = bcDep.ldflags.size(); - const auto actN = bcDep.actions.size(); - const auto runnerN = bcDep.runner.size(); - auto namedBefore = bcDep.namedRunners; // by value: the delta below - const bool exclusiveBefore = bcDep.runExclusive; - if (auto r = mcpp::build::run_build_program( - pkg.manifest, pkg.root, host->first, host->second, - pkg.manifest.cppStandard, bpEnv); - !r) { - // #699 item 2 (E3): under `emit build-database` (`plan_only`), - // a failing build program describes its package without that - // program's directives, instead of costing the whole plan — - // the manifest's own configuration, the toolchain and the - // module graph are still worth describing. Nothing is applied - // either way: `run_build_program` returns before - // `Directives::apply` on every failure path. A later failure - // that follows from the missing directives (a source the - // program would have added, say) fails the member under the - // ordinary rule (E1). - if (overrides.plan_only) { - planNotes.push_back({"MCPP_BUILD_DATABASE_PROGRAM_FAILED", - std::format("dependency '{}': {}", - pkg.manifest.package.name, r.error()), - mcpp::wire::Severity::Error, - (pkg.root / "build.mcpp").string()}); - continue; - } - return std::unexpected(std::format( - "dependency '{}': {}", pkg.manifest.package.name, r.error())); - } - // Cargo scope wiring: compile-visible tail → privateBuild (the - // shared fold above; the dep's TUs read privateBuild, not bc — - // its consumers read publicUsage, which the fold never touches; - // the bcDep entries themselves are inert here: the descriptor - // include_dirs propagation snapshotted publicUsage at - // makePackageRoot, long before this pass). Dep residue: link - // flags — dep ldflags were propagated to the root during the - // BFS walk, which ran before this pass — forward the new tail - // (link-search paths are already absolute from parse_line). - foldDirectiveTailIntoPrivateBuild(pkg, pkg.manifest, mark); - adoptActionOutputs(pkg.manifest, pkg.root, actN); - - // Scope::RunGlobal — how the artifact is EXECUTED, forwarded to - // the root like link flags but with the opposite merge rule. - // - // EXACTLY ONE provider. Link flags from two dependencies - // concatenate and that is correct; two runners cannot — appending - // produces an argv that is neither one's and fails at exec time - // with nothing to say which package contributed which token. So - // the second provider is a hard error that names BOTH, because - // naming only the loser tells the reader half of what they need. - // THE DEFAULT RUNNER AND EVERY NAMED ONE, BY ONE RULE. - // - // Link flags from two dependencies concatenate and that is correct; - // two runners for the same name cannot — appending produces an argv - // that is neither one's and fails at exec with nothing to say which - // package contributed which token. - // - // MISSING THIS SITE IS HOW THE FEATURE FAILED FIRST. `apply()` - // merges a package's directives into its OWN config; this is where a - // dependency's RunGlobal entries reach the ROOT. Wiring only the - // first left `mcpp run --runner flash` reporting "no such runner" - // while `mcpp run` found the runner the same build program emitted - // three lines away — measured. - if (bcDep.runner.size() > runnerN) { - std::vector supplied( - bcDep.runner.begin() + static_cast(runnerN), - bcDep.runner.end()); - if (!m->buildConfig.runner.empty() && !runnerProvider.empty()) { - return std::unexpected(std::format( - "two dependencies both supply a runner for this target: " - "'{}' and '{}'.\n" - " A runner is how the artifact is reached — there " - "can only be one.\n" - " Drop one of them, or override both with an " - "explicit [target.].runner.", - runnerProvider, pkg.manifest.package.name)); - } - m->buildConfig.runner = std::move(supplied); - runnerProvider = pkg.manifest.package.name; - } - for (auto const& [name, nr] : bcDep.namedRunners) { - auto before = namedBefore.find(name); - const bool grew = (before == namedBefore.end()) - || nr.argv.size() > before->second.argv.size() - || (nr.longLived && !before->second.longLived); - if (!grew) continue; - auto& slot = m->buildConfig.namedRunners[name]; - auto& who = namedRunnerProvider[name]; - if (!slot.argv.empty() && !who.empty()) { - return std::unexpected(std::format( - "two dependencies both supply a runner named '{}' for " - "this target: '{}' and '{}'.\n" - " Drop one of them, or override both with an " - "explicit [target..runners].{}.", - name, who, pkg.manifest.package.name, name)); - } - slot = nr; - who = pkg.manifest.package.name; - } - // A CLAIM THAT ONLY EVER TIGHTENS. - if (bcDep.runExclusive && !exclusiveBefore) - m->buildConfig.runExclusive = true; - m->buildConfig.ldflags.insert(m->buildConfig.ldflags.end(), - bcDep.ldflags.begin() + ldN, bcDep.ldflags.end()); - } - - // apply() may have added interface defines to packages' publicUsage - // flags (a dependency's active-feature `defines`). Re-run the usage - // fixpoint so those flags flow into each consumer's privateBuild — the - // first pass (above) ran before features were activated. Idempotent: - // include-dir/flag propagation is unique-append. - computeUsageRequirements(); - - // ─── Capability binding (Stage 3) ────────────────────────────────── - // For each required capability, bind exactly one provider from the - // graph. Deterministic: an explicit [capabilities] pin wins; otherwise - // 0 providers / ≥2 providers are hard errors (never a silent guess); a - // single provider binds with no config. The provider's link/include - // requirements already flow through normal dependency mechanics — this - // pass is the selection-and-validation layer. See the capability-model - // design doc. - // --cap cap=provider[,cap=provider] overrides [capabilities] pins. - for (std::size_t p = 0; p < overrides.capabilities.size();) { - auto c = overrides.capabilities.find_first_of(", ", p); - auto tok = overrides.capabilities.substr( - p, c == std::string::npos ? std::string::npos : c - p); - if (auto eq = tok.find('='); eq != std::string::npos) - m->capabilityPins[tok.substr(0, eq)] = tok.substr(eq + 1); - if (c == std::string::npos) break; - p = c + 1; - } - - // EXCLUSIVE CAPABILITIES, CHECKED BEFORE REQUIREMENTS ARE BOUND. - // - // Ordering is deliberate. A requirement conflict is reported by naming - // the requirement; this one exists whether or not anything requires the - // capability, because the defect is that two implementations of one - // interface are in the same link. Reporting it first means the message - // names the real problem rather than a symptom of it. - for (auto const& [cap, claimers] : capExclusive) { - auto it = capProviders.find(cap); - if (it == capProviders.end()) continue; - std::vector providers; - for (auto const& p : it->second) - if (std::find(providers.begin(), providers.end(), p) == providers.end()) - providers.push_back(p); - if (providers.size() < 2) continue; - - std::string list, claimed; - for (auto const& p : providers) list += (list.empty() ? "" : ", ") + p; - for (auto const& c : claimers) claimed += (claimed.empty() ? "" : ", ") + c; - refusal::record(refusal::Code::ExclusiveCapability); - return std::unexpected(std::format( - "capability '{}' is provided by more than one package, and {} " - "declares it EXCLUSIVE.\n" - " providers: [{}]\n" - " exclusive: [{}]\n" - " Two implementations of one interface define the same " - "symbols, so the link would\n" - " resolve every call to whichever archive it reached " - "first. Keep one of them —\n" - " a `[capabilities]` pin selects a provider for a " - "REQUIREMENT and cannot make two\n" - " definitions of one symbol safe.", - cap, claimers.size() == 1 ? "it" : "they", list, claimed)); - } - - // VERSION FLOORS. A package states what it needs of the machine; a - // package that established a fact about the machine states it. Neither - // string means anything to this code -- `cuda.driver` is data flowing - // through -- which is why a second backend needs no change here and why - // `test_runtime_contract`'s gate stays satisfied. - // - // A FLOOR WITH NO FACT IS SILENT. A machine that never declared what - // it has is not a machine that fails the floor; it is one nobody asked. - // Reporting a refusal there would turn "we do not know" into "no", and - // the whole reason this exists is that a wrong answer is worse than no - // answer. - if (auto err = checkVersionFloors(); err) return std::unexpected(*err); - - // `requires_abi`: a package needs the artefact's ABI switch on. The - // root's `[target..abi]` is the only table that sets it - // (whether the value is written there directly, or reaches it - // through a matching `[target..abi]` predicate resolved by - // merge_conditional_config), so a mismatch is refused naming both - // halves, before anything compiles -- otherwise - // it surfaces as a precompiled-module configuration mismatch that - // names neither. Two members (A1's `exceptions` beside the original - // `threads`), checked and refused the same way, parametrised so a - // wording change to one cannot drift from the other. - auto checkAbiRequirement = [](std::string_view member, bool rootHasIt, - std::vector> const& reqs) - -> std::optional { - if (rootHasIt || reqs.empty()) return std::nullopt; - auto const& [what, requirer] = reqs.front(); - return std::format( - "`{}` requires the artefact's ABI to have {} ({}), and this " - "build does not state it.\n" - " Add to the root manifest, for the targets that need it:\n" - "\n" - " [target.'cfg(os = \"\")'.abi]\n" - " {} = true", requirer, member, what, member); - }; - if (auto err = checkAbiRequirement( - "threads", m->buildConfig.abiThreads, abiRequires)) - return std::unexpected(*err); - if (auto err = checkAbiRequirement( - "exceptions", m->buildConfig.abiExceptions, abiRequiresExceptions)) - return std::unexpected(*err); - - std::set boundCaps; - for (auto& [cap, requirer] : capRequires) { - if (!boundCaps.insert(cap).second) continue; // one diagnosis per cap - auto& pins = m->capabilityPins; - // Dedup candidates, preserve first-seen order. - std::vector cands; - if (auto it = capProviders.find(cap); it != capProviders.end()) - for (auto& p : it->second) - if (std::find(cands.begin(), cands.end(), p) == cands.end()) - cands.push_back(p); - if (auto pit = pins.find(cap); pit != pins.end()) { - const auto& pin = pit->second; - if (std::find(cands.begin(), cands.end(), pin) == cands.end()) { - std::string list; - for (auto& c : cands) list += (list.empty() ? "" : ", ") + c; - return std::unexpected(std::format( - "capability '{}' pinned to provider '{}' (via [capabilities]), " - "but no such provider is in the graph; candidates: [{}]", - cap, pin, list)); - } - continue; // pin satisfied - } - if (cands.empty()) - return std::unexpected(std::format( - "no package provides capability '{}' required by '{}'; add a " - "dependency that declares `provides = [\"{}\"]`", cap, requirer, cap)); - if (cands.size() > 1) { - std::string list; - for (auto& c : cands) list += (list.empty() ? "" : ", ") + c; - return std::unexpected(std::format( - "capability '{}' has multiple providers in the graph: [{}]; select " - "one with [capabilities] {} = \"\" or --cap {}=", - cap, list, cap, cap)); - } - // exactly one → bound implicitly. - } - } - - mcpp::targetside::TargetSide resolvedTargetSide; - // The package that supplies the C++ layer when the graph does, as an index - // into `packages`. Recorded where the provider is found so that the check - // after planning (#641) reads the same package the resolution chose. - std::optional cxxLayerProviderIndex; - // Whether the block below ran at all. `resolvedTargetSide` is default - // constructed, so "no layer resolved" and "resolution has not happened" - // read identically off its members — and the layer-conditional pass must - // tell them apart: the first is an answer a predicate may legitimately - // fail to match, the second means the pass has no business running. - bool targetSideResolved = false; - - // What the packages supplying the target side's layers publish: the header - // directories and interface flags the whole build is compiled against. - // - // ONE SET, TWO READERS, and that is deliberate: it is merged into every - // package's `privateBuild` (so every compile edge sees it) and handed to - // the `std` module's own command line (which is one more translation unit - // of the same build). Before this existed, only the second reader was - // written, and it derived the set itself — which is how the two could - // describe different worlds. - mcpp::modgraph::UsageRequirements targetSideUsage; - - // ── THE TARGET SIDE, RESOLVED ONCE ─────────────────────────────────────── - // - // HERE AND NOT EARLIER, AND THAT IS THE WHOLE POINT. - // - // mcpp serves two ways of supplying a target's platform interface, C - // library and C++ runtime, and the moment each becomes knowable is - // opposite: a prebuilt directory is known before dependency resolution, a - // set of packages only after it. Until now three separate derivations ran - // at the earlier moment and guessed the later answer — the family name in - // this file, `graphTargetSide` in flags, `graphCxxRuntime` in the contract - // — and they disagreed on the case none of them was written for. Measured: - // - // ld64.lld: error: …/lib/x86_64-unknown-linux-gnu/libc++.so: - // unhandled file type - // - // for a pure C program crossed to macOS, whose graph supplies a C library - // and no C++ runtime at all. - // - // Placing the resolution after capability binding and before the root - // build.mcpp means every later consumer reads one value, and a build - // program can be told what was resolved rather than re-deriving it. - { - namespace tsd = mcpp::targetside; - - // Scan the graph once for every layer. A package declares the layer it - // supplies and, optionally, the interface name it answers to: - // - // provides = ["mcpp:kernel-abi=openkal"] - // - // The engine knows the five layer names and nothing about the - // implementations that fill them. `hosted-standard-library` is accepted - // for the C++ layer as the spelling that shipped before this one, so an - // existing package keeps working unchanged. - // - // ONE SUPPLIER PER LAYER, AND TWO IS AN ERROR RATHER THAN A PICK. - // A C library, a kernel interface and a C++ runtime are mutually - // exclusive choices; the same rule already governs `[build] runner` for - // the same reason. Until this scan collected candidates instead of - // keeping the first acceptable one, two suppliers resolved by graph - // traversal order — an order the author neither writes nor can predict — - // and the loser's `[build]` section still reached the command line. - // `index` — WHICH PACKAGE this candidate is, not just its name. - // - // Needed once resolution is done: a layer supplied from the graph - // publishes an include set the WHOLE build must see (see - // `targetSideUsage` below), and reaching that package by name would be - // a second lookup of something already in hand. - struct Candidate { tsd::Provider p; bool direct; std::size_t index = 0; }; - std::map> byLayer; - std::vector requirements; - - const auto& rootDeps = m->dependencies; - auto is_direct = [&](std::string_view name) { - for (auto const& [k, _] : rootDeps) { - if (k == name) return true; - // Selectors are `.` or a bare tail; a tail - // match is what the author sees in their own manifest. - if (k.size() > name.size() && k.ends_with(name) - && k[k.size() - name.size() - 1] == '.') - return true; - } - return false; - }; - - for (std::size_t pkgIndex = 0; pkgIndex < packages.size(); ++pkgIndex) { - auto const& pkg = packages[pkgIndex]; - const auto pkgId = pkg.manifest.package.version.empty() - ? pkg.manifest.package.name - : std::format("{}@{}", pkg.manifest.package.name, - pkg.manifest.package.version); - - // EVERY PACKAGE KIND, NOT ONLY THE ONES WITH AN XPKG - // DESCRIPTOR. `warn_unknown_xpkg_keys` reaches a dependency - // resolved through the index; a path or git dependency carries a - // manifest of its own and reached no warning at all, so a layer - // this engine does not know went by in silence. This loop sees - // every package in the graph. - for (auto const& cap : pkg.manifest.unknownCapabilities) { - if (&pkg == &packages.front()) continue; // root: already refused - // The same text the root's refusal carries, including the list - // of layers that do exist. A warning that says less than the - // error it replaced would be a worse diagnostic wearing a - // milder severity. - auto why = tsd::parse_capability(cap); - mcpp::ui::warning(std::format( - "package '{}': {}\n" - " Ignored, and this build proceeds without that layer. " - "A newer mcpp may resolve it.", - pkgId, - why ? std::format("`{}` names no capability mcpp knows.", cap) - : why.error())); - } - - for (auto const& entry : pkg.manifest.provides) { - std::optional decl; - if (auto parsed = tsd::parse_capability(entry); parsed && *parsed) - decl = **parsed; - else if (entry == "hosted-standard-library") - decl = tsd::CapDecl{ tsd::CapLayer::CxxAbi, {} }; - if (!decl) continue; - if (!tsd::layer_is_suppliable_by_package(decl->layer)) { - return std::unexpected(std::format( - "package '{}' declares `provides = [\"{}\"]`, and the " - "compiler is not a layer a package can supply.\n" - " A compiler is a payload this engine installs and " - "drives; the differences between families are things the " - "engine must know rather than data a package can " - "describe.\n" - " A package may REQUIRE one: `requires = " - "[\"mcpp:compiler=\"]`.", - pkgId, entry)); - } - - tsd::Provider p; - p.name = pkg.manifest.package.name; - p.version = pkg.manifest.package.version; - p.interfaceName = decl->interfaceName; - p.hasStdModule = !pkg.manifest.stdModule.empty(); - p.cAbiDecl = pkg.manifest.cAbiDecl; - - auto& slot = byLayer[static_cast(decl->layer)]; - // A package may carry both spellings during the transition, and - // the array order is the author's, not a preference. Two entries - // from the SAME package are one supplier; the current spelling - // names the interface and the older one cannot, so the entry - // that carries an interface name wins. - auto same = std::find_if(slot.begin(), slot.end(), - [&](const Candidate& c){ return c.p.name == p.name; }); - if (same != slot.end()) { - // `index` MOVES WITH `p` AND NOT ON ITS OWN. The two - // describe one package, and this branch is reached only - // from the same `pkgIndex` today — a package carrying both - // spellings — so they cannot differ yet. Tying them keeps - // it that way if a second package ever reaches here. - if (same->p.interfaceName.empty() && !p.interfaceName.empty()) { - same->p = p; - same->index = pkgIndex; - } - } else { - slot.push_back({ p, is_direct(p.name), pkgIndex }); - } - } - - // `requires` — the symmetric half. An entry naming a layer this - // engine does not know is an error for the same reason a `provides` - // one is: a typo would otherwise disable a check silently. - for (auto const& entry : pkg.manifest.requires_) { - auto parsed = tsd::parse_capability(entry); - // An unknown layer name is reported where the manifest was - // read — as an error for the root and a warning for a - // dependency — so it is skipped rather than refused twice. - if (!parsed || !*parsed) continue; - requirements.push_back({ pkgId, (*parsed)->layer, - (*parsed)->interfaceName }); - } - } - - for (auto const& [layerInt, slot] : byLayer) { - if (slot.size() < 2) continue; - tsd::Conflict c; - c.layer = static_cast(layerInt); - c.first = slot[0].p.id(); - c.firstVia = slot[0].direct ? "" : "a transitive dependency"; - c.second = slot[1].p.id(); - c.secondVia = slot[1].direct ? "" : "a transitive dependency"; - return std::unexpected(tsd::format_conflict(c)); - } - - auto provider_of = [&](tsd::CapLayer want) - -> std::optional { - auto it = byLayer.find(static_cast(want)); - if (it == byLayer.end() || it->second.empty()) return std::nullopt; - return it->second.front().p; - }; - - tsd::Inputs in; - if (tc) { - if (auto tt = mcpp::toolchain::triple::parse(tc->targetTriple)) { - in.llvmTriple = tt->llvm_triple( - min_platform_version(*m, *tt, tc->binaryPath)); - in.targetOs = tt->os; - in.targetEnv = tt->env; - in.freestandingTarget = tt->is_freestanding(); - // NOT `tt->envExplicit`. By this line the triple has been - // canonicalised, and the canonical form of `x86_64-linux` is - // `x86_64-linux-gnu` — re-parsing it reports a segment the - // project never wrote. The request was captured upstream, where - // the distinction still existed. - in.requestedCAbi = requestedCAbi; - if (!requestedCAbi.empty()) { - auto bare = *tt; bare.env.clear(); - in.requestFreeTarget = bare.str(); - } - // The segment names a different axis on each platform, and - // saying WHICH lets the report gloss it instead of merely - // withholding a warning. Only the C-library case can contradict - // what the graph resolved; the other two are simply a different - // question, and the report says so. - in.envAxis = - tt->os == "linux" ? tsd::EnvAxis::CLibrary - : tt->os == "windows" ? tsd::EnvAxis::ObjectAbi - : tt->is_freestanding() ? tsd::EnvAxis::ObjectFormat - : tsd::EnvAxis::Unknown; - - // `sysroot = ""` and "no sysroot key" are different answers and - // must not be collapsed: the first says this project wants no - // prebuilt C library, the second says it did not say. - // On an MSVC-ABI row the key names the MSVC toolset, which the - // toolchain binding consumes (`bind_msvc_sysroot`); it is not a - // C library package for this model to report as one. - if (tt->is_msvc_env()) - ; - else if (auto const* ovr = sysroot_override(*m, *tt); ovr && ovr->empty()) - in.sysrootDeclaredEmpty = true; - else - in.sysrootXpkg = mcpp::toolchain::triple::effective_sysroot( - *tt, sysroot_override(*m, *tt)); - } - in.payloadLibcRef = tc->targetSysrootPkg; - in.payloadCxxInterface = tc->stdlibId; - // The compiler is a layer, and it is the one layer no package can - // supply. It enters here so that a requirement has something to be - // checked against and so the report can show the whole stack. - in.compilerFamily = std::string(tc->compiler_family()); - in.compilerVersion = tc->version; - // WHETHER THE PAYLOAD HAS A COMPILER RUNTIME FOR AN APPLE CROSS - // TARGET, read from the payload's own resource directory. Clang's - // Darwin driver adds `libclang_rt..a` from there when - // the file exists and continues silently when it does not, and - // the official payload builds only the macOS archive (measured, - // 22.1.8: `lib/clang/22/lib/darwin/` holds `libclang_rt.osx.a` and - // no `ios` or `iossim`). The consequence without this line is - // `__isPlatformVersionAtLeast` undefined at link with nothing - // said earlier (mcpp#630). The engine never looks in Xcode for the - // archive: a compiler runtime the payload lacks is a graph - // package, as it is on the bare rows. - if (!tc->appleSdkRoot.empty()) { - if (auto tt = mcpp::toolchain::triple::parse(tc->targetTriple); - tt && tt->is_ios()) { - const std::string archive = std::format( - "libclang_rt.{}.a", tt->is_ios_simulator() ? "iossim" : "ios"); - const auto payloadRoot = - tc->binaryPath.parent_path().parent_path(); - bool found = false; - std::error_code ec; - for (auto const& ver : std::filesystem::directory_iterator( - payloadRoot / "lib" / "clang", ec)) { - if (std::filesystem::exists( - ver.path() / "lib" / "darwin" / archive, ec)) { - found = true; - break; - } - } - in.payloadCompilerRuntimeAbsent = !found; - } - } - } - in.compilerRuntime = provider_of(tsd::CapLayer::CompilerRuntime); - in.kernelAbi = provider_of(tsd::CapLayer::KernelAbi); - in.cAbi = provider_of(tsd::CapLayer::CAbi); - in.cxxAbi = provider_of(tsd::CapLayer::CxxAbi); - - // A ROW THAT LINKS THROUGH lld DIRECTLY, ON A PAYLOAD WITH NO lld. - // - // `x86_64-none-elf` is the only row carrying an `lldEmulation`, and its - // column comment in mcpp.freestanding.target says why the driver is - // bypassed for it: the driver "would hand the link to a host `g++` that - // cannot take our linker's path". - // - // MEASURED TWICE ON windows-2022, AND THE SECOND TIME WAS MY OWN - // FALLBACK. First, an empty `resolve_lld` left linker vocabulary on a - // driver line: - // - // clang++: error: unknown argument: '-m' - // - // Then, falling back to the driver line reproduced exactly what the - // bypass exists to prevent: - // - // clang++: error: linker (via gcc) command failed - // collect2.exe: error: ld returned 1 exit status - // - // There is no third shape. The row needs lld by name; when the - // payload has none, the answer is a refusal at the decision, not a - // different link. - if (auto fsT = tc.has_value() - ? mcpp::toolchain::triple::parse(tc->targetTriple) - : std::nullopt; - fsT && fsT->is_freestanding()) { - auto fsSpec = mcpp::freestanding::resolve(*fsT); - if (fsSpec && !fsSpec->lldEmulation.empty() - && mcpp::freestanding::resolve_lld(tc->binaryPath).empty()) { - refusal::record(refusal::Code::LldRequiredAbsent); - return std::unexpected(std::format( - "target '{}' links through lld directly, and this toolchain " - "payload ships none.\n" - " The row carries an lld emulation ('{}'), which means " - "the compiler driver is\n" - " bypassed — for this target it would hand the link to " - "a host linker that\n" - " cannot take a freestanding ELF.\n" - " install a toolchain whose payload contains ld.lld, " - "or build this target\n" - " from a host that has one.", - fsT->str(), fsSpec->lldEmulation)); - } - } - - resolvedTargetSide = tsd::resolve(in); - targetSideResolved = true; - - // `__OPENKAL__` — design 2026-09-18 §2.1, §3.4. Read from the - // resolved LAYER's interface name, never from a package name, so a - // second implementation of `mcpp:kernel-abi=openkal` needs no engine - // change. Applies to every target-side unit unconditionally — even - // one that declares `c-environment = "platform"`, because the - // exception in §3.4 is about the C ENVIRONMENT a package sees, not - // about whether `kal_*` may be called from it. - if (tc) tc->kernelAbiIsOpenkal = - resolvedTargetSide.kernelAbi.interfaceName == "openkal"; - - // [c-abi] REALISATION — design §3.2-§3.4. Everything below is - // skipped, and every command line unchanged, for the graph this - // engine has always built. - // - // THE TEST IS `declared`, NOT THE OPTIONAL. `TargetSide::cAbiDecl` is - // also set by a `[c-abi-absent]` table on a provider that wrote no - // `[c-abi]` block, and those absences are diagnostic data with - // nothing in them to realise — `cenv::realise` requires `declared` - // (cenv.cppm) and would be reading fields nobody wrote. - if (tc && resolvedTargetSide.cAbiDecl - && resolvedTargetSide.cAbiDecl->declared) { - // `cenv::realise` FIRST, THE COMPILER-FAMILY GATE SECOND — not - // the other way around (coordinator report, openkal-musl 0.15.0 - // regression: GCC on Linux refused for a declaration - // `presents = "posix", data-model = "arch-default", wchar = 32, - // builtins = "iso"` that Linux/x86_64's own default ALREADY - // satisfies, needing no substitution at all — a gratuitous - // refusal that lost the package for every GCC user on Linux). - // `cenv::realise` is a pure function of the declaration and the - // TARGET, not of the compiler (`mcpp.toolchain.cenv`'s own - // module header) — computing it before asking anything about the - // compiler is what lets an EMPTY realisation answer "does this - // compiler need to be Clang" correctly: no. The Clang-specific - // mechanisms (`--target=` substitution, `-f[no-]short-wchar`, - // the `builtins = "iso"` flags) are only needed when realisation - // actually produces tokens; when it produces none, the target's - // own default already IS the declaration, and any compiler that - // can run the verification probe below (`-E -dM`, not a - // Clang-specific flag) can be trusted to have gotten there — - // which is exactly what that probe then confirms rather than - // assumes. The Windows/GCC case is UNCHANGED by this: there the - // realisation is non-empty (the Cygwin-flavoured substitution), - // and MinGW's `long` is 32-bit regardless of flags (openkal-musl - // measured), so the gate below still refuses it. - auto tt = mcpp::toolchain::triple::parse(tc->targetTriple); - auto realised = mcpp::toolchain::cenv::realise( - *resolvedTargetSide.cAbiDecl, tt ? tt->os : std::string{}, - tt ? tt->arch : std::string{}, tt && tt->is_freestanding()); - if (!realised) { - refusal::record(refusal::Code::CEnvUnrealisable); - return std::unexpected(realised.error()); - } - if ((!realised->tokens.empty() || !realised->builtinsTokens.empty()) - && !mcpp::toolchain::is_clang(*tc)) { - refusal::record(refusal::Code::CEnvUnrealisable); - return std::unexpected(std::format( - "the C library ('{}', {}) declares [c-abi] whose " - "realisation for this target requires Clang-specific " - "substitution, and this build's compiler ('{}') cannot " - "carry it out.\n" - " [c-abi] is realised, for targets that need " - "anything at all, with Clang-specific mechanisms — a " - "`--target=` substitution and `-f[no-]short-wchar` — so " - "a Clang toolchain is required for this target while " - "this declaration is in the graph.\n" - " Select one: [toolchain] default = \"llvm@\", " - "or [target.] toolchain = \"llvm@\".", - resolvedTargetSide.cAbi.interfaceName, - resolvedTargetSide.cAbi.impl, tc->compiler_family())); - } - tc->cEnvTokens = realised->tokens; - tc->cEnvBuiltinsTokens = realised->builtinsTokens; - tc->cEnvExpectWcharBits = realised->expectWcharBits; - tc->cEnvExpectLongBytes = realised->expectLongBytes; - tc->cEnvExpectDefined = realised->expectDefined; - tc->cEnvExpectUndefined = realised->expectUndefined; - - // VERIFICATION, NOT TRUST (design §3.2). The probe's argv is the - // IDENTITY-AFFECTING SUBSET of the real command line — the - // `--target=` substitution and the `-U`/`-f[no-]short-wchar` - // tokens `cenv::realise` just produced — because those are the - // only tokens that change what a compiler predefines; include - // paths and library search flags do not, and leaving them out - // is what makes this probe cheap AND cacheable across every - // package that shares this build's target side. - // - if (tc->cEnvExpectWcharBits != 0 || tc->cEnvExpectLongBytes != 0 - || !tc->cEnvExpectDefined.empty() - || !tc->cEnvExpectUndefined.empty()) { - // THE FREESTANDING TARGET HAD NEVER REACHED THE PROBE, AND - // THAT IS WHY 2026.9.18.3 MEASURED THE HOST (mcpp#674 - // review, 2026-09-20). `Toolchain::crossTargetFlag` is set - // for a HOSTED target only — the assignment above states the - // reason: a freestanding target carries its own `--target` - // together with the ISA flags that must accompany it, and a - // second one there would be the same decision in two places. - // That other place is `mcpp.freestanding.linkline`, which the - // real compile goes through and this probe did not. - // `cenv::realise` adds no `--target` for a freestanding - // target either, so the probe ran with NO target selection at - // all and clang answered for the machine it was running on. - // - // Measured: the probe's own argv shape on a Linux host - // (`-D__unix__ -fno-short-wchar -ffreestanding -x c++ -E -dM - // -`) reports `__linux__`; with `--target=riscv64-none-elf` - // it reports `__riscv`, no `__linux__`, and - // `__SIZEOF_WCHAR_T__` 4. A Windows host answered `_WIN32` - // and 2 for the same reason, and 2026.9.18.3 read that as the - // `--target=` substitution failing to strip host predefines. - // Clang's predefines follow the target; there was no - // substitution to fail. - // - // `hostStripMacros` is therefore GONE, and its removal is the - // point rather than a tidy-up: `-U_WIN32 -U_WIN64 - // -U__MINGW32__ -U__MINGW64__` deleted the one piece of - // evidence that said the probe was measuring the wrong - // machine. A future host leak, if one exists, must reach the - // mismatch report rather than be undefined before it can. - // - // THE ASSEMBLY REFUSES THE OMISSION rather than this site - // remembering not to make it — `cenv_probe::assemble_argv` - // holds the invariant, and the unit tests reach it without a - // cross toolchain. - std::vector freestandingFlags; - if (tt && tt->is_freestanding()) { - auto spec = mcpp::freestanding::resolve(*tt); - if (spec) { - freestandingFlags.push_back( - "--target=" + std::string(spec->triple)); - for (auto const& f : mcpp::freestanding::compile_flags(*spec)) - freestandingFlags.push_back(f); - } - } - auto assembled = mcpp::toolchain::cenv_probe::assemble_argv( - tc->crossTargetFlag, freestandingFlags, - tc->cEnvTokens, tc->cEnvBuiltinsTokens, - tt && tt->is_freestanding(), tc->targetTriple); - if (!assembled) { - refusal::record(refusal::Code::CEnvUnrealisable); - return std::unexpected(assembled.error()); - } - const auto& probeArgv = *assembled; - auto probe = mcpp::toolchain::cenv_probe::verify( - tc->binaryPath, probeArgv, - tc->cEnvExpectWcharBits, tc->cEnvExpectLongBytes, - tc->cEnvExpectDefined, tc->cEnvExpectUndefined, - mcpp::home::cache_root()); - if (!probe) { - refusal::record(refusal::Code::CEnvUnrealisable); - return std::unexpected(probe.error()); - } - if (!probe->mismatches.empty()) { - refusal::record(refusal::Code::CEnvVerificationMismatch); - std::string lines; - for (auto& mm : probe->mismatches) - lines += std::format( - "\n {:<24} declared {:<10} measured {}", - mm.fact, mm.declared, mm.measured); - return std::unexpected(std::format( - "the C library's [c-abi] declaration does not match " - "what the compiler actually produced for '{}'.{}\n" - " A declaration is checked, never trusted " - "(design 2026-09-18 §3.2) — the mismatch above was " - "measured from the compiler's own predefined macros, " - "compiled with the exact tokens this build derived " - "from the declaration.", - tc->targetTriple, lines)); - } - } - } - - // REPORTED, NOT REFUSED (mcpp#662, D4). `mcpp.toolchain.hostflags` - // closes the compiler's own C-library search with `-nostdlibinc` - // when a package supplies the target's C library (M1) — but only on - // a Clang-family driver; GCC has no one-token equivalent - // (`hostflags.cppm`'s own note on the shape it would need). Silently - // building unisolated was ruled out once already: the resolver - // refusing the combination outright was ALSO tried and reverted — - // it fired before `format_report` below and broke three existing - // e2e fixtures (268, 282, 303) that use a synthetic C-library - // provider on this host's native, GCC-default target to assert - // something else entirely, none of them about isolation. A - // degradation is the third option: it changes no command line - // (this branch decides nothing `hostflags.cppm` does not already - // decide on its own), and it does not stop a build the previous - // release would have allowed — it names, once, the gap the previous - // release left silent. - if (tc && resolvedTargetSide.cAbi.fromGraph() - && !mcpp::toolchain::is_clang(*tc)) { - mcpp::diag::degraded("target/c-abi-isolation", std::format( - "the target's C library ('{}', {}) comes from the " - "dependency graph, and the resolved compiler ('{}') has no " - "way to stop its own driver from also searching the host's " - "C library headers", - resolvedTargetSide.cAbi.interfaceName, - resolvedTargetSide.cAbi.impl, tc->compiler_family()), - "a host header can still satisfy an #include the graph's " - "own headers do not, silently — a Clang-family toolchain " - "closes that search entirely (docs/22 'Adaptation To The " - "Resolved Target Side')", - "add [toolchain] default = \"llvm@\", or for one " - "target only [target.] toolchain = \"llvm@\""); - } - - // REPORTED ONCE, NOT REFUSED. A program that never reaches an - // availability check links and runs without the archive; refusing it - // would trade a diagnosed hazard for a regression. The degradation - // names the platform, the file and the package that supplies it. - if (resolvedTargetSide.compilerRuntime.absent() && tc) { - auto tt = mcpp::toolchain::triple::parse(tc->targetTriple); - mcpp::diag::degraded("target/compiler-runtime", std::format( - "the toolchain payload carries no compiler runtime for {} " - "(no libclang_rt.{}.a under its lib/clang/*/lib/darwin), and no " - "package in the graph provides mcpp:compiler-runtime", - tc->targetTriple, - tt && tt->is_ios_simulator() ? "iossim" : "ios"), - "a program that reaches an availability check " - "(`__builtin_available`, or a system header that uses it) fails " - "at link with `__isPlatformVersionAtLeast` undefined", - "declare `llvm.compiler-rt-builtins` under the target's " - "[target.'cfg(os = \"ios\")'.dependencies]"); - } - - // RECORDED ON THE TOOLCHAIN THE MOMENT IT IS KNOWN, because three - // producers of a compile line need it and only one of them can see - // `resolvedTargetSide`. - // - // `flags.cppm` reads `plan.targetSide` directly; the std module build - // (`mcpp.toolchain.stdmod`) and the build.mcpp host helper cannot — - // they are in the toolchain layer and take a `Toolchain`. Giving them a - // second way to derive the answer is exactly the shape this release - // exists to remove, so the answer travels on the value they already - // share. - // - // HERE AND NOT LATER: `ensure_built` runs at :7368 and every compile - // line is assembled after it. A std BMI built against a different C - // library than its importers is what e2e 181 catches. - if (tc) tc->cAbiPrebuilt = resolvedTargetSide.cAbi.prebuilt(); - - // ── The target side's include set is a property of the BUILD ───────── - // - // IT WAS ALREADY COMPUTED, AND IT REACHED EXACTLY ONE TRANSLATION - // UNIT. - // - // A package that supplies a target-side layer publishes the headers the - // whole target is built against — libc++'s, the C library's, the - // architecture's. Those travel today as an ordinary `publicUsage`, - // which propagates ALONG DEPENDENCY EDGES. So a workspace member that - // depends on the provider receives them and a SIBLING DEPENDENCY - // PACKAGE does not: `nlohmann.json` is not downstream of - // `openkal-llvm-runtime`, it is beside it. - // - // The result is two flavours of BMI in one build — `std` compiled over - // the target's libc++ (correct: the block at :7232 hands it exactly - // this set) and the dependency packages compiled over the payload's. - // Any unit importing both fails at the first template instantiation - // that touches a declaration present in both header sets: - // - // istream:1245: error: reference to 'space' is ambiguous - // note: candidate … xim-x-llvm/…/__locale:321 - // note: candidate … openkal-llvm-runtime/…/__locale:302 - // - // mcpp#514. Reproduced in twenty lines with no openkal at all: a path - // package declaring `provides = ["mcpp:c++-abi=libc++"]` and one - // `include_dirs` entry reaches the root and its own units, and reaches - // no sibling dependency package. - // - // THE FIX IS THE ONE `mcpp.targetside` OPENS WITH: resolve once, - // after the graph is known, and have every consumer read that one - // value. A `publicUsage` describes what a library asks of ITS USERS; a - // target side is beneath everything. Modelling the second as the first - // is what made it edge-scoped. - // - // ONLY LAYERS THE GRAPH SUPPLIES. `Layer::fromGraph()` is the whole - // condition. A payload-supplied layer already reaches every unit - // through `mcpp.toolchain.hostflags`, and emitting it twice would put - // the ordering of one decision in two places. - { - std::set layerProviderIndices; - auto note_layer = [&](tsd::CapLayer which, const tsd::Layer& resolved) { - if (!resolved.fromGraph()) return; - auto it = byLayer.find(static_cast(which)); - if (it != byLayer.end() && !it->second.empty()) { - layerProviderIndices.insert(it->second.front().index); - if (which == tsd::CapLayer::CxxAbi) - cxxLayerProviderIndex = it->second.front().index; - } - }; - note_layer(tsd::CapLayer::CompilerRuntime, resolvedTargetSide.compilerRuntime); - note_layer(tsd::CapLayer::KernelAbi, resolvedTargetSide.kernelAbi); - note_layer(tsd::CapLayer::CAbi, resolvedTargetSide.cAbi); - note_layer(tsd::CapLayer::CxxAbi, resolvedTargetSide.cxx); - - for (auto idx : layerProviderIndices) { - if (idx >= packages.size()) continue; - auto const& provider = packages[idx]; - appendUniquePaths(targetSideUsage.includeDirs, - provider.publicUsage.includeDirs); - appendUniquePaths(targetSideUsage.includeDirsAfter, - provider.publicUsage.includeDirsAfter); - appendUniqueFlags(targetSideUsage.cflags, - provider.publicUsage.cflags); - appendUniqueFlags(targetSideUsage.cxxflags, - provider.publicUsage.cxxflags); - } - - // Into `privateBuild` and NOT into `publicUsage`. - // - // It is visible to the whole graph already, so it needs no further - // propagation; and writing it into `publicUsage` would fold the - // target side into the usage requirements of any library this - // build packages — a promise about a different machine. - // - // APPENDED, so a package's own directories keep coming first. - // The target side only has to precede the DRIVER's own defaults, - // and those are always searched last. - if (!targetSideUsage.includeDirs.empty() - || !targetSideUsage.includeDirsAfter.empty() - || !targetSideUsage.cflags.empty() - || !targetSideUsage.cxxflags.empty()) { - for (auto& p : packages) { - appendUniquePaths(p.privateBuild.includeDirs, - targetSideUsage.includeDirs); - appendUniquePaths(p.privateBuild.includeDirsAfter, - targetSideUsage.includeDirsAfter); - appendUniqueFlags(p.privateBuild.cflags, - targetSideUsage.cflags); - appendUniqueFlags(p.privateBuild.cxxflags, - targetSideUsage.cxxflags); - } - } - } - - // `__OPENKAL__` AND THE REALISED [c-abi] ENVIRONMENT — design - // 2026-09-18 §2.1, §3.2-§3.4. Broadcast into every package's OWN - // `privateBuild`, the same channel `targetSideUsage` just used above: - // it reaches that package's C/C++ compiles AND its dependency scan - // (`mcpp.modgraph.scanner` reads `privateBuild.cflags`/`cxxflags`), - // and it is APPENDED, so it follows every flag the package wrote for - // itself and the driver's own defaults still come last. - // - // `c-environment = "platform"` (§3.4) opts a package OUT of the - // [c-abi] REALISATION ONLY — `__OPENKAL__` still reaches it, because - // the exception is about the C environment a package's headers see, - // not about whether its own code may call `kal_*`. The base command - // line these tokens are appended to is untouched either way, which is - // what keeps a package that declares neither field byte-identical to - // a build before this feature existed. - // - // ALSO INTO `privateBuild.asmflags` (openkal-musl spike, post-review): - // the environment is a property of the TARGET, so it has to reach - // every translation unit built for that target, assembly (.S/.s) - // included — assembly is preprocessed with the same macros, and real - // code selects on them (openkal-musl's own `okm_setjmp.S`; upstream - // libunwind's `assembly.h`). `cflags`/`cxxflags` do not reach a .S - // file wholesale (`mcpp.build.compile_commands::unit_asm_flags` keeps - // only their -D/-U/-I words, on purpose — a -std= or -O token meant - // for the C compiler has no meaning for GAS), so the object-format - // and wchar-width tokens have to be named again here, into the - // channel `unit_asm_flags` passes through UNFILTERED. `__OPENKAL__` - // needs no second copy: it is a -D, and the -D/-U/-I filter already - // carries it from `cflags` into every assembly unit. - // - // Every token in `cEnvTokens`/`cEnvBuiltinsTokens` was checked against - // clang's GAS (`-x assembler-with-cpp`) front end before this was - // written (`--target=`, `-f[no-]short-wchar`, - // `-fno-builtin-memset_pattern16`) and none is rejected — so nothing - // here is filtered a second time; if a future token IS GAS-hostile, - // `cenv::realise` is where to split it, not this broadcast. - // THE MACROS THIS ENGINE DEFINES --- the contract, the rules for - // reading them and the reason each one exists rather than a manifest - // key, are `mcpp.toolchain.predefines`. That module is the - // specification and the implementation of the same thing, so this - // site decides only WHERE the tokens go, never WHICH they are. - if (tc) { - std::string targetOs; - if (auto ttOs = mcpp::toolchain::triple::parse(tc->targetTriple)) - targetOs = ttOs->os; - const auto engineDefines = - mcpp::toolchain::predefines::define_tokens( - targetOs, tc->kernelAbiIsOpenkal); - // Into `cflags`/`cxxflags` only: these are all `-D`, and the - // channel that builds an assembly unit's flags keeps the -D/-U/-I - // words of those two (`compile_commands::unit_asm_flags`), so a - // second copy here would put each one on a `.S` line twice. - for (auto& p : packages) { - appendUniqueFlags(p.privateBuild.cflags, engineDefines); - appendUniqueFlags(p.privateBuild.cxxflags, engineDefines); - } - } - - if (tc && (tc->kernelAbiIsOpenkal || !tc->cEnvTokens.empty() - || !tc->cEnvBuiltinsTokens.empty())) { - for (auto& p : packages) { - // `__OPENKAL__` is emitted above, with the rest of the - // engine's own defines; it is NOT subject to the - // `c-environment = "platform"` exception below, because that - // exception is about which C environment a package's headers - // see, not about whether its code may call `kal_*`. - if (p.manifest.cEnvironment == "platform") continue; - appendUniqueFlags(p.privateBuild.cflags, tc->cEnvTokens); - appendUniqueFlags(p.privateBuild.cxxflags, tc->cEnvTokens); - appendUniqueFlags(p.privateBuild.asmflags, tc->cEnvTokens); - appendUniqueFlags(p.privateBuild.cflags, tc->cEnvBuiltinsTokens); - appendUniqueFlags(p.privateBuild.cxxflags, tc->cEnvBuiltinsTokens); - appendUniqueFlags(p.privateBuild.asmflags, tc->cEnvBuiltinsTokens); - } - } - - // INTERFACE ENUMERATION — THE RESOLUTION-TIME HALF OF THE CAPABILITY - // MODEL (design 2026-09-20 §5.5; openkal SPEC 0.14 §3.3, §6.2). - // - // A package states which interfaces of the `kernel-abi` layer it uses; - // the package that supplies the layer states which it provides. This - // engine compares the two sets and knows no member of either: the - // names belong to the specification that owns the layer, and one may - // be added to it without a release of this engine. - // - // THE QUESTION IS ANSWERED HERE BECAUSE HERE IS WHERE THE ANSWER FIRST - // EXISTS. §6.2 tabulates three times and states that each is the - // earliest at which its information exists; "may this program be built - // against this implementation" is the first of them. Source asking the - // same question with `#ifdef` asks it during preprocessing, earlier - // than any answer, which is why each macro-shaped answer to it has had - // to be replaced by the next one. - // - // SILENT WHEN NOTHING DECLARES ANYTHING. A graph in which no package - // writes `[kernel-abi]` reaches neither loop below, so this addition - // changes no command line and no diagnostic for every project built - // before it. - { - // THE LIST COMES FROM THE PACKAGE THAT RESOLVED AS THE LAYER, NOT - // FROM THE FIRST ONE IN THE GRAPH THAT STATED ONE. A graph may - // carry more than one candidate for a layer — a workspace member - // beside a dependency, a second implementation reached through a - // feature that did not activate — and only one of them is the - // provider this build resolved. Reading whichever came first in - // `packages` would compare a consumer's requirements against an - // implementation the build is not using, which is a wrong answer - // rather than a missing one. - std::vector providedInterfaces; - std::string providerId; - for (auto& pkg : packages) { - if (pkg.manifest.kernelAbiProvidesInterfaces.empty()) continue; - // `impl` is `name@version`; the name is what precedes the - // separator. A substring test would match `openkal` against - // `openkal-linux@0.15.0` and read one implementation's list - // as another's. - if (!resolvedTargetSide.kernelAbi.impl.empty()) { - auto const& impl = resolvedTargetSide.kernelAbi.impl; - const auto at = impl.find('@'); - const auto implName = at == std::string::npos - ? impl : impl.substr(0, at); - if (implName != pkg.manifest.package.name) continue; - } - providedInterfaces = pkg.manifest.kernelAbiProvidesInterfaces; - providerId = pkg.manifest.package.name; - break; - } - // A REQUIREMENT NOBODY ANSWERED IS SAID SO, because otherwise - // "yes" and "never asked" are the same reading. - // - // Three situations exist and two of them build: the provider - // states a list and it contains the requirement (build); it - // states a list and does not (refuse, below); it states nothing - // at all (build, and until this note, in silence). The third is - // deliberate --- `provides-interfaces` is younger than the - // implementations that exist, and a graph that has not adopted it - // must keep building --- but a consumer reading a green build - // cannot tell it from the first. One line closes that, and it - // costs nothing to a graph where the provider does declare. - std::size_t uncheckedRequirements = 0; - for (auto& pkg : packages) { - const auto& need = pkg.manifest.kernelAbiRequiresInterfaces; - if (need.empty()) continue; - if (providerId.empty()) { - uncheckedRequirements += need.size(); - continue; - } - auto missing = mcpp::targetside::interfaces_not_provided( - need, providedInterfaces); - if (missing.empty()) continue; - refusal::record(refusal::Code::InterfaceNotProvided); - std::string names; - for (auto const& mI : missing) { - names += "\n "; - names += mI; - } - // THE CODE IS PRINTED, THE WAY E0006 IS, BECAUSE SOMETHING - // READS THIS. A refusal that only a person can recognise - // forces every machine consumer to match prose --- and prose - // that a package's own compile error could coincidentally - // contain. The mcpp-index compatibility measurement - // distinguishes "this graph does not supply what the member - // asked for" from "the member did not build" on exactly this - // token, and that distinction decides whether a member counts - // against a compatibility figure. - // THE LABEL SAYS WHICH IMPLEMENTATION WAS RESOLVED, NOT - // "provided by". The missing names are listed immediately - // above it, and `provided by fakekernel` under `openkal.space` - // reads as the statement that fakekernel provides it --- the - // exact opposite of what this refusal is about. Read once, - // rendered, which is the only way that kind of defect is - // visible: every assertion on this message matches an - // identifier inside it, and an identifier is in the right - // place under either wording. - return std::unexpected(std::format( - "'{}' requires interfaces the resolved implementation does " - "not provide. [interface-not-provided]{}\n" - " the resolved implementation is {} ({} interface{}), " - "and none of those listed above is among them.\n" - " This is refused before anything is compiled " - "because dependency resolution is the earliest time the " - "question can be answered. Select an implementation that " - "provides them, or remove them from [kernel-abi] " - "requires-interfaces in '{}'.", - pkg.manifest.package.name, names, providerId, - providedInterfaces.size(), - providedInterfaces.size() == 1 ? "" : "s", - pkg.manifest.package.name)); - } - - if (uncheckedRequirements > 0) { - // THE IMPLEMENTATION IS NAMED FROM THE RESOLVED LAYER, not - // from whichever package happened to be first: the note has - // to say WHOSE silence this is, or a reader cannot act on it. - const auto& impl = resolvedTargetSide.kernelAbi.impl; - mcpp::ui::info("note", std::format( - "kernel-abi interfaces: {} states none, {} requirement{} " - "unchecked", - impl.empty() ? std::string("the resolved implementation") - : impl, - uncheckedRequirements, - uncheckedRequirements == 1 ? "" : "s")); - } - } - - if (auto why = tsd::check_layering(resolvedTargetSide)) { - refusal::record(refusal::Code::LayerOrdering); - return std::unexpected(*why); - } - // REQUIREMENTS ARE CHECKED BEFORE ANYTHING IS COMPILED, WHICH IS THE - // WHOLE POINT OF DECLARING THEM. The combination this rejects — a C++ - // runtime configured for one compiler family being handed to another — - // otherwise fails inside that runtime's own headers, in a message that - // names a file the reader has never opened and no decision mcpp made. - // The origin travels with the check: reaching a compiler-layer refusal - // now means the project stated its own compiler, and the remedy has to - // name that statement rather than a global default it is not using. - if (auto why = tsd::check_requirements( - resolvedTargetSide, requirements, - tc_origin_is_user_explicit(tcOrigin) ? tc_origin_name(tcOrigin) - : std::string_view{})) { - refusal::record(refusal::Code::LayerRequirement); - return std::unexpected(*why); - } - // A WARNING, NOT A REFUSAL. The graph decides the C library either - // way, so the segment is ignored rather than violated and the artifact - // is the same with or without it. Refusing was tried and broke every - // project spelling the host target `x86_64-linux-gnu` — which is what - // `mcpp toolchain list` prints, and therefore what people write. - if (auto why = tsd::check_request(resolvedTargetSide)) - mcpp::diag::warning("target", *why); - - // The refusal held since toolchain resolution, released now that the - // other half of its question has an answer. A payload on this machine - // does not produce this target; if the graph does not supply the - // target's system either, then nothing does and the diagnosis stands. - if (!unservedTargetDiagnosis.empty() - && !resolvedTargetSide.system_from_graph()) { - refusal::record(refusal::Code::HostCannotServe); - return std::unexpected(unservedTargetDiagnosis); - } - - // THE TARGET AND THE COMPILER ARE NOT BOUND TOGETHER, AND THE - // TARGET ROW'S CONVENTION IS A FALLBACK RATHER THAN A RULE. - // - // A row pins a toolchain because the payload that toolchain belongs to - // is what supplies THAT TARGET'S C library. A project whose target side - // comes from its dependency graph does not use that payload, so the - // substitution was unnecessary — and this is the first line at which - // that is knowable, because it is the first line at which the graph - // exists. - // - // The decision itself is NOT revised here. `tc` has been read and - // mutated at 39 sites between its resolution and this point — the - // effective triple, the cross flag, the target sysroot, the MSVC - // runtime contract — and re-resolving it here would redo all of them - // out of order. Deferring the CHOICE the way the target side itself was - // deferred is the structural fix and is its own change; until then the - // user is told what happened and how to state the preference once. - // - // AND NOT FOR A ROW WHOSE PIN IS A CAPABILITY, WHERE BOTH HALVES OF - // THIS SENTENCE ARE FALSE. - // - // The warning says the default "would have served" the target and then - // tells the reader to declare it. On a capability row neither holds: - // nothing but the pinned payload can emit the target at all, and the - // declaration it suggests is REFUSED by the capability gate a few - // hundred lines above -- so following the advice replaces a warning - // with an error. - // - // Measured on `openkal-linux` built for `x86_64-linux-android`, whose - // target side does come from the graph: - // - // warning: ... so gcc@16.1.0 would have served x86_64-linux-android. - // State the preference: [target.x86_64-linux-android] - // toolchain = "gcc@16.1.0" - // $ (declaring exactly that) - // error: target 'x86_64-linux-android' cannot be emitted by - // 'gcc@16.1.0'. - // - // The first claim is false on its own terms too: this gcc payload - // cannot emit an Android object whatever the graph supplies. `graph` - // answers "who supplies the SYSTEM", and a capability pin answers "who - // can emit the FORMAT AND THE SYSTEM" -- two questions, and only the - // second one decides whether a substitution was avoidable. - const bool pinIsCapability = [&] { - auto tt = mcpp::toolchain::triple::parse(resolvedTargetCanonical); - return tt && tt->pin_is_capability(); - }(); - if (!pinReplacedDefault.empty() - && resolvedTargetSide.system_from_graph() - && !pinIsCapability) { - mcpp::diag::warning("toolchain", std::format( - "this project's target side comes from its dependency graph, so " - "{} would have served {}.\n" - " mcpp used the target row's convention because the graph " - "is not known when the\n" - " toolchain is chosen. State the preference for this " - "target to skip the substitution:\n" - " [target.{}]\n" - " toolchain = \"{}\"", - pinReplacedDefault, resolvedTargetCanonical, - resolvedTargetCanonical, pinReplacedDefault)); - } - - // A request that cannot be honoured is said so rather than dropped. - // - // Measured 2026-08-23: `linkage = "dynamic"` on a project whose system - // comes from the graph produced a statically linked artifact and - // printed nothing. The outcome is correct — the graph supplies its - // libraries as objects compiled into this build, and there is no shared - // object for a loader to resolve at run time — but a directive that has - // no effect and no diagnostic is indistinguishable from one that was - // never read. - // - // THE C LIBRARY IS THE LAYER THIS DEPENDS ON, NOT "THE SYSTEM". - // `system_from_graph()` spans two layers, and the arrangement that - // separates them is real: a backend running ON a platform takes its - // kernel interface from the graph while the C library stays the - // payload's. Measured 2026-08-25 on exactly that project — the - // predicate was true, this warning printed "The artifact is static", - // and the artifact had three DT_NEEDED entries including `libc.so.6`. - // The reason the message gives is a property of the C library alone: - // a payload libc has a shared object, so `dynamic` is honoured and - // there is nothing to warn about. Same shape as the three defects this - // release fixes — see `TargetSide::system_from_graph`'s own note. - if (resolvedTargetSide.cAbi.fromGraph() - && m->buildConfig.linkage == "dynamic") - mcpp::ui::warning( - "`linkage = \"dynamic\"` has no effect when the " - "target's system comes from the dependency graph: those " - "packages are compiled into this build as objects, and there " - "is no shared object to link against. The artifact is static."); - - // Reported, and reported HERE rather than recorded in a manifest field. - // - // A line a project writes states an intention, and it goes stale the - // moment the packages beneath it change — a program that names its C - // library by name is naming a transitive dependency it did not choose. - // This states the outcome, so it cannot be stale, and it answers a - // question that until now had no answer at all: reading every manifest - // in the graph did not tell anyone what would end up on the link line, - // because three places derived it separately and could disagree. - // - // AND IT PRINTS ONLY WHAT IS NOT ORDINARY. A zero-configuration build - // resolves all five layers from one compiler payload, and five lines - // reading `(payload)` answer a question nobody asked. `MCPP_VERBOSE` - // prints them all; a diagnostic always does. - // THE REQUESTED TARGET AND THE RESOLVED ONE MUST NAME THE SAME - // OPERATING SYSTEM, AND UNTIL THIS LINE NOTHING CHECKED. - // - // Measured 2026-08-25 in CI, on a machine that had installed only a - // native gcc — the report itself said it, and the build carried on: - // - // Target x86_64-windows-gnu → x86_64-unknown-linux-gnu - // … - // src/stream.cpp:68:9: error: 'GetFileType' was not declared - // - // Two operating systems on one line. The cross payload was absent, so - // resolution fell back to the host compiler, and Windows sources were - // compiled for Linux; the failure surfaced a hundred lines later as an - // undeclared identifier, naming a symbol rather than the decision. - // openkal-uefi hit the same fallback at the linker - // (`ld: unrecognized option '--subsystem'`). - // - // THE REPORT ALREADY HELD THE EVIDENCE — this asserts on it rather - // than deriving the question again. A refusal here costs one line; the - // alternative is a message about a Win32 function, in a file the reader - // did not write, for a decision made in this one. - // - // Scope is deliberately the OS and not the whole triple: an ABI or - // vendor difference between `x86_64-windows-gnu` and - // `x86_64-w64-windows-gnu` is the normalisation this very line reports, - // and refusing on it would reject every correct cross build. - // THE NAME THE REPORT PRINTS, DERIVED ONCE AND USED BY BOTH. - // - // The first version of this guard read `resolvedTargetCanonical` - // directly while the report below chose among three sources. They - // agreed on the machine it was written on and disagreed in CI, where - // the canonical string was empty and the report still named the target - // from `targetDisplayName` — so the report showed the mismatch and the - // guard, asking a different variable, saw nothing to refuse. One fact, - // derived twice: the shape this whole release exists to remove. - const std::string reportedTargetName = - !targetDisplayName.empty() - ? targetDisplayName - : (resolvedTargetCanonical.empty() - ? (tc ? tc->targetTriple : std::string{}) - : resolvedTargetCanonical); - if (!resolvedTargetSide.llvmTriple.empty() - && !reportedTargetName.empty()) { - auto want = mcpp::toolchain::triple::parse(reportedTargetName); - auto got = mcpp::toolchain::triple::parse( - resolvedTargetSide.llvmTriple); - // The inputs, when asked for. A guard that declines to fire and a - // guard that was never reached read the same from outside. - if (mcpp::log::is_verbose()) - mcpp::ui::info("Target", std::format( - "same-OS check: '{}'(os={}) vs '{}'(os={})", - reportedTargetName, want ? want->os : "", - resolvedTargetSide.llvmTriple, got ? got->os : "")); - if (want && got && !want->os.empty() && !got->os.empty() - && want->os != got->os) { - refusal::record(refusal::Code::OsMismatch); - return std::unexpected(std::format( - "target '{}' resolved to a toolchain for '{}'.\n" - " Those are different operating systems, so nothing " - "built here would be for\n" - " the target that was asked for. No payload on this " - "host produces '{}',\n" - " and mcpp will not substitute the host's.\n" - " install one with `mcpp toolchain install " - "`, or name it\n" - " explicitly with `[target.{}] toolchain = \"…\"`.", - reportedTargetName, resolvedTargetSide.llvmTriple, - reportedTargetName, reportedTargetName)); - } - } - mcpp::ui::info("Target", tsd::format_report( - resolvedTargetSide, reportedTargetName, mcpp::log::is_verbose())); - - // CLOSURE VISIBILITY — design §6. Distinct from the five-layer - // report above: a platform dependency is not a LAYER (no engine - // vocabulary names it, and `mcpp.targetside` — the pure, layer-only - // module the report above comes from — stays that way), it is an - // ORDINARY package that happens to declare `provides = - // ["platform-sdk"]`. That is the precise, machine-checkable - // definition this build uses: a package brings a platform - // dependency if and only if it says so, the same way a package - // states any other capability (docs/22, "provides"). Nothing infers - // this from header paths or link flags, because inference here would - // have exactly the silent-typo failure mode the reserved `mcpp:` - // prefix exists to avoid for the five layers — except this - // capability is deliberately UNPREFIXED, because it names no layer - // this engine resolves, only a fact a package states about itself. - std::vector platformDeps; - for (auto& pkg : packages) { - if (std::ranges::find(pkg.manifest.provides, "platform-sdk") - == pkg.manifest.provides.end()) - continue; - platformDeps.push_back(pkg.manifest.package.version.empty() - ? pkg.manifest.package.name - : std::format("{}@{}", pkg.manifest.package.name, - pkg.manifest.package.version)); - } - if (!platformDeps.empty() || mcpp::log::is_verbose()) { - std::string joined; - for (auto& d : platformDeps) { - if (!joined.empty()) joined += ", "; - joined += d; - } - mcpp::ui::info("Target", std::format( - " {:<17} {}", "platform-deps", - joined.empty() ? std::string("—") : joined)); - } - if (!platformDeps.empty() - && m->buildConfig.platformDependencies == "refuse") { - refusal::record(refusal::Code::PlatformDependency); - std::string joined; - for (auto& d : platformDeps) { - if (!joined.empty()) joined += ", "; - joined += d; - } - return std::unexpected(std::format( - "[build] platform-dependencies = \"refuse\", and the " - "dependency graph brings {}: {}.\n" - " This build asked to be a closure entirely on its " - "kernel-abi implementation and nothing else (design " - "2026-09-18 §6).\n" - " Remove the dependency, remove the feature that " - "pulled it in, or drop the refusal to allow it.", - platformDeps.size() == 1 ? "a platform dependency" - : "platform dependencies", - joined)); - } - } - - // ── L1b: conditional sections whose predicate names a target-side layer ── - // - // The second half of the conditional axis, and it runs HERE for the same - // reason the root build.mcpp below does: the target side is now resolved, - // and from this point on everything that consumes build inputs — the P1689 - // scan, the `stdModuleFlags` collection, the fingerprint, `compute_flags` — - // reads `packages[]` and `*m`, both of which are still writable. - // - // EVERY PACKAGE, NOT JUST THE ROOT. The build.mcpp mirror below patches - // `packages[0]`, which is right for build.mcpp because a build program - // speaks for its own package and the dep loop already handled the others. - // Here the motivating case IS a dependency — a package supplying one C++ - // runtime over several C libraries — so patching only the root would leave - // the one package this feature exists for unserved. - // - // `*m` as well as the snapshots: `canonical_compile_flags(*m)` feeds the - // fingerprint, so a contribution reaching the snapshots and not the - // manifest would compile with flags the fingerprint does not describe. - // MUTATING `pkg.manifest` IS NOT ENOUGH, AND THAT IS THE WHOLE - // DIFFICULTY OF A LATE PRODUCER. `makePackageRoot` snapshots the manifest's - // build inputs into `privateBuild` / `linkUsage`, and the compile and link - // edges read THOSE. The build.mcpp tail below solves the identical problem - // with `directives::mark` + `fold_private_tail`, so this uses the same two - // helpers rather than a second mechanism — measured first: writing only - // `pkg.manifest.buildConfig` produced a build in which every layer - // predicate matched and no flag reached the compiler. - if (targetSideResolved) { - auto layerCtx = cfgCtx(); - layerCtx.layersKnown = true; - layerCtx.compiler = resolvedTargetSide.compiler.interfaceName; - layerCtx.compilerRuntime = resolvedTargetSide.compilerRuntime.interfaceName; - layerCtx.kernelAbi = resolvedTargetSide.kernelAbi.interfaceName; - layerCtx.cAbi = resolvedTargetSide.cAbi.interfaceName; - layerCtx.cxxAbi = resolvedTargetSide.cxx.interfaceName; - // The root manifest feeds `canonical_compile_flags`, and therefore the - // fingerprint: a contribution reaching the snapshots but not `*m` would - // compile with flags the fingerprint does not describe, and the next - // build would call that a cache hit. - merge_layer_conditional_config(*m, layerCtx); - for (auto& pkg : packages) { - const auto mark = markDirectiveTail(pkg.manifest); - const auto ldN = pkg.manifest.buildConfig.ldflags.size(); - const auto privN = pkg.manifest.buildConfig.privateIncludeDirs.size(); - if (!merge_layer_conditional_config(pkg.manifest, layerCtx)) continue; - // cflags / cxxflags / include_dirs / include_dirs_after. - foldDirectiveTailIntoPrivateBuild(pkg, pkg.manifest, mark); - // ldflags: the link reads linkUsage. - pkg.linkUsage.ldflags.insert( - pkg.linkUsage.ldflags.end(), - pkg.manifest.buildConfig.ldflags.begin() - + static_cast(ldN), - pkg.manifest.buildConfig.ldflags.end()); - // private_include_dirs: expanded at makePackageRoot and folded into - // privateBuild.includeDirs, which is what keeps them OUT of - // publicUsage. A conditional entry has to take the same route or a - // vendored header overlay would reach every consumer — the blast - // radius e2e 304 exists to hold. - for (auto it = pkg.manifest.buildConfig.privateIncludeDirs.begin() - + static_cast(privN); - it != pkg.manifest.buildConfig.privateIncludeDirs.end(); ++it) { - if (it->is_absolute()) { - auto n = *it; n.make_preferred(); - if (std::ranges::find(pkg.privateBuild.includeDirs, n) - == pkg.privateBuild.includeDirs.end()) - pkg.privateBuild.includeDirs.push_back(std::move(n)); - continue; - } - for (auto& dir : mcpp::modgraph::expand_dir_glob( - pkg.root, it->generic_string())) - if (std::ranges::find(pkg.privateBuild.includeDirs, dir) - == pkg.privateBuild.includeDirs.end()) - pkg.privateBuild.includeDirs.push_back(dir); - } - } - } - - // ── #519: which FORM does each dependency take in this build ──────────── - // - // The decision itself lives in `mcpp.build.linkage_form`, which is a pure, - // table-driven function with no filesystem and no manifest knowledge. What - // happens here is only the two halves that need this scope: collecting the - // facts, and MATERIALISING the answer. - // - // COMPUTED HERE, APPLIED AFTER THE SCAN (#642 E2). A build program that - // generates a loader entry, or `dllimport` definitions, needs the form a - // dependency takes, and the root's program runs next, before the scan. - // Every input is final at this point: the requests are the root manifest's, - // which no directive changes; the target facts are resolved; each - // dependency's own build program has run, so its `ldflags` are complete; - // and the layer-conditional sections above have been folded. What the scan - // used to contribute, whether a package has sources of its own, is read - // from the scanner's own selection (`package_source_files`), so the two - // cannot disagree. The answers are stored in `dependencyLinkForms`; the - // application after the scan and the root program's environment both read - // them, and nothing resolves a second time. - { - namespace lf = mcpp::build::linkage_form; - - lf::Request request; - if (auto parsed = lf::parse(m->buildConfig.dependencyLinkage)) - request.whole = *parsed; - request.wholeIsExplicit = !m->buildConfig.dependencyLinkage.empty(); - // ONLY THE ROOT MANIFEST'S EDGES. See DependencySpec::linkage — a - // package deep in the graph imposing a whole-image layout on its - // consumer is a supply-chain property, not a convenience. - for (auto const& [depName, spec] : m->dependencies) { - if (spec.linkage.empty()) continue; - if (auto parsed = lf::parse(spec.linkage)) { - request.perPackage[depName] = *parsed; - auto shortKey = spec.shortName.empty() ? depName : spec.shortName; - request.perPackage.emplace(shortKey, *parsed); - } - } - - lf::TargetFacts targetFacts; - if (auto t = mcpp::toolchain::triple::parse(tc->targetTriple)) - targetFacts.hasLoader = !t->is_freestanding(); - // The libc axis. Spelled exactly as `compute_flags` spells it, because - // the two must agree about what `-static` means: an image linked that - // way has no interpreter, so no shared object can ever be loaded into - // it. Two keys with `linkage` in the name, and they are NOT independent. - targetFacts.fullStaticLibc = - m->buildConfig.linkage == "static" - && mcpp::toolchain::target_supports_full_static( - tc->targetTriple, mcpp::platform::supports_full_static); - - for (std::size_t i = 1; i < packages.size(); ++i) { - auto const& pkg = packages[i].manifest; - const std::string fq = pkg.package.namespace_.empty() - ? pkg.package.name - : std::format("{}.{}", pkg.package.namespace_, pkg.package.name); - - lf::PackageFacts facts; - facts.label = std::format("{}@{}", fq, pkg.package.version); - facts.hasSources = !mcpp::modgraph::package_source_files( - packages[i].root, pkg).empty(); - facts.carriesForeignLinkInputs = - lf::carries_foreign_link_inputs( - mcpp::manifest::flag_words(pkg.buildConfig.ldflags)); - facts.isDistribution = mcpp::pack::is_distribution_package(pkg); - for (auto const& artifact : pkg.runtimeConfig.artifacts) { - if (artifact.role == "static-library") facts.shipsStatic = true; - if (artifact.role == "shared-library") facts.shipsShared = true; - } - bool hasLibraryTarget = false; - for (auto const& t : pkg.targets) { - if (t.kind == mcpp::manifest::Target::SharedLibrary - && !facts.declaredShared) { - facts.declaredShared = true; - facts.declaredSharedBy = t.kindDeclaredBy; - facts.declaredSharedByRow = t.kindFromRow; - } - if (t.kind == mcpp::manifest::Target::Library) { - hasLibraryTarget = true; - // One package, one form: the first library target that - // states a default speaks for the package, as the first - // `kind = "shared"` does for the constraint. - if (!facts.defaultLinkage && !t.linkageDefault.empty()) { - facts.defaultLinkage = lf::parse(t.linkageDefault); - facts.defaultDeclaredBy = t.linkageDeclaredBy; - } - } - } - - // A consumer addresses a dependency by whatever it wrote in - // `[dependencies]` — the fully-qualified name or the bare one — - // while every message wants the version too. Rather than swapping - // the label to whichever spelling matches (which drops the version - // from every refusal), make the request answer to the descriptive - // label as well. - for (auto const& key : { fq, pkg.package.name }) { - if (auto it = request.perPackage.find(key); - it != request.perPackage.end()) { - request.perPackage.emplace(facts.label, it->second); - break; - } - } - auto allowed = lf::admissible(facts, targetFacts); - DependencyLinkForm form; - form.answer = lf::resolve(facts, allowed, request); - // Recorded for a package that has a library to link; a package of - // programs or rules has no form to report. - form.recorded = facts.isDistribution || facts.declaredShared - || hasLibraryTarget; - form.facts = std::move(facts); - dependencyLinkForms.emplace(i, std::move(form)); - } - } - - // ── The resolved graph, one derivation for two readers (#634 X, #647 E1) ── - // - // `resolution.json`'s `graph` section and the document the root build - // program reads (`mcpp::graph_file()`) describe the same packages, and they - // are built by this one function so they cannot disagree. Each entry holds - // the package's identity, every request that reached it (the key as - // written and the table that declared it) and, for a library, its link - // form with the reason. The build program's entries add what a program - // needs to act on a package: its manifest directory, the features it is - // built with, its targets, and its `[package.metadata]` verbatim. - // - // The link form is read from `dependencyLinkForms`, which is computed once, - // before this point, for exactly this program (#642 E2). - auto graph_package_entry = [&](std::size_t i, bool forBuildProgram) { - auto const& pm = packages[i].manifest; - const auto id = mcpp::manifest::package_id(pm.package); - nlohmann::json entry = { - {"package", { - {"canonical", id.canonical()}, - {"namespace", id.namespace_}, - {"name", id.name}, - {"version", id.version}, - {"source", id.sourceProvenance}, - }}, - {"root", i == 0}, - }; - nlohmann::json requests = nlohmann::json::array(); - for (auto const& r : graphRequests) { - if (r.dependencyPackageIndex != i) continue; - requests.push_back({ - {"requester", mcpp::manifest::package_id( - packages[r.consumerPackageIndex].manifest.package).canonical()}, - {"key", r.key}, - {"table", r.table}, - }); - } - entry["requested_by"] = std::move(requests); - if (auto form = dependencyLinkForms.find(i); - form != dependencyLinkForms.end() && form->second.recorded) - entry["link"] = { - {"form", std::string(mcpp::build::linkage_form::to_string( - form->second.answer.linkage))}, - {"reason", form->second.answer.reason}, - }; - if (!forBuildProgram) return entry; - - std::error_code ec; - auto dir = std::filesystem::absolute(packages[i].root, ec).lexically_normal(); - entry["manifest_dir"] = dir.string(); - nlohmann::json feats = nlohmann::json::array(); - if (i < activeFeaturesByPackage.size()) - for (auto const& f : activeFeaturesByPackage[i]) feats.push_back(f); - entry["features"] = std::move(feats); - nlohmann::json targets = nlohmann::json::array(); - for (auto const& t : pm.targets) { - using K = mcpp::manifest::Target::Kind; - const std::string_view kind = - t.kind == K::Library ? "lib" - : t.kind == K::Binary ? "bin" - : t.kind == K::SharedLibrary ? "shared" - : t.kind == K::TestBinary ? "test" - : "app"; - targets.push_back({{"name", t.name}, {"kind", std::string(kind)}}); - } - entry["targets"] = std::move(targets); - entry["metadata"] = pm.packageMetadataJson.empty() - ? nlohmann::json::object() - : nlohmann::json::parse(pm.packageMetadataJson, nullptr, - /*allow_exceptions=*/false); - if (entry["metadata"].is_discarded()) entry["metadata"] = nlohmann::json::object(); - return entry; - }; - - // ── L3: ROOT build.mcpp (moved after dependency resolution, design §3.1 - // item 4) ──────────────────────────────────────────────────────────────── - // Runs HERE — after dep resolution + feature activation (so the contract - // env can expose MCPP_DEP__DIR exactly like the dep loop above does) - // and BEFORE the modgraph scan / flag canonicalization / fingerprint (so - // its generated=/source= sources and flag directives are fully visible). - // Ordering invariants preserved relative to the pre-move call site: - // materialize_generated_files (may produce build.mcpp itself) and the L1 - // cfg merge still run earlier — ONLY this call moved later. - // - // One wrinkle the old ordering hid: back then apply() mutated *m BEFORE - // `packages[0] = makePackageRoot(*root, *m)` snapshotted buildConfig into - // privateBuild/manifest — the copies the scan and per-TU flag assembly - // actually read. Now the snapshot (and root feature activation on it) - // already happened, so mirror the directive TAILS into packages[0] - // explicitly, the same way the dep loop does for its package. - // A package with no `build.mcpp` still runs one when a rule dependency - // described it: `run_build_program` writes that program into the build - // directory. This guard therefore asks the same question the function does, - // and a guard that asked only about the file left the synthesis unreachable - // -- the shaders went uncompiled and the refusal named the missing program - // rather than the guard. Measured. - if (std::filesystem::exists(*root / "build.mcpp") - || !m->buildConfig.ruleModules.empty()) { - auto host = host_tc_for_build_program(); - if (!host) return std::unexpected(host.error()); - mcpp::build::BuildProgramEnv bpEnv; - bpEnv.targetTriple = resolvedTargetCanonical; - // Everything the engine already knows and a build program would - // otherwise hardcode: the payload ROOT (not the driver), the target's - // C library, which compiler and which C++ standard library resolved, - // and the three answers that keep a board package from naming a - // toolchain. One call — see fill_target_build_env. - fill_target_build_env(bpEnv, *m, tc ? &*tc : nullptr, cfg_opt ? &*cfg_opt : nullptr); - bpEnv.toolsBin = projectSubosBin; - bpEnv.profile = effectiveProfile; - bpEnv.accel = resolvedAccel(); - fill_package_build_env(bpEnv, *m); - bpEnv.packFormat = overrides.pack_format; - bpEnv.packStageDir = overrides.pack_stage_dir; - bpEnv.languageModules = m->language.modules; - bpEnv.ruleModules = m->buildConfig.ruleModules; - if (auto dit = deviceSourcesByPackage.find(root->string()); dit != deviceSourcesByPackage.end()) - bpEnv.deviceSources = dit->second; - // Set explicitly rather than relying on build_dir()'s root-relative - // default: under BuildOverrides::work_dir the package root is shared - // and may be read-only, and the default would write the compiled - // helper straight into it. Same value as the default when work_dir is - // unset, so an ordinary build is unchanged. - bpEnv.artifactsDir = workRoot / "target" / ".build-mcpp"; - // Root mode keeps genBase empty: a relative `generated=` from the ROOT - // package resolves against the package root (the documented contract), - // not against OUT_DIR. - // Same expression as the pre-move call site (and same order), so the - // contract hash — and therefore the build.mcpp cache — is unchanged - // across the move for feature-identical builds. - bpEnv.features = feature_closure(*m, parse_feature_request(overrides.features)); - // mcpp#241 (root): consumer index 0, same owner as the dep loop. - // - // AND THE LINK FORM OF EACH DEPENDENCY (#642 E2), to this program only. - // The root decides every dependency's form, and when this program runs - // every input of that decision is final: the requests are the root - // manifest's, and each dependency's own program has already run. A - // DEPENDENCY's program is not offered the forms. It runs in discovery - // order, before the programs of packages discovered after it, and those - // programs supply facts the answer depends on (a `-L` they add makes a - // package static-only), so the value it could be given would be a guess. - { - std::map rootLinkForms; - for (auto const& [idx, form] : dependencyLinkForms) - if (form.recorded) - rootLinkForms.emplace(idx, std::string( - mcpp::build::linkage_form::to_string(form.answer.linkage))); - fillDepDirs(bpEnv, 0, &rootLinkForms); - } - fillXpkgDirs(bpEnv, *m, 0); - // #355: the host tools the ROOT package requested (consumer index 0). - if (auto tit = toolEnvByConsumer.find(0u); tit != toolEnvByConsumer.end()) - bpEnv.toolPaths = tit->second; - bpEnv.hostModules = hostModulesByConsumer.count(0u) - ? hostModulesByConsumer.at(0u) - : decltype(bpEnv.hostModules){}; - // #649 E5: the packaging pass's strip decision, beside its format. - bpEnv.packStrip = overrides.pack_strip; - bpEnv.packDebugSymbolsDir = overrides.pack_debug_symbols_dir; - // #647 E1: THE RESOLVED GRAPH, FOR THE ROOT'S PROGRAM ONLY. - // - // Every package, dependencies before the packages that request them - // (ties in discovery order), so a program that merges what libraries - // contribute can apply them in override order without a sort of its - // own. The root decides the graph, and every input of that decision is - // final here -- the same reason `dep_linkage` is offered to this - // program alone. A file, not variables: a graph with metadata does not - // fit an environment block (`MAX_ARG_STRLEN`, the Windows limit). - // - // ITS DIGEST JOINS THE RE-RUN KEY. Editing a dependency's - // `[package.metadata]` changes what this program would answer, so it - // must run again; editing that dependency's sources does not, and the - // document does not change. - { - std::vector order; - std::vector placed(packages.size(), false); - while (order.size() < packages.size()) { - std::size_t pick = packages.size(); - for (std::size_t i = 0; i < packages.size() && pick == packages.size(); ++i) { - if (placed[i]) continue; - bool ready = true; - for (auto const& r : graphRequests) - if (r.consumerPackageIndex == i && r.dependencyPackageIndex != i - && r.dependencyPackageIndex < packages.size() - && !placed[r.dependencyPackageIndex]) { ready = false; break; } - if (ready) pick = i; - } - // A cycle leaves nothing ready; its first member in discovery - // order is taken so the document is still complete. - if (pick == packages.size()) - for (std::size_t i = 0; i < packages.size(); ++i) - if (!placed[i]) { pick = i; break; } - placed[pick] = true; - order.push_back(pick); - } - nlohmann::json doc; - doc["kind"] = "mcpp.graph"; - doc["version"] = 1; - nlohmann::json list = nlohmann::json::array(); - for (auto i : order) list.push_back(graph_package_entry(i, true)); - doc["packages"] = std::move(list); - const auto text = doc.dump(2) + "\n"; - const auto graphPath = bpEnv.artifactsDir / "graph.json"; - std::error_code gec; - std::filesystem::create_directories(graphPath.parent_path(), gec); - const auto tmp = graphPath.string() + ".tmp"; - { - std::ofstream out(tmp, std::ios::binary | std::ios::trunc); - out << text; - } - std::filesystem::rename(tmp, graphPath, gec); - if (gec) - return std::unexpected(std::format( - "cannot write the graph document '{}': {}", - graphPath.string(), gec.message())); - bpEnv.graphFile = graphPath; - bpEnv.graphDigest = mcpp::toolchain::hash_string(text); - } - auto& bcRoot = m->buildConfig; - const auto mark = markDirectiveTail(*m); - const auto rldN = bcRoot.ldflags.size(), rsrcN = bcRoot.sources.size(), - rmodN = m->modules.sources.size(); - const auto ractN = bcRoot.actions.size(); - // #622 A4: how many `[runtime] deploy` entries existed before this - // program ran — the manifest-sourced ones, already in `packages[0]`'s - // snapshot. Anything past this index is a `mcpp::deploy()` residue - // that needs the same mirror the flag/source tails get below. - const auto rdeployN = m->runtimeConfig.linkIntent.deploy.size(); - // Same reason, one field wide: `mcpp::runtime_search_dir()` residue - // needs the same mirror `deploy` does, or `resolve_runtime_contract` - // (which reads `packages[0]`'s snapshot, not `*m`) never sees it. - const auto rsearchDirN = m->runtimeConfig.linkIntent.runtimeSearchDirs.size(); - // What the dependencies supplied as runners, before the root's program - // speaks. The root's emissions are appended to the same slots, so a - // name both supply becomes one argv joining the two (#634, §9 item 8, - // measured: `run-A.sh run-B.sh `). - const auto runnerBeforeRoot = bcRoot.runner; - const auto namedBeforeRoot = bcRoot.namedRunners; - auto bp = mcpp::build::run_build_program( - *m, *root, host->first, host->second, - m->cppStandard, bpEnv); - if (!bp && !overrides.plan_only) { - return std::unexpected(bp.error()); - } - // #699 item 2 (E3): under `emit build-database` (`plan_only`), a - // failing root build program describes the package without its - // directives rather than costing the whole plan. Every mirror below - // reads what the program would have added to `*m`, so skipping - // straight past it (nothing runs on this path) is what "without its - // directives" means; a later failure that follows from the gap - // fails the member under the ordinary rule (E1). - if (!bp) { - planNotes.push_back({"MCPP_BUILD_DATABASE_PROGRAM_FAILED", - bp.error(), mcpp::wire::Severity::Error, - (*root / "build.mcpp").string()}); - } - if (bp) { - // THE SAME RULE THE DEPENDENCIES ARE HELD TO, WITH THE ROOT AS A PARTY. - // Two suppliers of one runner are refused naming both, and the - // manifest is the way to choose: a `[target.]` runner the - // project writes outranks every supplied one where the runner is - // looked up, so a name the manifest declares is not refused here. - { - const auto rowKey = [&]() -> std::string { - if (!tc) return {}; - auto t = mcpp::toolchain::triple::parse(tc->targetTriple); - return t ? t->str() : tc->targetTriple; - }(); - const auto row = m->targetOverrides.find(rowKey); - const auto manifestNames = [&](std::string_view name) { - if (row == m->targetOverrides.end()) return false; - if (name.empty()) return !row->second.runner.empty(); - return row->second.namedRunners.contains(std::string(name)); - }; - if (!runnerProvider.empty() && !runnerBeforeRoot.empty() - && bcRoot.runner.size() > runnerBeforeRoot.size() - && !manifestNames({})) { - return std::unexpected(std::format( - "the dependency '{}' and this project's build program both " - "supply the runner for this target, and the two would be " - "joined into one argv.\n" - " Drop one of them, or state the runner in " - "[target.{}].runner.", - runnerProvider, rowKey)); - } - for (auto const& [name, nr] : bcRoot.namedRunners) { - auto before = namedBeforeRoot.find(name); - auto who = namedRunnerProvider.find(name); - if (before == namedBeforeRoot.end() || before->second.argv.empty() - || who == namedRunnerProvider.end() || who->second.empty()) - continue; - if (nr.argv.size() <= before->second.argv.size()) continue; - if (manifestNames(name)) continue; - return std::unexpected(std::format( - "the dependency '{}' and this project's build program both " - "supply a runner named '{}' for this target, and the two " - "would be joined into one argv.\n" - " Drop one of them, or state it in " - "[target.{}.runners].{}.", - who->second, name, rowKey, name)); - } - } - auto& pkg0 = packages[0]; - // Compile-visible tail → privateBuild: the shared fold (same owner - // as the dep loop; the root's TUs read privateBuild). - foldDirectiveTailIntoPrivateBuild(pkg0, *m, mark); - // Before the source residues are mirrored below: adopting an action's - // outputs APPENDS to bcRoot.sources, and those appends must be inside - // the tail that gets copied into the packages[0] snapshot the scan reads. - adoptActionOutputs(*m, *root, ractN); - // The root's build program has spoken; a floor it stated is checked - // now, with the facts every package (it included) established. - if (auto err = checkVersionFloors(); err) return std::unexpected(*err); - // Root residues — apply() mutated *m, but packages[0].manifest is a - // value-copy snapshot taken at makePackageRoot, so everything the - // scan/fingerprint read from the snapshot needs the tail mirrored: - // sources → the scan walks packages[0].manifest, not *m. - pkg0.manifest.buildConfig.sources.insert( - pkg0.manifest.buildConfig.sources.end(), - bcRoot.sources.begin() + rsrcN, bcRoot.sources.end()); - pkg0.manifest.modules.sources.insert( - pkg0.manifest.modules.sources.end(), - m->modules.sources.begin() + rmodN, m->modules.sources.end()); - // Fingerprint metadata (canonical_package_build_metadata folds - // packages[].manifest.buildConfig) — mirror the flag/include tails, - // as the old pre-snapshot ordering implicitly did. - pkg0.manifest.buildConfig.cflags.insert( - pkg0.manifest.buildConfig.cflags.end(), - bcRoot.cflags.begin() + static_cast(mark.cflags), - bcRoot.cflags.end()); - pkg0.manifest.buildConfig.cxxflags.insert( - pkg0.manifest.buildConfig.cxxflags.end(), - bcRoot.cxxflags.begin() + static_cast(mark.cxxflags), - bcRoot.cxxflags.end()); - pkg0.manifest.buildConfig.includeDirs.insert( - pkg0.manifest.buildConfig.includeDirs.end(), - bcRoot.includeDirs.begin() + static_cast(mark.includeDirs), - bcRoot.includeDirs.end()); - pkg0.manifest.buildConfig.includeDirsAfter.insert( - pkg0.manifest.buildConfig.includeDirsAfter.end(), - bcRoot.includeDirsAfter.begin() - + static_cast(mark.includeDirsAfter), - bcRoot.includeDirsAfter.end()); - // Link flags → the final link reads *m (already applied); keep the - // linkUsage snapshot and fingerprint metadata equivalent too. - pkg0.linkUsage.ldflags.insert(pkg0.linkUsage.ldflags.end(), - bcRoot.ldflags.begin() + rldN, bcRoot.ldflags.end()); - pkg0.manifest.buildConfig.ldflags.insert( - pkg0.manifest.buildConfig.ldflags.end(), - bcRoot.ldflags.begin() + rldN, bcRoot.ldflags.end()); - // #622 A4: `mcpp::deploy()` residue → `packages[0].manifest`, the - // object `resolve_runtime_contract` (plan.cppm) actually reads. - // Without this mirror a directive-sourced deploy entry lands in `*m` - // and nowhere the planner looks — the same gap this block already - // closes for sources/flags, one more field wide. - pkg0.manifest.runtimeConfig.linkIntent.deploy.insert( - pkg0.manifest.runtimeConfig.linkIntent.deploy.end(), - m->runtimeConfig.linkIntent.deploy.begin() + static_cast(rdeployN), - m->runtimeConfig.linkIntent.deploy.end()); - // `mcpp::runtime_search_dir()` residue → `packages[0].manifest`, the - // same object and the same reason as the `deploy` mirror above: without - // it a directive-sourced entry lands in `*m` and `resolve_runtime_contract` - // never looks there. - pkg0.manifest.runtimeConfig.linkIntent.runtimeSearchDirs.insert( - pkg0.manifest.runtimeConfig.linkIntent.runtimeSearchDirs.end(), - m->runtimeConfig.linkIntent.runtimeSearchDirs.begin() - + static_cast(rsearchDirN), - m->runtimeConfig.linkIntent.runtimeSearchDirs.end()); - } - } - - // ── Every device source must reach some action ───────────────────────── - // - // A device-kind file is the one source the engine has no compile rule for. - // It is handed to the package's build program (MCPP_DEVICE_SOURCES) and - // comes back as an action, or it is not compiled at all. Nothing checked - // that it came back. Two ways it does not, both silent until now: - // - // - the package has no `build.mcpp`. The engine computed the list and - // dropped it. Both run sites above are guarded on that file existing, - // so there was not even a program to ignore it. - // - a program runs but no imported rule claims the extension. A project - // with a `.cu` and a `.comp` that imports only `mcpp.rules.spirv` is - // this case, and it is the ordinary case for a project with two - // backends: a rule takes the extensions it knows and leaves the rest. - // - // What they produce today is an undefined reference at the link, naming a - // symbol and never the file that would have defined it -- and for a - // `kind = "lib"` target not even that, because an archive is not resolved. - // A device source that compiles nothing is never what was meant, so it is - // refused here, where both halves of the fact are still in hand. - // - // THE CRITERION IS THE ACTION INPUTS, not "a build program ran": a program - // that ran and consumed nothing is exactly the second case. It is also the - // condition an action needs anyway -- one that compiles a file it does not - // declare as an input does not rerun when that file changes -- so a rule - // that satisfies it is a rule that rebuilds correctly. - for (std::size_t i = 0; i < packages.size(); ++i) { - auto const& pkg = packages[i]; - auto dit = deviceSourcesByPackage.find(pkg.root.string()); - if (dit == deviceSourcesByPackage.end() || dit->second.empty()) continue; - auto const& mm = (i == 0) ? *m : pkg.manifest; - std::set consumed; - for (auto const& a : mm.buildConfig.actions) - for (auto const& in : a.inputs) { - std::filesystem::path ip(in); - consumed.insert((ip.is_absolute() ? ip : pkg.root / ip).lexically_normal()); - } - std::string orphans; - for (auto const& rel : dit->second) - if (!consumed.contains((pkg.root / rel).lexically_normal())) - orphans += " " + rel + "\n"; - if (orphans.empty()) continue; - std::error_code hasEc; - const bool hasProgram = std::filesystem::exists(pkg.root / "build.mcpp", hasEc) - || !pkg.manifest.buildConfig.ruleModules.empty(); - refusal::record(refusal::Code::DeviceSourceUnconsumed); - return std::unexpected(std::format( - "`{}`: device sources that no action compiles:\n{}" - " A device-kind source is compiled by this package's build program\n" - " and by nothing else -- the engine has no rule for these extensions\n" - " and never will.\n" - "{}", - mm.package.name, orphans, - hasProgram - ? " `build.mcpp` ran but declared no action taking them as inputs.\n" - " fix: import the rule package that claims these extensions and\n" - " call it, or drop them from `[build] sources`. A rule that\n" - " compiles a file must also declare it as an action input, or the\n" - " action will not rerun when the file changes." - : " This package has no `build.mcpp`, so nothing was ever offered\n" - " them.\n" - " fix: add a `build.mcpp` importing the rule for these files (e.g.\n" - " `mcpp.rules.cuda` for `.cu`, `mcpp.rules.spirv` for shaders), or\n" - " drop them from `[build] sources`.")); - } - - // ── R1.3: a re-run input inside a `prepare` directory (SPEC-007 §3) ───── - // - // A build program's re-run set is declared BEFORE anything is built - // (`rerun_if_changed`/`rerun_if_changed_glob`), and a `prepare` action's - // directory is filled AFTER a build program has already run once for - // this build — it is a ninja edge, scheduled after `mcpp build`'s - // configure step ends. A program that also names a file or a glob inside - // such a directory as its own re-run input reads a CONSTRUCTION RESULT - // while it configures: correct on the SECOND build, once a previous - // build's `prepare` action has populated the directory, and wrong on the - // first — the exact pattern of a plugin placing a first installation's - // libraries on the NEXT plan (design §5.2's route on 2026.9.26.1, which - // this directive and role exist to remove). - // - // WARNED, NOT REFUSED: the program still configures correctly today (its - // FIRST run sees what the tree already held), and R1.2 already asks a - // program to say what it could not find with `mcpp::warning`. This is the - // engine naming an author obligation SPEC-007 states (R1.3), not a build - // it can complete no differently. - // - // `declared_program_inputs` reads back what every package's build.mcpp - // just declared (or, on a cache hit, declared on its last run) from the - // caches under `/target/.build-mcpp`, so no extra plumbing is - // needed to carry the re-run set out of `run_build_program`. - { - std::map ownerName; - std::vector> prepareDirs; - for (std::size_t i = 0; i < packages.size(); ++i) { - auto const& mm = (i == 0) ? *m : packages[i].manifest; - ownerName.emplace(packages[i].root.lexically_normal(), mm.package.name); - for (auto const& a : mm.buildConfig.actions) { - if (a.role != mcpp::manifest::BuildAction::Role::Prepare) continue; - if (a.outputDir.empty()) continue; - prepareDirs.emplace_back(mm.package.name, - std::filesystem::path(a.outputDir).lexically_normal()); - } - } - if (!prepareDirs.empty()) { - // `p` reaches strictly inside `dir`: equal paths and a sibling - // that merely shares a prefix (`lexically_relative` starting with - // `..`) both do not count. - auto isUnder = [](const std::filesystem::path& p, - const std::filesystem::path& dir) { - auto rel = p.lexically_relative(dir); - if (rel.empty()) return false; - auto s = rel.generic_string(); - return s != "." && s.compare(0, 2, "..") != 0; - }; - for (auto const& decl : mcpp::build::declared_program_inputs(workRoot)) { - std::vector watched(decl.files); - for (auto const& pattern : decl.globs) { - // The glob's fixed prefix — everything before its first - // wildcard character — is enough to answer whether the - // PATTERN reaches into a `prepare` directory; resolving it - // into the file set it matches is not needed for that. - auto wildcard = pattern.find_first_of("*?["); - auto fixed = wildcard == std::string::npos - ? pattern : pattern.substr(0, wildcard); - watched.push_back((decl.root / fixed).lexically_normal()); - } - auto ownerIt = ownerName.find(decl.root.lexically_normal()); - const std::string declName = - ownerIt != ownerName.end() ? ownerIt->second : decl.root.string(); - for (auto const& w : watched) { - for (auto const& [pkgName, dir] : prepareDirs) { - if (!isUnder(w, dir)) continue; - mcpp::ui::warning(std::format( - "{}'s build.mcpp re-runs on '{}', which is inside " - "'{}', the directory package '{}' declared with a " - "`prepare` action's output_dir. That directory is " - "populated at BUILD time, after build.mcpp has " - "already configured, so this program sees the " - "PREVIOUS build's contents, never the current " - "one's (SPEC-007 R1.3).", - declName, w.string(), dir.string(), pkgName)); - } - } - } - } - } - - // [targets.*] required_features gate: a target is emitted only when ALL its - // required features are active in this build; otherwise it is silently - // skipped. A pure build-selection knob — it runs before the modgraph/plan - // so gated-out targets cost nothing. - std::erase_if(m->targets, [&](const mcpp::manifest::Target& t) { - for (auto const& rf : t.requiredFeatures) - if (!activeRootFeatures.contains(rf)) return true; - return false; - }); - - // The dialect-complete standard flag: spelled per-dialect and carrying - // the module-graph-global dialect flags (issue #210). ONE string shared - // by the p1689 scan and the std BMI prebuild so scan-time, prebuild-time - // and compile-time dialect provably agree. Both this and make_plan go - // through the same cppfly merge, so the c++fly gates (and the - // c++latest/c++fly per-toolchain std spelling) stay graph-consistent. - std::string stdFlagAndDialect = mcpp::toolchain::cppfly::std_flag( - *tc, m->cppStandard.canonical, m->cppStandard.level); - if (m->cppStandard.experimental) { - // c++fly is best-effort by design: say exactly what this toolchain - // got and what it lacks (the value's contract, design §5.4). - auto fly = mcpp::toolchain::cppfly::resolve(*tc); - std::string enabled, skipped; - for (auto& f : fly.features) { - auto& dst = f.enabled ? enabled : skipped; - if (!dst.empty()) dst += ", "; - dst += f.name; - if (f.enabled && !f.flags.empty()) dst += std::format(" ({})", f.flags); - if (!f.enabled) dst += std::format(" ({})", f.reason); - } - std::println("c++fly on {}: {}; enabled: {}; skipped: {}", - tc->label(), stdFlagAndDialect, - enabled.empty() ? "(none)" : enabled, - skipped.empty() ? "(none)" : skipped); - } - for (auto& f : mcpp::toolchain::cppfly::effective_dialect_flags( - *tc, m->cppStandard.experimental, - mcpp::manifest::dialect_flags(m->buildConfig))) { - stdFlagAndDialect += ' '; - stdFlagAndDialect += f; - } - - // mcpp#225 (E2): observability marker for the source-discovery phase — - // `mcpp run`'s fast path (build_run_target/try_fast_run in execute.cppm) - // skips prepare_build ENTIRELY on a cache hit, so this line's absence - // under MCPP_VERBOSE=1 on a second `mcpp run` is the "did we re-scan" - // signal the e2e test asserts on (tests/e2e/114_run_scan_scope.sh). - mcpp::log::verbose("scan", "scanning module sources"); - - // Modgraph: regex scanner by default; opt-in to compiler-driven P1689 - // scanner via env var MCPP_SCANNER=p1689 (see docs/27). - // - // A dependency whose declared targets are all programs compiles nothing in - // this build (#649 E6): its programs come from the tool sub-build, which - // scans it as its own root. The root itself is always scanned. - std::vector scannedPackages; - scannedPackages.reserve(packages.size()); - for (std::size_t i = 0; i < packages.size(); ++i) - if (i == 0 || !isProgramOnlyPackage(packages[i].manifest)) - scannedPackages.push_back(packages[i]); - auto scan = [&] { - const char* sel = std::getenv("MCPP_SCANNER"); - if (sel && std::string_view(sel) == "p1689") { - auto tmp = std::filesystem::temp_directory_path() - / std::format("mcpp_p1689_{}", std::random_device{}()); - std::filesystem::create_directories(tmp); - return mcpp::modgraph::scan_packages_p1689(scannedPackages, *tc, tmp, - stdFlagAndDialect); - } - return mcpp::modgraph::scan_packages(scannedPackages); - }(); - if (!scan.errors.empty()) { - std::string msg = "scanner errors:\n"; - for (auto& e : scan.errors) msg += " " + e.format() + "\n"; - return std::unexpected(msg); - } - for (auto& w : scan.warnings) { - mcpp::diag::warning("modgraph/scan", w.format()); - } - - auto report = mcpp::modgraph::validate(scan.graph, *m, *root); - for (auto& w : report.warnings) { - if (w.path.empty()) mcpp::diag::warning("modgraph/validate", w.message); - else mcpp::diag::warning("modgraph/validate", - std::format("{}: {}", w.path.string(), w.message)); - } - if (!report.ok()) { - std::string msg = "validation errors:\n"; - for (auto& e : report.errors) { - if (e.path.empty()) msg += " " + e.message + "\n"; - else msg += " " + e.path.string() + ": " + e.message + "\n"; - } - return std::unexpected(msg); - } - - bool needsStdModule = graph_or_targets_import_std(scan.graph, *m, *root); - - // A DEPENDENCY THAT DECLARED A HIGHER STANDARD THAN THE GRAPH IS BUILT AT. - // - // A C++ module graph has ONE standard — cross-level BMIs are hard - // incompatible — so the root package's level is imposed graph-wide, and a - // dependency's `standard` is parsed and then discarded. That is correct and - // is not the defect. The defect is the silence: a package that declared - // c++26 because it needs c++26 is compiled at whatever the consumer says, - // and fails — if it fails at all — with a compiler error inside a - // translation unit the user does not own, naming neither package nor the - // mechanism. - // - // SCOPED TO MANIFESTS THE PROJECT AUTHOR CONTROLS, and that scope is the - // whole reason this check is shippable. The cpp20 design doc's §9-Q3 - // declined it because the default and a declaration were indistinguishable; - // `standardDeclared` fixes that for `mcpp.toml`, and NOT for the index: - // measured over the local registry, every descriptor with an mcpp segment - // declares `language` (782 of 782), and 756 of those 774 packages are C - // libraries with `import_std = false` carrying a boilerplate "c++23". A - // check that trusted declaredness everywhere would fire against essentially - // the whole index for any root at c++20 — exactly the outcome §9-Q3 - // refused, reached through a different door. - // - // DEGRADED, NOT AN ERROR. The condition is not a proven failure: a package - // declaring c++26 compiles perfectly well at c++23 whenever it happens not - // to use a C++26 construct, and that is a working configuration today for - // anyone who wrote the key aspirationally. `--strict` promotes it. - { - const auto graphLevel = m->cppStandard.level; - for (std::size_t i = 1; i < packages.size(); ++i) { - auto const& pkg = packages[i]; - if (!pkg.manifest.package.standardDeclared) continue; - // A C++-layer provider's declaration IS applied, to every unit of - // it that neither provides nor imports a module (`make_plan`), so - // "is not applied" would be false for exactly the package whose - // sources need the level. Its module units stay at the graph's - // level, as every module unit does. - if (mcpp::manifest::cxx_layer_implementation_standard(pkg.manifest)) - continue; - // The scope gate. A package whose root is under a store directory - // arrived from an index and its declaration was written by a - // descriptor generator, not by the person reading this diagnostic. - if (mcpp::build::path_is_under_any(pkg.root, storeRoots)) - continue; - auto declared = mcpp::manifest::normalize_cpp_standard( - pkg.manifest.package.standard); - if (!declared || declared->level <= graphLevel) continue; - mcpp::diag::degraded( - "build/standard", - std::format("dependency `{}` declares standard = \"{}\", and " - "this graph is built at {}", - pkg.manifest.package.name, - declared->canonical, m->cppStandard.canonical), - "a C++ module graph has one standard, so the dependency's " - "declaration is not applied and its sources are compiled at the " - "graph's level", - std::format( - "raise the consumer's standard to \"{}\", or declare it " - "once for every member:\n\n [workspace.package]\n " - "standard = \"{}\"", declared->canonical, declared->canonical)); - } - } - - // A DIALECT FLAG THAT REACHES EVERY TU AND NOT THE `import std` PREBUILD - // IS A BUILD THAT CANNOT SUCCEED, AND MCPP KNOWS IT BEFORE COMPILING. - // - // `[build] cxxflags = ["-fno-exceptions"]` is applied to each translation - // unit; the std BMI in `stdFlagAndDialect` is precompiled without it, - // because only `dialect_flags()` rides that channel. Every importer then - // fails inside a file mcpp generated: - // - // std: error: language dialect differs 'C++23', expected - // 'C++23/no-exceptions' - // std: error: failed to read compiled module: Bad file data - // - // The message names the mechanism and not the key, so the way out - // (`dialect_cxxflags`, which IS applied to the prebuild, the scan and every - // TU) is not discoverable from it. Both facts are known here: whether the - // graph imports `std`, and which flags reached the prebuild. - // - // REFUSED RATHER THAN WARNED, and that is the same rule the host-dependence - // diagnostics follow from the other side: this build provably cannot - // succeed, so there is no user decision to respect. Contrast - // `[toolchain] system`, which builds and runs and is therefore warned about. - // - // GATED ON `needsStdModule` — without `import std` in the graph there is no - // prebuilt BMI to disagree with, and `-fno-exceptions` is then an ordinary - // per-unit flag that works. A check that refused in both cases would have - // stopped testing the condition it claims to test. - if (needsStdModule) { - const auto prebuilt = mcpp::toolchain::cppfly::effective_dialect_flags( - *tc, m->cppStandard.experimental, - mcpp::manifest::dialect_flags(m->buildConfig)); - // THE ROOT PACKAGE ONLY, and the narrowing is a correctness bound - // rather than a shortcut. - // - // A dependency carrying the same flag fails identically — but only if - // ITS OWN translation units import `std`. `needsStdModule` is a - // property of the whole graph: a C++ wrapper package that uses no std - // module can carry `-fno-exceptions` in its `[build] cxxflags` and - // compile perfectly well inside a graph whose ROOT imports std. - // Refusing there would stop a build that works, which is the one thing - // a refusal must never do — the rule is "provably cannot build", and - // for a dependency this evidence does not prove it. - // - // Extending it needs a per-package answer to "does this package import - // std", which the scan graph holds and does not expose in that shape. - // Recorded here so the next person meets the reason and not the gap. - for (auto const& pkg : std::span{packages}.first(1)) { - const auto words = mcpp::manifest::flag_words(pkg.manifest.buildConfig.cxxflags); - auto missing = mcpp::manifest::dialect_flags_missing_from_prebuild(words, prebuilt); - if (missing.empty()) continue; - std::string list; - for (auto const& f : missing) { - if (!list.empty()) list += ", "; - list += '`'; list += f; list += '`'; - } - // NAMES THE FLAG, NOT THE TABLE IT CAME FROM. The same flag - // reaches the compile line from `[build] cxxflags`, from - // `[profile.] cxxflags` and from a `[target.…]` / `cfg(...)` - // block; by the time it is read here they have been merged, and - // asserting one of them would be wrong two times in three. - return std::unexpected(std::format( - "{} changes the language dialect{}, but the `import std` BMI is " - "precompiled without it, so every importing translation unit " - "will fail with \"language dialect differs\".\n" - " Declare it as a dialect flag instead — that channel is " - "applied to the std BMI prebuild, the module scan and every TU " - "in the graph:\n" - "\n" - " [build]\n" - " dialect_cxxflags = [{}]\n" - "\n" - " It belongs in `[build]` and not in a profile or a " - "per-target block: a dialect the standard library was not built " - "with cannot be held by one package or one profile alone.", - list, std::string{}, - [&] { - std::string q; - for (auto const& f : missing) { - if (!q.empty()) q += ", "; - q += '"'; q += f; q += '"'; - } - return q; - }())); - } - } - - // A standard library that came from a PACKAGE brings its own module - // source, because the compiler cannot be asked for one it does not have. - // - // `-print-library-module-manifest-path' is the right question when the - // standard library is the compiler's own. It is the wrong question when - // the library was configured by a package for a target the compiler - // knows nothing about: the source exists, and the compiler has never - // heard of it. So the package says where it is, and what it needs --- - // its include path and its own __config_site, neither of which the - // compiler would find. - // - // Both are read only from a package that ALSO provides the capability - // below. A package that named a std module without supplying the - // library would be describing something it does not have. - for (auto& pkg : packages) { - if (pkg.manifest.stdModule.empty()) continue; - // Either spelling of the C++ layer (see `provides_cxx_layer`). The - // same predicate decides which package's implementation units keep - // their own standard in `make_plan`, so the two cannot name different - // packages as the standard library. - if (!mcpp::manifest::provides_cxx_layer(pkg.manifest)) continue; - auto src = pkg.root / pkg.manifest.stdModule; - if (!std::filesystem::exists(src)) { - return std::unexpected(std::format( - "package '{}' declares [package].std-module = '{}', and there " - "is no such file under '{}'", - pkg.manifest.package.name, pkg.manifest.stdModule, - pkg.root.string())); - } - tc->stdModuleSource = src; - // AND THE COMPAT MODULE, FROM THE SAME PACKAGE OR NOT AT ALL. - // - // `std.compat` is a second module over the SAME library. Leaving it - // pointing at the toolchain's copy does not fail where it is set — it - // fails later, in that copy's own headers, against a configuration that - // was never generated for this target. Measured on a macOS cross: - // - // error: std module precompile failed (rc=1): - // …/xim-x-llvm/22.1.8/share/libc++/v1/std.compat.cppm - // …/include/c++/v1/__config:13: '__config_site' file not found - // - // — which reads as a broken toolchain payload and says nothing about - // the two libraries having been mixed. A package that supplies one - // module supplies both, or the pair is not offered. - if (!pkg.manifest.stdCompatModule.empty()) { - auto csrc = pkg.root / pkg.manifest.stdCompatModule; - if (!std::filesystem::exists(csrc)) { - return std::unexpected(std::format( - "package '{}' declares [package].std-compat-module = '{}', " - "and there is no such file under '{}'", - pkg.manifest.package.name, pkg.manifest.stdCompatModule, - pkg.root.string())); - } - tc->stdCompatSource = csrc; - } else { - tc->stdCompatSource.clear(); - } - tc->targetCxxRuntime = true; - tc->hasImportStd = true; - tc->importStdMinLevel = 20; // libc++'s own floor; see clang.cppm - // The target, first. On a freestanding target that means the whole ISA - // profile --- `--target', `-march', `-mabi', `-mcmodel' --- because a - // module built without them disagrees with every unit that imports it, - // and clang reports that as an ABI mismatch naming a .pcm file rather - // than the flag that split them. On a hosted one it is the triple alone. - std::string flags; - if (auto fs = mcpp::toolchain::triple::parse(tc->targetTriple); - fs && fs->is_freestanding()) { - if (auto spec = mcpp::freestanding::resolve(*fs)) - flags += mcpp::freestanding::compile_prefix(*spec, true); - } else if (!tc->crossTargetFlag.empty()) { - // `crossTargetFlag` and not `targetTriple`. The triple is mcpp's - // vocabulary (`aarch64-macos`); the flag carries the spelling a - // compiler takes (`arm64-apple-macos14.0`). Measured: emitting the - // first produced `--target=aarch64-macos`, which clang accepts as a - // triple it has never heard of and then treats as a bare-metal - // aarch64 — the module and its importers would agree with each - // other and with nothing else. - flags += " " + tc->crossTargetFlag; - // AND THE SECOND CHANNEL. `hostflags.cppm` reaches every ordinary - // translation unit; this command is assembled here instead, so a - // `std.pcm` built with SEH would be imported by units built with - // DWARF. Same function, not a second copy of the decision. - for (auto& f : mcpp::toolchain::graph_runtime_compile_flags(*tc)) - flags += " " + f; - } - // `__OPENKAL__` AND THE REALISED [c-abi] ENVIRONMENT REACH THE STD - // MODULE TOO (design §3.4: "环境作用于目标侧的全部编译单元... 以及图中 - // 所有普通包"). The std module's own command is assembled here rather - // than through `mcpp.toolchain.hostflags`'s shared string (see the - // comment above), so it needs the same broadcast the ordinary - // per-package loop gives every other unit — this is that same rule, - // stated once more at the one site it cannot reach on its own. - if (tc->kernelAbiIsOpenkal) flags += " -D__OPENKAL__"; - if (pkg.manifest.cEnvironment != "platform") { - for (auto& t : tc->cEnvTokens) flags += " " + t; - for (auto& t : tc->cEnvBuiltinsTokens) flags += " " + t; - } - // Everything up to here says which machine the module is for; what - // follows says where its headers are. The codegen step needs only the - // first — see Toolchain::stdModuleTargetFlags. - tc->stdModuleTargetFlags = flags; - for (auto& f : pkg.manifest.buildConfig.stdModuleFlags) { - // A flag naming a path is relative to the package that named it, - // for the same reason the module source is. - auto candidate = pkg.root / f; - flags += " " + mcpp::xlings::shq( - std::filesystem::exists(candidate) ? candidate.string() : f); - } - // AND THE HEADERS THIS PACKAGE ITSELF IS BUILT AGAINST. - // - // The std module source is one of this package's translation units in - // every way that matters, and it reaches the C library's headers the - // same way the rest of them do --- through the requirements the packages - // BENEATH this one publish. A package cannot name those in its own - // manifest: they belong to its dependencies, and their paths are known - // only after resolution. - // - // Measured: without them the module compiles until libc++ includes - // , which is the C library's, and stops there. - // publicUsage rather than privateBuild: the module is compiled once and - // imported by consumers, so the headers it must see are the ones the - // package PUBLISHES, not the ones it happens to build itself against. - // The two differ, and the difference is not cosmetic --- a package's own - // build path carries directories that exist for its .cpp files and that - // shadow the library's headers when a module is compiled against them. - // - // AND IT IS `targetSideUsage`, NOT THIS PACKAGE'S `publicUsage`. - // - // The two are the same set whenever one package supplies every layer, - // which is the arrangement this block was written for — so reading the - // package directly was correct and stayed correct until a second - // provider appeared. `openkal-llvm-runtime` supplies the C++ runtime - // while `openkal-musl` supplies the C library, and the std module needs - // both: libc++'s own headers reach ``, which is the C - // library's. - // - // Reading the assembled set also makes this site and every compile - // edge read ONE value. Deriving it here a second time is the shape - // #233/#240/#242/#344 each cost a release, and the same set has to - // reach both or the `std` BMI describes a different world than the - // units importing it — which is mcpp#514 exactly. - for (auto& d : targetSideUsage.includeDirs) - flags += " -isystem " + mcpp::xlings::shq(d.string()); - for (auto& d : targetSideUsage.includeDirsAfter) - flags += " -idirafter " + mcpp::xlings::shq(d.string()); - // And the definitions, for the same reason as the directories: a C - // library's headers show a different library depending on which feature - // macros are set, and the ones this package is built with are the ones - // its own translation units see. Measured: without them the module - // reaches musl's and stops on `clockid_t', a name that header - // declares only under the macro the package carries. - // THE PREBUILT C LIBRARY'S OWN TOKENS, FROM THE PRODUCER EVERY UNIT - // USES. A package that supplies the C++ layer over a prebuilt C - // library (`llvm.libcxx` over glibc, or over an Apple SDK) has no - // way to name that library's headers in its manifest, and the - // target-side broadcast below carries only graph layers. Without - // these the precompile reads whatever the driver finds on its own: - // on Linux the runner's `/usr/include` rather than the payload's - // glibc, a host dependency no report showed; on macOS nothing, and - // the precompile stops on `mbstate_t`; on the iOS rows nothing, and - // it stopped on the same name. Measured on 2026-09-14 across the - // three. `host_compile_tokens` is what every translation unit of the - // build gets, asked with the C++ layer marked as the graph's so that - // it withholds the payload's libc++ and emits the rest: the cfg - // bypass, the C library's directories, the SDK and the deployment - // floor. Same function, not a second copy. - // - // AND LAST ON THE COMMAND, after the package's own directories: - // `-isystem` order is search order, and libc++'s headers must precede - // the C library's, which libc++ states in as many words (`` - // stops the build if it reaches a `` that is not its own). - // Emitted ahead of them, glibc's `` shadowed libc++'s wrapper - // and `` failed on `std::__builtin_isnan` (measured). - if (tc->cAbiPrebuilt) { - mcpp::toolchain::HostFlagOptions hopt; - hopt.cfgBypass = mcpp::toolchain::HostFlagOptions::CfgBypass::Always; - hopt.cAbiPrebuilt = true; - hopt.cxxFromGraph = true; - hopt.appleSdkRoot = tc->appleSdkRoot; - // Target-keyed, not host-keyed (#685) — see `min_platform_version`. - const bool cAbiTargetIsMacos = [&] { - auto cAbiTt = mcpp::toolchain::triple::parse(tc->targetTriple); - return cAbiTt && cAbiTt->os == "macos"; - }(); - hopt.macosDeploymentTarget = mcpp::platform::macos::deployment_target( - cAbiTargetIsMacos, m->buildConfig.macosDeploymentTarget); - for (auto& t : mcpp::toolchain::host_compile_tokens( - *tc, hopt, mcpp::toolchain::no_escape)) { - const auto q = " " + mcpp::xlings::shq(t); - if (flags.find(q) == std::string::npos) flags += q; - } - for (auto& t : mcpp::toolchain::apple_float_macro_words(*tc)) { - const auto q = " " + mcpp::xlings::shq(t); - if (flags.find(q) == std::string::npos) flags += q; - } - } - // The same words the package's own units receive from this list - // (mcpp.manifest.flag_words), one quoted word each. - for (auto& w : mcpp::manifest::flag_words(targetSideUsage.cxxflags)) - flags += " " + mcpp::xlings::shq(w); - tc->stdModuleFlags = flags; - break; - } - - // AN APPLE CROSS TARGET WITHOUT A GRAPH C++ RUNTIME LINKS THE SDK'S - // libc++ (the Mach-O cell in distribution.cppm), AND THE HEADERS FOLLOW - // THE RUNTIME. The payload's `std.cppm` and headers describe libc++ 22; - // the SDK's dylib is libc++ 19 (Xcode 16.4, measured), and Apple's SDKs - // ship no module sources of their own (no `usr/share/libc++/v1` on the - // macOS 15.5 and iOS 18.5 SDKs). So: - // - // - a graph that does not import `std` takes the SDK's headers - // (hostflags.cppm, `appleSdkCxxHeaders`): one libc++ on every line, - // and the payload's module, unused, is withdrawn; - // - a graph that imports `std` keeps the payload's module and headers - // over the SDK's dylib. That pairing links until an inline path in - // the newer headers names an export the older dylib lacks - // (`__hash_memory`, `__atomic_notify_all_global_table`, measured), - // and it is what every iOS program built before this release got. - // It is REPORTED ONCE rather than refused: refusing would break a - // program that built yesterday, and the report names the two lines - // that make the hazard disappear. - if (tc && !tc->appleSdkRoot.empty() && targetSideResolved - && !resolvedTargetSide.cxx.fromGraph()) { - if (!needsStdModule) { - tc->appleSdkCxxHeaders = true; - tc->hasImportStd = false; - tc->stdModuleSource.clear(); - tc->stdCompatSource.clear(); - } else { - mcpp::diag::degraded("target/cxx-runtime", std::format( - "{} links the SDK's libc++ under the toolchain payload's " - "libc++ headers and std module, which are a different " - "release of the library", tc->targetTriple), - "the program links while no inline path in the newer headers " - "names an export the SDK's dylib lacks; `std::unordered_map` " - "over `std::string` and `std::atomic::notify_all` are two " - "that do, and they fail at link with `__hash_memory` or " - "`__atomic_notify_all_global_table` undefined", - "declare the C++ standard library as a package, which brings " - "its headers, its module and its objects as one release: " - "[target.'cfg(os = \"ios\")'.dependencies] " - "llvm.libcxx = \"22.1.8.1\" (and " - "llvm.compiler-rt-builtins = \"22.1.8.5\" beside it)"); - } - } - - if (needsStdModule && !tc->hasImportStd) { - // A freestanding target reaches here for a reason the generic message - // gets wrong. Nothing is missing from the toolchain — libc++'s std - // module is right there — it is that `std` is ONE module over the whole - // library, threads and filesystem and iostreams included, so there is - // no subset of it to build without an OS. Saying "provides no std - // module source" sends the reader to look for a broken payload. - // - // The line below is copy-pasteable, and that is a PROMISE: it has - // to resolve today. It briefly did not — an earlier version of this - // message named `mcpplibs.std.freestanding` before any such package - // existed, so following the advice failed at the very next command - // with "package not found" and sent the reader off to debug their - // index. The package is published now (103 of libc++'s 110 headers, - // measured; the 7 that fail fail on a hosted x86_64 too), so the line - // is back. If it is ever removed from the index, this must change with - // it. - // - // And the VERSION is part of the promise, not decoration — which is - // how the same defect recurred in a second form. The line said "0.1.0" - // after 0.2.0 superseded it in the index, and 0.1.0 is not published, - // so pasting it produced - // - // E_NOT_FOUND: package 'compat.std-freestanding@0.1.0' not found - // in the synced index - // - // measured 2026-08-20 while documenting this message. A floor would - // not fix it either: the request has to name a version the index - // actually carries. Publishing a new std-freestanding means updating - // this literal in the same change. - // THE QUESTION IS WHETHER A HOSTED STANDARD LIBRARY IS PRESENT, NOT - // WHETHER THE TARGET IS FREESTANDING. - // - // Those were the same question for as long as no one had built one for - // such a target, and they stopped being the same when someone did: - // `mcpplibs/openkal-llvm-runtime' configures libc++, libc++abi and - // libunwind for a machine with no operating system, and a program above - // it has the library this refusal says it cannot have. - // - // The refusal is kept, because it is right in every case where nothing - // supplies one --- which is still the ordinary case, and the advice - // below is still the advice. What changes is that a package can now say - // otherwise, and it says so the way every other capability is declared: - // - // provides = ["hosted-standard-library"] - // - // A capability rather than a triple, because the fact is a property of - // the graph and not of the target, and because dependency resolution is - // the earliest time at which it is known. - const bool hostedStdProvided = - capProviders.find("hosted-standard-library") != capProviders.end(); - if (auto ft = mcpp::toolchain::triple::parse(tc->targetTriple); - ft && ft->is_freestanding() && !hostedStdProvided) - { - return std::unexpected(std::format( - "`import std;` is not available on '{}' — a freestanding target " - "has no hosted standard library.\n" - " `std` is one module over the entire library (threads, " - "filesystem, iostreams\n" - " included), so there is no subset of it to build without " - "an OS underneath.\n" - " Use the freestanding subset instead — an ordinary " - "dependency carrying\n" - " the parts of the library that need no OS " - "(array, span, optional, atomic,\n" - " string_view, ranges, expected, charconv, coroutines):\n" - "\n" - " [dependencies]\n" - " std-freestanding = \"0.2.0\"\n" - "\n" - " then `import mcpplibs.std.freestanding;` in place of " - "`import std;`.\n" - " The target's C library itself comes from the BOARD " - "package (riscv-virt-rt\n" - " exports `mcpplibs.riscv_virt_rt`).", - tc->targetTriple)); - } - return std::unexpected(std::format( - "source imports std but toolchain '{}' provides no std module source", - tc->label())); - } - // `import std` availability is two-dimensional once C++20 is a legal level: - // having a std module source is not the same as being able to build it at - // the project's level. Every toolchain mcpp ships answers 20; only an MSVC - // STL older than microsoft/STL#3977 answers 23, and those users would - // otherwise get an error from inside std.ixx. - if (needsStdModule && tc->importStdMinLevel > 0 - && m->cppStandard.level < tc->importStdMinLevel) { - return std::unexpected(std::format( - "source imports std but toolchain '{}' provides the std module only " - "from {} up, while [package].standard resolves to '{}'; raise the " - "standard or drop `import std;`", - tc->label(), - mcpp::manifest::cpp_standard_level_name(tc->importStdMinLevel), - m->package.standard)); - } - - // Compute fingerprint (no lockfile in M1 → empty hash) - mcpp::toolchain::FingerprintInputs fpi; - fpi.toolchain = *tc; - fpi.cppStandard = m->package.standard; - // Target-keyed, not host-keyed (#685): the fingerprint must fold - // `macos_deployment_target` whenever THIS BUILD's resolved toolchain - // targets macOS, whether mcpp itself is running on Linux, Windows or - // macOS — see the discriminator comment on `min_platform_version` and - // on `canonical_compile_flags`. - const bool fpTargetIsMacos = [&] { - auto fpTt = mcpp::toolchain::triple::parse(tc->targetTriple); - return fpTt && fpTt->os == "macos"; - }(); - fpi.compileFlags = canonical_compile_flags(*m, fpTargetIsMacos) - + canonical_package_build_metadata(packages, fpTargetIsMacos); - // [c-abi] REALISATION AND `__OPENKAL__` PARTICIPATE IN THE FINGERPRINT - // (design 2026-09-18 §3.4, gap #4 of the design's own self-review). Two - // builds whose C library declares `data-model = "lp64"` and `"llp64"` - // compile the SAME source, against the SAME manifest, into objects whose - // `long` disagrees in width — sharing an output directory between them is - // exactly the silent ABI mismatch §3.4 exists to rule out. Appended only - // when non-empty (`tc->cEnvTokens` is empty whenever no `[c-abi]` block - // resolved), so a graph that declares nothing keeps the directory it - // already had. - if (tc->kernelAbiIsOpenkal) fpi.compileFlags += " openkal-kernel-abi"; - for (auto& t : tc->cEnvTokens) fpi.compileFlags += " cenv:" + t; - for (auto& t : tc->cEnvBuiltinsTokens) fpi.compileFlags += " cenv:" + t; - // A package opting OUT via `c-environment = "platform"` (§3.4) still - // changes what ITS OWN objects contain, relative to a graph where it - // did not opt out — so the opt-out is folded in too, named by the - // package rather than by its flags, since the flags it now keeps are - // simply the ones already covered above. - // - // GATED ON THE REALISATION ACTUALLY BEING ACTIVE (`cEnvTokens` or - // `cEnvBuiltinsTokens` non-empty) — NOT unconditional. `cEnvironment == - // "platform"` is true for every `mcpp:kernel-abi=` provider now - // (it is INFERRED, this same revision), in every graph that uses one, - // whether or not that graph's C library declares `[c-abi]` at all. An - // unconditional loop here folded `cenv-platform:` into the - // fingerprint of EVERY project using openkal-windows (say) even when - // nothing about the realised environment was active — moving every - // such project's output directory on upgrade for a string that - // describes an opt-out from a realisation that never ran. There is - // nothing to opt OUT of when there is nothing being realised, so the - // opt-out changes nothing about that package's own objects and must - // not move the fingerprint either — the same "declares nothing, byte - // identical" guarantee the rest of this block already gives, which this - // loop had broken on its own. - if (!tc->cEnvTokens.empty() || !tc->cEnvBuiltinsTokens.empty()) { - for (auto& pkg : packages) - if (pkg.manifest.cEnvironment == "platform") - fpi.compileFlags += " cenv-platform:" + pkg.manifest.package.name; - } - // The module-edge schedule changes the SHAPE of build.ninja, and the fast - // path replays that file without a plan to compare against. Folding the - // switch into the fingerprint puts a differently-scheduled build in a - // different directory, which makes replaying the wrong shape structurally - // impossible instead of merely guarded. Only appended when non-default, so - // existing build directories keep their identity. - if (const auto sched = mcpp::build::schedule::requested_switch(*m); - sched != "auto") { - fpi.compileFlags += " #schedule="; - fpi.compileFlags += sched; - } - // The device axis decides which sources compile and which cfg sections - // apply, so two builds that differ only in it are two builds. Appended - // only when set, so a project that asks for no accelerator keeps the - // build directory it has. - if (const auto accel = resolvedAccel(); !accel.empty()) { - fpi.compileFlags += " #accel="; - fpi.compileFlags += accel; - } - if (m->cppStandard.experimental) { - // c++fly gate flags are derived (not manifest-declared): fold them in - // so a cppfly table change across mcpp versions re-fingerprints. - for (auto& f : mcpp::toolchain::cppfly::resolve(*tc).flags) { - fpi.compileFlags += ' '; - fpi.compileFlags += f; - } - } - fpi.dependencyLockHash = ""; // M2 - fpi.stdBmiHash = ""; // updated after stdmod build (chicken/egg ok for M1) - auto fp = mcpp::toolchain::compute_fingerprint(fpi); - - // Pre-build std module only when the source graph actually imports it. - std::filesystem::path stdBmiPath; - std::filesystem::path stdObjectPath; - std::filesystem::path stdCompatBmiPath; - std::filesystem::path stdCompatObjectPath; - std::optional describedStdModule; - if (needsStdModule) { - // The std BMI must be compiled with the SAME dialect set its - // importers use (issue #210: -freflection gates libstdc++'s — - // a std BMI built without it structurally lacks std::meta). Both - // pieces were already in the fingerprint; this fixes the COMMAND - // construction the fingerprint promised (stdFlagAndDialect above). - // #422: the CRT model reaches the std module too. Derived from the - // SAME expression the project's TUs use (flags.cppm), through the one - // helper, so the two cannot drift. A GNU dialect yields "-static" or "" - // here, and the gcc and clang std module builders do not read it, so - // their commands are unchanged; clang on the MSVC ABI is given no CRT - // model at all (see `MechanismInput::msvcCrtModelEmitted`). - const auto& stdDialect = mcpp::toolchain::dialect_for(*tc); - const auto stdCrt = mcpp::toolchain::msvc_crt_flag( - stdDialect, mcpp::toolchain::msvc_wants_static_crt( - m->buildConfig.linkage, m->buildConfig.cxxRuntime)); - // Whether THIS build's resolved toolchain targets macOS — the same - // target-not-host discriminator `min_platform_version` uses, parsed - // locally because `tc` (not a `triple::Triple`) is what is in scope - // here (#685). - const bool stdTargetIsMacos = [&] { - auto stdTt = mcpp::toolchain::triple::parse(tc->targetTriple); - return stdTt && stdTt->os == "macos"; - }(); - if (overrides.plan_only) { - // Described, not compiled: the paths and commands are the ones - // ensure_built would use, from the one derivation in stdmod.cppm. - auto described = mcpp::toolchain::describe_std_module( - *tc, m->package.standard, stdFlagAndDialect, - mcpp::platform::macos::deployment_target( - stdTargetIsMacos, m->buildConfig.macosDeploymentTarget), - mcpp::toolchain::default_cache_root(), stdCrt); - if (!described) { - refusal::record(refusal::Code::StdModulePrecompile); - return std::unexpected(described.error().message); - } - stdBmiPath = described->bmiPath; - stdObjectPath = described->objectPath; - stdCompatBmiPath = described->compatBmiPath; - stdCompatObjectPath = described->compatObjectPath; - describedStdModule = std::move(*described); - } else { - auto sm = mcpp::toolchain::ensure_built( - *tc, m->package.standard, stdFlagAndDialect, - mcpp::platform::macos::deployment_target( - stdTargetIsMacos, m->buildConfig.macosDeploymentTarget), - mcpp::toolchain::default_cache_root(), stdCrt); - if (!sm) { - // THE ONE CODE IN THE TAXONOMY THAT NOTHING WROTE. - // - // `Code::StdModulePrecompile` has existed, with a name and a - // comment, since the taxonomy was written; `grep` for it found the - // declaration and the `name()` arm and no third site. So every - // std-module refusal reported `other`, which is the bucket - // refusal.cppm defines as "a refusal that has not been given a code - // yet" -- a visible admission, and one nobody had cashed. - // - // Measured: `tests/matrix/expected.tsv` carried exactly ONE `other` - // row out of 176, `x86_64-windows-msvc x llvm@22.1.8` in graph mode, - // and `scan.sh` printed it under "无名拒绝" on every Windows run. - // The sentence was right and the classification was missing -- - // the same shape `Code::HostToolToolchain` was added for. - refusal::record(refusal::Code::StdModulePrecompile); - return std::unexpected(sm.error().message); - } - stdBmiPath = sm->bmiPath; - stdObjectPath = sm->objectPath; - stdCompatBmiPath = sm->compatBmiPath; - stdCompatObjectPath = sm->compatObjectPath; - // C5 / D5a (design 2026-09-26 §3.5): compile_commands.json and the - // S1 document list the standard-library units too, so the plan - // needs the commands mcpp ran to build them (§13396 below), not - // only their output paths. `describe_std_module` is the pure - // derivation `ensure_built` itself reads before running anything - // (mcpp.toolchain.stdmod's header); calling it again here starts - // no process and cannot name a different command or directory. - // A failure here is not this build's failure -- `ensure_built` - // above already succeeded with the same inputs -- so it only - // means the description is unavailable for the plan, silently. - auto described = mcpp::toolchain::describe_std_module( - *tc, m->package.standard, stdFlagAndDialect, - mcpp::platform::macos::deployment_target( - stdTargetIsMacos, m->buildConfig.macosDeploymentTarget), - mcpp::toolchain::default_cache_root(), stdCrt); - if (described) describedStdModule = std::move(*described); - } - } - - if (print_fingerprint) { - std::println("Toolchain: {}", tc->label()); - std::println("Fingerprint: {}", fp.hex); - for (std::size_t i = 0; i < fp.parts.size(); ++i) { - std::println(" [{}] {}", i + 1, fp.parts[i]); - } - } - - BuildContext ctx; - ctx.strict = overrides.strict; - ctx.manifest = *m; - ctx.tc = *tc; - ctx.fp = fp; - ctx.runtimeSelection = runtimeSelection; - ctx.runtimeBinding = runtimeBindingSnapshot; - ctx.profile = effectiveProfile; - ctx.activeFeatureRequest = overrides.features; - ctx.compilerChoice = { std::string(tc_origin_name(tcOrigin)), - graphCompilerRequiredBy, - graphCompilerReplaced.empty() ? pinReplacedDefault - : graphCompilerReplaced }; - ctx.cacheMode = cacheMode; - ctx.projectRoot= *root; - ctx.outputDir = target_dir(*tc, fp, workRoot); - { - std::error_code ec; - const bool firstPlan = !std::filesystem::exists(ctx.outputDir / "build.ninja", ec); - for (auto const& [what, hint] : pending_flag_words_notes()) - if (firstPlan) mcpp::diag::warning("build/flag-words", what, hint); - pending_flag_words_notes().clear(); - } - ctx.stdBmi = stdBmiPath; - ctx.stdObject = stdObjectPath; - // Copied, not moved: `describedStdModule` is read again once `ctx.plan` - // exists (below), to recover the standard-library units' commands onto - // it (StdModuleUnit, C5 / D5a-b). A `std::optional` move leaves the - // source engaged with a moved-from value, so a plain move here would - // hand build_database.cppm's render() a value and the plan an empty one. - ctx.stdModule = describedStdModule; - // Every directory a package payload may legitimately have been INSTALLED - // into. There is more than one: the global registry, plus the two - // project-local data roots a custom git index installs into - // (`config::project_xlings_data_roots`). make_plan uses these to anchor the - // cache address of a dependency source that lives outside its own package - // root, and the cacheability gate below uses the same list to decide - // whether a package's sources really came from a store. ONE definition, - // two uses — deriving the same fact twice is how the object layout and the - // cache key drifted apart in the first place (#344). - // Which source trees does the fast path have to watch besides this one? - // - // A package whose root is neither under `projectRoot` nor under a directory - // mcpp OWNS is a `path` dependency — the shape every workspace member takes - // towards its siblings — and its sources are read on every build. See - // BuildContext::depSourceRoots for what the list is for. - // - // WHAT IS EXCLUDED, AND WHY IT IS "WHO WROTE THE DIRECTORY" RATHER THAN - // "WHICH KIND OF DEPENDENCY". An xpkg payload under the store is written - // once at install time and never edited. A git checkout under - // `/git/` is a pinned revision in a hash-addressed - // directory: changing the revision changes the directory name, and the - // manifest that names it is already swept. Neither can change under a warm - // build, so sweeping them would buy nothing and cost a directory walk per - // dependency on every invocation — which is the fast path this whole change - // exists to keep. - // - // A `path` dependency is the opposite on both counts: it is the user's - // working tree, and editing it is the point. - { - std::vector owned = storeRoots; - owned.push_back(mcpp::home::root()); - std::vector roots; - // The same enumeration answers a second reader: which packages were - // read from an editable tree, with their source globs (the build - // database lists them as the inputs that change the plan). - auto qualified = [](const mcpp::manifest::Manifest& pm) { - return pm.package.namespace_.empty() - ? pm.package.name - : pm.package.namespace_ + "." + pm.package.name; - }; - for (std::size_t i = 0; i < packages.size(); ++i) { - const auto& pkgRoot = packages[i].root; - if (pkgRoot.empty()) continue; - if (i > 0 && mcpp::build::path_is_under_any(pkgRoot, owned)) continue; - auto normalized = pkgRoot.lexically_normal(); - const bool known = std::ranges::any_of(ctx.sourcePackages, - [&](const BuildContext::SourcePackage& sp) { - return sp.root.lexically_normal() == normalized; - }); - if (!known) - ctx.sourcePackages.push_back({qualified(packages[i].manifest), - normalized, - packages[i].manifest.modules.sources}); - if (i == 0 || normalized == root->lexically_normal()) continue; - if (std::find(roots.begin(), roots.end(), normalized) == roots.end()) - roots.push_back(std::move(normalized)); - } - ctx.depSourceRoots = std::move(roots); - } - // Where a runner may find the programs this project declared (#544). The - // same resolution `fillXpkgDirs` hands to build programs, kept as - // directories rather than env vars because the reader is mcpp's own - // lookup, not a child process. See BuildContext::xlingsDepBinDirs. - // - // AND EVERY PACKAGE IN THE GRAPH, NOT ONLY THE ROOT — WHICH IS THE - // CASE THIS FEATURE EXISTS FOR. - // - // A board-support package is precisely the thing that knows which emulator - // or probe reaches its machine, and it declares that emulator under its own - // `[xlings] deps`. Collecting only the ROOT's declarations meant a runner - // could name a program by bare name only when the CONSUMER had also - // declared it — which is the duplication the board package exists to - // remove. Measured on `mcpplibs/aarch64-virt-rt`: with the board naming - // `qemu-system-aarch64` bare, `mcpp run` searched PATH, found the shim or - // nothing, and reported a missing runner while the emulator sat installed - // in the payload the board had declared. - // - // Ordering is root-first: a consumer that declares its own payload gets to - // decide, and a dependency supplies the answer when the consumer said - // nothing. A payload that is declared but not installed contributes - // nothing, and the lookup continues to PATH. - // - // AND THE SET COLLECTED HERE IS ALSO THE SET PROVISIONED. Looking in a - // directory that nothing installed is a lookup that can only fail, and the - // engine had exactly that shape: a dependency's declaration was searched - // and never acted on. The two definitions are one expression below, so - // they cannot drift — the third of the three hazards §12.6 named. - { - // THE SAME SPLIT THE EARLY PASS USED. Written once, above, next to the - // provisioning that has to happen before build.mcpp; this site reads it - // for the records below. Two copies of "what did the graph declare" - // would be two definitions of the same word. - auto split = graph_xlings_split(); - if (!split) { - refusal::record(refusal::Code::ToolVersionConflict); - return std::unexpected(split.error()); - } - auto& xlingsSpecs = split->first; - auto& fromGraph = split->second; - // THE ROOT'S OWN PASS RAN LONG AGO, AND THIS ONE MUST NOT REPEAT IT. - // The stamp is keyed by the LIST, so provisioning root+graph together - // would key a different list than the early pass wrote and re-run an - // xlings round trip on every build. Only what the graph added is - // provisioned here, under its own key. - // - // Since the graph's pass moved above build.mcpp this call is normally a - // stamp hit. It is kept rather than deleted because the stamp is keyed - // by content: if the early pass did not run, or ran on a different - // list, this is still the site that makes the record true. - if (!fromGraph.empty()) { - if (auto cfg = get_cfg()) { - if (auto pv = provision_xlings_addresses( - **cfg, fromGraph, *root, - "[xlings.workspace] entries declared by dependencies"); - !pv) return std::unexpected(pv.error()); - } - } - xlingsSpecs.insert(xlingsSpecs.end(), fromGraph.begin(), fromGraph.end()); - // What a RUN would additionally have asked for. Recorded rather than - // installed: this verb is not running anything, and installing it - // anyway is the behaviour the tier exists to remove. - if (toolPurpose == ToolPurpose::Build) { - for (std::size_t i = 0; i < packages.size() && !ctx.runTierPending; ++i) { - const auto& man = packages[i].manifest; - const auto feats = i < activeFeaturesByPackage.size() - ? activeFeaturesByPackage[i] : std::vector{}; - for (auto const& spec : applicable_xlings_addresses( - man, feats, ToolPurpose::Run, /*isRoot=*/i == 0)) - if (std::ranges::find(xlingsSpecs, spec) == xlingsSpecs.end()) - { ctx.runTierPending = true; break; } - } - } - if (!xlingsSpecs.empty()) { - if (auto cfg = get_cfg()) { - auto xlEnv = mcpp::config::make_xlings_env(**cfg); - for (auto const& spec : xlingsSpecs) { - auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec); - if (auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref)) { - // `bin/`, then the payload root. The measurement that - // added the second entry is recorded with the rule, in - // runner_lookup::payload_search_dirs. - for (auto& d : - mcpp::build::runner_lookup::payload_search_dirs(*dir)) - ctx.xlingsDepBinDirs.push_back(std::move(d)); - } - } - } - } - } - // ─── Prebuilt dependencies: check before planning to link them ───── - // - // Here rather than at each place a dependency manifest is loaded, because - // there are three of those and the check needs the RESOLVED toolchain, - // which only exists by now. One pass over the assembled package list is - // also the only spelling under which a package cannot be checked twice - // with two different answers. - // - // The current tag's SHAPE follows the package's: a package that publishes - // a triple-only tag is saying its interface is `extern "C"`, and comparing - // it against a full tag would refuse a combination it explicitly allows. - // `tag_check` already treats an unnamed dimension as don't-care, so one - // full tag on this side is correct for both. - { - const auto canonicalTriple = tc->targetTriple.empty() - ? mcpp::toolchain::triple::host_triple().str() - : [&] { - auto t = mcpp::toolchain::triple::parse(tc->targetTriple); - return t ? t->str() : tc->targetTriple; - }(); - auto currentTag = mcpp::pack::cxx_surface_tag( - *tc, canonicalTriple, m->cppStandard.level); - // What THIS build targets on the device axis. Absent means it asks for - // no accelerator, and every artifact then satisfies it vacuously — - // which is correct, and is why a descriptor lists its CPU-only variant - // first: the first accepted artifact wins. - currentTag.accel = mcpp::pack::parse_accel(resolvedAccel()); - for (std::size_t i = 1; i < packages.size(); ++i) { - auto const& pkg = packages[i]; - if (!mcpp::pack::is_distribution_package(pkg.manifest)) continue; - mcpp::pack::PrebuiltCheck chk{ - .packageRoot = pkg.root, - .packageLabel = mcpp::manifest::package_id(pkg.manifest.package).canonical(), - .current = currentTag, - }; - if (auto ok = mcpp::pack::check_prebuilt(pkg.manifest, chk); !ok) - return std::unexpected(ok.error()); - } - } - - // ── #519: the form each dependency takes, APPLIED ────────────────────── - // - // The answers were computed before the root build program (see there). - // - // MATERIALISED AS A TARGET KIND, on purpose. A dependency resolved to - // the shared form becomes an ordinary `SharedLibrary` target, so every - // emitter mcpp already has applies to it unchanged — the ELF soname and - // `$ORIGIN`, the PE import library and generated `.def`, the Mach-O - // install name. That is the whole reason this axis needs no new backend - // code on any of the three formats. It also means `make_plan` READS the - // answer instead of deriving it a second time. - { - namespace lf = mcpp::build::linkage_form; - - // A non-root edge that writes the key gets its request IGNORED, and - // says so — a silently dropped knob is how a knob becomes decoration. - for (std::size_t i = 1; i < packages.size(); ++i) - for (auto const& [depName, spec] : packages[i].manifest.dependencies) - if (!spec.linkage.empty()) - mcpp::diag::warning("build/dependency-linkage", std::format( - "'{}' asks for dependency '{}' to be linked as '{}'; only " - "the root project decides link forms, so this is ignored", - packages[i].manifest.package.name, depName, spec.linkage)); - - for (auto const& [i, form] : dependencyLinkForms) { - auto const& answer = form.answer; - auto const& facts = form.facts; - - if (!answer.diagnostic.empty()) - mcpp::diag::degraded("build/dependency-linkage", answer.diagnostic, - "this dependency is linked in the other form, which changes " - "whether its code travels inside the images that use it"); - // An explicit request honoured against the package's own default - // (#642 E1): the build did what was asked, so this is information, - // and it names both statements. - if (!answer.note.empty()) - mcpp::ui::info("Linkage", answer.note); - - if (answer.linkage != lf::DepLinkage::Shared) continue; - if (facts.isDistribution) continue; // nothing here to build - // A package that ALREADY declares a shared target has decided - // for itself, and its remaining library targets are not part of - // that decision. Flipping them would change what such a package - // builds under the DEFAULT request, which is the one property this - // axis promises never to touch. (No package in mcpp-index has both - // shapes at once — compat.vulkan's `lib` is overridden to `shared` - // on Linux rather than joined by it — but "unreachable today" is - // how the last few of these got in.) - if (facts.declaredShared) continue; - for (auto& t : packages[i].manifest.targets) - if (t.kind == mcpp::manifest::Target::Library) - t.kind = mcpp::manifest::Target::SharedLibrary; - } - } - - auto planResult = mcpp::build::make_plan(*m, *tc, fp, scan.graph, report.topoOrder, - packages, *root, ctx.outputDir, - stdBmiPath, stdObjectPath, storeRoots); - if (!planResult) return std::unexpected(planResult.error()); - ctx.plan = std::move(*planResult); - // Resolved far above, where the dependency graph first exists. It is - // attached here rather than threaded through `make_plan` because nothing - // that function does depends on it: the flag assembly that does reads the - // plan, and every reader of `compute_flags` runs after this line. - ctx.plan.targetSide = resolvedTargetSide; - - // C5 / D5a-b (design 2026-09-26 §3.5): the standard-library units this - // configuration's build compiles, when it imports `std`. Recovered here, - // once, from the SAME command derivation `ensure_built` and - // `describe_std_module` both read (mcpp.toolchain.stdmod), and carried on - // the plan (BuildPlan::stdModuleUnits) so compile_commands.json, - // `emit --spec compile-commands` and the S1 document render the exact - // same record and cannot disagree (P1, mcpp.build.compile_commands). - if (describedStdModule) { - const auto& sm = *describedStdModule; - auto add_std_unit = [&](const std::filesystem::path& source, - const std::vector& commands, - const std::filesystem::path& object, - const std::filesystem::path& bmi, - std::string_view module, - std::vector requiresModules) { - if (source.empty() || commands.empty()) return; - auto inv = mcpp::build::recover_invocation( - commands, source, tc->binaryPath, sm.cacheDir, - mcpp::platform::is_windows); - if (!inv) { - planNotes.push_back({"MCPP_BUILD_DATABASE_STD_UNIT_UNDESCRIBED", - std::format("no command that builds the {} module names its " - "source '{}'; the unit is not listed", - module, source.string())}); - return; - } - ctx.plan.stdModuleUnits.push_back(mcpp::build::StdModuleUnit{ - .source = source, - .workDirectory = std::move(inv->workDirectory), - .arguments = std::move(inv->arguments), - .object = object, - .bmi = bmi, - .module = std::string(module), - .requiresModules = std::move(requiresModules), - }); - }; - add_std_unit(tc->stdModuleSource, sm.stdCommands, sm.objectPath, - sm.bmiPath, "std", {}); - add_std_unit(tc->stdCompatSource, sm.compatCommands, sm.compatObjectPath, - sm.compatBmiPath, "std.compat", {"std"}); - } - - // A DEPENDENCY'S C++ SHARED LIBRARY OVER A C++ RUNTIME THAT IS A PACKAGE - // (#641, item 5). - // - // The runtime package is linked like every other static package: its - // objects go into the program. A dependency's shared library is linked from - // its own package's objects, and `-nostdlib++` withholds the driver's - // runtime, so the library has no C++ runtime at all. A private copy does - // not come for free either: `llvm.libcxx` compiles its classes with hidden - // visibility, so no image resolves against another's copy, and each image - // then holds its own type information for the library's classes. Measured - // on x86_64 Linux, an exception of `std::runtime_error` thrown in such a - // library is not caught by that type in the program; libc++ documents the - // same identity split for hidden types on arm64 Apple. - // - // So the private copy is linked only when the manifest states it for - // shared libraries (`cxx_runtime = { shared = "self-contained" }`, the key - // that already means a private runtime in each shared library for the - // payload's runtime), and every other case is refused here, before - // anything compiles. Each build this refuses failed at link before. - if (resolvedTargetSide.cxx.fromGraph() && cxxLayerProviderIndex - && *cxxLayerProviderIndex < packages.size()) { - namespace dist = mcpp::build::dist; - auto const& provider = packages[*cxxLayerProviderIndex].manifest; - const auto providerName = mcpp::build::qualified_package_name(provider); - auto const& bc = ctx.plan.manifest.buildConfig; - const auto format = dist::format_for( - tc->targetTriple, - mcpp::platform::is_windows ? dist::Format::Pe - : mcpp::platform::is_macos ? dist::Format::MachO - : dist::Format::Elf); - const bool privateCopy = - dist::stated_shared_library_contract(bc.cxxRuntime, bc.cxxRuntimeShared, - bc.staticStdlib, format) - == dist::Contract::SelfContained; - // A refused library, and the statement that makes it shared when its - // own package makes it so: an edge's `linkage = "static"` cannot change - // a form the package constrains (`declaredShared`), so that remedy is - // offered only where a request or the package's default decided. - struct Refused { std::string name; std::string statedBy; }; - std::vector withoutRuntime; - const auto runtimeObjects = mcpp::build::package_link_objects(ctx.plan, providerName); - for (auto& lu : ctx.plan.linkUnits) { - if (lu.kind != mcpp::build::LinkUnit::SharedLibrary || !lu.dependencyOwned) - continue; - if (!mcpp::build::link_unit_holds_cxx(ctx.plan, lu)) continue; - if (privateCopy) { - for (auto const& o : runtimeObjects) - if (std::ranges::find(lu.objects, o) == lu.objects.end()) - lu.objects.push_back(o); - continue; - } - Refused r{ lu.targetName, {} }; - for (auto const& [i, form] : dependencyLinkForms) { - if (!form.facts.declaredShared || i >= packages.size()) continue; - for (auto const& t : packages[i].manifest.targets) - if (t.name == lu.targetName) - r.statedBy = form.facts.declaredSharedBy.empty() - ? std::string("its manifest declares a shared library target") - : form.facts.declaredSharedBy; - } - withoutRuntime.push_back(std::move(r)); - } - if (!withoutRuntime.empty()) { - std::string names, constrained; - bool anyRequested = false; - for (auto const& r : withoutRuntime) { - names += (names.empty() ? "'" : ", '") + r.name + "'"; - if (r.statedBy.empty()) anyRequested = true; - else constrained += std::format( - " '{}' states its form itself ({}), so its edge cannot " - "link it static.\n", r.name, r.statedBy); - } - const std::string staticRemedy = anyRequested - ? " Link the dependency static, on its edge in [dependencies]:\n" - "\n" - " = { ..., linkage = \"static\" }\n" - "\n" - " or give each shared library a private copy of the runtime:\n" - : " Give each shared library a private copy of the runtime:\n"; - refusal::record(refusal::Code::SharedLibraryCxxRuntime); - return std::unexpected(std::format( - "{} {} linked as a shared library, and this graph's C++ runtime is " - "the package '{}@{}', whose objects are linked into the program.\n" - " A shared library built here would have no C++ runtime: the " - "package compiles its runtime\n" - " with hidden visibility, so one image cannot use another " - "image's copy.\n" - "{}{}" - "\n" - " [build]\n" - " cxx_runtime = {{ shared = \"self-contained\" }}\n" - "\n" - " With a private copy, an exception of a standard library class " - "thrown in the shared\n" - " library is not caught by that class in the program, because " - "each copy has its own\n" - " type information.", - names, withoutRuntime.size() == 1 ? "is" : "are", - providerName, provider.package.version, constrained, staticRemedy)); - } - } - - // ONE PROCESS, ONE C++ RUNTIME; ONE STATIC PACKAGE, ONE IMAGE (#646). - // - // Both are decided by `make_plan` and the contract table; this is where a - // decision that cannot be delivered stops the build before it compiles. - { - namespace dist = mcpp::build::dist; - auto const& bc = ctx.plan.manifest.buildConfig; - const auto format = dist::format_for( - tc->targetTriple, - mcpp::platform::is_windows ? dist::Format::Pe - : mcpp::platform::is_macos ? dist::Format::MachO - : dist::Format::Elf); - const dist::CxxSharedLoad load{ - .program = mcpp::build::image_loads_cxx_shared_library( - ctx.plan, mcpp::build::LinkUnit::Binary), - .tests = mcpp::build::image_loads_cxx_shared_library( - ctx.plan, mcpp::build::LinkUnit::TestBinary), - }; - const auto contracts = dist::role_contracts( - dist::ContractStatement{ - .cxxRuntime = bc.cxxRuntime, - .cxxRuntimeTests = bc.cxxRuntimeTests, - .cxxRuntimeShared = bc.cxxRuntimeShared, - .staticStdlib = bc.staticStdlib, - }, - format, load); - // F3a. A stated self-contained program over a coupled C++ shared - // library of this build: the program would carry a static C++ runtime - // and the library would load a shared one. The unstated case needs no - // refusal, because `role_contracts` then gives the program the - // library's contract. - if (auto role = dist::runtime_split(contracts, format, load)) { - std::string libraries; - for (auto const& lu : ctx.plan.linkUnits) { - if (lu.kind != mcpp::build::LinkUnit::SharedLibrary) continue; - if (!mcpp::build::link_unit_holds_cxx(ctx.plan, lu)) continue; - libraries += (libraries.empty() ? "'" : ", '") + lu.targetName + "'"; - } - const bool tests = *role == dist::Role::Test; - refusal::record(refusal::Code::ProgramCxxRuntimeSplit); - return std::unexpected(std::format( - "this build's {} state a self-contained C++ runtime and load the C++ " - "shared library {}, which is linked against the {} C++ runtime.\n" - " The process would hold two C++ runtimes: the program exports the " - "runtime symbols the\n" - " library references, the library binds some of them there and keeps " - "the rest, and the two\n" - " halves disagree about shared state (measured: a string formatted in " - "the library aborts\n" - " with std::bad_cast).\n" - " Remove the self-contained statement for {} (the `{}` value of " - "[build] cxx_runtime), and\n" - " they take the shared library's contract, or give the shared library " - "a private copy of the\n" - " runtime:\n" - "\n" - " [build]\n" - " cxx_runtime = {{ shared = \"self-contained\" }}", - tests ? "tests" : "programs", - libraries.empty() ? std::string("'(unnamed)'") : libraries, - dist::to_string(contracts.shared), - tests ? "tests" : "programs", tests ? "tests" : "default")); - } - - // MACH-O: EVERY IMAGE CARRIES ITS OWN HIDDEN libc++ (#646 F2). - // - // The Mach-O default is self-contained for every role, and each image - // embeds the payload's `libc++.a` through `-load_hidden`, so the type - // information of a standard library class exists once per image and libc++ - // compares it by address. Measured on macos-15 for this release: with the - // default, a `std::runtime_error` thrown in a dylib is NOT caught by its - // class in the program and two `std::error_code` categories compare - // unequal; with `cxx_runtime = "host-coupled"` for every role, both hold. - // The default is not changed here, because it is what every macOS build - // ships today and changing it is its own record; a build that would meet - // the split is told, once, what it is and how to avoid it. - if (format == dist::Format::MachO && (load.program || load.tests) - && contracts.shared == dist::Contract::SelfContained) { - std::string libraries; - for (auto const& lu : ctx.plan.linkUnits) { - if (lu.kind != mcpp::build::LinkUnit::SharedLibrary) continue; - if (!mcpp::build::link_unit_holds_cxx(ctx.plan, lu)) continue; - libraries += (libraries.empty() ? "'" : ", '") + lu.targetName + "'"; - } - mcpp::diag::degraded("build/cxx-runtime-identity", - std::format("this build's program and the C++ shared library {} each " - "carry a private copy of the C++ runtime", - libraries.empty() ? std::string("'(unnamed)'") : libraries), - "on Mach-O every image embeds the payload's libc++ with hidden " - "visibility, so the type information of a standard library class exists " - "once per image: measured on macOS, an exception of such a class thrown " - "in the library is not caught by that class in the program, and two " - "error categories compare unequal", - "state one runtime for the process, for example [build] cxx_runtime = " - "\"host-coupled\", when objects cross the boundary as exceptions or as " - "libc++ values compared by identity"); - } - - // F1. A static package that several images reach. Refused where the - // build cannot work (Mach-O and PE resolve every reference at link - // time; Android's Java host loads an application's shared library - // before anything that could supply the package), reported on other - // ELF rows, where the library binds to the program's copy at run time - // as it always has. - if (!ctx.plan.staticPlacementConflicts.empty()) { - const bool applicationRow = std::ranges::any_of(ctx.plan.linkUnits, - [](auto const& lu) { - return lu.kind == mcpp::build::LinkUnit::SharedLibrary - && !lu.dependencyOwned && lu.entryMain.has_value(); - }); - const bool refuse = format == dist::Format::MachO - || format == dist::Format::Pe || applicationRow; - std::string listing; - for (auto const& c : ctx.plan.staticPlacementConflicts) { - std::string reachers; - if (c.program) reachers = "the program"; - for (auto const& image : c.images) - reachers += (reachers.empty() ? "'" : ", '") + image + "'"; - listing += std::format(" '{}' is reached by {}\n", c.package, reachers); - } - const std::string first = ctx.plan.staticPlacementConflicts.front().package; - const std::string remedy = std::format( - " Link the package shared, so that every image loads one copy: on its " - "edge in [dependencies],\n" - "\n" - " {} = {{ ..., linkage = \"shared\" }}\n" - "\n" - " or as the package's own default, in its manifest:\n" - "\n" - " [targets.]\n" - " linkage = \"shared\"", first); - if (refuse) { - refusal::record(refusal::Code::StaticPackageInTwoImages); - return std::unexpected(std::format( - "a static package is linked into more than one image of this build, " - "and on this target\n" - " an image cannot use another image's copy:\n{}{}", - listing, remedy)); - } - mcpp::diag::degraded("build/static-placement", - std::format("a static package is reachable from more than one image of " - "this build and is linked into the program only:\n{}", - listing), - "the shared libraries bind to the program's copy at run time, which only " - "an ELF process whose program links the package can do; the same graph " - "is refused on Mach-O, PE and the Android application row", - std::format("give the package the shared form, e.g. {} = {{ ..., linkage " - "= \"shared\" }}", first)); - } - } - - // The module graph outlives the plan for one consumer: `mcpp pack`, which - // has to know which units are INTERFACE (published as source) and which - // are implementation (published only as an object). The plan flattens that - // away — a CompileUnit records what to compile, not what it provides — so - // the packer would otherwise have to scan the tree a second time and could - // then disagree with the build about what the package even contains. - ctx.graph = std::move(scan.graph); - // mcpp#407. Both callers that produce a non-plain graph arrive here the - // same way: dev-dependencies enabled, synthetic test targets appended. The - // resulting `default` line names the test binaries and omits the package's - // own target, and the output directory is shared with plain builds because - // the fingerprint covers neither input. Stamping it on the plan is what - // lets the graph say so about itself. - ctx.plan.graphShape = (includeDevDeps || !extraTargets.empty()) - ? mcpp::build::GraphShape::WithTests - : mcpp::build::GraphShape::Normal; - // The device variant an override chose is stamped for the same reason: the - // fast path runs without overrides, so a graph written under one must not - // be the graph it replays. - ctx.plan.accelOverridden = !overrides.accel.empty(); - - // THE MACHINE'S JOB DEFAULT, resolved unconditionally and never fatally. - // - // `get_cfg` is lazy, so by this point the config may or may not have been - // loaded -- a project with no dependencies can reach here without touching - // it. Asking for it here rather than reading whatever `cfg_opt` happens to - // hold is the point: otherwise the same project would honour - // `[build] default_jobs` or ignore it depending on whether it has - // dependencies, which is an answer that depends on an unrelated axis. - // - // A failure is discarded. This value is a concurrency hint, and a build - // must not fail because the machine's preferred job count could not be - // read; every other consumer of the config already reports its own - // failures with a diagnostic that fits what it needed the config FOR. - // `requireBootstrap=false` because nothing here needs the bootstrap - // toolchain. - int globalDefaultJobs = 0; - if (auto c = get_cfg(/*requireBootstrap=*/false)) - globalDefaultJobs = static_cast((*c)->defaultJobs); - ctx.globalDefaultJobs = globalDefaultJobs; - - // Resolve the module-edge schedule ONCE, here, where both the toolchain and - // the manifest are in hand. The backend writes the graph in this shape, the - // graph records the tag, and `mcpp build --verbose` prints the reason — all - // three read this, none of them re-derives it. - { - const auto decision = mcpp::build::schedule::decide( - ctx.plan.toolchain, - // Warned HERE and not at the fingerprint call above, which reads the - // same switch a few hundred lines earlier: both get the normalised - // value, only one of them says anything, so a typo produces exactly - // one warning rather than two identical ones. - mcpp::build::schedule::requested_switch(*m, [](std::string_view bad) { - mcpp::ui::warning(std::format( - "ignoring invalid bmi_schedule '{}' (expected \"auto\", \"on\" or \"off\")", bad)); - }), - mcpp::build::schedule::resolve_jobs(*m, [](std::string_view bad) { - mcpp::ui::warning(std::format( - "ignoring invalid job count '{}' (expected a positive number or 'auto')", bad)); - }, globalDefaultJobs), - // What this machine would pick if asked. Impure, so it is resolved - // here and handed to the pure `decide`. Only DetachCodegen uses it, - // and only when the user gave no job count — without it that - // strategy ships `sched_cap = 0`, which disables the semaphore that - // is its ONLY bound on how many compilers run at once. - mcpp::platform::capacity::recommended_jobs( - mcpp::platform::capacity::host_capacity())); - ctx.plan.scheduleTag = std::string(mcpp::build::schedule::to_string(decision.strategy)); - ctx.plan.scheduleNinjaJobs = decision.ninjaJobs; - ctx.plan.scheduleCompilerCap = decision.compilerCap; - mcpp::log::verbose("build", std::format("schedule: {} — {}", - ctx.plan.scheduleTag, decision.reason)); - - } - ctx.plan.runtimeBinding = runtimeBindingSnapshot; - mcpp::build::merge_runtime_binding_contract( - ctx.plan, runtimeBindingSnapshot); - ctx.plan.compileDbPath = workRoot / "compile_commands.json"; - // GCC: a clean `*link:` for this build, so the payload's specs cannot - // inject other homes' rpath entries into the artifact. AFTER the plan is - // moved in — an earlier assignment was silently overwritten by that move, - // which produced a generated file that nothing ever passed to the driver. - // Generated here rather than in compute_flags, which runs twice per build. - // A link input only: a plan that builds nothing (`plan_only`) neither reads - // it nor runs the driver to produce it, and its compile arguments are the - // same without it. - if (tc->compiler == mcpp::toolchain::CompilerId::GCC && !overrides.plan_only) - ctx.plan.gccCleanSpecs = mcpp::toolchain::write_clean_link_specs( - tc->binaryPath, ctx.outputDir); - - // ── Declared build-graph nodes → the plan ─────────────────────────────── - // - // Collected here rather than inside make_plan because the engine-variable - // vocabulary an action may reference includes values that only exist once - // the plan does (outputDir is fingerprint-derived; a target's file name is - // a link unit's output). - // - // The vocabulary is CLOSED on purpose. An action's command is an argv, not - // a shell string, and the only interpolations are these four — which is - // what makes an action portable (Windows has no shell to assume) and - // cacheable (nothing can smuggle in ambient state). - { - // An engine variable that resolves to nothing must be an ERROR, not an - // empty string: `${mcpp.target_file:tpyo}` would otherwise silently - // become an edge with a blank path, and ninja reports that far away - // from the typo that caused it. - std::set unresolvedTargets; - // `${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, - // and the tool then reads the build directory root -- which exists, so - // the mistake produces a plausible artifact instead of a diagnostic. - // Section 2 of the design record measured that shape: a valid, empty, - // 52 KB installer with nothing said about it. - std::set stageDirNoPass, stageDirWrongRole; - // Carried from the overrides so the refusal below can say WHY there is - // no tree, which is a different sentence from "you are not packaging". - std::string stageDirWhy; - // WHETHER *THIS* ACTION REFERENCED THE STAGED TREE, and deliberately a - // flag rather than a set keyed on the action's id: an id is unique - // within the package that declared it and nothing more, so two packages - // may each submit a `dist` action called `package`. A set would then - // hand one package's implicit dependency to the other's edge -- the - // shape where a predicate is right and the object is wrong, which does - // not fail, it answers about something else. - // - // The diagnostic sets below stay keyed by id because a diagnostic - // NAMES ids and a collision there costs a duplicate line, not a wrong - // edge. - bool thisActionUsesStageDir = false; - const bool stagePass = !overrides.pack_stage_dir.empty(); - auto substitute = [&](std::string s, const char* actionId, - mcpp::manifest::BuildAction::Role role) { - auto rep = [&](std::string_view what, const std::string& with) { - for (std::size_t p; (p = s.find(what)) != std::string::npos; ) - s.replace(p, what.size(), with); - }; - rep("${mcpp.out_dir}", ctx.plan.outputDir.string()); - rep("${mcpp.bin_dir}", (ctx.plan.outputDir / "bin").string()); - rep("${mcpp.compile_db}", ctx.plan.compileDbPath.string()); - // The engine's own executable, absolute (2026.9.13.1+). An action - // whose command is an argv with no shell has no portable way to - // copy, touch or compare a file, and the engine is the one - // program present wherever a build runs -- the reason a `check` - // is wrapped with `mcpp __action-stamp` (ninja_backend.cppm). This - // token lets a build program say the same thing: `${mcpp.self} - // stage --verify content --output ` is the copy every - // `stage_file` edge already performs. The same caveat as the - // wrapper's: a version change regenerates build.ninja, and a - // binary moved under an unchanged version leaves a stale path, - // exactly as it would for the compiler. - rep("${mcpp.self}", mcpp::platform::fs::self_exe_path().string()); - // ABSOLUTE, unlike `${mcpp.target_file:}` and for the same reason - // stated the other way round: the staged tree lives outside the - // build directory and no ninja edge produces it, so there is no - // edge-declared spelling to agree with. `${mcpp.out_dir}` above is - // absolute on the same grounds. - if (s.find("${mcpp.stage_dir}") != std::string::npos) { - if (!stagePass) { - stageDirNoPass.insert(actionId); - stageDirWhy = overrides.pack_stage_reason; - } else if (role != mcpp::manifest::BuildAction::Role::Artifact) { - stageDirWrongRole.insert(actionId); - } else { - thisActionUsesStageDir = true; - } - rep("${mcpp.stage_dir}", overrides.pack_stage_dir.string()); - } - constexpr std::string_view kTf = "${mcpp.target_file:"; - for (std::size_t p; (p = s.find(kTf)) != std::string::npos; ) { - auto close = s.find('}', p); - if (close == std::string::npos) break; - auto name = s.substr(p + kTf.size(), close - p - kTf.size()); - // The link unit's BUILD-DIR-RELATIVE output, not an absolute - // path. ninja identifies a file by the string an edge declares, - // and the link edge declares `bin/app`; an absolute reference - // to the same bytes is a DIFFERENT node, which ninja reports as - // "missing and no known rule to make it". Commands run with - // cwd = the build dir, so the relative form is also what the - // tool being invoked should receive. - std::string resolved; - for (auto const& lu : ctx.plan.linkUnits) - if (lu.targetName == name) - resolved = lu.output.generic_string(); - if (resolved.empty()) unresolvedTargets.insert(name); - s.replace(p, close - p + 1, resolved); - } - return s; - }; - auto collect = [&](const mcpp::manifest::Manifest& mm) { - // The declaring package, recorded here because this is the only - // place that knows it: the build program emitted the action, and a - // program has no idea which package the engine loaded it for. - // mcpp#534's ordering edge is scoped to this name. - auto owner = mcpp::build::qualified_package_name(mm); - for (auto a : mm.buildConfig.actions) { - thisActionUsesStageDir = false; - const auto sub = [&](std::string v) { - return substitute(std::move(v), a.id.c_str(), a.role); - }; - for (auto& x : a.inputs) x = sub(x); - for (auto& x : a.outputs) x = sub(x); - for (auto& x : a.command) x = sub(x); - // Same closed vocabulary as outputs — a depfile commonly - // wants to live at `${mcpp.out_dir}/.d`, beside the - // output it describes, and `prepare_actions` above - // deliberately left a `${mcpp.` depfile untouched for - // exactly this phase to resolve. - if (!a.depfile.empty()) a.depfile = sub(a.depfile); - // 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 - // shared library while the program's own bytes did not would - // leave the previous distributable in place, reported as - // up to date. - if (thisActionUsesStageDir) { - a.consumesStageDir = true; - a.inputs.push_back( - mcpp::pack::stage_manifest_path(overrides.pack_stage_dir).string()); - } - a.packageName = owner; - ctx.plan.actions.push_back(std::move(a)); - } - // Every package's declaration, on every pass. Sorted and de-duplicated - // below so the refusal's list reads the same whatever order resolution - // walked the graph in. - for (auto const& f : mm.buildConfig.packFormats) - ctx.plan.providedPackFormats.push_back(f); - }; - collect(*m); - for (std::size_t i = 1; i < packages.size(); ++i) - collect(packages[i].manifest); - std::ranges::sort(ctx.plan.providedPackFormats); - ctx.plan.providedPackFormats.erase( - std::ranges::unique(ctx.plan.providedPackFormats).begin(), - ctx.plan.providedPackFormats.end()); - ctx.plan.packFormat = overrides.pack_format; - if (!stageDirNoPass.empty()) { - std::string ids; - for (auto const& n : stageDirNoPass) ids += (ids.empty() ? "" : ", ") + n; - if (!stageDirWhy.empty()) { - return std::unexpected(std::format( - "build.mcpp action(s) [{}] reference ${{mcpp.stage_dir}}, and no " - "tree could be staged for this target.\n" - " {}\n" - " The format was requested and the provider was reached; what is " - "missing is the staged\n" - " closure itself. A member that names a built file with " - "${{mcpp.target_file:}} instead\n" - " of reading the tree is unaffected on this target.", - ids, stageDirWhy)); - } - return std::unexpected(std::format( - "build.mcpp action(s) [{}] reference ${{mcpp.stage_dir}}, and this " - "build is not packaging.\n" - " The staged tree is produced by `mcpp pack` after the link, so " - "it does not exist during\n" - " a plain build and there is nothing for the placeholder to name.\n" - " Gate the submission on the format you provide:\n" - " mcpp::provides_pack_format(\"\"); // always\n" - " if (std::string_view(mcpp::pack_format()) == \"\") " - "// then submit\n" - " and reach the tree with `mcpp pack --format `.", ids)); - } - if (!stageDirWrongRole.empty()) { - std::string ids; - for (auto const& n : stageDirWrongRole) ids += (ids.empty() ? "" : ", ") + n; - return std::unexpected(std::format( - "build.mcpp action(s) [{}] reference ${{mcpp.stage_dir}} with a role " - "other than \"artifact\".\n" - " Only an artifact action runs after the link, and the staged tree " - "is a link output's\n" - " successor: a source, object or check action is scheduled before " - "there is anything to stage.\n" - " use: role = \"artifact\"", ids)); - } - if (!unresolvedTargets.empty()) { - std::string bad, known; - for (auto const& n : unresolvedTargets) bad += (bad.empty() ? "" : ", ") + n; - for (auto const& lu : ctx.plan.linkUnits) - known += (known.empty() ? "" : ", ") + lu.targetName; - return std::unexpected(std::format( - "build.mcpp action references unknown target(s) via " - "${{mcpp.target_file:...}}: {}\n" - " targets in this build: [{}]\n" - " (a target gated by required_features is absent unless those " - "features are active)", - 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. - // - // The strings are pushed VERBATIM. ninja identifies a file by the string - // an edge declares, and the action edge declares whatever - // prepare_actions produced (an absolute path); handing the link edge a - // prettier relative spelling of the same bytes creates a second node and - // "missing and no known rule to make it" — the same trap - // ${mcpp.target_file:} documents just above. - std::set unknownObjectTargets; - for (auto const& a : ctx.plan.actions) { - if (a.role != mcpp::manifest::BuildAction::Role::Object) continue; - - // Validate EVERY named target, not just the case where none of them - // matched. Gating the check on "nothing attached" meant - // `.target("app").target("aap")` attached to `app` and dropped the - // typo without a word — while both the type comment and the docs - // promise an unknown name is an error. A per-name check is also the - // only one that scales: the failure it catches is a target that - // exists in one configuration and not another. - for (auto const& t : a.targets) { - bool known = false; - for (auto const& lu : ctx.plan.linkUnits) - if (lu.targetName == t) { known = true; break; } - if (!known) unknownObjectTargets.insert(t); - } - - bool attached = false; - for (auto& lu : ctx.plan.linkUnits) { - // Empty targets = every LINKED IMAGE, and a test binary is one. - // Excluding it made `mcpp build` succeed while `mcpp test` died - // with `undefined symbol` on the very symbol the action exists - // to provide — the library code under test links the same - // objects, so a blob/`.def`/pre-built `.o` has to reach it too. - // Naming the test target instead is not a workaround: test link - // units are DISCOVERED from tests/*.cpp, so their names are not - // in mcpp.toml and a build.mcpp that spells one stops building - // under plain `mcpp build`, where that unit does not exist. - // (`[resources]` makes the opposite call on purpose: an icon - // belongs to what ships, not to a test runner.) - // - // A STATIC LIBRARY IS ONE OF THEM, and leaving it out was - // the whole of what C-6 needed. A package whose device code is - // its point -- ggml's CUDA backend is 305 `.cu` files behind a - // `kind = "lib"` target -- emitted its actions, watched every - // one of them be dropped with a warning, and produced an - // archive with no device code in it. The archive rule already - // consumes `lu.objects`, so the objects an action produced - // belong there for exactly the reason a compiled `.cpp`'s do: - // the target's content is what it was told to contain. - // - // AND NOT A DEPENDENCY'S IMAGE. "Every linked image" means - // every image THIS PACKAGE produces; a `kind = "shared"` - // dependency contributes a link unit to this plan and is not - // one of them. Without the qualifier the SYCL example's device - // island was linked into `compat:opencl`'s ICD loader as well - // -- a C library carrying `saxpy_device` -- and the process - // held two copies of it. An action that means to reach a - // dependency's target cannot: it is not this package's to - // fill, and naming it explicitly already fails as unknown. - const bool image = !lu.dependencyOwned - && (lu.kind == mcpp::build::LinkUnit::Binary - || lu.kind == mcpp::build::LinkUnit::SharedLibrary - || lu.kind == mcpp::build::LinkUnit::StaticLibrary - || lu.kind == mcpp::build::LinkUnit::TestBinary); - const bool wanted = a.targets.empty() - ? image - : std::find(a.targets.begin(), a.targets.end(), - lu.targetName) != a.targets.end(); - if (!wanted) continue; - for (auto const& o : a.outputs) lu.objects.emplace_back(o); - attached = true; - } - - // No consumer at all. The edge is excluded from `actionDefaults` - // (its outputs are supposed to be reachable through a link edge), so - // this is not "builds but unused" — the command never runs and the - // build says nothing. Same shape, and same diagnostic, as - // `resources/no-image`. - if (!attached && a.targets.empty()) { - mcpp::diag::degraded("action/no-target", std::format( - "build.mcpp action '{}' has role = \"object\" but this build " - "produces no target to put its outputs into", - a.id.empty() ? "" : a.id), - "the action never runs and its outputs are never produced", - "add a [targets.] — a bin, a lib, a shared lib or a " - "test all take one — or name the targets explicitly with " - ".target(\"…\")"); - } - } - if (!unknownObjectTargets.empty()) { - std::string bad, known; - for (auto const& n : unknownObjectTargets) bad += (bad.empty() ? "" : ", ") + n; - for (auto const& lu : ctx.plan.linkUnits) - known += (known.empty() ? "" : ", ") + lu.targetName; - return std::unexpected(std::format( - "build.mcpp action with role = \"object\" names unknown " - "target(s): {}\n" - " targets in this build: [{}]\n" - " (a target gated by required_features is absent unless those " - "features are active; test binaries exist only under `mcpp " - "test`, so name none and the outputs reach every target " - "including them)", - bad, known.empty() ? std::string("none") : known)); - } - } - ctx.plan.stdCompatBmiPath = stdCompatBmiPath; - ctx.plan.stdCompatObjectPath = stdCompatObjectPath; - - // Clang: discover clang-scan-deps for P1689 dyndep scanning. - if (mcpp::toolchain::is_clang(*tc)) { - if (auto sd = mcpp::toolchain::clang::find_scan_deps(*tc)) { - ctx.plan.scanDepsPath = *sd; - } - } - - // ─── Assembly units: validate + resolve the assembler ───────────── - // .S/.s ride the C driver (GAS) — the MSVC dialect has no such path. - // .asm is NASM: x86-family only, and the binary is resolved LAZILY — - // only when the plan actually contains .asm units — as a hard failure, - // never a silent skip (a dropped .o surfaces as undefined references - // much later; fail here with the real cause instead). - { - bool hasGas = false, hasNasm = false; - for (auto& cu : ctx.plan.compileUnits) { - if (cu.kind == mcpp::SourceKind::GasAsm) hasGas = true; - else if (cu.kind == mcpp::SourceKind::NasmAsm) hasNasm = true; - } - if (hasGas && mcpp::toolchain::dialect_for(*tc).id == "msvc") { - return std::unexpected(std::string( - "GAS assembly sources (.S/.s) are not supported by the MSVC " - "toolchain; use NASM syntax (.asm) or a MinGW/LLVM toolchain, " - "or `!`-exclude them in [build].sources")); - } - if (hasNasm) { - auto trip = mcpp::toolchain::triple::parse(tc->targetTriple) - .value_or(mcpp::toolchain::triple::host_triple()); - auto fmt = trip.nasm_format(); - if (!fmt) { - return std::unexpected(std::format( - "NASM sources (.asm) are x86-only, but the target is {}; " - "gate them off non-x86 targets (a feature, or a " - "`!`-exclude glob in [build].sources)", trip.str())); - } - ctx.plan.nasmFormat = *fmt; - - // #232: nasm used to go through a bespoke `ensure_nasm` path - // whose `if (cfgNasm)` guard silently swallowed a `get_cfg()` - // bootstrap failure (misreporting it as "no nasm"), and whose - // install fallback never refreshed the package index and - // downgraded a failed install to a warning. Surface the real - // config error, then provision through the SAME synchronous - // gate the compiler toolchain uses (index refresh before - // install, blocking install, hard error on failure) — see the - // toolchain resolution block above (~line 872-899). - auto cfgNasm = get_cfg(); - if (!cfgNasm) return std::unexpected(cfgNasm.error()); - - std::optional nasmBin = - mcpp::xlings::find_usable_nasm(mcpp::config::make_xlings_env(**cfgNasm)); - if (!nasmBin) { - mcpp::fetcher::Fetcher nasmFetcher(**cfgNasm); - mcpp::fetcher::InstallProgressHandler nasmProgress; - auto nasmTarget = std::format("xim:nasm@{}", - mcpp::xlings::pinned::kNasmVersion); - auto payload = nasmFetcher.resolve_xpkg_path( - nasmTarget, /*autoInstall=*/true, &nasmProgress); - if (!payload) { - return std::unexpected(std::format( - "NASM sources (.asm) present but nasm provisioning " - "failed: {}", payload.error().message)); - } - nasmBin = mcpp::xlings::find_sandbox_nasm( - mcpp::config::make_xlings_env(**cfgNasm)); - } - if (!nasmBin) { - return std::unexpected(std::string( - "NASM sources (.asm) present but no usable nasm (>= 2.16) " - "was found or installable; install one via `xlings install " - "nasm` or your system package manager")); - } - // A HOST TOOL THAT REACHES A BUILD IS NAMED THERE. The sandbox - // copy is tried first (mcpp.xlings::find_usable_nasm), so this - // fires only where that route could not serve: an offline machine - // that already has an assembler. Saying nothing would leave two - // machines assembling the same source with different tools and - // no line in either build recording which. - if (mcpp::xlings::nasm_is_from_host( - mcpp::config::make_xlings_env(**cfgNasm), *nasmBin)) { - mcpp::diag::degraded("build/nasm-from-host", std::format( - "the assembler for this build is the host's ('{}'), not " - "the one this engine pins", nasmBin->string()), - "two machines can assemble the same source with different " - "assemblers, and the build records only this line", - "run `xlings install nasm` so the pinned copy is used"); - } - ctx.plan.nasmPath = *nasmBin; - } - } - - // ─── Windows resources: [resources] → a tracked link input (mcpp#365) ── - // - // Four rules, in this order: - // 1. Only the ROOT package's [resources] is read. A dependency's version - // resource would fight its consumer's for ordinal 1, and a dependency - // that produces no PE image of its own has nothing to embed into. - // 2. A DECLARED FILE THAT DOES NOT EXIST IS AN ERROR — on EVERY target. - // Whether a path exists is a fact about the working tree, not about - // the target; gating it on is_pe() meant a Linux or macOS CI could not - // see a typo in `icon = …` at all and only the Windows job went red, - // which is the same "find out late" failure the hard error exists to - // remove. Existence is checked everywhere; only COMPILATION is PE-only. - // 3. On a non-PE target nothing is compiled — no units, no warning, - // byte-identical build. This is what makes `cfg(windows)` unnecessary - // (and it could not be used anyway: the conditional channel carries - // BuildInputs only). - // 4. Nothing to embed into (an archive-only package) → say so and stop. - // - // The same pipeline carries the application manifest of `windows_code_page` - // (#693). A PE executable embeds one that makes its process ANSI code page - // UTF-8 when its target says `windows_code_page = "utf-8"`, or, with nothing - // said, when it is built as a host tool (D6): such a tool receives mcpp's - // UTF-8 paths on its command line. `legacy` opts out, and an ordinary target - // that says nothing embeds nothing (M6: the program's encoding is its own). - // - // The host-tool default yields to a manifest the package embeds itself - // through `[resources] files`: both would sit at ordinal 1, the package - // said nothing about code pages, and its own manifest is the one it ships. - // A DECLARED `utf-8` beside such a manifest is refused below instead. - const bool hostToolBuild = overrides.tool_depth > 0; - const bool ownManifest = hostToolBuild - && std::ranges::any_of(m->resources.files, [&](const auto& f) { - const auto abs = (f.is_absolute() ? f : (*root / f)).lexically_normal(); - return mcpp::build::resources::scan_rc(abs).declaresManifest; - }); - auto codePageOf = [&](const mcpp::manifest::Target& t) -> std::string_view { - if (!t.windowsCodePage.empty()) return t.windowsCodePage; - return (hostToolBuild && t.is_program() && !ownManifest) ? "utf-8" : "legacy"; - }; - const bool anyUtf8Image = std::ranges::any_of(m->targets, [&](const auto& t) { - return t.is_program() && codePageOf(t) == "utf-8"; - }); - if (m->resources.declared() || anyUtf8Image) { - namespace rsrc = mcpp::build::resources; - const auto& R = m->resources; - - // Rule 2 — target-independent, so it runs before the is_pe() gate. - auto resolve_declared = [&](const std::filesystem::path& p, - std::string_view key) - -> std::expected - { - // Lexical, not weakly_canonical: canonicalising resolves symlinks, - // and a symlinked source tree would then bake a different path into - // the generated script than the one the user wrote. (Same reason - // mcpp#344 made the cache anchor lexical.) - auto abs = (p.is_absolute() ? p : (*root / p)).lexically_normal(); - std::error_code ec; - if (!std::filesystem::is_regular_file(abs, ec)) - return std::unexpected(std::format( - "[resources] {} = \"{}\" does not exist (looked at {}).\n" - " A declared resource is a build input like any other " - "source: mcpp will not quietly ship a binary without it. " - "Remove the key if the resource is not wanted.", - key, p.generic_string(), abs.generic_string())); - return abs; - }; - - std::filesystem::path iconAbs; - if (!R.icon.empty()) { - auto r = resolve_declared(R.icon, "icon"); - if (!r) return std::unexpected(r.error()); - iconAbs = *r; - } - std::vector extraInputs; - for (auto const& e : R.extraInputs) { - auto r = resolve_declared(e, "extra-inputs"); - if (!r) return std::unexpected(r.error()); - extraInputs.push_back(*r); - } - std::vector scriptFiles; - for (auto const& f : R.files) { - auto r = resolve_declared(f, "files"); - if (!r) return std::unexpected(r.error()); - scriptFiles.push_back(*r); - } - - const auto trip = mcpp::toolchain::triple::parse(tc->targetTriple) - .value_or(mcpp::toolchain::triple::host_triple()); - - // Rules 3 and 4 are early returns rather than nesting: the body below is - // ~150 lines and an `else` around all of it reads as an accident. - auto plan_resources = [&]() -> std::expected { - const auto dialectId = mcpp::toolchain::dialect_for(*tc).id; - const bool msvcStyle = (dialectId == "msvc"); - const std::string_view outExt = msvcStyle ? ".res" : ".o"; - const auto resDir = ctx.plan.outputDir / "res"; - std::error_code mkEc; - std::filesystem::create_directories(resDir, mkEc); - - // Which link units embed resources: images, not archives. A `.res` - // inside a static library is dropped by every linker that reads one. - // Test binaries are images too, but deliberately excluded: an icon - // and an OriginalFilename belong to what the project SHIPS, and a - // test executable is not that. (`role = "object"` makes the opposite - // call, for the opposite reason — see its note above.) - std::vector peUnits; - for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) { - auto k = ctx.plan.linkUnits[i].kind; - if (k == mcpp::build::LinkUnit::Binary || - k == mcpp::build::LinkUnit::SharedLibrary) - peUnits.push_back(i); - } - // Nothing to embed into. Compiling the scripts anyway would leave - // orphan edges nothing depends on, and demanding a resource - // compiler for them would fail a build that has no use for one. - // A degradation, not a warning: the user asked for something and - // got nothing, so `--strict` should see it. - if (peUnits.empty()) { - mcpp::diag::degraded("resources/no-image", std::format( - "[resources] is declared but '{}' produces no executable or " - "shared library for {}", m->package.name, trip.str()), - "nothing embeds the icon or the version metadata", - "add a [targets.] with kind = \"bin\" or \"shared\", " - "or drop the [resources] section"); - return {}; - } - - // Two scripts with the same stem in different directories would - // otherwise write the same artifact — a silent "multiple rules - // generate" that ninja reports far from the cause. - std::set usedStems; - auto add_unit = [&](const std::filesystem::path& src, - std::string_view stem, - std::vector inputs, - std::size_t attachTo) - -> std::expected - { - if (!usedStems.insert(std::string(stem)).second) - return std::unexpected(std::format( - "[resources] two resource scripts are named '{}.rc'; " - "they would produce the same artifact. Rename one.", stem)); - mcpp::build::ResourceUnit ru; - ru.source = src; - ru.output = std::filesystem::path("res") / - (std::string(stem) + std::string(outExt)); - ru.implicitInputs = std::move(inputs); - ctx.plan.resourceUnits.push_back(std::move(ru)); - const auto& out = ctx.plan.resourceUnits.back().output; - if (attachTo == static_cast(-1)) { - for (auto i : peUnits) ctx.plan.linkUnits[i].objects.push_back(out); - } else { - ctx.plan.linkUnits[attachTo].objects.push_back(out); - } - return {}; - }; - - // Author-written scripts: compiled once, linked into every image. - for (auto const& rcSrc : scriptFiles) { - auto scan = rsrc::scan_rc(rcSrc); - if (scan.versionInfoNamedByString) { - // The mcpp#365 silent failure, caught on the way in. A - // degradation rather than a warning: the impact is exactly - // the thing this feature exists to remove — a shipped binary - // whose version metadata Windows cannot read — so a build - // that asked for `--strict` must not pass over it. - mcpp::diag::degraded("resources/versioninfo", std::format( - "{}: `{} VERSIONINFO` names the version resource '{}' " - "instead of ordinal 1", - rcSrc.filename().generic_string(), scan.versionInfoName, - scan.versionInfoName), - "Windows will not find it — GetFileVersionInfo looks up " - "MAKEINTRESOURCE(1) and every field comes back empty, " - "while every tool that prints the resource TYPE still " - "says it is fine", - "VS_VERSION_INFO is a macro from ; add " - "`#include ` to the script, or write " - "`1 VERSIONINFO`"); - } - for (auto const& g : scan.gaps) { - mcpp::diag::degraded("resources/inputs", - std::format("{}: `{}` names its file through a macro, so " - "mcpp cannot track it", - rcSrc.filename().generic_string(), g), - "editing that file will not trigger a rebuild", - "list it in [resources] extra-inputs = [...]"); - } - if (scan.declaresManifest && anyUtf8Image) - return std::unexpected(std::format( - "[resources] {} embeds an application manifest, and " - "`windows_code_page = \"utf-8\"` embeds another at the same " - "ordinal (1).\n Keep one: add `" - "UTF-8` to your manifest and set " - "`windows_code_page = \"legacy\"`, or drop your manifest.", - rcSrc.filename().generic_string())); - auto inputs = std::move(scan.inputs); - inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); - if (auto a = add_unit(rcSrc, rcSrc.stem().string(), - std::move(inputs), - static_cast(-1)); !a) - return std::unexpected(a.error()); - } - - // The synthesised script: per image, because OriginalFilename and - // the version block belong to a specific artifact, and the - // manifest to a specific executable. - 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; - for (auto const& t : m->targets) - if (t.name == lu.targetName) - return t.is_program() && codePageOf(t) == "utf-8"; - return false; - }; - if (!iconAbs.empty() || synthVersion || anyUtf8Image) { - // A version key mcpp cannot order (an upstream build number) - // leaves FILEVERSION's four numeric fields at zero while the - // string fields keep the real text. Say so — the properties - // dialog will disagree with `[package].version` and nothing - // else would explain why. - if (synthVersion && !m->package.version.empty() - && !mcpp::version_req::parse_version(m->package.version)) { - mcpp::diag::degraded("resources/version", - std::format("[package].version = \"{}\" has no numeric " - "form", m->package.version), - "the embedded FILEVERSION / PRODUCTVERSION fields are " - "0,0,0,0 (the string fields keep the real version)", - "set [resources.version-info] explicitly, or use a " - "dotted numeric version"); - } - for (auto i : peUnits) { - const auto& lu = ctx.plan.linkUnits[i]; - const bool utf8 = wantsUtf8(lu); - if (iconAbs.empty() && !synthVersion && !utf8) continue; - std::filesystem::path manifestAbs; - if (utf8) { - manifestAbs = resDir / (lu.targetName + ".mcpp.manifest"); - const auto manifestText = rsrc::utf8_code_page_manifest(); - std::string had; - if (std::ifstream in(manifestAbs, std::ios::binary); in) - had.assign(std::istreambuf_iterator(in), {}); - if (had != manifestText) { - std::ofstream os(manifestAbs, std::ios::binary); - if (!os) return std::unexpected(std::format( - "cannot write the application manifest '{}'", - manifestAbs.string())); - os << manifestText; - } - } - // A script synthesised for the manifest alone carries - // nothing else: a package that declares no [resources] - // asked for no version resource. - mcpp::manifest::Resources forScript = R; - if (!synthVersion) forScript.versionInfo = false; - auto text = rsrc::synthesize_rc( - m->package, forScript, lu.output.filename().string(), - iconAbs, manifestAbs); - if (!text) return std::unexpected(text.error()); - // A stable path, so `cp` + `files = [...]` reproduces the - // same resource byte for byte (the L0→L1 escape hatch). - auto rcPath = resDir / (lu.targetName + ".mcpp.rc"); - // Write only on change: rewriting unconditionally would - // relink on every build. - std::string existing; - if (std::ifstream in(rcPath, std::ios::binary); in) - existing.assign(std::istreambuf_iterator(in), {}); - if (existing != *text) { - std::ofstream os(rcPath, std::ios::binary); - if (!os) return std::unexpected(std::format( - "cannot write generated resource script '{}'", - rcPath.string())); - os << *text; - } - std::vector inputs; - if (!iconAbs.empty()) inputs.push_back(iconAbs); - if (!manifestAbs.empty()) inputs.push_back(manifestAbs); - inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); - if (auto a = add_unit(rcPath, lu.targetName + ".mcpp", - std::move(inputs), i); !a) - return std::unexpected(a.error()); - } - } - - if (ctx.plan.resourceUnits.empty()) return {}; - - // Lazy + hard failure, exactly like nasm: a dropped resource - // surfaces as "where did my icon go", which is unattributable. - auto tool = rsrc::find_rc_tool(*tc, dialectId); - if (!tool) { - return std::unexpected(std::format( - "[resources] needs a Windows resource compiler for the " - "{} toolchain targeting {}, and none was found next to " - "{}.\n Expected {} in the toolchain's own bin directory " - "(mcpp does not search PATH for build tools).", - dialectId, trip.str(), tc->binaryPath.string(), - msvcStyle ? "rc.exe or llvm-rc" - : "-windres, windres or llvm-windres")); - } - ctx.plan.rcPath = tool->path; - ctx.plan.rcStyle = tool->style; - - // UTF-8 input, always. `[package]` metadata is user text and - // routinely non-ASCII; without this llvm-rc refuses the script - // outright ("Non-ASCII 8-bit codepoint can't be interpreted in - // the current codepage") rather than mangling it, so a project - // with a Chinese description could not build at all. - ctx.plan.rcFlags.push_back(msvcStyle ? "/C" : "--codepage=65001"); - if (msvcStyle) ctx.plan.rcFlags.push_back("65001"); - - // Include search: the project first, then whatever the toolchain - // puts on INCLUDE. llvm-rc preprocesses but does NOT read INCLUDE - // (rc.exe does), so the SDK dirs have to be spelled out for it — - // that is what makes `#include ` work, and it is the - // supported way to get VS_VERSION_INFO defined. - const std::string ip = msvcStyle ? "/I" : "-I"; - ctx.plan.rcFlags.push_back(ip + root->string()); - for (auto const& d : m->buildConfig.includeDirs) { - auto abs = d.is_absolute() ? d : (*root / d); - ctx.plan.rcFlags.push_back(ip + abs.string()); - } - if (msvcStyle && tool->name().find("llvm-rc") != std::string::npos) { - for (auto const& ev : tc->envOverrides) { - if (ev.key != "INCLUDE") continue; - // Shared splitter: `;` only. See rsrc::split_env_list — - // the drive colon is not a separator. - for (auto dir : rsrc::split_env_list(ev.value)) - ctx.plan.rcFlags.push_back(ip + std::string(dir)); - } - } - return {}; - }; - - if (trip.is_pe()) - if (auto r = plan_resources(); !r) return std::unexpected(r.error()); - } - - // ─── Global dependency cache: per-package keys, hit → stage edges ── - // - // Every index package gets a key over the axes that actually reach its - // compiler command lines (mcpp.build.cache_key), computed bottom-up so a - // package's key includes its direct dependencies' keys. A hit marks that - // package's compile units `servedFromCache`, and the ninja backend emits - // `stage_file` edges instead of compile edges for them — which is the only - // way ninja will accept a cached artifact. A miss records a populate task - // for after the build. - // - // `--cache=local|off` skips this block entirely: nothing is read and, in - // run_build_plan, nothing is written. - auto cfg2 = get_cfg(); - if (cfg2 && ctx.cacheMode == CacheMode::Global) { - std::error_code mkEc; - std::filesystem::create_directories(ctx.outputDir, mkEc); - - // NOTE (mcpp#344): there is deliberately no local "derive the entry - // address from the object path" helper here any more. There used to be - // one, and it was the SECOND derivation of a fact plan.cppm already - // owns — it stripped `obj/` off the consumer's build path, so the entry - // layout followed the consumer's package mix while the key did not. - // `CompileUnit::packageObjectRel` is now the only answer to "where does - // this object live inside a cache entry", and it is computed in exactly - // one place. Do not reintroduce a second one. - - // ── Per-package keys, bottom-up ────────────────────────────────── - // Axes A/B/C are whole-graph, so they are computed once. Axes D/E are - // per package. Axis F is each direct dependency's key, which forces a - // bottom-up order: `dependencyEdges` is a DAG (the modgraph validator - // rejects cycles), so a simple memoized recursion suffices — with an - // explicit in-progress guard so a cycle that slipped past validation - // fails loudly instead of recursing until the stack dies. - namespace ck = mcpp::build::cache_key; - auto axes = ck::build_axes( - *tc, *m, stdFlagAndDialect, - mcpp::toolchain::cppfly::effective_dialect_flags( - *tc, m->cppStandard.experimental, - mcpp::manifest::dialect_flags(m->buildConfig)), - // ONE SLOT, BOTH PLATFORMS. See `min_platform_version`: a target - // is either Apple or Android, and the level selects which bionic - // symbols are visible, so two levels must be two build - // directories. - [&] { - auto tt = mcpp::toolchain::triple::parse(tc->targetTriple); - return tt ? min_platform_version(*m, *tt, tc->binaryPath) - : std::string{}; - }(), - // The GLOBAL registry root — the same one `fill_package_config` - // relativizes against below, so both halves of the key describe - // payload paths the same way. - storeRoots.empty() ? std::filesystem::path{} : storeRoots.front(), - // The bit `make_plan` decided and `compute_flags` emits. Reading - // it here rather than re-deriving is what keeps the objects a - // cache entry HOLDS and the objects a build ASKS FOR describable - // by one sentence. - ctx.plan.needsPic); - - // Sources belonging to each package, package-root-relative and sorted. - std::vector> pkgSources(packages.size()); - for (auto& cu : ctx.plan.compileUnits) { - // Longest matching root wins. Package roots can nest — a workspace - // member lives under the workspace root — and taking the first match - // would file the member's sources under the outer package, putting - // them in the wrong key. (Index payloads live in the xpkgs store and - // cannot be shadowed this way, so no cached entry is affected today; - // resolving it by specificity rather than by iteration order is what - // keeps that true if roots ever move.) - std::size_t best = packages.size(); - std::size_t bestLen = 0; - std::string bestRel; - for (std::size_t p = 0; p < packages.size(); ++p) { - std::error_code ec; - auto rel = std::filesystem::relative(cu.source, packages[p].root, ec); - if (ec || rel.empty()) continue; - auto rels = rel.generic_string(); - if (rels.starts_with("..")) continue; - auto len = packages[p].root.generic_string().size(); - if (best == packages.size() || len > bestLen) { - best = p; bestLen = len; bestRel = std::move(rels); - } - } - if (best != packages.size()) pkgSources[best].push_back(std::move(bestRel)); - } - for (auto& v : pkgSources) std::ranges::sort(v); - - std::vector pkgKeys(packages.size()); - std::vector pkgInputs(packages.size(), - nlohmann::json::object()); - std::vector keyState(packages.size(), 0); // 0 new/1 busy/2 done - std::string keyCycleError; - // Does this package's own transitive upstream contain anything that is - // not an immutable index payload? If so it cannot be cached either, even - // when the package itself is an index package. - // - // A key covers an upstream package by folding in that package's KEY, and - // a local package's key covers its file list but not its file CONTENTS — - // nothing could, without hashing a tree that may change between the hash - // and the compile. So editing a local upstream's source would leave a - // downstream entry looking valid. No index descriptor can declare a path - // dependency today, which makes this shape unreachable in practice; it is - // enforced structurally anyway, because "unreachable today" is how the - // transitive path-dep leak got in. - std::vector localTaint(packages.size(), 0); - auto compute_key = [&](auto&& self, std::size_t idx) -> const std::string& { - static const std::string kEmpty; - if (keyState[idx] == 2) return pkgKeys[idx]; - if (keyState[idx] == 1) { - if (keyCycleError.empty()) { - keyCycleError = std::format( - "dependency cycle through package '{}' while computing " - "its build-cache key", packages[idx].manifest.package.name); - } - return kEmpty; - } - keyState[idx] = 1; - - ck::PackageAxes pa; - if (idx > 0 && idx - 1 < dep_cache_identities.size()) { - pa.indexName = dep_cache_identities[idx - 1].indexName; - pa.packageName = dep_cache_identities[idx - 1].packageName; - pa.version = dep_cache_identities[idx - 1].version; - } - if (pa.packageName.empty()) { - // The root package, or a package with no resolution identity. - // It is never cached, but its key still has to exist because - // downstream packages fold it in via axis F. - pa.packageName = packages[idx].manifest.package.namespace_.empty() - ? packages[idx].manifest.package.name - : std::format("{}.{}", packages[idx].manifest.package.namespace_, - packages[idx].manifest.package.name); - } - if (pa.version.empty()) pa.version = packages[idx].manifest.package.version; - // The GLOBAL registry root — index 0 by construction above. Include - // dirs are relativized against it so a key survives a different - // MCPP_HOME; a project-local payload falls back to the `` - // prefix inside fill_package_config and is equally stable. - ck::fill_package_config(pa, packages[idx], - storeRoots.empty() ? std::filesystem::path{} - : storeRoots.front()); - pa.sources = pkgSources[idx]; - const bool selfIsIndex = idx > 0 - && idx - 1 < dep_cache_identities.size() - && dep_cache_identities[idx - 1].sourceKind == "version"; - if (!selfIsIndex) localTaint[idx] = 1; - for (auto& e : dependencyEdges) { - if (e.consumerPackageIndex != idx) continue; - auto& up = self(self, e.dependencyPackageIndex); - if (!up.empty()) pa.upstreamKeys.push_back(up); - if (localTaint[e.dependencyPackageIndex]) localTaint[idx] = 1; - for (auto& f : e.requestedFeatures) pa.features.push_back(f); - } - std::ranges::sort(pa.upstreamKeys); - pa.upstreamKeys.erase(std::unique(pa.upstreamKeys.begin(), - pa.upstreamKeys.end()), - pa.upstreamKeys.end()); - std::ranges::sort(pa.features); - pa.features.erase(std::unique(pa.features.begin(), pa.features.end()), - pa.features.end()); - - pkgKeys[idx] = ck::key_hex(axes, pa); - pkgInputs[idx] = ck::to_json(axes, pa); - keyState[idx] = 2; - return pkgKeys[idx]; - }; - for (std::size_t i = 0; i < packages.size(); ++i) - (void)compute_key(compute_key, i); - if (!keyCycleError.empty()) return std::unexpected(keyCycleError); - - for (std::size_t i = 1; i < packages.size(); ++i) { // skip [0] = main - const auto& pkgRoot = packages[i]; - const auto* depIdent = i - 1 < dep_cache_identities.size() - ? &dep_cache_identities[i - 1] - : nullptr; - // Only index ("version") packages are cacheable, and the identity - // recorded at resolution time is the ONLY admissible evidence. - // - // The predicate this replaces looked the package up in the ROOT - // manifest's dependencies/dev-dependencies and skipped it when the - // spec was path/git. A transitively-reached package is in neither - // map, so `specIt == end()` left skipCache false and local sources - // were cached — with `indexName` falling back to defaultIndex, so a - // workspace member `B` landed on disk as `mcpplibs/B@0.1.0`. Its - // sources can then change without changing name@version, i.e. the - // cache key cannot see the change. - // - // Note the direction of the judgment: `mcpp add`'s existence gate - // admits anything it cannot disprove. A build cache must do the - // opposite — anything it cannot prove came from the immutable - // xpkgs store stays out, because the failure mode here is a - // silently wrong object rather than a rejected command. - if (!depIdent || depIdent->sourceKind != "version") continue; - // ...and neither may anything it was built against be local. - if (localTaint[i]) continue; - // ...and the package's sources must ACTUALLY be in the immutable - // store, not merely labelled as coming from it. - // - // The rule stated three paragraphs up is about provenance on disk; - // `sourceKind` is a label recorded at resolution time, which is a - // weaker proxy — and there is already a case where the two - // disagree. Multi-version mangling re-anchors a consumer package's - // root at `/target/.mangled//__self__` and REWRITES - // its sources (module/import declarations renamed) while leaving - // `sourceKind == "version"` and `localTaint` clear. Nothing about - // that copy is immutable or shareable. It stays out of the cache - // today only because axis F happens to fold in the mangled - // secondary's differing key — one axis away from serving objects - // compiled against renamed modules, which is the silent-wrong-`.o` - // failure this gate exists to prevent. - // - // Judge the location, not the label. - // - // LEXICALLY, not via std::filesystem::relative. `relative()` runs - // weakly_canonical on both sides, which RESOLVES SYMLINKS — and a - // store whose entries are symlinks into another store is ordinary - // (tests/e2e/_inherit_toolchain.sh builds exactly that, and so do - // CI caches that link a warm payload tree into a fresh - // MCPP_HOME). Canonicalizing turns - // `/registry/data/xpkgs/` into wherever the link points - // and the package stops looking like a store package at all. The - // question here is where the payload was INSTALLED, which is a - // statement about the path, not about the inode. - if (!mcpp::build::path_is_under_any(pkgRoot.root, storeRoots)) - continue; - - const auto& depName = depIdent->packageName; - const auto& depVer = depIdent->version.empty() - ? pkgRoot.manifest.package.version - : depIdent->version; - - auto bmiT = mcpp::toolchain::bmi_traits(*tc); - mcpp::bmi_cache::CacheKey key { - .cacheRoot = mcpp::home::cache_root(), - .indexName = depIdent->indexName, - .packageName = depName, - .version = depVer, - .keyHex = pkgKeys[i], - .inputs = pkgInputs[i], - .bmiDirName = std::string(bmiT.bmiDir), - .manifestTag = std::string(bmiT.manifestPrefix), - }; - - // The artifacts this package contributes, and the compile units - // that produce them. Collected together so a hit can mark exactly - // those units — the artifact list alone would not say which edges - // must stop being compile edges. - mcpp::bmi_cache::DepArtifacts arts; - std::vector unitIdx; - bool addressable = true; - for (std::size_t u = 0; u < ctx.plan.compileUnits.size(); ++u) { - auto& cu = ctx.plan.compileUnits[u]; - std::error_code ec; - auto rel = std::filesystem::relative(cu.source, pkgRoot.root, ec); - if (ec || rel.empty()) continue; - auto rels = rel.string(); - if (rels.starts_with("..")) continue; // not under depRoot - - // ALL OR NOTHING. A unit plan.cppm could not give a - // machine-independent entry address to takes its whole package - // out of the cache, rather than leaving the package half - // staged. Mixing cached and freshly built artifacts within one - // package is the case GCC reports as a BMI CRC mismatch in a - // consumer three edges away, which is far harder to read than - // one extra compile. - if (cu.packageObjectRel.empty()) { addressable = false; break; } - - if (!cu.providesModule.empty()) { - std::string bmi; - for (char c : cu.providesModule) - bmi.push_back(c == ':' ? '-' : c); - bmi += std::string(bmiT.bmiExt); - arts.bmiFiles.push_back(std::move(bmi)); - } - arts.objFiles.push_back({cu.packageObjectRel.generic_string(), - cu.object}); - unitIdx.push_back(u); - } - if (!addressable) continue; - - // Validate the entry against THIS build's artifact list, not - // against the entry's own (mcpp#344). Anything short of a full - // match is a miss — never a failure: the stage edges below are - // simply not emitted and the units compile normally. - auto probe = mcpp::bmi_cache::probe_cached(key, arts); - if (probe.ok) { - // Mark the units. The backend turns each into a stage_file - // edge; nothing is copied here. Copying behind ninja's back is - // exactly what made the old cache a no-op: the staged file was - // still declared as a compile edge's output, and an output with - // no .ninja_log command-line record is dirty, so every unit was - // recompiled while the CLI printed "Cached". - for (auto u : unitIdx) { - auto& cu = ctx.plan.compileUnits[u]; - cu.servedFromCache = true; - cu.cachedObject = mcpp::bmi_cache::cached_obj_path( - key, cu.packageObjectRel.generic_string()); - if (!cu.providesModule.empty()) { - std::string bmi; - for (char c : cu.providesModule) - bmi.push_back(c == ':' ? '-' : c); - bmi += std::string(bmiT.bmiExt); - cu.cachedBmi = mcpp::bmi_cache::cached_bmi_path(key, bmi); - } - } - mcpp::bmi_cache::touch_accessed(key); - ctx.cachedDeps.push_back({depName, depVer, unitIdx.size()}); - continue; // no populate task; it is already cached - } - // A valid entry that does not hold what we asked for means the - // entry and this build disagree about the layout under one key. - // After #344 that is unreachable; say so out loud if it ever - // happens again, because the alternative presentation is "the - // cache silently never hits", and a cache that lies about its own - // effectiveness went unnoticed for three months once already. - if (!probe.layoutMismatch.empty()) { - mcpp::ui::warning(std::format( - "build cache entry for {}@{} [{}] does not contain the " - "artifacts this build needs ({} of {} missing, e.g. `{}`); " - "treating it as a miss. Run `mcpp cache verify` for details.", - depName, depVer, key.keyHex, - probe.layoutMismatch.size(), - arts.bmiFiles.size() + arts.objFiles.size(), - probe.layoutMismatch.front())); - } - ctx.depsToPopulate.push_back({ std::move(key), std::move(arts) }); - } - } - // ────────────────────────────────────────────────────────────────── - - // Write/update mcpp.lock for any version-based deps that succeeded. - // Path deps are intentionally NOT locked — their source is local filesystem. - // - // mcpp#363: the version entries come from `resolved` — what the walk - // actually picked — not from `m->dependencies`, which still holds the - // constraint the user wrote and only covers DIRECT deps. Reading the input - // instead of the output made the lock record `^1.92.8` (a range locks - // nothing) and omit the transitive graph entirely. Git entries deliberately - // stay on `m->dependencies`: their lock line is read back as a resolution - // anchor (#329), keyed by the root manifest's map key, and that contract is - // unchanged here. - { - mcpp::lockfile::Lockfile lock; - lock.schemaVersion = 2; - - // The lock key for a dep the ROOT declares is the map key it declared - // it under (`compat.imgui`, `gtest`) — that is the key #329's git anchor - // lookup uses, and changing it would silently unpin every branch dep. - // A dep reached only transitively has no such key, so it is written - // under its fully-qualified identity. - auto lock_name_for = [&](const ResolvedKey& k) -> std::string { - for (auto const& [n, s] : m->dependencies) { - const std::string sn = s.shortName.empty() ? n : s.shortName; - if (s.namespace_ == k.ns && sn == k.shortName) return n; - } - return mcpp::pm::compat::qualified_name(k.ns, k.shortName); - }; - - // Lock custom index shas from manifest [indices] section. - for (auto const& [idxName, spec] : m->indices) { - if (spec.is_local() || spec.is_builtin()) continue; - mcpp::lockfile::LockedIndex li; - li.name = idxName; - li.url = spec.url; - li.rev = spec.rev; // may be empty if not yet resolved - lock.indices.push_back(std::move(li)); - } - - // Git deps: root-declared only, unchanged (see the note above). - for (auto const& [name, spec] : m->dependencies) { - if (!spec.isGit()) continue; - mcpp::lockfile::LockedPackage lp; - lp.name = name; - lp.version = spec.gitRev; - auto gitIt = root_git_lock_identities.find(name); - if (gitIt == root_git_lock_identities.end()) { - lp.source = std::format("git+{}#{}={}", - spec.git, spec.gitRefKind, spec.gitRev); - lp.hash = "fnv1a:" + mcpp::toolchain::hash_string(lp.source); - } else { - lp.source = gitIt->second.source; - lp.hash = gitIt->second.hash; - } - lock.packages.push_back(std::move(lp)); - } - - // Version deps: the whole resolved graph, at the versions actually - // chosen. `resolved` is an ordered map, so the file is deterministic. - for (auto const& [key, rec] : resolved) { - if (rec.source != "version") continue; // path / git handled elsewhere - if (rec.version.empty()) continue; - // See ResolvedRecord::devOnly: `mcpp test` resolves dev-deps and - // `mcpp build` does not, so writing them would make the file depend - // on which command ran last. - if (rec.devOnly) continue; - mcpp::lockfile::LockedPackage lp; - lp.name = lock_name_for(key); - lp.namespace_ = key.ns; - lp.version = rec.version; - // Use the namespace and resolved version as the source identifier. - // For custom indices, include the index name for traceability. - auto sourceIndex = lp.namespace_.empty() - ? std::string(mcpp::pm::kDefaultNamespace) - : lp.namespace_; - lp.source = std::format("index+{}@{}", sourceIndex, lp.version); - // Use a deterministic hash based on namespace + name + version. - // A future PR can replace this with a real content hash from the - // xpkg.lua's declared sha256 or from the install plan. - // - // NOT `std::hash`: its output is implementation-defined - // (MSVC FNV-1a, libstdc++/libc++ MurmurHash), so the same dependency - // used to hash differently on Windows and Linux while the `fnv1a:` - // prefix claimed otherwise. `index_package_digest` is FNV-1a on - // every host. - lp.hash = mcpp::pm::index_package_digest(sourceIndex, lp.name, lp.version); - lock.packages.push_back(std::move(lp)); - } - if (!lock.packages.empty() || !lock.indices.empty()) { - auto lockPath = workRoot / "mcpp.lock"; - // `--locked` ASSERTS THAT THIS RESOLUTION IS THE RECORDED ONE. - // - // The file has always been written after the walk and never read - // back as a constraint; its own header says so ("does not yet pin - // future builds"). Making it an input to resolution is a change to - // the resolver. Making it an ASSERTION is not, and it is the half - // that reproducibility actually needs: a release build, a CI job or - // an audit can demand that what resolved today is what was recorded, - // and find out when it is not. - // - // THE FAILURE NAMES THE DIFFERENCE. "The lock is out of date" is - // true and useless; which package moved, from which version to - // which, is what the reader does something about. - if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") { - auto prior = mcpp::lockfile::load(lockPath); - if (!prior) { - return std::unexpected(std::format( - "--locked was given and there is no readable mcpp.lock at {}\n" - " Run the same command without --locked once to record " - "this resolution, then commit mcpp.lock.", - lockPath.string())); - } - auto key = [](const mcpp::lockfile::LockedPackage& p) { - return p.namespace_.empty() ? p.name - : p.namespace_ + "." + p.name; - }; - std::map was, now; - for (auto const& p : prior->packages) was[key(p)] = p.version; - for (auto const& p : lock.packages) now[key(p)] = p.version; - std::vector drift; - for (auto const& [k, v] : now) { - auto it = was.find(k); - if (it == was.end()) drift.push_back(k + " " + v + " (not in the lock)"); - else if (it->second != v) drift.push_back(k + " " + it->second + " -> " + v); - } - for (auto const& [k, v] : was) - if (!now.contains(k)) drift.push_back(k + " " + v + " (no longer resolved)"); - if (!drift.empty()) { - std::string msg = "--locked was given and this resolution " - "differs from mcpp.lock:"; - for (auto const& d : drift) msg += "\n " + d; - msg += "\n Re-run without --locked to update the lock, " - "or pin the dependency that moved."; - return std::unexpected(msg); - } - } - (void)mcpp::lockfile::write(lock, lockPath); - } - - // Same data, second consumer: the "Compiling v" banner. - // It reads this rather than re-deriving from the manifest, so the banner - // and the lock cannot disagree about what was built. - for (auto const& [key, rec] : resolved) { - if (rec.source != "version" || rec.version.empty()) continue; - ctx.resolvedVersions[lock_name_for(key)] = rec.version; - } - } - - // Apply [runtime.] provider = "" overrides. Canonical - // identity wins; the old short spelling is accepted only when it denotes - // exactly one provider. A same-short-name collision is never guessed. - for (auto& [capKey, prov] : ctx.manifest.runtimeConfig.providerOverrides) { - std::vector candidates; - for (auto const& entry : ctx.plan.runtimeProviders) { - if (!entry.capability.starts_with(capKey)) continue; - const auto withoutVersion = entry.provider.namespace_.empty() - ? entry.provider.name - : entry.provider.namespace_ + "." + entry.provider.name; - if (entry.provider.canonical() == prov || withoutVersion == prov) - candidates = {entry.provider}; - } - if (candidates.empty()) { - for (auto const& entry : ctx.plan.runtimeProviders) { - if (entry.capability.starts_with(capKey) - && entry.provider.name == prov) - candidates.push_back(entry.provider); - } - } - std::ranges::sort(candidates); - candidates.erase(std::ranges::unique(candidates).begin(), candidates.end()); - if (candidates.empty()) { - return std::unexpected(std::format( - "[runtime.{}] provider = \"{}\" does not name a provider in " - "the resolved dependency graph", capKey, prov)); - } - if (candidates.size() != 1) { - std::string choices; - for (auto const& candidate : candidates) - choices += (choices.empty() ? "" : ", ") + candidate.canonical(); - return std::unexpected(std::format( - "[runtime.{}] provider = \"{}\" is ambiguous; use one exact " - "canonical identity: [{}]", capKey, prov, choices)); - } - const auto selected = candidates.front(); - std::stable_partition(ctx.plan.runtimeProviders.begin(), - ctx.plan.runtimeProviders.end(), - [&](const auto& pr) { - return pr.capability.starts_with(capKey) && pr.provider == selected; - }); - } - - // Capability-driven ABI enforcement, dimensional (see src/toolchain/abi.cppm - // and .agents/docs/2026-06-27-abi-compat-model-single-pr-design.md). Each - // dependency may constrain specific toolchain dimensions via `abi:` - // capabilities (libc / cxxstdlib / arch / os / cxxabi); UNSPECIFIED - // DIMENSIONS ARE DON'T-CARE. The legacy bare form `abi:glibc` maps to the - // libc dimension only — so a glibc *C library* (glfw) builds fine under a - // clang+libc++ toolchain on `*-linux-gnu` (libc is still glibc), which the - // previous single-axis check wrongly rejected. The toolchain is resolved - // before the dep graph, so this enforces/diagnoses rather than reselects — - // abi-driven reselection is a resolution-ordering follow-up. - { - const auto prof = mcpp::toolchain::abi_profile(ctx.tc); - std::vector constraints; - for (auto& cap : ctx.plan.runtimeCapabilities) { - std::string provider; - for (auto& [c, p] : ctx.plan.runtimeProviders) - if (c == cap) { provider = p.canonical(); break; } - if (auto con = mcpp::toolchain::parse_abi_capability( - cap, provider.empty() ? std::string_view{"?"} : std::string_view{provider})) - constraints.push_back(std::move(*con)); - } - if (auto mismatches = mcpp::toolchain::abi_check(prof, constraints); - !mismatches.empty()) { - const auto& mm = mismatches.front(); - return std::unexpected(std::format( - "ABI incompatibility: dependency '{}' requires {}={}, but the " - "resolved toolchain '{}' provides {}={}.\n" - " fix: select a {}-compatible toolchain " - "(e.g. gcc@16.1.0 for glibc) or set [toolchain] in mcpp.toml.", - mm.source, mcpp::toolchain::dim_name(mm.dim), mm.need, - ctx.tc.label(), mcpp::toolchain::dim_name(mm.dim), mm.got, - mm.need)); - } - } - - // Per-build resolution manifest: the durable, provider-neutral facts that - // `mcpp why runtime` interprets without resolving again or probing the - // current host. The post-link validator replaces `validation.pending` - // with the exact artifact verdict produced at the link seam. - { - const std::string tcAbi = - ctx.tc.targetTriple.find("musl") != std::string::npos ? "musl" - : ctx.tc.stdlibId == "libc++" ? "libc++" - : ctx.tc.compiler == mcpp::toolchain::CompilerId::MSVC ? "msvc" - : "glibc"; - auto package_json = [](const mcpp::manifest::PackageId& id) { - return nlohmann::json{ - {"canonical", id.canonical()}, - {"namespace", id.namespace_}, - {"name", id.name}, - {"version", id.version}, - {"source", id.sourceProvenance}, - }; - }; - auto path_array = [](auto const& paths) { - nlohmann::json values = nlohmann::json::array(); - for (auto const& path : paths) - values.push_back(path.lexically_normal().generic_string()); - return values; - }; - nlohmann::json j; - j["schema_version"] = 2; - j["toolchain"] = { - {"spec", ctx.tc.label()}, {"abi", tcAbi}, - {"triple", ctx.tc.targetTriple}, {"stdlib", ctx.tc.stdlibId}, - }; - nlohmann::json dirs = nlohmann::json::array(); - for (auto& d : ctx.plan.runtimeLibraryDirs) dirs.push_back(d.string()); - nlohmann::json legacyCaps = nlohmann::json::array(); - nlohmann::json providers = nlohmann::json::array(); - for (auto& [cap, prov] : ctx.plan.runtimeProviders) - { - legacyCaps.push_back({{"capability", cap}, - {"provider", prov.canonical()}}); - providers.push_back({{"capability", cap}, - {"provider", package_json(prov)}}); - } - nlohmann::json requirements = nlohmann::json::array(); - for (auto const& requirement : ctx.plan.runtimeRequirements) { - requirements.push_back({ - {"kind", requirement.kind}, - {"value", requirement.value}, - {"phase", requirement.phase}, - {"requester", package_json(requirement.requester)}, - {"required", requirement.required}, - }); - } - nlohmann::json artifacts = nlohmann::json::array(); - for (auto const& artifact : ctx.plan.runtimeArtifacts) { - artifacts.push_back({ - {"role", artifact.role}, - {"provider", package_json(artifact.provider)}, - {"path", artifact.path.lexically_normal().generic_string()}, - {"provenance", artifact.provenance}, - {"abi", artifact.abi}, - {"digest", artifact.digest}, - {"host_fingerprint", artifact.hostFingerprint}, - // A requirement must land on a THING, and the thing must be - // the one that was declared. mcpp already enforces this for - // the private libc; recording it per artifact makes a stale - // binding visible instead of leaving `providers:` naming - // something nobody checked. - {"identity", std::string( - mcpp::build::runtime_validation::to_string( - mcpp::build::runtime_validation - ::artifact_identity_verdict(artifact)))}, - }); - } - nlohmann::json binding = nlohmann::json::parse( - mcpp::platform::runtime::serialize_runtime_binding( - ctx.plan.runtimeBinding), nullptr, false); - if (binding.is_discarded()) binding = nlohmann::json::object(); - - // ASKED OF THE PARSED TRIPLE, with the substring test kept only for a - // spelling `parse` rejects. This field is the SECOND copy of a - // derivation `mcpp.build.dist::format_for` already owns, and it had - // the same defect: mcpp's canonical `aarch64-macos` contains neither - // "apple" nor "darwin", so an explicit `--target aarch64-macos` - // recorded `"elf"` while the native build on the same machine recorded - // `"macho"` -- one report contradicting the other about one machine. - std::string format = "elf"; - if (auto t = mcpp::toolchain::triple::parse(ctx.tc.targetTriple)) { - format = std::string(mcpp::toolchain::triple::to_string(t->object_format())); - std::ranges::transform(format, format.begin(), - [](unsigned char c) { return std::tolower(c); }); - if (format == "mach-o") format = "macho"; - } else { - auto triple = ctx.tc.targetTriple; - std::ranges::transform(triple, triple.begin(), - [](unsigned char c) { return std::tolower(c); }); - const bool pe = triple.find("windows") != std::string::npos - || triple.find("mingw") != std::string::npos; - const bool macho = triple.find("darwin") != std::string::npos - || triple.find("apple") != std::string::npos; - format = pe ? "pe" : macho ? "macho" : "elf"; - } - // The ORDERED run-time search closure with provenance. Order is - // semantics here, not presentation: it is what the loader will walk, - // and the mutable SubOS farm sitting last is the invariant that keeps - // libc resolving from the pinned payload. Recorded so "why does my GL - // program find its driver" is answerable without readelf, and so a - // regression in the ordering is visible to CI and to `mcpp why`. - nlohmann::json closure = nlohmann::json::array(); - for (auto const& dir : ctx.plan.runtimeSearch) { - closure.push_back({ - {"path", dir.path.generic_string()}, - {"origin", std::string( - mcpp::platform::search::to_string(dir.origin))}, - {"machine_local", - mcpp::platform::search::is_machine_local(dir.origin)}, - }); - } - nlohmann::json search = { - {"format", format}, - {"link_library", format == "pe" ? "libpath" : "library_path"}, - {"transitive_needed", format == "elf" ? "rpath_link" : "none"}, - {"runtime", format == "pe" ? "deploy" - : format == "macho" ? "loader_rpath" : "runpath"}, - {"closure", closure}, - }; - // #418 — the contract each ROLE actually got, after any downgrade. - // - // `CompileFlags::contractByRole` was written and never read: a valuable - // observation with no way out of the process. Since #414 the shared - // library role can legitimately end up on a different contract from the - // binaries beside it, so "which one did my .so actually get?" is a - // question a user has, and the only answer available was to run - // `readelf` and infer. - // - // Recorded as the RESOLVED value, not the requested one — a request - // that was downgraded is exactly the case worth being able to see. - // `compute_flags` is pure in the plan; prepare does not otherwise hold - // the result, and threading it through just for this would widen a - // signature for one field. - const auto roleFlags = mcpp::build::compute_flags(ctx.plan); - nlohmann::json contracts = nlohmann::json::object(); - for (std::size_t i = 0; i < mcpp::build::dist::kRoleCount; ++i) { - contracts[std::string(mcpp::build::dist::to_string( - static_cast(i)))] = - std::string(mcpp::build::dist::to_string(roleFlags.contractByRole[i])); - } - - // #634, X: the resolved dependency graph. One entry per package, the - // root first: its identity as `runtime` records identities, every - // request that reached it with the key as written and the table that - // declared it, and for a library the link form with its reason. It is - // what `mcpp why deps` prints, and what a test of a resolution rule - // reads instead of a warning's wording. - { - nlohmann::json graphPackages = nlohmann::json::array(); - for (std::size_t i = 0; i < packages.size(); ++i) - graphPackages.push_back(graph_package_entry(i, /*forBuildProgram=*/false)); - j["graph"] = { {"packages", std::move(graphPackages)} }; - } - - j["runtime"] = { - {"cxx_runtime_by_role", contracts}, - {"library_dirs", dirs}, - {"dlopen_libs", ctx.plan.runtimeDlopenLibs}, - {"capabilities", legacyCaps}, - {"binding", binding}, - {"requirements", requirements}, - {"artifacts", artifacts}, - {"providers", providers}, - {"link_intent", { - {"libraries", ctx.plan.linkIntent.libraries}, - {"link_library_dirs", - path_array(ctx.plan.linkIntent.linkLibraryDirs)}, - {"transitive_needed_dirs", - path_array(ctx.plan.linkIntent.transitiveNeededDirs)}, - {"runtime_search_dirs", - path_array(ctx.plan.linkIntent.runtimeSearchDirs)}, - {"frameworks", ctx.plan.linkIntent.frameworks}, - {"deploy_files", path_array(ctx.plan.linkIntent.deployFiles)}, - {"deploy", [&] { - auto a = nlohmann::json::array(); - for (auto const& d : ctx.plan.linkIntent.deploy) - a.push_back({{"from", d.from.generic_string()}, - {"to", d.to}}); - return a; - }()}, - }}, - {"search", search}, - {"validation", { - {"status", format == "elf" ? "pending" : "not_exercised"}, - {"source", "post_link"}, - {"artifacts", nlohmann::json::array()}, - }}, - }; - // THE MSVC SYSROOT OF THE CLANG ROW: which toolset and SDK the build - // compiled against, and where each came from. Absent on every other - // row, so a reader can tell "not this row" from "not recorded". - if (!ctx.plan.toolchain.msvcToolsDir.empty()) { - const auto& tcr = ctx.plan.toolchain; - j["msvc_toolset"] = { - {"version", tcr.msvcToolsVersion}, - {"origin", tcr.msvcOrigin}, - {"product", tcr.msvcProduct}, - {"root", tcr.msvcToolsDir.generic_string()}, - }; - j["windows_sdk"] = { - {"version", tcr.windowsSdkVersion}, - {"root", tcr.windowsSdkRoot.generic_string()}, - }; - } - std::error_code ec; - std::filesystem::create_directories(ctx.plan.outputDir, ec); - auto path = ctx.plan.outputDir / "resolution.json"; - auto tmp = path; - tmp += ".tmp"; - if (std::ofstream js(tmp); js) { - js << j.dump(2) << "\n"; - js.close(); - std::filesystem::rename(tmp, path, ec); - if (ec) { - ec.clear(); - std::filesystem::remove(path, ec); - ec.clear(); - std::filesystem::rename(tmp, path, ec); - } - } - } - - // ── A link unit with no inputs is not a build (mcpp#533) ──────────────── - // - // Checked HERE, last, because objects arrive from three places and each - // one is legitimate: the compile set, a `role = "object"` action - // (`lu.objects.emplace_back` above), and a Windows resource unit. A check - // placed before any of them would refuse a unit that was about to be - // filled. If a fourth source is ever added, it must land before this line. - // - // WHY THIS IS AN ERROR AND NOT A WARNING. The two library kinds fail - // differently and BOTH failures are worse than this message: - // - // shared — `$cc -shared` over an empty response file. Measured on - // gcc 16.1.0: `gcc: fatal error: no input files`, which names - // the driver and not the target. Before `cc` was emitted - // unconditionally it was `/bin/sh: 1: -shared: not found`, - // which names neither. - // static — `ar rcs libfoo.a` with no members. Measured: exit 0, an - // 8-byte archive, and a build that REPORTS SUCCESS. Every - // consumer then fails with undefined symbols, one repository - // further from the cause. - // - // The silent one is why this is not merely a nicer diagnostic. mcpp#533 - // reached here because a dependency's `install()` was skipped over a - // package-identity collision, leaving a version directory with no source - // tree; the shape is the same for any package whose sources fail to - // materialise, which is why the check is on the link unit rather than on - // the install path. - for (auto const& lu : ctx.plan.linkUnits) { - if (!lu.objects.empty()) continue; - const char* kindName = - lu.kind == mcpp::build::LinkUnit::SharedLibrary ? "shared library" - : lu.kind == mcpp::build::LinkUnit::StaticLibrary ? "static library" - : lu.kind == mcpp::build::LinkUnit::TestBinary ? "test binary" - : "binary"; - return std::unexpected(std::format( - "target '{}' ({}) has no inputs to link\n" - " no translation unit and no `role = \"object\"` action " - "output reached it, and an empty link is not a build: `ar` writes " - "an empty archive and reports success, so this would otherwise " - "surface as undefined symbols in whatever consumes '{}'\n" - " if '{}' is an installed dependency, its package directory " - "has no sources — reinstall it and check that its descriptor's " - "install step ran", - lu.targetName, kindName, lu.output.generic_string(), - lu.targetName)); - } - - ctx.planNotes = std::move(planNotes); - return ctx; -} - + BuildOverrides overrides = {}); } // namespace mcpp::build diff --git a/src/build/prepare/config.cpp b/src/build/prepare/config.cpp new file mode 100644 index 000000000..948d39e61 --- /dev/null +++ b/src/build/prepare/config.cpp @@ -0,0 +1,804 @@ +// config.cpp -- the manifest rules the phases apply: conditional +// `[target.*]` and `[xlings]` merges, build flags and defines, workspace +// inheritance, feature requests and std-module detection. Moved verbatim from +// the former preamble of prepare.cppm; declared in `:state`, or exported from +// prepare.cppm where the interface or the unit tests need them. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.targetside; +import mcpp.diag; +import mcpp.xlings.address_set; +import mcpp.build.version_floor; +import mcpp.platform.axis; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.modgraph.glob; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.modgraph.validate; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.pack.abi_tag; // the tag a prebuilt dependency is checked against +import mcpp.pack.prebuilt; // …and the check itself +import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its manifest +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.xlings; +import mcpp.platform; +import mcpp.ui; +import mcpp.project; + +namespace mcpp::build { + +// mcpp#237: surface xpkg-descriptor mcpp-segment keys this mcpp did not +// recognise. The parser collects them into `xpkgUnknownKeys` and skips the +// value; without this a typo like `dependencies = {...}` (correct key: `deps`) +// dropped the dependency with no diagnostic. Called at the descriptor-adoption +// sites (a fetched dep with no mcpp.toml, synthesized from the index `mcpp={}` +// block) — the single place the descriptor becomes a build input. Warning (not +// hard error) keeps forward-compat: an older mcpp building a newer descriptor +// should not fail outright, only tell the user what it ignored. +void warn_unknown_xpkg_keys(const mcpp::manifest::Manifest& dm, + std::string_view depLabel) { + // A LAYER NAME THIS ENGINE DOES NOT KNOW IS A VERSION GAP, NOT A TYPO, + // WHEN IT ARRIVES FROM A DEPENDENCY. + // + // The reserved `mcpp:` prefix is a closed set so a misspelling cannot + // silently disable a behaviour. Refusing a DEPENDENCY's manifest for it made + // the set closed in a second sense nobody intended: a published package + // could never declare a layer named after the reader was released. + // Ignoring the layer and saying so is what this engine already does for + // every other unknown key, and it is the only response that lets the + // vocabulary grow. + for (auto const& cap : dm.unknownCapabilities) { + auto why = mcpp::targetside::parse_capability(cap); + mcpp::ui::warning(std::format( + "dependency '{}': {}\n" + " Ignored, and this build proceeds without that layer. " + "A newer mcpp may resolve it.", + depLabel, + why ? std::format("`{}` names no capability mcpp knows.", cap) + : why.error())); + } + for (auto const& key : dm.xpkgUnknownKeys) { + auto suggestion = mcpp::manifest::closest_known_xpkg_key(key); + if (suggestion.empty()) + mcpp::ui::warning(std::format( + "dependency '{}': unknown mcpp-segment key '{}' in its xpkg " + "descriptor — ignored (schema mismatch or typo)", depLabel, key)); + else + mcpp::ui::warning(std::format( + "dependency '{}': unknown mcpp-segment key '{}' in its xpkg " + "descriptor — ignored; did you mean '{}'?", depLabel, key, suggestion)); + } +} + +// `stale`, when given, turns the function into a comparison: nothing is +// created or written, and every declared file that is missing or differs from +// its declared content is appended. A build that describes itself rather than +// running (BuildOverrides::plan_only) reads the root package's generated files +// this way, because they live in the source tree it promises not to write. +std::expected +materialize_generated_files(const std::filesystem::path& root, + const mcpp::manifest::Manifest& manifest, + std::vector* stale) { + for (auto const& [relPath, content] : manifest.buildConfig.generatedFiles) { + if (relPath.empty()) { + return std::unexpected("generated_files contains an empty path"); + } + if (relPath.is_absolute()) { + return std::unexpected(std::format( + "generated_files path '{}' must be relative", relPath.generic_string())); + } + auto const genericPath = relPath.generic_string(); + for (std::size_t begin = 0; begin <= genericPath.size();) { + auto const end = genericPath.find('/', begin); + auto const part = genericPath.substr(begin, end == std::string::npos + ? std::string::npos + : end - begin); + if (part == "..") { + return std::unexpected(std::format( + "generated_files path '{}' must not escape the package root", + relPath.generic_string())); + } + if (end == std::string::npos) { + break; + } + begin = end + 1; + } + + auto out = root / relPath.lexically_normal(); + + // Skip the write when the on-disk content is already identical: ninja + // is mtime-driven, and an unconditional rewrite bumps the mtime every + // build, recompiling every TU that #includes the materialized file + // (via depfiles) — e.g. a frozen-snapshot config.h included by + // thousands of TUs. Change detection is already owned by the + // fingerprint (content is folded in above), so skipping only + // preserves the mtime — mirroring the build.mcpp cache design, + // which likewise avoids mtime churn on unchanged outputs. + { + std::ifstream is(out, std::ios::binary); + if (is) { + std::string existing((std::istreambuf_iterator(is)), + std::istreambuf_iterator()); + if (is && existing == content) { + continue; + } + } + } + if (stale) { + stale->push_back(out); + continue; + } + + std::error_code ec; + std::filesystem::create_directories(out.parent_path(), ec); + if (ec) { + return std::unexpected(std::format( + "cannot create directory for generated file '{}': {}", + out.string(), ec.message())); + } + std::ofstream os(out, std::ios::binary); + if (!os) { + return std::unexpected(std::format( + "cannot write generated file '{}'", out.string())); + } + os << content; + if (!os) { + return std::unexpected(std::format( + "failed while writing generated file '{}'", out.string())); + } + } + return {}; +} + +void merge_conditional_xlings(mcpp::manifest::Manifest& m, + const mcpp::manifest::ConditionalConfig& cc) { + // ONE DEFINITION OF IDENTITY, and it is not local to this merge. It used + // to be `parse_address(a).target` — the bare name, so `xim:cuda` and a + // hypothetical `scode:cuda` collided, and the graph split a few thousand + // lines below compared whole address strings instead. See + // mcpp.xlings.address_set for what the two definitions cost. + auto package_of = [](std::string_view address) { + return mcpp::xlings::addrset::package_key(address); + }; + for (auto const& a : cc.xlings.deps) { + const auto pkg = package_of(a); + auto it = std::ranges::find_if(m.xlings.deps, [&](const std::string& e) { + return package_of(e) == pkg; + }); + if (it == m.xlings.deps.end()) { m.xlings.deps.push_back(a); continue; } + if (*it != a) + mcpp::diag::warning("xlings/axis-override", std::format( + "'{}' is declared on both tool axes, as '{}' and as '{}'. The " + "[target.] entry is the more specific statement and " + "is the one used. Declare a tool that runs on the build machine " + "in the top-level [xlings.workspace], and what the produced " + "code is compiled against under [target..xlings." + "workspace] — see docs/05 section 2.13.", pkg, *it, a)); + *it = a; + } + // Keyed by PACKAGE, so the same override applies without a second search. + for (auto const& [pkg, pin] : cc.xlings.workspace) + m.xlings.workspace.insert_or_assign(pkg, pin); + // Keyed by ADDRESS. `insert_or_assign` rather than `try_emplace` for the + // same reason: the address that survived above is the conditional one. + for (auto const& [addr, w] : cc.xlings.depWhen) + m.xlings.depWhen.insert_or_assign(addr, w); + for (auto const& [f, addrs] : cc.xlings.featureDeps) { + auto& dst = m.xlings.featureDeps[f]; + for (auto const& a : addrs) { + const auto pkg = package_of(a); + auto it = std::ranges::find_if(dst, [&](const std::string& e) { + return package_of(e) == pkg; + }); + if (it == dst.end()) dst.push_back(a); else *it = a; + } + } + for (auto const& [addr, pin] : cc.xlings.featurePins) + m.xlings.featurePins.insert_or_assign(addr, pin); +} + +std::optional +layer_predicated_xlings_refusal(const mcpp::manifest::Manifest& m) { + for (auto const& cc : m.conditionalConfigs) { + if (cc.xlings.empty()) continue; + if (!cfgpred::uses_layer(cc.predicate)) continue; + std::string named; + for (auto const& a : cc.xlings.deps) { + if (!named.empty()) named += ", "; + named += a; + } + for (auto const& [f, addrs] : cc.xlings.featureDeps) + for (auto const& a : addrs) { + if (!named.empty()) named += ", "; + named += std::format("{} (feature '{}')", a, f); + } + return std::format( + "[target.'{}'] declares tools ({}), but its predicate names a " + "target-side layer. A layer is answered by dependency resolution, " + "which happens after tools are installed and after build programs " + "run, so a tool conditioned on one would be declared and never " + "installed. Condition it on the target instead " + "(`[target.'cfg(os = \"linux\")'.xlings.workspace]`), on the " + "accelerator (`[target.'cfg(accelerator = \"cuda\")'.xlings" + ".workspace]`, which IS answered before provisioning), or on a " + "feature (`[feature-xlings.]`). See docs/05 section 2.13.", + cc.predicate, named); + } + return std::nullopt; +} + +// Two declarations of one dependency, compared by the identity their keys +// normalise to rather than by the keys themselves: `fw` and `mcpplibs.fw` are +// one package under two map keys (`selector.stableMapKey`), and a comparison +// of keys would leave both entries in the map for the resolver to see. +bool same_dependency_identity(const mcpp::manifest::DependencySpec& a, + const mcpp::manifest::DependencySpec& b) { + if (a.shortName.empty() || b.shortName.empty()) return false; + return a.namespace_ == b.namespace_ && a.shortName == b.shortName; +} + +void replace_dependencies( + std::map& into, + const std::map& from) { + for (auto const& [key, spec] : from) { + std::erase_if(into, [&](auto const& entry) { + return entry.first == key || same_dependency_identity(entry.second, spec); + }); + into[key] = spec; + } +} + +void merge_conditional_config(mcpp::manifest::Manifest& m, + const cfgpred::Ctx& ctx) { + // Recorded before the first merge; see Manifest::beforeConditionalMerge. + if (!m.beforeConditionalMerge) + m.beforeConditionalMerge = std::make_shared(m); + // A DISTRIBUTION package may carry a leg's link line twice: as `ldflags` + // (GNU spelling, which is all an older mcpp reads) and as the neutral + // `[target..runtime]` pair, which mcpp renders per dialect. Applying + // both would put `-L` on a native `cl.exe` command line, which is exactly + // what the neutral form exists to avoid — so where the neutral form is + // present it REPLACES the ldflags rather than adding to them. + // + // Scoped to distribution packages on purpose: a hand-written manifest that + // states both may well mean both (`ldflags` also carries things like + // `-Wl,--as-needed`), and silently dropping half of it would be its own + // silent failure. + const bool generatedPackage = mcpp::pack::is_distribution_package(m); + + for (auto const& cc : m.conditionalConfigs) { + // THE TWO PASSES MUST BE DISJOINT, AND `matches()` ALONE DOES NOT + // MAKE THEM SO. A layer key answers false here because `layersKnown` is + // false — but `cfg(any(linux, c-abi = "musl"))` still matches on its + // triple leg, and the second pass would match it again and `append()` + // the same inputs twice. Membership, not the answer, decides ownership: + // a predicate that NAMES a layer belongs to the second pass entirely. + if (cfgpred::uses_layer(cc.predicate)) continue; + if (!cfgpred::matches(cc.predicate, ctx)) continue; + const bool neutralWins = generatedPackage + && (!cc.linkLibraryDirs.empty() || !cc.libraries.empty() + || !cc.frameworks.empty()); + // One append() for every field the axis may carry (#258). Matching + // sections land AFTER the base entries, so a conditional rule beats + // a broader unconditional one under GNU last-wins — which is what + // makes an off-OS REMOVAL expressible (`-U` after the base `-D`). + if (neutralWins) { + // Drop the LIBRARY REFERENCES, not the whole ldflags list. + // + // Clearing it outright was a measured regression: a PE/MinGW shared + // leg's ldflags also carry `-Wl,-Bdynamic`, without which `-static` + // leaves ld in static-only mode and it refuses the import library + // with `have you installed the static version of the mathkit + // library?`. e2e 257 caught it. + // + // The neutral form replaces exactly what it can express — a library + // and where to find it. Anything else in that block says something + // it cannot say, and must survive. + auto inputs = cc.inputs; + std::erase_if(inputs.ldflags, [](std::string_view f) { + return f.starts_with("-L") || f.starts_with("-l") + || f.starts_with("/LIBPATH:"); + }); + mcpp::manifest::append(m.buildConfig, inputs); + } else { + mcpp::manifest::append(m.buildConfig, cc.inputs); + } + // The neutral half goes where `render_link_intent_flags` will find it. + for (auto const& d : cc.linkLibraryDirs) + m.runtimeConfig.linkIntent.linkLibraryDirs.push_back(d); + for (auto const& l : cc.libraries) + m.runtimeConfig.linkIntent.libraries.push_back(l); + for (auto const& f : cc.frameworks) + m.runtimeConfig.linkIntent.frameworks.push_back(f); + merge_conditional_xlings(m, cc); + // `[target..abi]`: recorded for every package; rendered only for + // the root, where prepare_build reads it. Last matching section wins, + // the rule every other conditional scalar follows. + if (cc.abiThreadsDeclared) { + m.buildConfig.abiThreads = cc.abiThreads; + m.buildConfig.abiThreadsDeclared = true; + } + if (cc.abiExceptionsDeclared) { + m.buildConfig.abiExceptions = cc.abiExceptions; + m.buildConfig.abiExceptionsDeclared = true; + } + // `[target.] requires_abi` / `.feature-requires-abi` (A6): a + // requirement on the TARGET axis, unioned in -- not overwritten -- + // because more than one matching selector may ask for the same + // member, and every one of them is a true statement. The selector + // text rides along so the unmet-requirement check can name what + // asked, the same courtesy `[package] requires_abi` gets by naming + // "the package" and a feature's entry by naming the feature. + if (cc.requiresAbiThreads) + m.targetRequiresAbiThreads.push_back(cc.predicate); + if (cc.requiresAbiExceptions) + m.targetRequiresAbiExceptions.push_back(cc.predicate); + for (auto const& [f, val] : cc.featureRequiresAbiThreads) + if (val) m.targetFeatureRequiresAbiThreads[f].push_back(cc.predicate); + for (auto const& [f, val] : cc.featureRequiresAbiExceptions) + if (val) m.targetFeatureRequiresAbiExceptions[f].push_back(cc.predicate); + // `modules.sources` is the scanner's own view and is not part of + // BuildInputs, so conditional sources are mirrored into it here. + for (auto const& s : cc.inputs.sources) + m.modules.sources.push_back(s); + // A matching conditional declaration of a dependency REPLACES the + // declaration of the same identity, and a later matching section + // replaces an earlier one: the rule every conditional scalar above + // follows (#634, A1). This used to be `insert()`, which kept the + // unconditional entry, so `linkage = "shared"` written for one row was + // dropped on that row without a word. No manifest among 509 scanned + // declared one dependency in both tables, so no build that worked + // changes; the declaring table rides on the spec (`declaredIn`) into + // the resolution record. + replace_dependencies(m.dependencies, cc.dependencies); + replace_dependencies(m.devDependencies, cc.devDependencies); + replace_dependencies(m.buildDependencies, cc.buildDependencies); + // #359: `[target..feature-deps.]`. The feature is + // registered by the parser regardless of the predicate; only what it + // pulls in is conditional. + for (auto const& [fname, deps] : cc.featureDeps) + replace_dependencies(m.featureDeps[fname], deps); + // `[target..targets.] kind`: the row's form of a library + // target, applied before resolution, so the link-form resolution + // reads it exactly as it reads `[targets.] kind`. `load` has + // already refused a name that is not a library target. + // + // `linkage` (#642 E1) is the row's default form, and a row's statement + // REPLACES the statement it follows, whichever of the two each one is: + // a default after `kind = "shared"` returns the target to the library + // form a consumer may choose from, and a `kind` after a default clears + // the default. Last matching section wins, as for every conditional + // scalar. + for (auto const& [name, row] : cc.targetKinds) { + for (auto& t : m.targets) { + if (t.name != name) continue; + t.kindDeclaredBy = row.statement; + t.kindFromRow = true; + if (!row.linkage.empty()) { + t.kind = mcpp::manifest::Target::Library; + t.linkageDefault = row.linkage; + t.linkageDeclaredBy = row.statement; + } else { + t.kind = row.kind; + t.linkageDefault.clear(); + t.linkageDeclaredBy.clear(); + } + } + } + } +} + +// ── An element whose words changed in 2026.9.17.1 (#655) ───────────────────── +// +// A compile-flag element used to reach the compiler as its host's command-line +// reader made it (POSIX `sh`, or the MSVCRT rules), after ninja had replaced +// `$` sequences, with a `-D` element containing a space quoted whole (#234). +// It now reaches the compiler as `flag_words` reads it, and a `defines` value +// is one word. Most spellings mean the same under both readings; the ones that +// do not are told what the compiler receives now and what it received before. +// The previous reading is modelled on quote removal only: `sh` expansions +// (`$VAR`, globs) are not reproduced. +// +// WHEN IT IS SAID. The notes are collected while manifests load and released +// where the output directory is decided, only if that directory has no +// build.ninja yet. The fingerprint names the mcpp version and every flag, so +// that is the first plan after an upgrade, after a flag was edited, or in a +// fresh checkout. A build that repeats a plan says nothing, so a manifest that +// is already spelled for the new reading is not warned about on every run. +std::vector>& pending_flag_words_notes() { + static std::vector> notes; + return notes; +} + +std::vector previous_release_words(std::string element, bool define) { + if (define) element = "-D" + element; + if ((element.starts_with("-D") || element.starts_with("/D")) + && element.find(' ') != std::string::npos) + element = mcpp::build::shell_quote_arg(element); + std::string line; + for (std::size_t i = 0; i < element.size(); ++i) { + if (element[i] != '$' || i + 1 == element.size()) { line.push_back(element[i]); continue; } + const char n = element[i + 1]; + if (n == '$' || n == ' ' || n == ':') { line.push_back(n); ++i; continue; } + // A ninja variable reference: `${name}` or `$name`, empty on a compile edge. + std::size_t j = i + 1; + if (n == '{') { + while (j < element.size() && element[j] != '}') ++j; + } else { + while (j + 1 < element.size() + && (std::isalnum(static_cast(element[j + 1])) + || element[j + 1] == '_' || element[j + 1] == '-')) + ++j; + } + i = j; + } + return mcpp::manifest::host_command_words(line, mcpp::platform::is_windows); +} + +void report_flag_words_changes(const mcpp::manifest::Manifest& m) { + auto show = [](const std::vector& words) { + std::string out = "["; + for (auto const& w : words) + out += std::format("{}'{}'", out.size() > 1 ? ", " : "", w); + return out + "]"; + }; + auto note_change = [&](std::string what, std::string hint) { + auto note = std::pair{std::move(what), std::move(hint)}; + auto& notes = pending_flag_words_notes(); + if (std::ranges::find(notes, note) == notes.end()) notes.push_back(std::move(note)); + }; + auto const who = m.package.name.empty() ? std::string("(root)") : m.package.name; + auto check = [&](std::string_view where, const std::vector& list, + bool define) { + for (auto const& e : list) { + auto now = define ? std::vector{"-D" + e} + : mcpp::manifest::flag_words(e); + auto before = previous_release_words(e, define); + if (now == before) continue; + note_change(std::format( + "{}: {} element '{}' reaches the compiler as {}; mcpp before " + "2026.9.17.1 passed {} on this host", + who, where, e, show(now), show(before)), + std::string( + "a compile-flag element is read by one syntax on every host, and a " + "`defines` entry is one value (docs/04-mcpp-toml.md, " + "\"Compile-flag syntax\"); spell the element so that it reads as the " + "words meant")); + } + }; + // THE SAME QUESTION FOR THE LINK FLAGS, which take the reading from + // 2026.9.26.2 (#703). Before, a `-L` or `-Wl,-rpath,` element was escaped + // for ninja, so its text reached the host's reader as written, and any + // other element was pasted into the ninja rule, so ninja replaced its `$` + // sequences first. `$ORIGIN` written plainly reads the same under both + // models, because neither reproduces the shell's expansion that lost it; + // an element escaped for ninja or for the shell by hand is what differs. + auto check_link = [&](std::string_view where, const std::vector& list) { + for (auto const& e : list) { + auto now = mcpp::manifest::flag_words(e); + auto before = e.starts_with("-L") || e.starts_with("-Wl,-rpath,") + ? mcpp::manifest::host_command_words(e, mcpp::platform::is_windows) + : previous_release_words(e, false); + if (now == before) continue; + note_change(std::format( + "{}: {} element '{}' reaches the linker as {}; mcpp before " + "2026.9.26.2 passed {} on this host", + who, where, e, show(now), show(before)), + std::string( + "a link-flag element is read by the compile-flag syntax, so `$ORIGIN` " + "reaches the linker as written and an element escaped for ninja or the " + "shell by hand is no longer unescaped (docs/04-mcpp-toml.md, " + "\"Compile-flag syntax\"); spell the element so that it reads as the " + "words meant")); + } + }; + auto const& bc = m.buildConfig; + check_link("[build] ldflags", bc.ldflags); + check("[build] cflags", bc.cflags, false); + check("[build] cxxflags", bc.cxxflags, false); + check("[build] defines", bc.defines, true); + for (auto const& gf : bc.globFlags) { + check("flags cflags", gf.cflags, false); + check("flags cxxflags", gf.cxxflags, false); + check("flags asmflags", gf.asmflags, false); + check("flags defines", gf.defines, true); + } + for (auto const& [feature, defines] : bc.featureDefines) + check(std::format("features.{} defines", feature), defines, true); + for (auto const& [feature, globs] : bc.featureFlags) { + for (auto const& gf : globs) { + check(std::format("features.{} cflags", feature), gf.cflags, false); + check(std::format("features.{} cxxflags", feature), gf.cxxflags, false); + check(std::format("features.{} asmflags", feature), gf.asmflags, false); + check(std::format("features.{} defines", feature), gf.defines, true); + } + } + for (auto const& t : m.targets) { + check(std::format("targets.{} cflags", t.name), t.cflags, false); + check(std::format("targets.{} cxxflags", t.name), t.cxxflags, false); + check(std::format("targets.{} defines", t.name), t.defines, true); + } +} + +// The macro name a `defines` entry or a `-D` word defines: the text before +// the first `=`, or the whole text when there is no value. +std::string_view define_name(std::string_view entry) { + return entry.substr(0, entry.find('=')); +} + +void fold_build_defines_into_flags(mcpp::manifest::BuildConfig& bc) { + if (bc.defines.empty()) return; + + std::vector resolved; // entries, first-seen order + std::vector named; // every name this call touches + auto touch = [&](std::string_view name) { + if (std::ranges::find(named, name) == named.end()) + named.emplace_back(name); + }; + for (auto const& d : bc.defines) { + if (d.starts_with('!')) { + const auto name = std::string_view(d).substr(1); + std::erase_if(resolved, [&](const std::string& e) { + return define_name(e) == name; + }); + touch(name); + continue; + } + const auto name = define_name(d); + touch(name); + auto it = std::ranges::find_if(resolved, [&](const std::string& e) { + return define_name(e) == name; + }); + if (it != resolved.end()) *it = d; + else resolved.push_back(d); + } + + auto superseded = [&](const std::string& element) { + auto words = mcpp::manifest::flag_words(element); + if (words.size() != 1 || !words.front().starts_with("-D")) return false; + const auto name = define_name(std::string_view(words.front()).substr(2)); + return std::ranges::find(named, name) != named.end(); + }; + std::erase_if(bc.cflags, superseded); + std::erase_if(bc.cxxflags, superseded); + + for (auto const& d : resolved) { + const auto element = mcpp::manifest::flag_element("-D" + d); + bc.cflags.push_back(element); + bc.cxxflags.push_back(element); + } + bc.defines.clear(); +} + +std::optional +unfolded_defines_error(const mcpp::manifest::Manifest& m) { + auto const& d = m.buildConfig.defines; + if (d.empty()) return std::nullopt; + return std::format( + "internal error: [build].defines of package '{}' reached the build " + "graph unfolded ({} entr{}, first '{}'); a merge ran after " + "fold_build_defines_into_flags (please report)", + m.package.name.empty() ? std::string("(root)") : m.package.name, + d.size(), d.size() == 1 ? "y" : "ies", d.front()); +} + +// WHAT A MEMBER RECEIVES FROM ITS WORKSPACE WHEN IT IS REACHED AS A DEPENDENCY. +// +// Three parts of the inheritance matter to a dependency: `[workspace.package]` +// (a member may omit `version`), `x.workspace = true` dependency entries +// (without the merge the entry reaches resolution with neither version nor +// path), and `[workspace.build]`. They are applied at the dependency's LOAD +// site, before the conditional merge and the `defines` fold, which is the +// order the root follows; `makePackageRoot` only captures the result (#690). +// `[toolchain]`, `[target.]` and `[indices]` are decided by the root +// for the whole graph and are not applied to a dependency. +// +// One function for every way a member is reached: a sibling `path` +// dependency, a member of a git-hosted workspace, and a member inside an +// index package's archive. The same commit then compiles the same way in its +// own checkout and in every consumer's graph. +std::optional +inherit_as_workspace_member(mcpp::manifest::Manifest& member, + const mcpp::manifest::Manifest& workspace, + const std::filesystem::path& workspaceRoot, + const std::filesystem::path& memberDir) { + 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); +} + +// The workspace whose `members` list `memberDir`, searched upward from its +// parent and never above `bound` (an index package's install root: the +// archive is the only tree the package's author wrote). +std::optional> +workspace_listing(const std::filesystem::path& memberDir, + const std::filesystem::path& bound) { + auto inside = [&](const std::filesystem::path& p) { + auto rel = p.lexically_normal().lexically_relative(bound.lexically_normal()); + return !rel.empty() && *rel.begin() != ".."; + }; + for (auto p = memberDir.parent_path(); inside(p); p = p.parent_path()) { + if (std::filesystem::exists(p / "mcpp.toml")) { + if (auto ws = mcpp::manifest::load(p / "mcpp.toml"); + ws && ws->workspace.present + && mcpp::project::is_workspace_member(*ws, p, memberDir)) + return std::pair{std::move(*ws), p}; + } + if (p == p.parent_path()) break; + } + return std::nullopt; +} + +// ── The SECOND conditional pass: predicates that name a target-side layer ──── +// +// #540/#494. `docs/14` documents a package adapting to the C library it was +// built over — `[target.'cfg(c-abi = "musl")'.build] std-module-flags = +// ["-D_GNU_SOURCE"]`, wrong for picolibc — and `stdModuleFlags` was moved onto +// BuildInputs FOR this, its member comment saying membership "is what makes the +// cfg axis carry it". Nothing evaluated the predicate: `cfgpred::Ctx` was built +// from the triple alone, so every such section was dropped in silence and the +// package built with the wrong C-library configuration, successfully. +// +// WHY A SECOND PASS AND NOT AN EARLIER CONTEXT. A layer is answerable only +// after dependency resolution — a package in the graph may supply the C library +// (openkal-musl under a `-gnu` triple), which is exactly why the triple's `env` +// segment is a REQUEST and not the answer (docs/specs/target-side.md §3.4). The +// first merge runs before resolution because conditional DEPENDENCIES have to. +// +// WHERE IT RUNS. Between `tsd::resolve` and the P1689 scan — the same window in +// which build.mcpp already contributes build inputs by mirroring into +// `packages[0]`. Everything downstream reads the snapshot from there on: the +// scan, `stdModuleFlags` collection, the fingerprint, and `compute_flags`. +// +// SCOPE. Build INPUTS only, which is what docs/14 promises ("available in +// [build] sections only"). Dependencies are excluded by construction — they are +// already resolved by now — and a section that tries is reported rather than +// silently ignored; see `warn_layer_predicate_dependencies`. +bool merge_layer_conditional_config(mcpp::manifest::Manifest& m, + const cfgpred::Ctx& ctx) { + bool any = false; + for (auto const& cc : m.conditionalConfigs) { + if (!cfgpred::uses_layer(cc.predicate)) continue; + if (!cfgpred::matches(cc.predicate, ctx)) continue; + any = true; + mcpp::manifest::append(m.buildConfig, cc.inputs); + // Same mirror the first pass does: `modules.sources` is the scanner's + // own view and is not a BuildInputs member. + for (auto const& s : cc.inputs.sources) + m.modules.sources.push_back(s); + for (auto const& d : cc.linkLibraryDirs) + m.runtimeConfig.linkIntent.linkLibraryDirs.push_back(d); + for (auto const& l : cc.libraries) + m.runtimeConfig.linkIntent.libraries.push_back(l); + for (auto const& f : cc.frameworks) + m.runtimeConfig.linkIntent.frameworks.push_back(f); + // NO `merge_conditional_xlings` HERE, DELIBERATELY. A cc that reaches + // this pass has a layer in its predicate, and one carrying tools was + // refused long before — see `layer_predicated_xlings_refusal`. Folding + // it here would be folding after the tools were provisioned and after + // every build.mcpp ran, which is the silent-absence failure the + // refusal exists to prevent. + } + // Re-fold only when something was added. The call is safe either way — the + // function clears `defines` after folding and says so — but skipping it + // keeps this pass a no-op for the overwhelming majority of manifests, which + // name no layer at all. + if (any) fold_build_defines_into_flags(m.buildConfig); + return any; +} + +// Feature-activation closure — THE single implementation (build.mcpp env +// contract, Stage 2a feature-deps, and the main feature pass all call this): +// seed = [features].default ∪ requested, expanded transitively over implies; +// the literal name "default" is never itself a feature. +// +// `seedDefault` is the funnel for consumer-side `default-features = false` +// (#242, Cargo parity): when false the dependency's own `[features].default` +// is NOT seeded, so only the explicitly `requested` features (and their +// transitive `implies`) activate. The root package always seeds its own +// default (seedDefault=true); a dependency passes its dep spec's +// `defaultFeatures` flag. `requested` is applied identically either way. +std::vector feature_closure(const mcpp::manifest::Manifest& pm, + const std::vector& requested, + bool seedDefault) { + std::vector act, q; + if (seedDefault) + if (auto it = pm.featuresMap.find("default"); it != pm.featuresMap.end()) + q.insert(q.end(), it->second.begin(), it->second.end()); + q.insert(q.end(), requested.begin(), requested.end()); + std::set seen; + while (!q.empty()) { + auto f = q.back(); q.pop_back(); + if (f == "default" || !seen.insert(f).second) continue; + act.push_back(f); + if (auto it = pm.featuresMap.find(f); it != pm.featuresMap.end()) + q.insert(q.end(), it->second.begin(), it->second.end()); + } + return act; +} + +// --features value → tokens (comma/space separated). +std::vector feature_request_tokens(std::string_view s) { + std::vector out; + for (std::size_t p = 0; p < s.size();) { + auto c = s.find_first_of(", ", p); + auto tok = s.substr(p, c == std::string_view::npos ? std::string_view::npos : c - p); + if (!tok.empty()) out.emplace_back(tok); + if (c == std::string_view::npos) break; + p = c + 1; + } + return out; +} + +// The root's own features among the --features tokens. +// +// A TOKEN CONTAINING `/` IS NOT A FEATURE OF THE ROOT (#649 E8). It can only +// mean "open this feature of that dependency", which is what the same token +// means inside `[features]`, so it is taken out here and applied as a forward +// of the root (`feature_forward_request`). It used to stay in this list, where +// a root without `[features]` turned it into `-DMCPP_FEATURE_SPIKE_FW_INSTALLER` +// and a root with the table reported it as an undeclared feature; neither +// opened the dependency's feature. +std::vector parse_feature_request(std::string_view s) { + std::vector out; + for (auto& tok : feature_request_tokens(s)) + if (tok.find('/') == std::string::npos) out.push_back(std::move(tok)); + return out; +} + +// The `/` tokens of --features, in the keyspace of a +// `[features]` forward. A token with an empty half is kept whole and named by +// the caller, rather than being dropped as the manifest parser drops it: on a +// command line the user typed it just now. +std::vector feature_forward_request_tokens(std::string_view s) { + std::vector out; + for (auto& tok : feature_request_tokens(s)) + if (tok.find('/') != std::string::npos) out.push_back(std::move(tok)); + return out; +} + +bool is_std_module(std::string_view name) { + return name == "std" || name == "std.compat"; +} + +bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, + const mcpp::manifest::Manifest& manifest, + const std::filesystem::path& projectRoot) { + for (auto& u : graph.units) { + for (auto& req : u.requires_) { + if (is_std_module(req.logicalName)) + return true; + } + } + + // Some target entry files can be added to the plan after the package scan. + // Check them here so std BMI setup matches what make_plan will compile: they + // are read by the same scan_entry_file make_plan reads them with. + const auto extTable = mcpp::extension_table_for(manifest.buildConfig.moduleExtensions, + manifest.buildConfig.deviceExtensions); + for (auto& t : manifest.targets) { + if (t.main.empty()) continue; + const auto entry = mcpp::modgraph::scan_entry_file(projectRoot / t.main, + manifest.package.name, extTable); + for (auto const& req : entry.requires_) + if (is_std_module(req.logicalName)) return true; + } + return false; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/driver.cpp b/src/build/prepare/driver.cpp new file mode 100644 index 000000000..b044c4f9c --- /dev/null +++ b/src/build/prepare/driver.cpp @@ -0,0 +1,51 @@ +// driver.cpp -- prepare_build itself: construct the PrepareState, run the +// phases in order, and return what the last one builds. An early error of any +// phase ends the call with that phase's message. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.build.version_floor; +import mcpp.manifest; +import mcpp.source_kind; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.platform; + +namespace mcpp::build { + +std::expected +prepare_build(bool print_fingerprint, + bool includeDevDeps, + std::vector extraTargets, + BuildOverrides overrides) { + PrepareState state(print_fingerprint, includeDevDeps, + std::move(extraTargets), std::move(overrides)); + pending_flag_words_notes().clear(); + + if (auto r = phase0_manifest_and_workspace(state); !r) return std::unexpected(r.error()); + if (auto r = phase1_toolchain_spec_and_axes(state); !r) return std::unexpected(r.error()); + if (auto r = phase2_define_toolchain_resolver(state); !r) return std::unexpected(r.error()); + if (auto r = phase3_xlings_before_graph(state); !r) return std::unexpected(r.error()); + if (auto r = phase4a_graph_load(state); !r) return std::unexpected(r.error()); + if (auto r = phase4b_graph_worklist(state); !r) return std::unexpected(r.error()); + if (auto r = phase5_toolchain_after_graph(state); !r) return std::unexpected(r.error()); + if (auto r = phase6_features_and_host_tools(state); !r) return std::unexpected(r.error()); + if (auto r = phase9_target_side(state); !r) return std::unexpected(r.error()); + if (auto r = phase11_scan(state); !r) return std::unexpected(r.error()); + + return phase13_finish(state); +} + + +} // namespace mcpp::build diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp new file mode 100644 index 000000000..f4905860a --- /dev/null +++ b/src/build/prepare/features.cpp @@ -0,0 +1,2018 @@ +// features.cpp -- P6 to P8: feature activation, capability and ABI +// requirements, host-tool provisioning, and the dependencies' build programs. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.build.version_floor; +import mcpp.home; +import mcpp.platform.axis; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.modgraph.glob; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.modgraph.validate; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.pack.abi_tag; // the tag a prebuilt dependency is checked against +import mcpp.pack.prebuilt; // …and the check itself +import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its manifest +import mcpp.build.build_program; +import mcpp.build.tool_store; // #355 host tools: store layout + key + overrides +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.xlings; +import mcpp.xlings.runtime_selection; +import mcpp.runtime.binding; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.ui; +import mcpp.wire; // Severity, for PlanNote (#699 item 2, E3) + +namespace mcpp::build { + +std::expected phase6_features_and_host_tools(PrepareState& state) { + // ─── Feature activation (Cargo-style, additive) ──────────────────── + // activated(pkg) = pkg.[features].default ∪ features requested for it + // (root: --features; deps: the root dep spec's `features = [...]`). + // Implied features expand transitively. Each active feature becomes + // -DMCPP_FEATURE_ on that package's compile flags. + // (Transitive dep→dep feature requests are not yet propagated.) + // Also captured here: the root package's active feature set, reused below + // for the [targets.*] required_features gate. + // Capability accumulation (Stage 3): which packages provide each capability, + // and which (capability, requiring-package) pairs need binding. Filled by + // apply() as each package's features activate; bound after the loops below. + // `requires_abi`: (what, requirer). See Manifest::requiresAbiThreads. + // Same shape, for the second `abi` member (A1/A6). Two vectors rather + // than one tagged one, because every reader below already asks "threads + // or exceptions" as two separate questions. + // Who claimed sole provision of what. Separate from capProviders because + // the question it answers is different: capProviders asks "can this + // requirement be satisfied", this asks "can these two coexist at all". + // Callable twice: once here, for what the manifests and the + // dependencies' build programs declared, and once more after the + // root's build program has run -- a rule package it imports states + // its facts and floors from there (`mcpp::fact` / `mcpp::floor`), + // and a check that ran only before it would never see them. + // package name -> device-kind sources of its effective source set, filled + // by the narrowing pass after feature application and read at both + // build-program run sites (MCPP_DEVICE_SOURCES). + // Keyed by the package's ROOT DIRECTORY, not by its name. Two packages in + // one graph may share a bare name and differ only by namespace — that is + // what namespaces are for — and a name key would hand one package's + // device sources to the other's build program with nothing reporting it. + state.checkVersionFloors = [&]() -> std::optional { + std::map> facts; // name -> (version, who) + // #634, A9: THE TARGET'S PLATFORM FLOOR IS A FACT THE ENGINE STATES, + // in the platform's own words. A dependency that needs Android API 23 + // writes `android.api-level >= 23` as an ordinary `version-floor` + // requirement and is refused before compiling when the application + // targets less. The floor is not raised for it: the value is already + // inside the compiler's `--target` by now, and which devices an + // application installs on is the application's decision. A row that + // states no such fact (a desktop Linux build) leaves the requirement + // silent, so a requirement needs no selector. The engine's value is + // entered first, so a package stating the same name cannot replace it. + std::map platformFactOrigin; // name -> the key that sets it + if (state.tc) { + if (auto t = mcpp::toolchain::triple::parse(state.tc->targetTriple); + t && (t->is_android() || t->is_apple())) { + const auto value = min_platform_version(*state.m, *t, state.tc->binaryPath); + std::string name, origin; + if (t->is_android()) { + auto row = state.m->targetOverrides.find(t->str()); + name = "android.api-level"; + origin = row != state.m->targetOverrides.end() && row->second.minApiLevel > 0 + ? std::format("[target.{}] min_api_level", t->str()) + : std::format("the toolchain's lowest supported level, because " + "[target.{}] min_api_level is not set", t->str()); + } else if (t->is_ios()) { + name = "ios.deployment-target"; + origin = state.iosFloorFromSdk + ? std::string("the located SDK's version, because [build] " + "ios_deployment_target is not set") + : std::string("[build] ios_deployment_target"); + } else { + name = "macos.deployment-target"; + origin = state.m->buildConfig.macosDeploymentTarget.empty() + ? std::string("mcpp's default for macOS, because [build] " + "macos_deployment_target is not set") + : std::string("[build] macos_deployment_target"); + } + if (!value.empty()) { + facts.emplace(name, std::pair{value, std::string{}}); + platformFactOrigin.emplace(name, std::move(origin)); + } + } + } + for (std::size_t pi = 0; pi < state.packages.size(); ++pi) { + // The root's claims live in *m: its build program mutates + // *m, and packages[0] is a snapshot taken before it ran. + const auto& mf = pi == 0 ? *state.m : state.packages[pi].manifest; + const auto who = mf.package.name; + for (auto const& entry : mf.runtimeConfig.provides) { + auto fact = mcpp::build::parse_version_fact(entry); + if (fact.valid()) facts.emplace(fact.name, std::pair{fact.version, who}); + } + } + for (std::size_t pi = 0; pi < state.packages.size(); ++pi) { + // The root's claims live in *m: its build program mutates + // *m, and packages[0] is a snapshot taken before it ran. + const auto& mf = pi == 0 ? *state.m : state.packages[pi].manifest; + const auto who = mf.package.name; + for (auto const& req : mf.runtimeConfig.requirements) { + if (req.kind != "version-floor") continue; + auto floor = mcpp::build::parse_version_floor(req.value); + if (!floor.valid()) { + return std::format( + "`{}` declares a version-floor requirement mcpp " + "cannot read: '{}'.\n" + " The shape is ` >= `, e.g. " + "`cuda.driver >= 12.0`.", who, req.value); + } + auto it = facts.find(floor.name); + if (it == facts.end()) continue; // nobody stated it + auto met = mcpp::build::version_at_least(it->second.first, + floor.version); + if (!met || *met) continue; + refusal::record(refusal::Code::VersionFloorUnmet); + if (auto origin = platformFactOrigin.find(floor.name); + origin != platformFactOrigin.end()) + return std::format( + "`{}` requires {} >= {}, and this build targets {}.\n" + " set by: {}\n" + " This is checked before anything is compiled " + "because the failure it prevents is not:\n" + " a library that needs a newer platform links " + "cleanly and fails on the device that lacks it.", + who, floor.name, floor.version, it->second.first, + origin->second); + return std::format( + "`{}` requires {} >= {}, and {} is stated as {}.\n" + " stated by: {}\n" + " This is checked before anything is compiled " + "because the failure it prevents is not:\n" + " a build against too-new a runtime links " + "cleanly and fails at first use.", + who, floor.name, floor.version, floor.name, + it->second.first, it->second.second); + } + } + return std::nullopt; + }; + { + auto sanitize = [](std::string f) { + for (auto& c : f) + c = std::isalnum(static_cast(c)) + ? static_cast(std::toupper(static_cast(c))) : '_'; + return f; + }; + auto activate = [](const mcpp::manifest::Manifest& pm, + const std::vector& requested, + bool seedDefault = true) { + return feature_closure(pm, requested, seedDefault); // single shared implementation + }; + auto apply = [&](mcpp::modgraph::PackageRoot& pkg, + const std::vector& requested, + bool seedDefault = true) { + auto active = activate(pkg.manifest, requested, seedDefault); + // Capability accumulation: package-level provides always count; + // feature-scoped provides/requires count only when the feature is + // active. Requirements are bound after all packages are processed. + const auto& pcap = pkg.manifest.package.name; + for (auto& cap : pkg.manifest.provides) state.capProviders[cap].push_back(pcap); + for (auto& cap : pkg.manifest.exclusive) state.capExclusive[cap].push_back(pcap); + for (auto& f : active) { + if (auto it = pkg.manifest.featureProvides.find(f); + it != pkg.manifest.featureProvides.end()) + for (auto& cap : it->second) state.capProviders[cap].push_back(pcap); + if (auto it = pkg.manifest.featureRequires.find(f); + it != pkg.manifest.featureRequires.end()) + for (auto& cap : it->second) state.capRequires.emplace_back(cap, pcap); + if (auto it = pkg.manifest.featureRequiresAbiThreads.find(f); + it != pkg.manifest.featureRequiresAbiThreads.end() && it->second) + state.abiRequires.emplace_back(std::format("feature `{}`", f), pcap); + if (auto it = pkg.manifest.featureRequiresAbiExceptions.find(f); + it != pkg.manifest.featureRequiresAbiExceptions.end() && it->second) + state.abiRequiresExceptions.emplace_back(std::format("feature `{}`", f), pcap); + // The TARGET-AXIS per-feature form (A6): + // `[target..feature-requires-abi] `, already reduced + // by merge_conditional_config to the selectors that matched + // and asked. Named by the selector, as written, not "feature + // `f`" -- the feature only decided whether the section counts; + // the selector is what asked for the switch. + if (auto it = pkg.manifest.targetFeatureRequiresAbiThreads.find(f); + it != pkg.manifest.targetFeatureRequiresAbiThreads.end() && !it->second.empty()) + state.abiRequires.emplace_back( + std::format("[target.'{}']", it->second.front()), pcap); + if (auto it = pkg.manifest.targetFeatureRequiresAbiExceptions.find(f); + it != pkg.manifest.targetFeatureRequiresAbiExceptions.end() && !it->second.empty()) + state.abiRequiresExceptions.emplace_back( + std::format("[target.'{}']", it->second.front()), pcap); + } + if (pkg.manifest.requiresAbiThreads) + state.abiRequires.emplace_back("the package", pcap); + if (pkg.manifest.requiresAbiExceptions) + state.abiRequiresExceptions.emplace_back("the package", pcap); + // `[target.] requires_abi` (A6): the package-wide form of the + // same target-axis requirement, one entry per matching selector + // that asked. + for (auto const& sel : pkg.manifest.targetRequiresAbiThreads) + state.abiRequires.emplace_back(std::format("[target.'{}']", sel), pcap); + for (auto const& sel : pkg.manifest.targetRequiresAbiExceptions) + state.abiRequiresExceptions.emplace_back(std::format("[target.'{}']", sel), pcap); + // A DEPENDENCY'S OWN `[target..abi]` DOES NOT CHANGE THE + // BUILD. The switch belongs to the artefact, which the root decides; + // a table written in a dependency is reported rather than silently + // ignored, and points at the key a dependency does have. Covers + // BOTH members: a dependency that declares only `exceptions` must + // be reported exactly as one that declares only `threads`. + if (pcap != state.m->package.name + && (pkg.manifest.buildConfig.abiThreadsDeclared + || pkg.manifest.buildConfig.abiExceptionsDeclared)) + mcpp::diag::warning("abi/dependency-table", std::format( + "`{}` declares [target..abi], which only the root " + "manifest decides; a dependency states what it needs with " + "`requires_abi = {{ threads = true }}` or " + "`requires_abi = {{ exceptions = true }}`", pcap)); + // `[targets.*] required_features` on a DEPENDENCY. + // + // THIS GATE EXISTED ONLY FOR THE ROOT. The root's targets are + // filtered further down against the root's own active features; + // a dependency's were never filtered at all, so a descriptor that + // wrote `required_features` on a target got the opposite of what + // it asked for: the target was built for EVERY consumer, whether + // or not the feature was active. For a `kind = "shared"` target + // that is not a cosmetic difference — its mere presence changes + // how the whole package is linked into every consumer. + // + // Gated against THIS package's active set, not the root's. A + // feature name is package-scoped, so the root's set is a different + // vocabulary that happens to share a type. + // + // LIBRARY TARGETS ONLY, and the exclusion is load-bearing. + // + // A target requested as a HOST TOOL is what was ASKED FOR, so its + // `required_features` become that sub-build's INPUTS instead of a + // gate — docs/05 §2.2 says so in as many words. An earlier + // revision of this gate erased every kind, with a comment claiming + // the tool path "re-enters prepare_build with the dependency as + // the ROOT, so it never reaches this code". That was written from + // memory rather than read: the tool LOOKUP runs several hundred + // lines BELOW this point, against this very manifest, and it found + // an empty target list. `187_dep_host_tool.sh` caught it. + // + // Restricting the gate to libraries is not a workaround, it is the + // rule: a dependency's `bin` target produces no link unit in this + // build (make_plan only walks the ROOT's targets), so leaving it in + // place costs nothing. What the gate exists for is the shape where + // a target's mere presence changes how the package is linked into + // every consumer — and that is exactly a `shared` or `lib` target. + std::erase_if(pkg.manifest.targets, + [&](const mcpp::manifest::Target& t) { + if (t.kind != mcpp::manifest::Target::Library + && t.kind != mcpp::manifest::Target::SharedLibrary) + return false; + for (auto const& rf : t.requiredFeatures) + if (std::find(active.begin(), active.end(), rf) == active.end()) + return true; + return false; + }); + + for (auto& f : active) { + auto def = "-DMCPP_FEATURE_" + sanitize(f); + pkg.manifest.buildConfig.cflags.push_back(def); + pkg.manifest.buildConfig.cxxflags.push_back(def); + pkg.privateBuild.cflags.push_back(def); + pkg.privateBuild.cxxflags.push_back(def); + // Feature System v2 Stage 1: package-owned `defines` declared on + // this feature ride alongside the automatic MCPP_FEATURE_ macro. + // Bare names desugar to -D, matching [targets.*] `defines`. + if (auto it = pkg.manifest.buildConfig.featureDefines.find(f); + it != pkg.manifest.buildConfig.featureDefines.end()) + for (auto& d : it->second) { + auto fdef = mcpp::manifest::flag_element("-D" + d); + pkg.manifest.buildConfig.cflags.push_back(fdef); + pkg.manifest.buildConfig.cxxflags.push_back(fdef); + pkg.privateBuild.cflags.push_back(fdef); + pkg.privateBuild.cxxflags.push_back(fdef); + // Interface-propagate the user-declared feature define: + // a header-only dependency's switch (e.g. EIGEN_USE_BLAS) + // only takes effect in the TU that includes its headers, + // so consumers that enable the feature must see it too. + // computeUsageRequirements() flows publicUsage flags into + // each consumer's privateBuild along Public/Interface + // edges, mirroring include_dirs. The automatic + // MCPP_FEATURE_ macro stays private to the owning + // package (it is a build signal, not a public contract). + pkg.publicUsage.cflags.push_back(fdef); + pkg.publicUsage.cxxflags.push_back(fdef); + } + } + // Feature-gated sources (e.g. gtest's gtest_main.cc behind "main"): + // drop EVERY feature-listed glob from the default build, then add + // back only the ones whose feature is active. Runs even when no + // feature is active, so a gated source is excluded by default. + // + // The DROP is build-mode only (!state.includeDevDeps). `mcpp test` + // (state.includeDevDeps) keeps the full surface so the dev-dependency + // track's per-test main detection (run_tests / make_plan) still sees + // gtest_main.cc and prunes it per test — the two tracks stay + // decoupled; gtest's descriptor keeps gtest_main.cc in base `sources` + // too, so skipping the drop leaves it visible. + // + // The ADD runs in BOTH modes. A descriptor may list a glob ONLY under + // `features` and never in base `sources` (xpkg's `features.X.sources` + // lands in featureSources alone — compat.spdlog's `compiled`, + // compat.cjson's `utils`, compat.eigen's `eigen_blas`). Gating the add + // on !state.includeDevDeps meant those sources were never compiled under + // `mcpp test` → link-time `undefined reference` (the eigen_blas + // `dgemm_` failure, long misread as a linking follow-up: it was + // source-set resolution, not linking). Add is dedup'd so gtest's + // doubly-listed gtest_main.cc cannot land twice. + auto& bc = pkg.manifest.buildConfig; + if (!bc.featureSources.empty()) { + // WHETHER A FEATURE *GATES* A SOURCE OR *PROVIDES* IT, AND + // THE ANSWER IS WRITTEN IN THE MANIFEST ALREADY. + // + // Two families of package reach this code and they want + // opposite things under `mcpp test`: + // + // gtest lists `*/googletest/src/gtest_main.cc` in + // base `sources` AND under `features.main`. + // The package provides the file unconditionally; + // the feature is a gate over it. The + // dev-dependency track's per-test main detection + // must still SEE it in order to prune it per + // test, so an inactive gate must not make it + // vanish. + // + // riscv-virt-rt names `src/kal/**` under `features.openkal` + // and nowhere else. The package does not provide + // those files at all without the feature — the + // headers they include arrive through that + // feature's `[feature-deps]` — so compiling them + // fails on `'openkal/abort.h' file not found`. + // + // The discriminator is membership in base `sources`, evaluated + // BEFORE the drop below removes it. A glob in both places is a + // gate; a glob in one place is a provider. + // + // THIS IS THE FOURTH ATTEMPT, AND THE THIRD WAS ABANDONED ON + // A MISTAKEN READING. It was recorded as failing because + // "gtest's base entry is a glob that MATCHES the file rather + // than the same string". Measured against the descriptor the + // index actually carries, the two entries are byte-identical + // (`compat.gtest.lua` lines 71 and 90). The criterion was + // sound; what it was applied to was not — the earlier attempt + // compared against `bc.sources` AFTER `drop()` had already + // removed the entry, so the membership test could only ever be + // false. + std::set baseGlobs(bc.sources.begin(), bc.sources.end()); + baseGlobs.insert(pkg.manifest.modules.sources.begin(), + pkg.manifest.modules.sources.end()); + if (!state.includeDevDeps) { + // glob → owned by at least one ACTIVE feature? + std::set activeNow(active.begin(), active.end()); + std::map gated; + for (auto& [f, globs] : bc.featureSources) + for (auto& g : globs) + gated[g] = gated[g] || activeNow.contains(f); + auto drop = [&](std::vector& v) { + std::erase_if(v, [&](const std::string& s) { return gated.contains(s); }); + }; + drop(bc.sources); + drop(pkg.manifest.modules.sources); + // Dropping the glob STRING is not enough: files it matches + // may still be covered by a broader base glob (the default + // src/** — the mcpp.toml G5 case). An inactive gate becomes + // a `!` exclusion so the gate actually gates; active gates + // are re-added below. + for (auto& [g, isActive] : gated) { + if (isActive || g.starts_with("!")) continue; + bc.sources.push_back("!" + g); + pkg.manifest.modules.sources.push_back("!" + g); + } + } + else { + // `mcpp test`. The gate that build mode applies wholesale is + // applied here only to the globs the package provides + // NOWHERE ELSE, which leaves gtest's doubly-listed source + // visible and stops riscv-virt-rt's feature-only sources + // from being compiled without their feature. + // + // THE `!` EXCLUSION IS THE WHOLE MECHANISM, NOT THE GLOB + // REMOVAL. `src/kal/**` is never IN `bc.sources` — the + // package declares no `sources` at all and its files are + // matched by the inferred `src/**`. Erasing the string + // erases nothing; only an exclusion gates. + std::set activeNow(active.begin(), active.end()); + std::map gated; + for (auto& [f, globs] : bc.featureSources) + for (auto& g : globs) + gated[g] = gated[g] || activeNow.contains(f); + for (auto& [g, isActive] : gated) { + if (isActive || g.starts_with("!")) continue; + if (baseGlobs.contains(g)) continue; // a gate, not a provider + bc.sources.push_back("!" + g); + pkg.manifest.modules.sources.push_back("!" + g); + } + } + std::set activeSet(active.begin(), active.end()); + auto add = [](std::vector& v, const std::string& g) { + if (std::ranges::find(v, g) == v.end()) v.push_back(g); + }; + for (auto& [f, globs] : bc.featureSources) { + if (!activeSet.contains(f)) continue; + for (auto& g : globs) { + add(bc.sources, g); + add(pkg.manifest.modules.sources, g); + } + } + } + // #253: per-feature per-glob flags — fold each ACTIVE feature's + // entries into the base globFlags funnel. Everything downstream + // (scanner glob match, per-TU flag landing, zero-hit warning, + // fingerprint serialization) consumes the ONE vector unchanged. + // Appended AFTER base entries, features in map (= name) order, so + // application order is deterministic and a feature rule wins over + // a broader base rule via "last flag wins". An inactive feature + // contributes nothing — its dead globs no longer exist to warn + // about. Deliberately OUTSIDE any state.includeDevDeps gate: like the + // sources ADD above, `mcpp build` and `mcpp test` must agree + // (0.0.94 dual-path invariant). featureOrigin tags the entry so + // the scanner's zero-hit warning can name the owning feature. + // + // Routed through the SAME append(BuildInputs&) the cfg axis uses + // (#258): both axes are contributing additive build inputs, so + // "how does a contribution combine with the base" must have one + // answer. Only the flags half of the feature axis is expressible + // that way — feature `sources` above carry DROP-then-ADD + // semantics, and feature `defines` are interface contributions + // that propagate along Public edges, so neither is a plain + // append and neither belongs in BuildInputs. + for (auto& [f, entries] : bc.featureFlags) { + if (std::ranges::find(active, f) == active.end()) continue; + mcpp::manifest::BuildInputs contribution; + for (auto const& gf : entries) { + auto tagged = gf; + tagged.featureOrigin = f; + contribution.globFlags.push_back(std::move(tagged)); + } + mcpp::manifest::append(bc, contribution); + } + }; + if (!state.packages.empty()) { + auto rootReq = parse_feature_request(state.overrides.features); + // Strict schema check: a requested feature must exist in the + // target package's [features] table when one is declared (a + // package with no [features] accepts any request — pure-define + // usage). Covers backend= sugar (feature backend-) too. + auto unknown_requested = [](const mcpp::manifest::Manifest& pm, + const std::vector& requested) + -> std::optional { + if (pm.featuresMap.empty()) return std::nullopt; + for (auto& f : requested) + if (!pm.featuresMap.contains(f)) return f; + return std::nullopt; + }; + if (auto bad = unknown_requested(state.packages[0].manifest, rootReq)) { + auto msg = std::format( + "--features requests '{}' which [features] does not declare", *bad); + if (state.overrides.strict) return std::unexpected(msg); + mcpp::diag::warning("features/request", msg); + } + apply(state.packages[0], rootReq); + for (auto& f : activate(*state.m, rootReq)) state.activeRootFeatures.insert(f); + } + // #242/#243: the feature request for a dependency PACKAGE, aggregated + // over ALL its incoming edges (a package may be depended on by several + // consumers — diamond — or reached only transitively). Cargo semantics: + // requested features UNION; default-features stays on unless EVERY + // consumer opted out. Sourcing this from the authoritative edge graph — + // rather than scanning only the root manifest's direct deps — makes + // activation AGREE with resolution (mergeActiveFeatureDeps, which reads + // the true per-edge spec): a transitive dep's requested features and its + // consumer's `default-features = false` are no longer silently dropped. + auto aggregatedRequest = [&](std::size_t depPkgIndex) + -> std::pair, bool> { + std::vector feats; + bool anyEdge = false, anyDefault = false; + for (auto const& edge : state.dependencyEdges) { + if (edge.dependencyPackageIndex != depPkgIndex) continue; + anyEdge = true; + if (edge.defaultFeatures) anyDefault = true; + for (auto const& f : edge.requestedFeatures) + if (std::find(feats.begin(), feats.end(), f) == feats.end()) + feats.push_back(f); + } + return { std::move(feats), anyEdge ? anyDefault : true }; + }; + for (std::size_t i = 1; i < state.packages.size(); ++i) { + auto& pname = state.packages[i].manifest.package.name; + auto [req, depDefaultFeatures] = aggregatedRequest(i); + if (!req.empty() && !state.packages[i].manifest.featuresMap.empty()) { + for (auto& f : req) { + if (state.packages[i].manifest.featuresMap.contains(f)) continue; + auto msg = std::format( + "dependency '{}' does not declare requested feature '{}' " + "in its [features] table", pname, f); + if (state.overrides.strict) return std::unexpected(msg); + mcpp::diag::warning("features/request", msg); + } + } + // Always apply: even with no requested/default feature, a dep with + // feature-gated sources must have those sources dropped by default. + // depDefaultFeatures carries the consumer's `default-features = false` + // (#242): when opted out, the dep's [features].default is not seeded. + apply(state.packages[i], req, depDefaultFeatures); + if (state.activeFeaturesByPackage.size() <= i) + state.activeFeaturesByPackage.resize(i + 1); + state.activeFeaturesByPackage[i] = + feature_closure(state.packages[i].manifest, req, depDefaultFeatures); + } + + // ─── Device extensions a rule dependency declared ────────────────── + // + // A rule package states which device extensions it compiles, on the + // feature that provides the rule. Collected here, after features are + // activated, because only an ACTIVE feature's declaration applies: a + // collection carrying a CUDA rule and a shader rule must not make `.cu` + // a device source in a project that asked for the shader rule alone. + // + // Written into the CONSUMER's `[build]` so every site that already + // builds an extension table for a package picks it up without a second + // plumbing route. + // + // THE POSITION IS LOAD-BEARING. It sits after feature activation and + // before the extension table that narrows the constrained globs, which + // is the first reader. Placed after that table instead, the declared + // extensions arrive too late to classify anything: the device source + // list comes out empty, the rule is handed nothing, it generates no + // module, and the failure surfaces three edges away as `failed to read + // compiled module` on the interface the consumer imported. Measured. + // + // It is what makes a new device language cost no engine change. Adding + // `.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(state.packages.size()); + for (std::size_t ci = 0; ci < state.packages.size(); ++ci) { + std::vector collected; + std::vector ruleModules; + for (auto const& edge : state.dependencyEdges) { + if (edge.consumerPackageIndex != ci) continue; + if (edge.dependencyPackageIndex >= state.packages.size()) continue; + auto const& dep = state.packages[edge.dependencyPackageIndex]; + const auto& depFeatures = + edge.dependencyPackageIndex < state.activeFeaturesByPackage.size() + ? state.activeFeaturesByPackage[edge.dependencyPackageIndex] + : edge.requestedFeatures; + for (auto const& f : depFeatures) { + auto it = dep.manifest.featureDeviceExtensions.find(f); + if (it == dep.manifest.featureDeviceExtensions.end()) continue; + for (auto const& e : it->second) + if (std::ranges::find(collected, e) == collected.end()) + collected.push_back(e); + // The module a synthesised build program imports for this + // rule. Declared by the feature rather than scanned out of + // its source, because the program has to be WRITTEN before + // anything is compiled and a build that scanned a + // dependency to decide what to write would order the two + // the wrong way round. + if (auto mit = dep.manifest.featureRuleModule.find(f); + mit != dep.manifest.featureRuleModule.end() + && std::ranges::find(ruleModules, mit->second) == ruleModules.end()) { + ruleModules.push_back(mit->second); + ruleClaims[ci].push_back(RuleClaim{ + mit->second, it->second, + std::format("{}:{}", dep.manifest.package.namespace_, + dep.manifest.package.name)}); + } + } + } + if (!collected.empty()) { + if (ci == 0) state.m->buildConfig.deviceExtensions = collected; + state.packages[ci].manifest.buildConfig.deviceExtensions = std::move(collected); + } + if (!ruleModules.empty()) { + // THE ROOT'S MANIFEST IS TWO OBJECTS. `packages[0]` holds a COPY + // made by `makePackageRoot`, and the build-program environment for the + // root reads `*m`. Writing only the copy left the synthesis with + // an empty list and the shaders uncompiled, with a refusal that + // named the missing build program rather than the missing write. + if (ci == 0) state.m->buildConfig.ruleModules = ruleModules; + state.packages[ci].manifest.buildConfig.ruleModules = std::move(ruleModules); + } + } + + // ── Constrained source globs: narrow to what this build targets ──── + // + // A `{ glob = "...", accel = "..." }` entry in `[build] sources` says + // what its files are FOR. Three outcomes, all decided here and none in + // the scanner, which keeps reading a plain list of globs: + // + // - the glob matches nothing: refused, naming the glob. An empty + // match is a typo or a moved directory, not a no-op, and the + // failure it would otherwise become is a kernel that is never + // compiled and a link that resolves nothing. + // - the build asks for no accelerator: the glob is EXCLUDED, with the + // same `!` mechanism feature gates use -- removing the string is not + // enough when a broader glob (the default `src/**`) covers the same + // files. This is how `--no-accel` yields the CPU-only variant. + // - the build asks for one: the constraint must lie within it, or the + // build is refused naming both. A file compiled for sm_89 under a + // build that targets sm_80 is not a variant, it is a mismatch. + // + // Device-kind files the effective set still matches are collected per + // package for the build program (MCPP_DEVICE_SOURCES); the engine has + // no compile rule for them and never will. + { + const auto buildAccel = mcpp::pack::parse_accel(state.resolvedAccel()); + for (std::size_t i = 0; i < state.packages.size(); ++i) { + auto& pkg = state.packages[i]; + auto& bc = pkg.manifest.buildConfig; + std::set excludedGlobs; + for (auto const& sc : bc.sourceConstraints) { + const auto hits = mcpp::modgraph::expand_glob(pkg.root, sc.glob); + if (hits.empty()) { + return std::unexpected(std::format( + "`{}`: [build] sources entry '{}' (accel = \"{}\") matches no file.\n" + " A constrained glob names the files a device build needs; an\n" + " empty match would leave nothing to compile for that device\n" + " and say so only at the link, or never.", + pkg.manifest.package.name, sc.glob, sc.accel)); + } + // A backend the package never declared. Checked BEFORE + // the build's own accel is consulted, because it is a + // property of the manifest alone and because the exclusion + // below would otherwise turn `accel = "cude12.9"` into a + // glob that is quietly never built. Only when the package + // states its backends -- `[package] accelerators` is + // optional, and a package that omits it has said nothing to + // contradict. + if (!pkg.manifest.package.accelerators.empty()) { + for (auto const& w : mcpp::pack::parse_accel(sc.accel)) { + if (std::ranges::find(pkg.manifest.package.accelerators, + w.backend) + != pkg.manifest.package.accelerators.end()) continue; + std::string declared; + for (auto const& a : pkg.manifest.package.accelerators) + declared += (declared.empty() ? "" : ", ") + a; + refusal::record(refusal::Code::AccelBackendUndeclared); + return std::unexpected(std::format( + "`{}`: [build] sources entry '{}' names accelerator " + "backend \"{}\", which this package does not declare.\n" + " [package] accelerators = [{}]\n" + " A constrained glob is left out of builds that do " + "not name its\n" + " backend, so a backend spelled wrong here is a file " + "that is never\n" + " compiled and never mentioned.\n" + " fix: correct the spelling, or add the backend to " + "`[package] accelerators`.", + pkg.manifest.package.name, sc.glob, w.backend, declared)); + } + } + if (buildAccel.empty()) { excludedGlobs.insert(sc.glob); continue; } + const auto want = mcpp::pack::parse_accel(sc.accel); + + // A GLOB WHOSE BACKEND THIS BUILD NEVER NAMED IS NOT A + // MISMATCH, IT IS ABSENT. + // + // The refusal below is about a real disagreement: a file + // written for sm_89 in a build that targets sm_80 is not a + // variant. Across DIFFERENT backends there is no such + // disagreement. A package with a CUDA island and a Vulkan + // one, built with `--accel vulkan1.2`, is asking for the + // Vulkan half; refusing it made a build that names a SUBSET + // of a package's backends impossible, so a package could + // have several device backends only if every build took all + // of them. + // + // The glob is dropped exactly as `--no-accel` drops it, and + // the `cfg(accelerator = ...)` section carrying that + // backend's host half does not activate either, so the two + // halves stay together. + // + // What keeps a TYPO from becoming a silent exclusion is the + // check below, against `[package] accelerators`: a backend + // the package never declared is refused before this point. + bool backendNamed = false; + for (auto const& w : want) + for (auto const& b : buildAccel) + if (b.backend == w.backend) backendNamed = true; + if (!backendNamed) { excludedGlobs.insert(sc.glob); continue; } + + if (!mcpp::pack::accel_accepts(buildAccel, want)) { + refusal::record(refusal::Code::AccelMismatch); + return std::unexpected(std::format( + "`{}`: [build] sources entry '{}' is constrained to accel \"{}\",\n" + " which this build does not cover.\n" + " this build targets: {}\n" + " fix: build with `--accel` covering it, or `--no-accel` to\n" + " leave every constrained glob out (the CPU-only variant).", + pkg.manifest.package.name, sc.glob, + mcpp::pack::accel_str(want), + mcpp::pack::accel_str(buildAccel))); + } + } + for (auto const& g : excludedGlobs) { + bc.sources.push_back("!" + g); + pkg.manifest.modules.sources.push_back("!" + g); + } + // The device-kind files the EFFECTIVE set matches, for the + // build program. Exclusions are honoured the way the scanner + // honours them: positives first, then `!` entries removed. + const auto extTable = mcpp::extension_table_for(bc.moduleExtensions, + bc.deviceExtensions); + std::set matched, dropped; + for (auto const& g : pkg.manifest.modules.sources) { + if (g.empty()) continue; + if (g[0] == '!') { for (auto& f : mcpp::modgraph::expand_glob(pkg.root, g.substr(1))) dropped.insert(f); } + else if (!std::filesystem::path(g).is_absolute()) + for (auto& f : mcpp::modgraph::expand_glob(pkg.root, g)) matched.insert(f); + } + std::vector device; + for (auto const& f : matched) { + if (dropped.contains(f)) continue; + if (mcpp::classify(f, extTable) != mcpp::SourceKind::Device) continue; + device.push_back(f.lexically_relative(pkg.root).generic_string()); + } + state.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 < state.packages.size(); ++ci) { + if (ruleClaims[ci].empty()) continue; + auto& bc = state.packages[ci].manifest.buildConfig; + const auto dit = state.deviceSourcesByPackage.find(state.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 != state.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) state.m->buildConfig.ruleModules = applies; + bc.ruleModules = std::move(applies); + } + state.activeFeaturesByPackage.resize(state.packages.size()); + + // ── The GRAPH's `[xlings.workspace]`, provisioned BEFORE build.mcpp ── + // + // Same ordering rule as the host-tool block directly below, and for the + // same reason: a build program consumes what was provisioned, so + // provisioning after it has run is provisioning that did not happen. + // + // MEASURED, on the published `ggml-org:llamacpp@b10069.2`. That package + // declares its shader compiler under the feature that needs it: + // + // [feature-xlings.backend-vulkan] + // "xim:shaderc" = "2026.3" + // + // and its build program asks for it with `xpkg_dir`. As the ROOT it + // works, because the root's pass runs early. As a DEPENDENCY it did + // not: the graph's pass ran ~1700 lines further down, after every + // build.mcpp, so `xpkg_dir` answered "" and the package refused its own + // headline feature with the very declaration it had already made. A + // clean `MCPP_HOME` pulled twenty-four xim payloads for that graph and + // not shaderc. + // + // IT WAS INVISIBLE ON ANY MACHINE THAT HAD BUILT THE PACKAGE ITSELF. + // Once `xim:shaderc` is in the registry for any reason, `xpkg_dir` + // finds it and the ordering stops mattering; only an empty registry can + // see this. The sandbox run is what caught it. + // + { + // ONE CHECK OVER THE WHOLE GRAPH, at the site whose consequence it + // describes. `merge_conditional_config` has three call sites and + // returns void; this loop sees the root and every package that + // reached the graph, and it runs before the first payload is + // fetched, so a refusal costs nothing that has to be undone. + if (auto why = layer_predicated_xlings_refusal(*state.runtimeOwnerManifest)) + return std::unexpected(*why); + for (auto const& pkg : state.packages) + if (auto why = layer_predicated_xlings_refusal(pkg.manifest)) + return std::unexpected(*why); + auto split = state.graph_xlings_split(); + if (!split) { + refusal::record(refusal::Code::ToolVersionConflict); + return std::unexpected(split.error()); + } + auto const& fromGraph = split->second; + if (!fromGraph.empty()) { + if (auto cfg = state.get_cfg(true)) { + if (auto pv = provision_xlings_addresses( + **cfg, fromGraph, *state.root, + "[xlings.workspace] entries declared by dependencies"); + !pv) return std::unexpected(pv.error()); + } + } + } + + // ── #355: HOST tool provisioning ──────────────────────────────────── + // + // Runs AFTER feature activation (a tool target's gate is a feature) and + // BEFORE any build.mcpp (which is what consumes the tools). That + // ordering is the whole point: build.mcpp runs inside prepare, so a + // tool produced by the main ninja graph would arrive far too late — + // and under --target it would be the wrong architecture besides. + // + // Each tool is built by re-entering prepare_build with the DEPENDENCY + // as the root and no --target, i.e. for the build machine. That is + // Cargo's [build-dependencies] / Bazel's exec configuration shape. + // It is affordable because an executable has zero ABI contact with the + // main build: the sub-build may use the tool package's own toolchain, + // its own profile, and its own resolution — none of it has to agree + // with the consumer. + { + // 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 : state.dependencyEdges) { + const auto d = edge.dependencyPackageIndex; + if (d >= state.packages.size() || d >= state.activeFeaturesByPackage.size()) continue; + auto const& ft = state.packages[d].manifest.featureTools; + if (ft.empty()) continue; + for (auto const& f : state.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 : state.dependencyEdges) + for (auto const& t : edge.requestedTools) + toolRequests[edge.dependencyPackageIndex].insert(t); + + // #359: one fixpoint decides who SEES what. `toolRequests` above + // still decides what gets BUILT — the two questions are separate, + // and conflating them is what made a re-exported tool impossible: + // the tool was built, but its path was recorded against the library + // that asked for it rather than the project that needs it. + state.provisionGraph = prov::propagate(state.dependencyEdges, state.packages.size()); + + // #355 step 5: dependencies offering HOST build rules. Nothing is + // compiled here — the interface is handed to build_program.cppm, + // which compiles it in the SAME command as build.mcpp so the BMI + // and its consumer agree on standard, dialect and compiler by + // construction rather than by luck. + // + // Driven off the visible set rather than the root manifest, so a + // rule a library re-exports is importable from the consumer's + // build.mcpp without the consumer naming it. The name matching the + // old loop needed is gone with it: the edge already knows which + // package it points at. + // + // The registered name is the one the rule's SOURCE declares, not + // the package's name. See provisions::host_module_name for why the + // two had drifted apart and what that cost on Clang and MSVC. + std::set prefixWarned; + // Providers of host modules that THIS package sees directly. + auto directHostProviders = [&](std::size_t p) { + std::vector out; + if (p >= state.provisionGraph.visible.size()) return out; + for (auto const& pr : state.provisionGraph.visible[p]) { + if (pr.kind != prov::Kind::HostModule) continue; + if (pr.provider >= state.packages.size()) continue; + out.push_back(pr.provider); + } + return out; + }; + auto identity = [&](std::size_t p) { + auto const& pkg = state.packages[p].manifest.package; + return pkg.namespace_.empty() + ? pkg.name : pkg.namespace_ + "." + pkg.name; + }; + // Every host module one package contributes, the lib root first. + // + // The lib root is what a rule package has always been: one unit, + // compiled alone, registered under the name it declares. A package + // that offers several rules through features (mcpp 2026.9.5.3+) + // lists their sources under `[features.] sources`, and those + // globs have been folded into `buildConfig.sources` by now for + // exactly the features the consumer activated. Every module + // INTERFACE unit among them is therefore a host module of its own, + // under its own declared name, and nothing else in the host-module + // path assumes one unit per package: `build_host_module` is per + // unit and the compile loop accumulates BMIs in list order, so a + // feature unit may import the lib root, which precedes it. + // + // Only sources the manifest LISTS take part. The inferred `src/**` + // of a package with no `sources` is not consulted, so a rule + // package published before this round exposes exactly what it + // exposed then; widening that implicitly would compile units that + // were written to be part of an ordinary library, alone. + auto units = [&](std::size_t p) { + auto const& depPkg = state.packages[p]; + auto const& pkg = depPkg.manifest.package; + std::vector out; + auto push = [&](std::filesystem::path iface, std::string name) { + prov::HostModule hm; + hm.module = std::move(name); + hm.package = identity(p); + hm.nameSpace = pkg.namespace_; + hm.interface = std::move(iface); + out.push_back(std::move(hm)); + }; + // PROBING form: a host-module dependency whose interface is + // `.ixx` resolves to a `src/.cppm` that does not exist, + // and the consumer's build.mcpp is then handed a path to + // nothing. + auto rel = mcpp::manifest::resolve_lib_root_path( + depPkg.manifest, depPkg.root); + auto iface = depPkg.root / rel; + push(iface, prov::host_module_name(iface, pkg.name)); + // A missing lib root is reported as such by build_host_module, + // and that has to stay the diagnostic. Enumerating the listed + // units first would let one of them collide with the missing + // root's fallback name and report a collision between a file + // and a file that does not exist. + std::error_code ec; + if (!std::filesystem::exists(iface, ec)) return out; + + std::set matched, dropped; + for (auto const& g : depPkg.manifest.buildConfig.sources) { + if (g.empty()) continue; + if (g[0] == '!') { + for (auto& f : mcpp::modgraph::expand_glob(depPkg.root, g.substr(1))) + dropped.insert(f.lexically_normal()); + } else { + for (auto& f : mcpp::modgraph::expand_glob(depPkg.root, g)) + matched.insert(f.lexically_normal()); + } + } + const auto root = iface.lexically_normal(); + // ORDERED BY WHAT THEY IMPORT, NOT BY WHERE THEY SIT. + // + // The compile loop accumulates BMIs in list order, so each + // entry sees only what precedes it. Path order was the previous + // rule and it is not a valid one: `rules/spirv.cppm` sorts + // before `src/surface.cppm`, so a member importing a unit its + // package shares was compiled first and failed with "failed to + // read compiled module ... imports must be built before being + // imported". Reproduced, and reproduced in both directions -- + // renaming the shared unit so its path sorted first made the + // same package build, which is what says the cause is the sort + // and nothing else. + // + // A package that works today is ordered IDENTICALLY: the sort + // below keeps path order wherever no import constrains it, so + // it differs only where the old order was already broken. + struct Unit { + std::filesystem::path path; + std::string name; + std::vector imports; + }; + std::vector pending; + for (auto const& f : matched) { // std::set: sorted + if (dropped.contains(f)) continue; + if (std::filesystem::equivalent(f, root, ec)) continue; + std::ifstream is(f); + if (!is) continue; + std::stringstream buf; + buf << is.rdbuf(); + auto text = buf.str(); + auto name = prov::declared_interface_name(text); + if (name.empty()) continue; + pending.push_back({f, std::move(name), prov::declared_imports(text)}); + } + + // Only names this package itself declares constrain anything. + // `import std;` and the lib root are already ahead of every + // entry here, and a name from another package is ordered by the + // cross-package DFS below rather than by this sort. + std::map byName; + for (std::size_t i = 0; i < pending.size(); ++i) + byName.emplace(pending[i].name, i); + + std::vector state(pending.size(), 0); // 0 new, 1 open, 2 done + std::vector order; + order.reserve(pending.size()); + // Iterative post-order DFS over the path-sorted list: the first + // unit that can be emitted is emitted, which is what preserves + // path order in the unconstrained case. + const auto visit = [&](std::size_t start) { + std::vector> stack{{start, 0}}; + while (!stack.empty()) { + auto& [u, k] = stack.back(); + if (state[u] == 2) { stack.pop_back(); continue; } + state[u] = 1; + if (k < pending[u].imports.size()) { + auto const& want = pending[u].imports[k++]; + auto it = byName.find(want); + // A CYCLE IS LEFT TO THE COMPILER, ON PURPOSE. It + // is ill-formed C++ and the compiler says so with + // the two units named; refusing here would report + // the same fact in a worse place, and getting the + // ordering wrong is no longer possible either way. + if (it != byName.end() && state[it->second] == 0) + stack.push_back({it->second, 0}); + continue; + } + state[u] = 2; + order.push_back(u); + stack.pop_back(); + } + }; + for (std::size_t i = 0; i < pending.size(); ++i) + if (state[i] == 0) visit(i); + + for (auto i : order) push(pending[i].path, std::move(pending[i].name)); + return out; + }; + for (std::size_t c = 0; c < state.provisionGraph.visible.size(); ++c) { + const auto direct = directHostProviders(c); + if (direct.empty()) continue; + std::set isDirect(direct.begin(), direct.end()); + + // Post-order DFS, so a rule's own host modules are compiled + // BEFORE it. That ordering is the entire mechanism: the + // compile loop in build_program.cppm accumulates the module + // flags as it goes, so each entry sees the BMIs of everything + // ahead of it, and "a rule may import another rule" needs no + // second machinery — only this sort. + std::vector ordered; + std::set done; + std::vector path; // for the cycle diagnostic + auto visit = [&](auto&& self, std::size_t p) -> std::expected { + if (done.contains(p)) return {}; + if (std::ranges::find(path, p) != path.end()) { + // A cycle, reported AS a cycle and naming the packages + // on it. A depth limit would answer a different + // question and would answer it later. + std::string ring; + bool started = false; + for (auto q : path) { + if (q == p) started = true; + if (!started) continue; + ring += identity(q); + ring += " -> "; + } + ring += identity(p); + return std::unexpected(std::format( + "build rules form an import cycle: {}\n" + " A rule's host modules are compiled before " + "it, so a cycle has no order that could satisfy " + "all of them.", ring)); + } + path.push_back(p); + for (auto q : directHostProviders(p)) + if (auto r = self(self, q); !r) return r; + path.pop_back(); + done.insert(p); + for (auto& hm : units(p)) { + hm.importable = isDirect.contains(p); + ordered.push_back(std::move(hm)); + } + return {}; + }; + for (auto p : direct) + if (auto r = visit(visit, p); !r) + return std::unexpected(r.error()); + + // Every provider on this consumer's rule closure, transitive + // ones included -- `done` is exactly that set, and a rule + // imported by another rule declares payloads just as directly. + state.hostModuleProvidersByConsumer[c].assign(done.begin(), done.end()); + + if (auto clash = prov::host_module_collision(ordered)) + return std::unexpected(*clash); + for (auto const& hm : ordered) { + // Warned once per (package, module), not once per consumer: + // a rule re-exported down a chain is visible to every + // package on it, and repeating one naming remark N times + // reads as N problems. + if (auto w = prov::reserved_prefix_warning( + hm.module, hm.nameSpace, hm.package)) { + if (prefixWarned.insert(hm.package + "\x1e" + hm.module).second) + mcpp::diag::warning("build/rule-namespace", *w); + } + state.hostModulesByConsumer[c].push_back( + {hm.module, hm.interface, hm.importable}); + } + } + + // A build rule is BUILD-TIME ONLY. Registering the module is not + // enough: the package is still an ordinary node of the consumer's + // graph, so its interface was ALSO compiled as a normal library and + // linked into the target. That is wrong on its own terms — a rule + // has no business in the consumer's binary — and it made the + // feature nearly unusable, because in that second compile the + // bundled `mcpp` module does not exist: any rule that actually used + // the API it exists to wrap died with `fatal error: module 'mcpp' + // not found` (2026.8.5.1). + // + // Emptying the source globs is how a package is removed from the + // compile set here — the same mechanism the feature-gated-sources + // drop above uses. Resolution is untouched: the package still lands + // on disk, which is what `resolve_lib_root_path` just read. + // + // Guarded on EVERY edge into the package being a host-module edge. + // A package can legitimately be both a rule and a library, and + // silently dropping its objects then would surface as an undefined + // reference far from here. (The predicate used to be "no consumer + // other than the root", which said the same thing only while the + // root was the only possible requester.) + // + // Stated as FORWARD REACHABILITY rather than as exclusion, and the + // difference is not cosmetic. + // + // The predicate used to be per-edge: "every in-edge into this + // package is a host-module edge". That is right about the rule + // itself and wrong about everything BEHIND it — a rule's own + // `[dependencies]` are reached by ordinary edges, so they kept + // their globs and were compiled and LINKED INTO THE CONSUMER'S + // BINARY, while the rule could not even import them. Both halves + // were wrong, and the sharper harm was that a rule's dependency + // versions took part in the consumer's real resolution, so a rule + // could create a version conflict in a project that never asked + // for one. + // + // Exclusion would also get the dual-role case backwards. A package + // the project depends on directly must stay in the target even + // when some rule's build dependencies also reach it: the + // build-time path never subtracts from what the project asked to + // link. Asking "can the target reach it" answers both cases with + // one rule and no special case. + { + auto reachable = [&](bool targetEdgesOnly) { + std::vector seen(state.packages.size(), false); + if (state.packages.empty()) return seen; + std::vector stack{0}; + seen[0] = true; + while (!stack.empty()) { + auto p = stack.back(); + stack.pop_back(); + for (auto const& e : state.dependencyEdges) { + if (e.consumerPackageIndex != p) continue; + if (targetEdgesOnly && (e.hostModule || e.buildOnly)) + continue; + auto d = e.dependencyPackageIndex; + if (d >= seen.size() || seen[d]) continue; + seen[d] = true; + stack.push_back(d); + } + } + return seen; + }; + const auto viaTarget = reachable(/*targetEdgesOnly=*/true); + const auto viaAny = reachable(/*targetEdgesOnly=*/false); + for (std::size_t d = 1; d < state.packages.size(); ++d) { + // `viaAny` is the guard that keeps this from acting on a + // package no edge ever mentioned. Such a package is a + // bookkeeping gap, not a build dependency, and clearing + // its sources would turn that gap into an undefined + // reference a long way from here. + if (viaTarget[d] || !viaAny[d]) continue; + auto& dm = state.packages[d].manifest; + dm.buildConfig.sources.clear(); + dm.buildConfig.featureSources.clear(); + dm.modules.sources.clear(); + } + } + + if (state.overrides.tool_depth >= mcpp::build::tool_store::kMaxDepth + && !toolRequests.empty()) { + return std::unexpected(std::format( + "tool provisioning nested more than {} levels deep — this is " + "almost certainly a cycle.\n chain: {}", + mcpp::build::tool_store::kMaxDepth, state.overrides.tool_chain)); + } + + for (auto const& [depIdx, wanted] : toolRequests) { + auto& depPkg = state.packages[depIdx]; + const auto& depName = depPkg.manifest.package.name; + std::string depShort = depName; + if (auto dot = depName.rfind('.'); + dot != std::string::npos && dot + 1 < depName.size()) + depShort = depName.substr(dot + 1); + + for (auto const& toolName : wanted) { + // The target must exist and be a binary. Naming the + // alternatives matters: the consumer wrote a string, and a + // typo is the likeliest cause. + // + // #622 A3: deliberately still `Binary`, not `is_program()`. + // A host tool is exec'd directly ON THE BUILD MACHINE + // during THIS build, so it is "literally an executable + // link" — the question this site was already asking — and + // an `app` whose row form happened to be a library (never + // the host row in practice, but the check would be a + // silent trap if the host itself were ever Android) could + // not stand in for it. A build-time tool is declared + // `kind = "bin"`; that is what the word means here. + const mcpp::manifest::Target* tgt = nullptr; + std::string binList; + for (auto const& t : depPkg.manifest.targets) { + if (t.kind != mcpp::manifest::Target::Binary) continue; + if (!binList.empty()) binList += ", "; + binList += t.name; + if (t.name == toolName) tgt = &t; + } + if (!tgt) { + // A package may declare a bin target on some platforms + // only. When the request came from a LIBRARY rather + // than from the user, the user cannot edit it away, so + // point at the knob that library needs (#359 D3a). + return std::unexpected(std::format( + "dependency '{}' has no `kind = \"bin\"` target named " + "'{}' (requested via tools = [...]).\n" + " available bin targets: [{}]\n" + " If the requesting package is a library, it can " + "scope the request per platform with\n" + " [target.'cfg(...)'.feature-deps.].", + depName, toolName, + binList.empty() ? std::string("none") : binList)); + } + + // #359: every consumer that can SEE this tool gets it, not + // just the one whose edge asked for it. The bare spelling + // is emitted only where the namespace ladder binds the tail + // to this package — otherwise two libraries re-exporting a + // same-tailed tool would decide the winner by append order. + // The spellings are `publishedNamesFor`'s, so a tool is + // addressed by exactly the names its directory is. + const prov::Provision want{ prov::Kind::Tool, depIdx, toolName }; + auto record = [&](const std::filesystem::path& p) { + for (std::size_t c = 0; c < state.provisionGraph.visible.size(); ++c) { + if (!state.provisionGraph.visible[c].contains(want)) continue; + auto& v = state.toolEnvByConsumer[c]; + std::vector vars; + for (auto const& n : state.publishedNamesFor(depIdx, state.bareBindingsFor(c))) { + auto var = mcpp::build::tool_store::env_var_name(n, toolName); + if (std::ranges::find(vars, var) != vars.end()) continue; + vars.push_back(var); + v.emplace_back(std::move(var), p.string()); + } + } + }; + + // Escape hatch first: it is the cheapest resolution and the + // one a user reaches for precisely when building is not an + // option. Deliberately not part of the store key — see + // tool_store.cppm. + if (auto ovr = mcpp::build::tool_store::find_override( + *state.m, depName, depShort, toolName)) { + if (!std::filesystem::exists(*ovr)) { + return std::unexpected(std::format( + "tool override for '{}:{}' points at '{}', which " + "does not exist", depName, toolName, ovr->string())); + } + mcpp::ui::info("Tool", std::format( + "{}:{} → {} (override)", depName, toolName, ovr->string())); + record(*ovr); + continue; + } + + // A TOOL WHOSE OWN BUILD REQUESTS IT AGAIN IS REFUSED AT + // THE FIRST REPETITION (#649 E6). The depth bound below + // caught it only after four nested sub-builds, with the + // same prefix repeated four times and no word about which + // edge asked. The edge is the one whose request reached + // this package in THIS graph. + const std::string toolSource = std::format( + "{}|{}", depPkg.root.lexically_normal().generic_string(), toolName); + if (std::ranges::find(state.overrides.tool_chain_sources, toolSource) + != state.overrides.tool_chain_sources.end()) { + std::string askedBy; + for (auto const& edge : state.dependencyEdges) { + if (edge.dependencyPackageIndex != depIdx) continue; + if (std::ranges::find(edge.requestedTools, toolName) + == edge.requestedTools.end()) continue; + if (edge.consumerPackageIndex < state.packages.size()) { + askedBy = mcpp::build::qualified_package_name( + state.packages[edge.consumerPackageIndex].manifest); + break; + } + } + return std::unexpected(std::format( + "the host tool '{}:{}' is requested by its own build: " + "{} -> {}:{}.\n" + " The request comes from '{}', which the tool's " + "sub-build resolves with the feature or dependency that " + "asks for the tool.\n" + " fix: the tool's own graph must not activate " + "that request (a feature it does not enable, or a " + "`[target..feature-deps]` row it does not match).", + depName, toolName, + state.overrides.tool_chain.empty() ? "root" : state.overrides.tool_chain, + depName, toolName, + askedBy.empty() ? std::string("a package of its graph") : askedBy)); + } + + // Build it. The feature set is the tool package's own + // defaults PLUS the target's required_features — in a tool + // sub-build the target is what was ASKED FOR, so its + // requirements are inputs rather than a gate. (Same field, + // opposite resolution direction; docs/05 says so.) + std::vector feats = tgt->requiredFeatures; + auto closure = feature_closure(depPkg.manifest, feats, true); + + // 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 = state.host_tc_for_build_program(); + if (!hostTc) return std::unexpected(hostTc.error()); + toolTcSpec = state.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 < state.dep_cache_identities.size() + ? state.dep_cache_identities[depIdx - 1].indexName + : std::string(mcpp::pm::kDefaultNamespace); + key.packageName = depName; + // THE VERSION IDENTIFIES THE SOURCES ONLY FOR AN INDEX + // PACKAGE. A `git` package is keyed by its commit and a + // `path` package by a stamp of its tree, because both + // change under an unchanged version and the store then + // serves a binary built from sources that no longer exist + // (#630, item 6; measured 2026-09-08 with examples/12). + // The same rule applies to every upstream below. + auto source_keyed_version = [&](std::size_t pkgIdx) { + const auto& man = state.packages[pkgIdx].manifest.package; + std::string v = man.version; + if (pkgIdx >= 1 && pkgIdx - 1 < state.dep_cache_identities.size()) { + const auto& id = state.dep_cache_identities[pkgIdx - 1]; + if (id.sourceKind == "git" && !id.sourceRef.empty()) + v += "+git." + id.sourceRef; + else if (id.sourceKind == "path") + v += "+path." + mcpp::build::tool_store::tree_stamp( + id.sourceRef.empty() ? state.packages[pkgIdx].root + : std::filesystem::path(id.sourceRef)); + } + return v; + }; + key.version = source_keyed_version(depIdx); + key.targetName = toolName; + key.hostTriple = mcpp::toolchain::triple::host_triple().str(); + key.compilerIdentity = compilerIdentity; + key.profile = "release"; + key.features = closure; + std::ranges::sort(key.features); + // The tool package's TRANSITIVE dependency closure, not just + // its direct edges. Direct-only would be enough for index + // packages (a frozen version cannot change its own deps), + // but a path dependency can: bump something two levels down + // and the tool's direct list is unchanged, so a stale binary + // stays in the store — a silently wrong artifact. + for (auto up : dg::transitive_dependencies(state.dependencyEdges, depIdx)) + key.upstreamKeys.push_back(std::format("{}@{}", + state.packages[up].manifest.package.name, + source_keyed_version(up))); + std::ranges::sort(key.upstreamKeys); + + const auto cacheRoot = mcpp::home::cache_root(); + const auto entry = mcpp::build::tool_store::entry_dir(cacheRoot, key); + const auto exeSuffix = std::string(mcpp::platform::exe_suffix); + const auto binOut = mcpp::build::tool_store::bin_path( + entry, toolName, exeSuffix); + + if (mcpp::build::tool_store::entry_valid(entry, key, toolName, + exeSuffix)) { + record(binOut); + continue; + } + + // 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 (state.overrides.plan_only) { + state.planNotes.push_back({"MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED", + std::format("host tool '{}' of package '{}' is not in " + "the tool store and is not built while " + "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 " + "host toolchain)", depName, toolName, depName, + depPkg.manifest.package.version)); + + BuildOverrides sub; + sub.project_root = depPkg.root; + // Never the package root: it is shared across projects and + // may be read-only. This is the reason work_dir exists. + // + // Scratch is keyed on the CONSUMING project, not shared: + // the store is GLOBAL, so two projects can want the same + // tool at once. A single `/build` would have them + // writing one ninja tree concurrently, and whichever + // finished first would `remove_all` it out from under the + // other. The published binary is what gets shared; the + // scratch is not. + // + // Hashed rather than random so a re-run reuses its own + // scratch (ninja stays incremental if the publish step + // never got to delete it). + // + // Beside the entries rather than inside one: every + // directory name of the entry is repeated in each object + // path the sub-build writes, and on Windows those paths + // crossed the 260-character limit (mcpp#641, item 3). + sub.work_dir = mcpp::build::tool_store::scratch_dir( + cacheRoot, entry, state.workRoot); + sub.target_triple = ""; // HOST — the whole point + sub.toolchain = toolTcSpec; + sub.profile = "release"; + sub.cache_mode = state.overrides.cache_mode; + sub.tool_depth = state.overrides.tool_depth + 1; + sub.tool_chain_sources = state.overrides.tool_chain_sources; + sub.tool_chain_sources.push_back(toolSource); + // The PRISTINE manifest the resolver produced for this + // package — `packages[depIdx].manifest` is a copy that + // feature activation has already mutated, and re-activating + // on top of it would fold the same feature sources in + // twice. A `compat` (Form B) package has no mcpp.toml on + // disk at all, so without this the sub-build could not read + // a manifest for it in the first place. + // + // UNMERGED, because the sub-build targets the HOST: the + // resolver merged this manifest's conditional sections for + // the consumer's target, and the sub-build merges them for + // its own (#690, F12). + if (depIdx >= 1 && depIdx - 1 < state.dep_manifests.size() + && state.dep_manifests[depIdx - 1]) { + auto const& dep = *state.dep_manifests[depIdx - 1]; + sub.preloaded_manifest = dep.beforeConditionalMerge + ? dep.beforeConditionalMerge + : std::make_shared(dep); + } + sub.inherited_runtime_selection = std::make_shared< + const mcpp::xlings::runtime::RuntimeSelection>( + state.runtimeSelection); + sub.inherited_runtime_binding = std::make_shared< + const mcpp::platform::runtime::RuntimeBinding>( + state.runtimeBindingSnapshot); + sub.tool_chain = state.overrides.tool_chain.empty() + ? std::format("root → {}:{}", depName, toolName) + : std::format("{} → {}:{}", state.overrides.tool_chain, depName, + toolName); + for (auto const& f : closure) { + if (!sub.features.empty()) sub.features += ","; + sub.features += f; + } + + // #359 (D3b): a sub-build failure must be attributable and + // REPRODUCIBLE. The Windows tool sub-build has been failing + // on three abseil TUs since #355 and is still unlocated, + // because what reached the log was a one-line summary with + // no scratch path, no chain, and — on the ninja branch below + // — a filtered view of the inner output. Naming the scratch + // directory is what lets a maintainer re-run the exact inner + // build; MCPP_TOOL_BUILD_VERBOSE turns off the filtering. + auto subContext = [&] { + return std::format( + "\n chain: {}\n sub-build scratch: {}\n" + " re-run it directly: mcpp build -p {} --release\n" + " (set MCPP_TOOL_BUILD_VERBOSE=1 for the inner " + "build's unfiltered output)", + sub.tool_chain, sub.work_dir.string(), + depPkg.root.string()); + }; + auto subCtx = prepare_build(/*print_fingerprint=*/false, + /*includeDevDeps=*/false, + /*extraTargets=*/{}, sub); + if (!subCtx) { + return std::unexpected(std::format( + "building host tool '{}:{}' failed: {}{}", + depName, toolName, subCtx.error(), subContext())); + } + + // Build ONLY the requested target (#274 gave the backend + // explicit goals) — a tool request must not drag the whole + // package's other artifacts along. + std::filesystem::path goal; + for (auto const& lu : subCtx->plan.linkUnits) { + if (lu.targetName == toolName) { goal = lu.output; break; } + } + if (goal.empty()) { + return std::unexpected(std::format( + "host tool '{}:{}' produced no link unit — its " + "required_features may not be satisfiable on this " + "platform", depName, toolName)); + } + + auto be = mcpp::build::make_ninja_backend(); + mcpp::build::BuildOptions bopt; + bopt.ninjaTargets = { goal.generic_string() }; + // Unfiltered inner output on demand: the filter drops + // ninja's own progress and command echoes, which is right + // for a normal build and wrong when the question is "what + // did the inner build actually do". + if (const char* v = std::getenv("MCPP_TOOL_BUILD_VERBOSE"); + v && *v && std::string_view(v) != "0") + bopt.verbose = true; + auto br = be->build(subCtx->plan, bopt); + if (!br) { + auto diag = br.error().diagnosticOutput; + if (diag.empty()) + diag = "(the inner build produced no diagnostic " + "output; re-run with MCPP_TOOL_BUILD_VERBOSE=1)"; + return std::unexpected(std::format( + "building host tool '{}:{}' failed: {}{}\n{}", + depName, toolName, br.error().message, + subContext(), diag)); + } + if (br->exitCode != 0) { + return std::unexpected(std::format( + "building host tool '{}:{}' failed (exit {}){}", + depName, toolName, br->exitCode, subContext())); + } + + // Publish into the store: build out of place, then move — + // the same discipline mcpp.build.stage follows, so a + // concurrent consumer never observes a half-written entry. + std::error_code cpEc; + auto produced = subCtx->plan.outputDir / goal; + if (!std::filesystem::exists(produced, cpEc)) { + return std::unexpected(std::format( + "host tool '{}:{}' built but '{}' is missing", + depName, toolName, produced.string())); + } + std::filesystem::create_directories(binOut.parent_path(), cpEc); + auto tmp = binOut; + tmp += ".tmp"; + std::filesystem::remove(tmp, cpEc); + std::filesystem::copy_file(produced, tmp, + std::filesystem::copy_options::overwrite_existing, cpEc); + if (cpEc) { + return std::unexpected(std::format( + "staging host tool '{}:{}' failed: {}", + depName, toolName, cpEc.message())); + } + std::filesystem::permissions(tmp, + std::filesystem::perms::owner_exec + | std::filesystem::perms::group_exec + | std::filesystem::perms::others_exec, + std::filesystem::perm_options::add, cpEc); + std::filesystem::rename(tmp, binOut, cpEc); + if (cpEc) { + return std::unexpected(std::format( + "publishing host tool '{}:{}' failed: {}", + depName, toolName, cpEc.message())); + } + mcpp::build::tool_store::write_entry(entry, key); + // The sub-build tree is large (protoc is several hundred + // objects) and the key covers every input, so a hit never + // needs it again. Removes only THIS consumer's scratch. + std::filesystem::remove_all(sub.work_dir, cpEc); + record(binOut); + } + } + } + + // ── G2: dependency build.mcpp (Cargo build.rs model) ──────────────── + // Runs AFTER feature activation (the env contract exposes the dep's + // active features) and BEFORE the modgraph scan (generated sources + // must be visible to the glob walk). Scope is Cargo's: flag directives + // land in the dep's own buildConfig (its TUs only); link directives + // ride the dep's ldflags to the final link. Artifacts and generated + // files live in the CONSUMING project's tree — a registry package + // root is shared across projects and may be read-only; it is never + // written to. + for (std::size_t i = 1; i < state.packages.size(); ++i) { + auto& pkg = state.packages[i]; + // A package of programs runs its build program in its own tool + // sub-build, where its sources are compiled (#649 E6). + if (!state.compilesHere(i)) continue; + std::error_code bpEc; + if (!std::filesystem::exists(pkg.root / "build.mcpp", bpEc) + && pkg.manifest.buildConfig.ruleModules.empty()) continue; + auto host = state.host_tc_for_build_program(); + if (!host) return std::unexpected(host.error()); + // Same edge-graph aggregation as feature activation above, so a + // dep build.mcpp sees the SAME active feature set the dep is built + // with (incl. transitive requests / default-features opt-out). + auto [req, depDefaultFeatures] = aggregatedRequest(i); + auto dirSafe = [](std::string s) { + for (auto& c : s) if (c == '/' || c == '\\' || c == ':') c = '_'; + return s; + }; + mcpp::build::BuildProgramEnv bpEnv; + bpEnv.targetTriple = state.resolvedTargetCanonical; + // Everything the engine already knows and a build program would + // otherwise hardcode: the payload ROOT (not the driver), the + // target's C library, which compiler and which C++ standard + // library resolved, and the three answers that keep a board + // package from naming a toolchain. One call, so a new answer + // reaches every build program at once — see fill_target_build_env. + fill_target_build_env(bpEnv, *state.m, state.tc ? &*state.tc : nullptr, state.cfg_opt ? &*state.cfg_opt : nullptr); + bpEnv.toolsBin = state.projectSubosBin; + bpEnv.profile = state.effectiveProfile; + bpEnv.accel = state.resolvedAccel(); + // The DECLARING package's setting, not the root project's: a rule + // generating a declaration for this package must match how this + // package is compiled. + fill_package_build_env(bpEnv, pkg.manifest); + bpEnv.packFormat = state.overrides.pack_format; + bpEnv.packStageDir = state.overrides.pack_stage_dir; + bpEnv.packStrip = state.overrides.pack_strip; + bpEnv.packDebugSymbolsDir = state.overrides.pack_debug_symbols_dir; + bpEnv.languageModules = pkg.manifest.language.modules; + bpEnv.ruleModules = pkg.manifest.buildConfig.ruleModules; + if (auto dit = state.deviceSourcesByPackage.find(pkg.root.string()); dit != state.deviceSourcesByPackage.end()) + bpEnv.deviceSources = dit->second; + bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures); + bpEnv.artifactsDir = state.workRoot / "target" / ".build-mcpp" / "deps" + / (dirSafe(pkg.manifest.package.name) + "@" + pkg.manifest.package.version); + bpEnv.genBase = bpEnv.artifactsDir / "out"; + // mcpp#241: this package's resolved dependencies as + // MCPP_DEP__DIR, from the authoritative edge graph (no + // name-guessing); covers feature-activated deps too + // (mergeActiveFeatureDeps folded them in before the edges were + // recorded). Shared owner — see fillDepDirs. + state.fillDepDirs(bpEnv, i, nullptr); + // …and the xlings packages this package itself declared. Its own + // manifest, not the root's: a dependency's `[xlings] deps` is what + // its build.mcpp asks about. + state.fillXpkgDirs(bpEnv, state.packages[i].manifest, i); + // #355: the host tools THIS package requested (resolved above). + if (auto tit = state.toolEnvByConsumer.find(i); tit != state.toolEnvByConsumer.end()) + bpEnv.toolPaths = tit->second; + bpEnv.hostModules = state.hostModulesByConsumer.count(i) + ? state.hostModulesByConsumer.at(i) + : decltype(bpEnv.hostModules){}; + auto& bcDep = pkg.manifest.buildConfig; + const auto mark = state.markDirectiveTail(pkg.manifest); + const auto ldN = bcDep.ldflags.size(); + const auto actN = bcDep.actions.size(); + const auto runnerN = bcDep.runner.size(); + auto namedBefore = bcDep.namedRunners; // by value: the delta below + const bool exclusiveBefore = bcDep.runExclusive; + if (auto r = mcpp::build::run_build_program( + pkg.manifest, pkg.root, host->first, host->second, + pkg.manifest.cppStandard, bpEnv); + !r) { + // #699 item 2 (E3): under `emit build-database` (`plan_only`), + // a failing build program describes its package without that + // program's directives, instead of costing the whole plan — + // the manifest's own configuration, the toolchain and the + // module graph are still worth describing. Nothing is applied + // either way: `run_build_program` returns before + // `Directives::apply` on every failure path. A later failure + // that follows from the missing directives (a source the + // program would have added, say) fails the member under the + // ordinary rule (E1). + if (state.overrides.plan_only) { + state.planNotes.push_back({"MCPP_BUILD_DATABASE_PROGRAM_FAILED", + std::format("dependency '{}': {}", + pkg.manifest.package.name, r.error()), + mcpp::wire::Severity::Error, + (pkg.root / "build.mcpp").string()}); + continue; + } + return std::unexpected(std::format( + "dependency '{}': {}", pkg.manifest.package.name, r.error())); + } + // Cargo scope wiring: compile-visible tail → privateBuild (the + // shared fold above; the dep's TUs read privateBuild, not bc — + // its consumers read publicUsage, which the fold never touches; + // the bcDep entries themselves are inert here: the descriptor + // include_dirs propagation snapshotted publicUsage at + // makePackageRoot, long before this pass). Dep residue: link + // flags — dep ldflags were propagated to the root during the + // BFS walk, which ran before this pass — forward the new tail + // (link-search paths are already absolute from parse_line). + state.foldDirectiveTailIntoPrivateBuild(pkg, pkg.manifest, mark); + state.adoptActionOutputs(pkg.manifest, pkg.root, actN); + + // Scope::RunGlobal — how the artifact is EXECUTED, forwarded to + // the root like link flags but with the opposite merge rule. + // + // EXACTLY ONE provider. Link flags from two dependencies + // concatenate and that is correct; two runners cannot — appending + // produces an argv that is neither one's and fails at exec time + // with nothing to say which package contributed which token. So + // the second provider is a hard error that names BOTH, because + // naming only the loser tells the reader half of what they need. + // THE DEFAULT RUNNER AND EVERY NAMED ONE, BY ONE RULE. + // + // Link flags from two dependencies concatenate and that is correct; + // two runners for the same name cannot — appending produces an argv + // that is neither one's and fails at exec with nothing to say which + // package contributed which token. + // + // MISSING THIS SITE IS HOW THE FEATURE FAILED FIRST. `apply()` + // merges a package's directives into its OWN config; this is where a + // dependency's RunGlobal entries reach the ROOT. Wiring only the + // first left `mcpp run --runner flash` reporting "no such runner" + // while `mcpp run` found the runner the same build program emitted + // three lines away — measured. + if (bcDep.runner.size() > runnerN) { + std::vector supplied( + bcDep.runner.begin() + static_cast(runnerN), + bcDep.runner.end()); + if (!state.m->buildConfig.runner.empty() && !state.runnerProvider.empty()) { + return std::unexpected(std::format( + "two dependencies both supply a runner for this target: " + "'{}' and '{}'.\n" + " A runner is how the artifact is reached — there " + "can only be one.\n" + " Drop one of them, or override both with an " + "explicit [target.].runner.", + state.runnerProvider, pkg.manifest.package.name)); + } + state.m->buildConfig.runner = std::move(supplied); + state.runnerProvider = pkg.manifest.package.name; + } + for (auto const& [name, nr] : bcDep.namedRunners) { + auto before = namedBefore.find(name); + const bool grew = (before == namedBefore.end()) + || nr.argv.size() > before->second.argv.size() + || (nr.longLived && !before->second.longLived); + if (!grew) continue; + auto& slot = state.m->buildConfig.namedRunners[name]; + auto& who = state.namedRunnerProvider[name]; + if (!slot.argv.empty() && !who.empty()) { + return std::unexpected(std::format( + "two dependencies both supply a runner named '{}' for " + "this target: '{}' and '{}'.\n" + " Drop one of them, or override both with an " + "explicit [target..runners].{}.", + name, who, pkg.manifest.package.name, name)); + } + slot = nr; + who = pkg.manifest.package.name; + } + // A CLAIM THAT ONLY EVER TIGHTENS. + if (bcDep.runExclusive && !exclusiveBefore) + state.m->buildConfig.runExclusive = true; + state.m->buildConfig.ldflags.insert(state.m->buildConfig.ldflags.end(), + bcDep.ldflags.begin() + ldN, bcDep.ldflags.end()); + } + + // apply() may have added interface defines to packages' publicUsage + // flags (a dependency's active-feature `defines`). Re-run the usage + // fixpoint so those flags flow into each consumer's privateBuild — the + // first pass (above) ran before features were activated. Idempotent: + // include-dir/flag propagation is unique-append. + state.computeUsageRequirements(); + + // ─── Capability binding (Stage 3) ────────────────────────────────── + // For each required capability, bind exactly one provider from the + // graph. Deterministic: an explicit [capabilities] pin wins; otherwise + // 0 providers / ≥2 providers are hard errors (never a silent guess); a + // single provider binds with no config. The provider's link/include + // requirements already flow through normal dependency mechanics — this + // pass is the selection-and-validation layer. See the capability-model + // design doc. + // --cap cap=provider[,cap=provider] overrides [capabilities] pins. + for (std::size_t p = 0; p < state.overrides.capabilities.size();) { + auto c = state.overrides.capabilities.find_first_of(", ", p); + auto tok = state.overrides.capabilities.substr( + p, c == std::string::npos ? std::string::npos : c - p); + if (auto eq = tok.find('='); eq != std::string::npos) + state.m->capabilityPins[tok.substr(0, eq)] = tok.substr(eq + 1); + if (c == std::string::npos) break; + p = c + 1; + } + + // EXCLUSIVE CAPABILITIES, CHECKED BEFORE REQUIREMENTS ARE BOUND. + // + // Ordering is deliberate. A requirement conflict is reported by naming + // the requirement; this one exists whether or not anything requires the + // capability, because the defect is that two implementations of one + // interface are in the same link. Reporting it first means the message + // names the real problem rather than a symptom of it. + for (auto const& [cap, claimers] : state.capExclusive) { + auto it = state.capProviders.find(cap); + if (it == state.capProviders.end()) continue; + std::vector providers; + for (auto const& p : it->second) + if (std::find(providers.begin(), providers.end(), p) == providers.end()) + providers.push_back(p); + if (providers.size() < 2) continue; + + std::string list, claimed; + for (auto const& p : providers) list += (list.empty() ? "" : ", ") + p; + for (auto const& c : claimers) claimed += (claimed.empty() ? "" : ", ") + c; + refusal::record(refusal::Code::ExclusiveCapability); + return std::unexpected(std::format( + "capability '{}' is provided by more than one package, and {} " + "declares it EXCLUSIVE.\n" + " providers: [{}]\n" + " exclusive: [{}]\n" + " Two implementations of one interface define the same " + "symbols, so the link would\n" + " resolve every call to whichever archive it reached " + "first. Keep one of them —\n" + " a `[capabilities]` pin selects a provider for a " + "REQUIREMENT and cannot make two\n" + " definitions of one symbol safe.", + cap, claimers.size() == 1 ? "it" : "they", list, claimed)); + } + + // VERSION FLOORS. A package states what it needs of the machine; a + // package that established a fact about the machine states it. Neither + // string means anything to this code -- `cuda.driver` is data flowing + // through -- which is why a second backend needs no change here and why + // `test_runtime_contract`'s gate stays satisfied. + // + // A FLOOR WITH NO FACT IS SILENT. A machine that never declared what + // it has is not a machine that fails the floor; it is one nobody asked. + // Reporting a refusal there would turn "we do not know" into "no", and + // the whole reason this exists is that a wrong answer is worse than no + // answer. + if (auto err = state.checkVersionFloors(); err) return std::unexpected(*err); + + // `requires_abi`: a package needs the artefact's ABI switch on. The + // root's `[target..abi]` is the only table that sets it + // (whether the value is written there directly, or reaches it + // through a matching `[target..abi]` predicate resolved by + // merge_conditional_config), so a mismatch is refused naming both + // halves, before anything compiles -- otherwise + // it surfaces as a precompiled-module configuration mismatch that + // names neither. Two members (A1's `exceptions` beside the original + // `threads`), checked and refused the same way, parametrised so a + // wording change to one cannot drift from the other. + auto checkAbiRequirement = [](std::string_view member, bool rootHasIt, + std::vector> const& reqs) + -> std::optional { + if (rootHasIt || reqs.empty()) return std::nullopt; + auto const& [what, requirer] = reqs.front(); + return std::format( + "`{}` requires the artefact's ABI to have {} ({}), and this " + "build does not state it.\n" + " Add to the root manifest, for the targets that need it:\n" + "\n" + " [target.'cfg(os = \"\")'.abi]\n" + " {} = true", requirer, member, what, member); + }; + if (auto err = checkAbiRequirement( + "threads", state.m->buildConfig.abiThreads, state.abiRequires)) + return std::unexpected(*err); + if (auto err = checkAbiRequirement( + "exceptions", state.m->buildConfig.abiExceptions, state.abiRequiresExceptions)) + return std::unexpected(*err); + + std::set boundCaps; + for (auto& [cap, requirer] : state.capRequires) { + if (!boundCaps.insert(cap).second) continue; // one diagnosis per cap + auto& pins = state.m->capabilityPins; + // Dedup candidates, preserve first-seen order. + std::vector cands; + if (auto it = state.capProviders.find(cap); it != state.capProviders.end()) + for (auto& p : it->second) + if (std::find(cands.begin(), cands.end(), p) == cands.end()) + cands.push_back(p); + if (auto pit = pins.find(cap); pit != pins.end()) { + const auto& pin = pit->second; + if (std::find(cands.begin(), cands.end(), pin) == cands.end()) { + std::string list; + for (auto& c : cands) list += (list.empty() ? "" : ", ") + c; + return std::unexpected(std::format( + "capability '{}' pinned to provider '{}' (via [capabilities]), " + "but no such provider is in the graph; candidates: [{}]", + cap, pin, list)); + } + continue; // pin satisfied + } + if (cands.empty()) + return std::unexpected(std::format( + "no package provides capability '{}' required by '{}'; add a " + "dependency that declares `provides = [\"{}\"]`", cap, requirer, cap)); + if (cands.size() > 1) { + std::string list; + for (auto& c : cands) list += (list.empty() ? "" : ", ") + c; + return std::unexpected(std::format( + "capability '{}' has multiple providers in the graph: [{}]; select " + "one with [capabilities] {} = \"\" or --cap {}=", + cap, list, cap, cap)); + } + // exactly one → bound implicitly. + } + } + + // The package that supplies the C++ layer when the graph does, as an index + // into `packages`. Recorded where the provider is found so that the check + // after planning (#641) reads the same package the resolution chose. + // Whether the block below ran at all. `resolvedTargetSide` is default + // constructed, so "no layer resolved" and "resolution has not happened" + // read identically off its members — and the layer-conditional pass must + // tell them apart: the first is an answer a predicate may legitimately + // fail to match, the second means the pass has no business running. + state.targetSideResolved = false; + + // What the packages supplying the target side's layers publish: the header + // directories and interface flags the whole build is compiled against. + // + // ONE SET, TWO READERS, and that is deliberate: it is merged into every + // package's `privateBuild` (so every compile edge sees it) and handed to + // the `std` module's own command line (which is one more translation unit + // of the same build). Before this existed, only the second reader was + // written, and it derived the set itself — which is how the two could + // describe different worlds. + + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/fetch.cpp b/src/build/prepare/fetch.cpp new file mode 100644 index 000000000..a06404a0f --- /dev/null +++ b/src/build/prepare/fetch.cpp @@ -0,0 +1,492 @@ +// fetch.cpp -- what prepare_build reaches outside the tree: git remotes and +// cached clones, network steps retried, the `[xlings]` addresses a verb needs, +// installed and recorded, and the index cause a resolution failure carries. +// Declared in `:state`, or exported from prepare.cppm for the unit tests. + +module; +#include +#include + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.build.refusal; +import mcpp.build.version_floor; +import mcpp.home; +import mcpp.platform.axis; +import mcpp.libs.json; +import mcpp.log; +import mcpp.manifest; +import mcpp.source_kind; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.config; +import mcpp.xlings; +import mcpp.platform; +import mcpp.fetcher; +import mcpp.fetcher.progress; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.ui; +import mcpp.log; + +namespace mcpp::build { + +// Is this git remote reachable without a network round-trip? +// +// `--offline` means "never touch the network" (docs/04-mcpp-toml.md), and its +// standing promise is that anything already on disk still builds. A remote that +// names a local directory — or a file:// URL — is served by plain filesystem +// reads, so refusing it would break that promise without buying any isolation. +// The dependency-download gate further down draws the same line. +// +// Recognising a scheme (`https://`, `ssh://`, `git://`) or scp-like syntax +// (`git@host:path`) as remote first keeps a Windows drive letter (`C:\repo`, +// which contains a colon but no `@`) on the local side. +bool is_local_git_remote(std::string_view url) { + if (url.starts_with("file://")) return true; + if (url.contains("://")) return false; + if (url.contains('@') && url.contains(':')) return false; + std::error_code ec; + return std::filesystem::exists(std::filesystem::path(url), ec); +} + +// The commit a cached clone is actually parked on, or "" if it cannot be read. +// +// Used to detect a clone that was interrupted between `git clone` and +// `git checkout` — the directory exists and looks like a repository, but sits +// on the wrong commit. Only meaningful when the expected revision is a sha, +// i.e. for branch deps after resolution. +// +// stderr is folded in so a git warning cannot leak to the user's terminal; +// the last line is taken so such a warning cannot corrupt the sha either. +std::string git_cache_head(const std::filesystem::path& gitRoot) { + auto r = mcpp::platform::process::capture(std::format( + "git -C {} rev-parse HEAD 2>&1", + mcpp::platform::shell::quote(gitRoot.string()))); + if (r.exit_code != 0) return {}; + std::string out = r.output; + while (!out.empty() && (out.back() == '\n' || out.back() == '\r' + || out.back() == ' ' || out.back() == '\t')) + out.pop_back(); + if (auto nl = out.find_last_of("\r\n"); nl != std::string::npos) + out.erase(0, nl + 1); + return out; +} + +// The target-facing answers a `build.mcpp` may ask the engine for. +// +// ONE function because there are TWO call sites — the root project and each +// dependency — and four values derived independently in two places is the +// shape this codebase keeps paying for. A board package that got the right +// answer as a root project and a stale one as a dependency would fail only in +// the consuming build, which is the harder direction to debug. +// A NETWORK STEP OF A BUILD, RETRIED — AND IT HAD NO RETRY AT ALL. +// +// A dependency resolved by `git` is fetched on every machine that has not +// cached it, and a transport that hiccups once failed the whole build: +// +// error: git clone of 'https://github.com/…' failed: +// Cloning into '/home/runner/.mcpp/git/63269d80b47f71e6'... +// +// — no message from git, which is what a connection that dies mid-transfer +// looks like. Measured twice on 2026-08-23: once in continuous integration and +// once locally as `TLS connect error: … unexpected eof while reading`. +// +// THREE ATTEMPTS, AND THE LAST FAILURE IS REPORTED UNCHANGED. A wrong URL +// and a missing branch fail exactly as a transient fault does, so this cannot +// tell them apart and does not try: a permanent failure costs three seconds and +// produces the message it always did. Hiding a real error behind a retry is the +// worse trade, which is why the count is small and the report is untouched. +// +// BOTH NETWORK STEPS, not one. The first version retried only the clone — +// and a probe with a nonexistent repository failed in ONE second, because the +// step that runs first is `git ls-remote` and it was still bare. A retry on +// half of a path is a retry that reports success at having been added. +// +// `between` runs after a failed attempt: the clone needs the partial directory +// removed, or git's next attempt fails with "already exists and is not an empty +// directory" — a second, different error that says nothing about the first. +mcpp::platform::process::RunResult run_with_network_retry( + std::string_view command, + const std::function& between) { + mcpp::platform::process::RunResult r{}; + mcpp::platform::env::note_network_access(); // the envelope's `effects` (#648 A4) + for (int attempt = 1; attempt <= 3; ++attempt) { + r = mcpp::platform::process::capture(command); + if (r.exit_code == 0) return r; + if (between) between(); + if (attempt < 3) + std::this_thread::sleep_for(std::chrono::seconds(attempt)); + } + return r; +} + +std::vector +applicable_xlings_addresses(const mcpp::manifest::Manifest& man, + const std::vector& activeFeatures, + ToolPurpose purpose, bool isRoot) { + using W = mcpp::manifest::ToolWhen; + std::vector out; + auto wanted = [&](const std::string& address) { + switch (man.xlings.when_of(address)) { + case W::Always: return true; + case W::Build: return true; + case W::Run: return purpose == ToolPurpose::Run; + case W::Dev: return isRoot; + } + return true; + }; + auto add = [&](const std::string& address) { + if (!wanted(address)) return; + if (std::ranges::find(out, address) == out.end()) out.push_back(address); + }; + for (auto const& a : man.xlings.deps) add(a); + // `[feature-xlings.]` contributes only while `` is active. A consumer + // that never asks for `hardware` never downloads a probe driver — which is + // the same mechanism `[feature-deps]` gives a package, applied to tools. + for (auto const& f : activeFeatures) + if (auto it = man.xlings.featureDeps.find(f); + it != man.xlings.featureDeps.end()) + for (auto const& a : it->second) add(a); + return out; +} + +// Install a set of `[xlings.workspace]` addresses, and record that the list was +// done. +// +// EXTRACTED SO THE DEPENDENCY GRAPH CAN USE THE SAME PATH. This was the +// root project's provisioning, inline and reachable only from there. A +// board-support package that declares the emulator its machine needs is +// precisely the thing that should say so once, and a consumer that has to +// repeat the declaration to get it installed is the duplication such a package +// exists to remove — so the graph pass calls this with what the dependencies +// declared, under the same stamp discipline and the same auto-install gate. +// +// `label` names the caller in every message, because "which of the two passes +// is this" is the first thing a reader of the failure needs. +std::expected +provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, + const std::vector& declaredDeps, + const std::filesystem::path& legacyStampRoot, + std::string_view label) { + if (declaredDeps.empty()) return {}; + // THE STAMP RECORDS A GLOBAL EFFECT, SO IT LIVES WHERE THE + // EFFECT DOES. It used to sit in `/.mcpp/`, while the + // installation goes to the registry a few lines below — the + // scope difference is deliberate and explained there. Two + // consequences followed from the mismatch: wiping or replacing + // `MCPP_HOME` left a project still claiming the packages were + // installed, and `mcpp clean` (which removes `target/` and + // never `.mcpp/`) could not clear it. Keyed by the LIST, not by + // the project, because the installation is shared: two projects + // declaring the same packages should pay for it once. + // + // 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 + // that host a different key space for no reason anyone could + // see. A collision is not a correctness problem either way — + // the file stores the LIST and the comparison below is against + // its content, so two lists sharing a key re-provision rather + // than silently adopt each other's record. + auto stamp_key = [&] { + std::uint64_t h = 1469598103934665603ull; // FNV-1a + for (auto const& d : declaredDeps) + for (unsigned char ch : d + "\n") + { h ^= ch; h *= 1099511628211ull; } + return std::format("xlings-deps-{:016x}", h); + }; + const auto stamp = stampDir / stamp_key(); + // Idempotence by CONTENT, not by existence: editing the list + // has to re-provision, and an unchanged list must not pay for + // an xlings round-trip on every build. + auto join_deps = [&](std::string_view sep) { + std::string out; + for (auto const& d : declaredDeps) { + if (!out.empty()) out += sep; + out += d; + } + return out; + }; + std::string want; + for (auto const& d : declaredDeps) { want += d; want += '\n'; } + std::string have; + if (std::ifstream in{stamp}; in) + have.assign(std::istreambuf_iterator(in), {}); + // The stamp the previous location left behind. Read, never + // deleted: an older mcpp sharing the checkout still uses it, + // and a stale extra file is cheaper than a downgrade that + // re-provisions on every build. + // + // IT DOES NOT MEAN "PROVISIONED SUCCESSFULLY". The release + // that wrote it did not read the result — that is the defect + // above — so it means only "this list was attempted". Treating + // it as proof would carry the bug across the very upgrade that + // fixes it: a project whose dependency never installed would + // adopt the stamp and stay silently broken. + // + // So it is consulted in ONE place, below, where the alternative + // is worse: the auto-install gate. Online, nothing is adopted + // and every project re-provisions once, which is a cheap round + // trip that re-validates the claim. + const auto legacyStamp = legacyStampRoot / ".mcpp" / ".xlings-deps.stamp"; + auto legacy_stamp_matches = [&] { + std::string legacy; + if (std::ifstream in{legacyStamp}; in) + legacy.assign(std::istreambuf_iterator(in), {}); + return legacy == want; + }; + 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 + // ("the same 'declare it and mcpp provisions it on first + // use' contract"), and that path refuses on either knob and + // names the one that fired — see the auto-install branch + // above. This one honoured neither, so a CI exporting + // MCPP_NO_AUTO_INSTALL specifically to prevent an unasked + // download got one anyway, from a path that had never heard + // of the variable. + // + // Placed inside `have != want`, so it gates the ATTEMPT and + // not the block: a project whose packages are already + // provisioned still builds offline, which is the behaviour + // that would otherwise regress. + if (mcpp::platform::env::offline_mode() + || mcpp::platform::env::no_auto_install()) { + // THE ONE PLACE THE LEGACY STAMP IS TRUSTED, and the + // reason is that relocating a record must not refuse a + // build that worked yesterday. Every project that had + // already provisioned carries the old stamp and no new + // one, so on the first build after upgrading it reads + // as un-provisioned — and here, with the network shut + // off, there is no way to find out otherwise. Refusing + // would be a regression caused entirely by moving a + // file, which is the least defensible kind. + // + // Proceeding is the pre-upgrade behaviour exactly: if + // the packages really are missing, the build fails + // downstream on a missing header, as it did before. + // The registry stamp is NOT written — nothing here + // verified anything. + if (!legacy_stamp_matches()) { + 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 declared but not provisioned, " + "and auto-install is off.\n" + " declared: {}\n" + " install them yourself with:\n" + " xlings install {}\n" + " or {} to let mcpp do it.", + label, join_deps(", "), join_deps(" "), release)); + } + mcpp::log::verbose("xlings", + std::format("{}: auto-install is off and this project " + "carries a pre-2026.9.1.1 provisioning stamp for the " + "same list; proceeding without re-checking", label)); + // Deliberately NOT writing the registry stamp: nothing + // here verified anything, and a record of a check that + // did not happen is the defect this release removes. + needProvision = false; + } + } + if (needProvision) { + mcpp::ui::status("Provisioning", + std::format("{} ({})", label, join_deps(", "))); + // An address whose index a `[index.repos.]` table + // redirects is installed from that source, and says so: an + // installation from a branch checkout must not read as one + // from the published index (#634, C4). + std::set redirected; + for (auto const& d : declaredDeps) { + const auto colon = d.find(':'); + if (colon == std::string::npos) continue; + const auto index = d.substr(0, colon); + for (auto const& r : cfg.indexRepos) { + if (!r.fromConfig || r.name != index) continue; + if (r.name == "mcpplibs" && r.url == mcpp::config::kMcpplibsIndexUrl) + continue; + if (redirected.insert(index).second) + mcpp::ui::status("Index", std::format( + "{} -> {} ([index.repos.{}] in config.toml)", + r.name, r.url, r.name)); + } + } + // GLOBAL scope, and the scope is the whole point. + // + // The obvious alternative -- `install_packages` against + // `make_project_xlings_env` -- installs at PROJECT scope, + // and that measurably does not work: on a fresh MCPP_HOME + // the headers land in + // `/.mcpp/.xlings/subos/_/usr/include` while + // `--sysroot` names `/registry/subos/default`, + // so `#include ` still failed with the dependency + // installed and declared. Two SubOS views, and the payload + // in the one the compiler does not read. + // + // `make_xlings_env` is the GLOBAL env, so this lands in the + // registry whose SubOS *is* mcpp's sysroot -- the same + // place `[toolchain]` has always installed into. A project + // dependency and a toolchain dependency now agree on where + // they live, which is the only arrangement in which one + // `--sysroot` can see both. + // + // `install_packages` rather than `resolve_xpkg_path`: the + // latter requires `@` and rejects a bare + // `mesa`, while a manifest is entitled to name a package + // without pinning it. install_packages resolves the version + // itself and reports an ambiguous name with its candidates, + // which is the error the author can act on. + // Built with the JSON library rather than by formatting + // the strings in. `deps` is manifest input, so a name + // containing a quote or a backslash would otherwise emit + // malformed JSON and the failure would surface as an + // unrelated xlings parse error naming neither the manifest + // nor the key. + nlohmann::json args; + args["targets"] = declaredDeps; + args["yes"] = true; + + mcpp::fetcher::InstallProgressHandler progress; + auto r = mcpp::xlings::call( + 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. + // + // `xlings::call` returns `expected` and + // is in the VALUE state whenever the child ran at all — the + // error channel means "the call did not happen". A + // capability's own status arrives inside `CallResult`, + // parsed off the NDJSON `{"kind":"result","exitCode":N}` + // line, because the xlings process itself exits 0 by design + // once it has spoken the protocol. + // + // Measured before this fix: a manifest declaring a package + // that cannot exist printed `Provisioning [xlings] deps + // (…)`, xlings answered `E_NOT_FOUND` with `exitCode: 1`, + // and mcpp stamped it as done and reported a successful + // build. #531 was written because "the declaration looked + // accepted and did nothing" is the worst shape a config key + // can have; unread, its own fix reproduced that shape and + // the stamp made it permanent. + // + // The correct idiom is not new — the dependency install + // path in this same file reads `r->exitCode` — it was + // simply not applied here. + const bool called = r.has_value(); + const int childRc = called ? r->exitCode : -1; + if (!called || childRc != 0) { + // Prefer xlings' own message: for an unresolvable name + // it names the repos it searched and whether the index + // is current, which is the part the author can act on. + std::string why = !called ? r.error() + : (r->error ? r->error->message + : std::format("xlings exited {}", childRc)); + if (auto captured = progress.captured_error(); + !captured.empty() && called && !r->error) + why = captured; + // The hint is where "run `xlings update` if the package + // was just published" lives, and for the commonest + // failure — a name that is not in the synced index — + // it is the whole of the actionable content. + if (called && r->error && !r->error->hint.empty()) + why += "\n " + r->error->hint; + // Shaped like the toolchain failure: say what failed and + // hand back a command the user can run themselves. An + // ambiguous bare name ("mesa" matching two repos) lands + // here, and xlings' own message names the candidates. + return std::unexpected(std::format( + "provisioning {} failed: {}\n" + " you can install them manually with:\n" + " 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 + // than not recording it — the next build skips the attempt. + std::error_code sec; + std::filesystem::create_directories(stamp.parent_path(), sec); + if (std::ofstream out{stamp}; out) out << want; + } + return {}; +} + +std::string with_index_cause(std::string msg) { + if (auto hint = mcpp::pm::unusable_index_hint(); !hint.empty()) + msg += "\n" + hint; + return msg; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp new file mode 100644 index 000000000..1a3815962 --- /dev/null +++ b/src/build/prepare/graph.cpp @@ -0,0 +1,2307 @@ +// graph.cpp -- P4b: the dependency worklist, the resolved graph and the +// package-cycle check. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.xlings.address_set; +import mcpp.build.version_floor; +import mcpp.home; +import mcpp.platform.axis; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.modgraph.glob; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.modgraph.validate; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.directives; // directive table: mark / fold_private_tail +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.config; +import mcpp.xlings; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.compat; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.version_req; +import mcpp.ui; +import mcpp.project; + +namespace mcpp::build { + +std::expected phase4b_graph_worklist(PrepareState& state) { + + // #634, X: every request that reached a package, as the requester wrote + // it, for the `graph` section of resolution.json. Kept apart from + // `dependencyEdges`, which merges two requests of one consumer for one + // dependency into one edge; the record has to keep both keys, because two + // keys over one identity (A2) and the table a declaration came from (A1) + // are what it exists to show. + // The link form each dependency takes and the facts it was decided from, + // by package index. COMPUTED ONCE, before the root build program runs, so + // that program can read the answer (#642 E2); APPLIED after the scan, where + // it always was. Every reader below reads this, never a second resolution. + // #355: consumer package index → (env var, absolute path) for each host + // tool that consumer requested. Filled by the provisioning pass below; + // read by BOTH build.mcpp call sites (the dependency loop and the root), + // which is why it lives out here rather than inside the resolution block. + // #355 step 5: consumer package index → (logical module name, interface + // path) for each dependency that offers HOST build rules. Same fan-out + // shape as toolEnvByConsumer, and read by the same two call sites. + // The same providers by INDEX, and the reason they are needed twice. + // + // A rule's code runs inside its CONSUMER's build program, so + // `mcpp::xpkg_dir("cuda-nvcc")` is asked there -- while the payload that + // answers it was declared by the RULE, under `[feature-xlings.]`, which + // is where it belongs: which packages a device compiler needs is the + // rule's knowledge and no project should have to rediscover it. + // + // The graph pass already INSTALLS what a dependency declares. Only the + // answer was missing: `fillXpkgDirs` read one manifest, so the address was + // fetched, unpacked, and then unreachable from the only code that wanted + // it -- a failure that reads as "the toolkit is not installed" while it + // sits on disk. + // + // The set is the host-module providers rather than every dependency: the + // code that can call `xpkg_dir` in this build program is the consumer's + // own `build.mcpp` plus exactly the rule modules compiled into it. + // #359: who can see which build-time provision. Computed once by the + // provisioning pass below (a fixpoint over `dependencyEdges`, the same + // shape as computeUsageRequirements) and read by every consumer of the + // three env channels above. Declared here because `fillDepDirs` closes + // over it and is defined long before the pass runs; every call site is + // after it. + // The spellings a given consumer may address a provider by. The qualified + // name always works; the bare tail only when the namespace ladder binds it + // to exactly this package FOR THIS CONSUMER. Scoped per consumer rather + // than globally because two packages sharing a tail only collide inside an + // environment that contains both. + state.bareBindingsFor = [&](std::size_t consumer) { + std::vector fqns; + if (consumer < state.provisionGraph.visible.size()) + for (auto const& pr : state.provisionGraph.visible[consumer]) { + if (pr.provider >= state.packages.size()) continue; + auto const& n = state.packages[pr.provider].manifest.package.name; + if (std::find(fqns.begin(), fqns.end(), n) == fqns.end()) + fqns.push_back(n); + } + return prov::bind_bare_names(fqns); + }; + // THE NAMES UNDER WHICH ONE PROVIDER IS PUBLISHED TO ONE CONSUMER, derived + // once for every channel (#647 E4.3). The manifest's `name`, the qualified + // `namespace.name` when the manifest writes the two apart, and the bare + // tail where the namespace ladder binds it to this provider for this + // consumer. `dep_dir`/`dep_linkage` and `dep_bin` used to derive this list + // separately; #642 added the qualified spelling to the first and the second + // kept publishing `MCPP_DEP_INSTALLER_BIN_*` alone for a package written + // `namespace = "spike"`, `name = "installer"`, so + // `dep_bin("spike.installer", ...)` read nothing. + state.publishedNamesFor = + [&](std::size_t provider, + const std::map& bind) { + std::vector out; + auto const& manifest = state.packages[provider].manifest; + auto const& canon = manifest.package.name; + out.push_back(canon); + if (auto qualified = mcpp::build::qualified_package_name(manifest); + qualified != canon) + out.push_back(std::move(qualified)); + if (auto tail = prov::tail_of(canon); tail != canon) { + auto it = bind.find(tail); + if (it != bind.end() && it->second.owner == canon) + out.push_back(std::move(tail)); + } + return out; + }; + + // A package whose DECLARED targets are all programs (#649 E6). See the + // worklist, where such a package is not walked into a consumer's graph. + state.isProgramOnlyPackage = [](const mcpp::manifest::Manifest& pm) { + if (pm.targetsInferred || pm.targets.empty()) return false; + return std::ranges::none_of(pm.targets, [](const mcpp::manifest::Target& t) { + return t.kind == mcpp::manifest::Target::Library + || 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. + state.isArtifactPackage = [&](std::size_t i) { + return std::ranges::any_of(state.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. + state.compilesHere = [&](std::size_t i) { + return i == 0 || !state.isProgramOnlyPackage(state.packages[i].manifest) || state.isArtifactPackage(i); + }; + auto parseVisibility = [](std::string_view visibility) { + if (visibility == "private") + return mcpp::modgraph::DependencyVisibility::Private; + if (visibility == "interface") + return mcpp::modgraph::DependencyVisibility::Interface; + return mcpp::modgraph::DependencyVisibility::Public; + }; + + auto packageIndexForConsumer = [&](std::size_t consumerDepIndex) { + if (consumerDepIndex == kMainConsumer) return std::size_t{0}; + return consumerDepIndex + 1; + }; + + state.appendUniquePath = + [](std::vector& dirs, + const std::filesystem::path& dir) -> bool + { + if (std::find(dirs.begin(), dirs.end(), dir) != dirs.end()) return false; + dirs.push_back(dir); + return true; + }; + + state.appendUniquePaths = + [&](std::vector& dirs, + const std::vector& additions) -> bool + { + bool changed = false; + for (auto const& dir : additions) { + changed = state.appendUniquePath(dirs, dir) || changed; + } + return changed; + }; + + // "Which compile-visible channels a build.mcpp directive lands in" is a + // property of the DIRECTIVE TABLE, not of this call site, so both the mark + // and the fold now live with the table in mcpp.build.directives. This pair + // used to be defined here and was already incomplete — the comment it + // replaced admitted that link/source residues stayed at the call sites, + // which is the #242 two-derivations shape. + // + // The fold is PRIVATE by design (Cargo discipline — a build-time program + // must not widen the package's public interface): privateBuild only, never + // publicUsage. The after-dirs ride the typed #249 channel, which owns the + // per-dialect degradations (cl.exe /I, NASM -I). + using DirectiveMark = mcpp::build::directives::Mark; + state.markDirectiveTail = [](const mcpp::manifest::Manifest& mm) { + return mcpp::build::directives::mark(mm); + }; + state.foldDirectiveTailIntoPrivateBuild = + [](mcpp::modgraph::PackageRoot& pkg, const mcpp::manifest::Manifest& ran, + const DirectiveMark& t) + { + mcpp::build::directives::fold_private_tail(pkg.privateBuild, ran, t); + }; + + // mcpp#241: the (name → dir) pairs a package's build.mcpp receives as + // MCPP_DEP__DIR. ONE owner: the dependency loop and the root call + // site had drifted into two near-identical copies of this, and #355 was + // about to add a third. Each dependency is emitted under BOTH its + // canonical name and its namespace-stripped tail, so + // `mcpp::dep_dir("compat.zlib")` and `mcpp::dep_dir("zlib")` both resolve + // regardless of which spelling the author used in `deps`. + // + // #359: the set is now the consumer's VISIBLE provisions rather than its + // direct edges, so a re-exported dependency's directory reaches it too. + // That is what makes a rule package able to find data files belonging to a + // dependency the user never declared — protoc's well-known .proto files + // are exactly such a directory, and `grpcgen` reads them through dep_dir. + // + // The bare tail is emitted only when the namespace ladder binds it here. + // Emitting it unconditionally was safe while only the root's own + // declarations reached build.mcpp; with re-export, two packages that never + // heard of each other can share a tail and the later emplace_back would + // silently win. + // The xlings half of fillDepDirs. Same question ("where did my declared + // dependency's payload land"), different namespace and store layout, so it + // cannot ride the mcpp dependency channel — but it must be an INTERFACE on + // the build.mcpp side for the same reason that one is: a program that + // reconstructs the store path is coupled to internals mcpp is free to + // change. See mcpp::build::hostprogram::xpkg_dir. + // Which dependency supplied the runner, for the exactly-one-provider + // error below. A name rather than a bool: the message has to name both. + // ONE PROVIDER PER RUNNER NAME. `runner` has had this rule since #544; + // a NAMED runner inherits it per name, because a board may legitimately + // supply `flash` while a different package supplies `monitor`. + + state.fillXpkgDirs = [&](mcpp::build::BuildProgramEnv& e, + const mcpp::manifest::Manifest& owner, + std::size_t consumer) { + // `[feature-xlings.]` is provisioned when `` is active, so it has + // to be answerable here too. Before this, a tool a feature declared was + // downloaded and installed and then `mcpp::xpkg_dir` returned "" for it + // — the build program was told to declare a package it had already + // declared, which is a diagnostic pointing at the wrong file. + // + // The set is taken from the SAME env the caller already computed, so + // "which features are on" is answered once. Installation stays the + // filter below: a declared address whose payload is absent answers "", + // which is what a `when = "dev"` entry looks like to a consumer. + std::vector declared = owner.xlings.deps; + for (auto const& f : e.features) + if (auto it = owner.xlings.featureDeps.find(f); + it != owner.xlings.featureDeps.end()) + for (auto const& address : it->second) + if (std::ranges::find(declared, address) == declared.end()) + declared.push_back(address); + // …and what the rule packages compiled INTO this build program + // declared. Their own active features, not the consumer's: the + // consumer asked for `features = ["rules-cuda"]` on the edge, and that + // is what decides which of the rule's `[feature-xlings]` tables apply. + if (auto pit = state.hostModuleProvidersByConsumer.find(consumer); + pit != state.hostModuleProvidersByConsumer.end()) { + for (auto q : pit->second) { + if (q >= state.packages.size()) continue; + auto const& pm = state.packages[q].manifest; + auto want = [&](const std::string& address) { + if (std::ranges::find(declared, address) == declared.end()) + declared.push_back(address); + }; + for (auto const& address : pm.xlings.deps) want(address); + const auto& pf = q < state.activeFeaturesByPackage.size() + ? state.activeFeaturesByPackage[q] : std::vector{}; + for (auto const& f : pf) + if (auto it = pm.xlings.featureDeps.find(f); + it != pm.xlings.featureDeps.end()) + for (auto const& address : it->second) want(address); + } + } + if (declared.empty()) return; + auto cfg = state.get_cfg(true); + if (!cfg) return; + auto xlEnv = mcpp::config::make_xlings_env(**cfg); + std::set answered; + for (auto const& raw : declared) { + // THE VERSION THIS BUILD INSTALLED, NOT THE ONE THIS MANIFEST + // WROTE. Both statements are about one package, and only one + // version of it exists on disk; answering from the local spelling + // is how a rule package could declare `>=8.5.0`, have the project's + // exact pin installed instead, and then be told nothing is there. + // `xlingsWinner` is empty only before the split has run, and every + // caller of this lambda runs after it — the fallback keeps that a + // fact about ordering rather than a crash. + const auto key = mcpp::xlings::addrset::package_key(raw); + if (!answered.insert(key).second) continue; + auto wit = state.xlingsWinner.find(key); + const std::string spec = wit == state.xlingsWinner.end() ? raw : wit->second; + auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec); + auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref); + if (!dir) continue; // declared but not installed: "" is the answer + // Namespaced first — it is the exact spelling, and the bare form + // below must not shadow it (the receiver keeps the first value it + // is given for a name). + e.xpkgDirs.emplace_back( + mcpp::build::xpkg_env_var(ref.ns, ref.name), dir->string()); + e.xpkgDirs.emplace_back( + mcpp::build::xpkg_env_var("", ref.name), dir->string()); + } + }; + + // `linkForms` (#642 E2): when given, each dependency that has a resolved + // library form is also offered under exactly the names its directory is, + // so `dep_linkage(n)` answers for every `n` that `dep_dir(n)` answers for. + // Only the root's program passes it; see the root call site for why. + state.fillDepDirs = [&](mcpp::build::BuildProgramEnv& e, std::size_t consumer, + const std::map* linkForms = nullptr) { + if (consumer >= state.provisionGraph.visible.size()) return; + auto bind = state.bareBindingsFor(consumer); + for (auto const& [tail, b] : bind) { + if (auto note = prov::contest_note(tail, b); !note.empty()) + mcpp::diag::warning("provisions/ambiguous", note); + } + for (auto const& pr : state.provisionGraph.visible[consumer]) { + if (pr.kind != prov::Kind::DepDir) continue; + if (pr.provider >= state.packages.size()) continue; + auto const& depPkg = state.packages[pr.provider]; + auto const& canon = depPkg.manifest.package.name; + const std::string* form = nullptr; + if (linkForms) + if (auto f = linkForms->find(pr.provider); f != linkForms->end()) + form = &f->second; + // Every spelling of `publishedNamesFor`: the manifest's name, the + // qualified name a manifest writing `namespace = "ns"` and + // `name = "fw"` is addressed by (#642: the framework's rule asks + // `dep_linkage("huxerui.huxerui")`), and the bound tail. + for (auto const& n : state.publishedNamesFor(pr.provider, bind)) { + e.depDirs.emplace_back(n, depPkg.root); + if (form) e.depLinkages.emplace_back(n, *form); + } + } + }; + + // A declared build-graph node's Source outputs must be visible to the + // scan, so they are materialized as placeholders and joined to the source + // set here — the same two lists `generated=` feeds, for the same reason + // (the scanner walks the legacy modules.sources mirror). ninja overwrites + // the placeholder before the compile edge runs, because that compile + // depends on the action's output. + state.adoptActionOutputs = [](mcpp::manifest::Manifest& mm, + const std::filesystem::path& pkgRoot, + std::size_t firstNewAction) { + if (firstNewAction >= mm.buildConfig.actions.size()) return; + std::vector fresh( + mm.buildConfig.actions.begin() + + static_cast(firstNewAction), + mm.buildConfig.actions.end()); + // The package that DECLARED the outputs classifies them: a dependency + // generating a `.ixx` asks its own manifest, not the root project's. + // Built once per package, not once per output — and BEFORE + // `prepare_actions`, which needs the same table to decide which + // outputs get a placeholder (a header does not; see mcpp#534). + const auto pkgExtTable = + mcpp::extension_table_for(mm.buildConfig.moduleExtensions, + mm.buildConfig.deviceExtensions); + mcpp::build::directives::prepare_actions(fresh, pkgRoot, pkgExtTable); + std::copy(fresh.begin(), fresh.end(), + mm.buildConfig.actions.begin() + + static_cast(firstNewAction)); + for (auto const& a : fresh) { + if (a.role != mcpp::manifest::BuildAction::Role::Source) continue; + for (auto const& o : a.outputs) { + if (o.find("${mcpp.") != std::string::npos) continue; + // Companion outputs (protoc's .pb.h next to its .pb.cc) are + // produced by the edge but are NOT translation units. + if (!mcpp::build::directives::is_compilable_output(o, pkgExtTable)) + continue; + mm.buildConfig.sources.push_back(o); + mm.modules.sources.push_back(o); + } + } + }; + + + state.appendUniqueFlags = + [](std::vector& flags, + const std::vector& additions) -> bool + { + bool changed = false; + for (auto const& f : additions) { + if (std::find(flags.begin(), flags.end(), f) != flags.end()) continue; + flags.push_back(f); + changed = true; + } + return changed; + }; + + auto expandIncludeDirs = + [&](const std::filesystem::path& packageRoot, + const mcpp::manifest::Manifest& manifest) + { + std::vector dirs; + for (auto const& inc : manifest.buildConfig.includeDirs) { + if (inc.is_absolute()) { + // Native spelling: a TOML `C:/SDL2/include` stays mixed on + // MSVC and leaks into the CDB's -I otherwise. Direct + // make_preferred — no generic_string round trip, which can + // throw for names the ANSI codepage cannot spell (mcpp#230). + auto n = inc; + n.make_preferred(); + state.appendUniquePath(dirs, std::move(n)); + continue; + } + for (auto& dir : mcpp::modgraph::expand_dir_glob( + packageRoot, inc.generic_string())) { + state.appendUniquePath(dirs, dir); + } + } + return dirs; + }; + + // #249: same glob expansion for `include_dirs_after` (the -idirafter + // channel — searched after the toolchain's system dirs). + auto expandIncludeDirsAfter = + [&](const std::filesystem::path& packageRoot, + const mcpp::manifest::Manifest& manifest) + { + std::vector dirs; + for (auto const& inc : manifest.buildConfig.includeDirsAfter) { + if (inc.is_absolute()) { + auto n = inc; + n.make_preferred(); + state.appendUniquePath(dirs, std::move(n)); + continue; + } + for (auto& dir : mcpp::modgraph::expand_dir_glob( + packageRoot, inc.generic_string())) { + state.appendUniquePath(dirs, dir); + } + } + return dirs; + }; + + // The same expansion for `private_include_dirs`, so a private entry may be + // a glob and still name exactly the directories it expands to. + auto expandPrivateIncludeDirs = + [&](const std::filesystem::path& packageRoot, + const mcpp::manifest::Manifest& manifest) + { + std::vector dirs; + for (auto const& inc : manifest.buildConfig.privateIncludeDirs) { + if (inc.is_absolute()) { + auto n = inc; + n.make_preferred(); + state.appendUniquePath(dirs, std::move(n)); + continue; + } + for (auto& dir : mcpp::modgraph::expand_dir_glob( + packageRoot, inc.generic_string())) { + state.appendUniquePath(dirs, dir); + } + } + return dirs; + }; + + auto makePackageRoot = + [&](const std::filesystem::path& packageRoot, + const mcpp::manifest::Manifest& manifest) + -> std::expected + { + // THE SNAPSHOT READS A NORMALISED MANIFEST; IT DOES NOT NORMALISE ONE. + // + // Every merge that feeds a package's build inputs (workspace + // inheritance, the conditional `[target..build]` sections) runs + // at the package's LOAD site, and `fold_build_defines_into_flags` runs + // after all of them. This lambda only captures the result. + // + // `[workspace.build]` inheritance used to run here (#539). The root + // had already inherited at load time, so it received the workspace + // entries twice; a member reached as a sibling's `path` dependency + // inherited after its `defines` had been folded, so the workspace + // `defines` never reached its compile lines (#690). Both follow from + // performing a merge at the snapshot, and both are removed by + // performing it at the load site, where the root already did. + // + // The post-condition below is what keeps it removed: a merge placed + // after the fold leaves `defines` non-empty here, and the build stops + // with an internal error instead of dropping the macros in silence. + if (auto unfolded = unfolded_defines_error(manifest)) + return std::unexpected(*unfolded); + + mcpp::modgraph::PackageRoot pkg; + pkg.root = packageRoot; + pkg.manifest = manifest; + pkg.usageResolved = true; + + pkg.privateBuild.includeDirs = expandIncludeDirs(packageRoot, manifest); + pkg.privateBuild.includeDirsAfter = expandIncludeDirsAfter(packageRoot, manifest); + pkg.privateBuild.cflags = manifest.buildConfig.cflags; + pkg.privateBuild.cxxflags = manifest.buildConfig.cxxflags; + // NOT `= privateBuild` ANY MORE — a package may now say which of + // its include directories stop at its own boundary. + // + // This line took the whole set for as long as the two were the same + // set, which they are for almost every package. The one shape where + // they are not is a package that vendors a library with an internal + // header overlay: musl's `src/include` adds `hidden`, `weak` and + // `weak_alias` for musl's own sources, and publishing it hands those + // names to every consumer. See BuildInputs::privateIncludeDirs. + // + // THE FILTER IS APPLIED AFTER GLOB EXPANSION, so a private entry may + // itself be a glob and still name exactly the directories it expands + // to. Comparing the unexpanded spellings would let `musl/src/*` be + // published because it is not literally equal to `musl/src/include`. + { + const auto privateExpanded = + expandPrivateIncludeDirs(packageRoot, manifest); + for (auto const& d : pkg.privateBuild.includeDirs) + if (std::ranges::find(privateExpanded, d) == privateExpanded.end()) + pkg.publicUsage.includeDirs.push_back(d); + + // AN ENTRY THAT WITHHOLDS NOTHING IS REPORTED, because the way + // it fails is the very defect this key exists to prevent: a + // directory the author believes is private stays published, and + // nothing about the build looks different until a consumer trips + // over a name months later. + // + // A WARNING AND NOT AN ERROR, for consistency with `include_dirs` + // itself: that key silently ignores a glob matching nothing, and a + // conditional manifest can legitimately name a directory that + // exists on one platform only. Refusing here would be stricter + // than the list this one filters. + for (auto const& want : privateExpanded) { + if (std::ranges::find(pkg.privateBuild.includeDirs, want) + != pkg.privateBuild.includeDirs.end()) + continue; + mcpp::diag::warning("manifest", std::format( + "package '{}': `private_include_dirs` names '{}', which is " + "not among this package's `include_dirs`.\n" + " It withholds nothing — `private_include_dirs` says " + "which entries OF `include_dirs`\n" + " stop at this package's boundary, and an entry that " + "is not one of them is published\n" + " exactly as before.", + manifest.package.name, want.generic_string())); + } + } + pkg.publicUsage.includeDirsAfter = pkg.privateBuild.includeDirsAfter; + pkg.linkUsage.ldflags = manifest.buildConfig.ldflags; + return pkg; + }; + + { + auto rootPackage = makePackageRoot(*state.root, *state.m); + if (!rootPackage) return std::unexpected(rootPackage.error()); + state.packages[0] = std::move(*rootPackage); + } + + auto recordDependencyEdge = + [&](std::size_t consumerDepIndex, + std::size_t dependencyPackageIndex, + const mcpp::manifest::DependencySpec& spec, + bool buildOnly, + const std::string& writtenKey) + { + const auto consumerPackageIndex = packageIndexForConsumer(consumerDepIndex); + if (consumerPackageIndex >= state.packages.size() + || dependencyPackageIndex >= state.packages.size()) { + return; + } + if (std::ranges::none_of(state.graphRequests, [&](const GraphRequest& r) { + return r.consumerPackageIndex == consumerPackageIndex + && r.dependencyPackageIndex == dependencyPackageIndex + && r.key == writtenKey && r.table == spec.declaredIn; + })) + state.graphRequests.push_back(GraphRequest{ + .consumerPackageIndex = consumerPackageIndex, + .dependencyPackageIndex = dependencyPackageIndex, + .key = writtenKey, + .table = spec.declaredIn, + }); + const auto visibility = parseVisibility(spec.visibility); + auto same = [&](const DependencyEdge& edge) { + return edge.consumerPackageIndex == consumerPackageIndex + && edge.dependencyPackageIndex == dependencyPackageIndex + && edge.visibility == visibility; + }; + auto it = std::find_if(state.dependencyEdges.begin(), state.dependencyEdges.end(), same); + if (it != state.dependencyEdges.end()) { + // One consumer naming one dependency in BOTH tables. The ordinary + // declaration wins, because the build-time path never subtracts + // from what the project asked to link — stating the rule the other + // way round would let a `[build-dependencies]` line quietly drop a + // library the target needs. + if (!buildOnly) it->buildOnly = false; + // AND THE SECOND DECLARATION'S REQUESTS ARE KEPT (#649 E7). Both + // declarations name one edge, so what each asks of the dependency + // is asked of that edge: this used to return here and lose the + // second one's `tools`, `features`, `host-module` and `reexport` + // without a word, under `--strict` too. The rule is the one + // `mergeActiveFeatureDeps` already applies to a feature's + // restatement: additive fields union, `default-features` stays on + // unless every declaration opts out. + 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()) + it->requestedFeatures.push_back(f); + it->defaultFeatures = it->defaultFeatures || spec.defaultFeatures; + if (spec.hostModule) it->hostModule = true; + else if (dependencyPackageIndex < state.packages.size()) + for (auto const& f : spec.features) + if (state.packages[dependencyPackageIndex].manifest.featureRuleModule.contains(f)) { + it->hostModule = true; + break; + } + it->reexport = it->reexport || spec.reexport; + return; + } + // A REQUESTED FEATURE THAT IS A BUILD RULE IMPLIES `host-module`. + // + // `host-module = true` says "compile this dependency's interface unit + // for the host so my build program can import it", and a feature + // declaring `rule_module` has already said that is the only way to use + // it. Requiring both was a second spelling of one fact, and the failure + // when only the feature was written landed in the consumer's build as + // an unresolved import rather than in the line that was incomplete. + bool hostModule = spec.hostModule; + if (!hostModule && dependencyPackageIndex < state.packages.size()) { + auto const& depManifest = state.packages[dependencyPackageIndex].manifest; + for (auto const& f : spec.features) + if (depManifest.featureRuleModule.contains(f)) { hostModule = true; break; } + } + state.dependencyEdges.push_back(DependencyEdge{ + .consumerPackageIndex = consumerPackageIndex, + .dependencyPackageIndex = dependencyPackageIndex, + .visibility = visibility, + .requestedFeatures = spec.features, + .defaultFeatures = spec.defaultFeatures, + .requestedTools = spec.tools, + .requestedArtifacts = spec.artifacts, + .hostModule = hostModule, + .reexport = spec.reexport, + .buildOnly = buildOnly, + }); + }; + + state.computeUsageRequirements = [&] { + bool changed = true; + while (changed) { + changed = false; + for (auto const& edge : state.dependencyEdges) { + if (edge.consumerPackageIndex >= state.packages.size() + || edge.dependencyPackageIndex >= state.packages.size()) { + continue; + } + auto& consumer = state.packages[edge.consumerPackageIndex]; + auto const& dependency = state.packages[edge.dependencyPackageIndex]; + // A package of programs publishes no usage requirements to its + // consumers (#649 E6): nothing of it is compiled or linked here. + if (edge.dependencyPackageIndex > 0 + && state.isProgramOnlyPackage(dependency.manifest)) continue; + + if (edge.visibility == mcpp::modgraph::DependencyVisibility::Private + || edge.visibility == mcpp::modgraph::DependencyVisibility::Public) { + changed = state.appendUniquePaths(consumer.privateBuild.includeDirs, + dependency.publicUsage.includeDirs) + || changed; + // #249: after-dirs ride the same edges but keep their + // after-ness — consumers receive them as -idirafter, + // never upgraded to -I. + changed = state.appendUniquePaths(consumer.privateBuild.includeDirsAfter, + dependency.publicUsage.includeDirsAfter) + || changed; + // Interface defines (a dependency's active-feature `defines`) + // ride the same edges as include dirs: they must reach the + // consumer's own TUs so header-only switches like + // EIGEN_USE_BLAS take effect where the headers are used. + changed = state.appendUniqueFlags(consumer.privateBuild.cflags, + dependency.publicUsage.cflags) + || changed; + changed = state.appendUniqueFlags(consumer.privateBuild.cxxflags, + dependency.publicUsage.cxxflags) + || changed; + } + if (edge.visibility == mcpp::modgraph::DependencyVisibility::Public + || edge.visibility == mcpp::modgraph::DependencyVisibility::Interface) { + changed = state.appendUniquePaths(consumer.publicUsage.includeDirs, + dependency.publicUsage.includeDirs) + || changed; + changed = state.appendUniquePaths(consumer.publicUsage.includeDirsAfter, + dependency.publicUsage.includeDirsAfter) + || changed; + changed = state.appendUniqueFlags(consumer.publicUsage.cflags, + dependency.publicUsage.cflags) + || changed; + changed = state.appendUniqueFlags(consumer.publicUsage.cxxflags, + dependency.publicUsage.cxxflags) + || changed; + } + } + } + }; + + auto normalizeDepLdflag = [](const std::filesystem::path& depRoot, + const std::string& flag) { + auto absolute_path = [&](std::string_view raw) { + std::filesystem::path p{std::string(raw)}; + // A loader token stays as written; see the predicate. + if (p.is_absolute() || mcpp::build::is_loader_relative_search_path(raw)) + return p; + return depRoot / p; + }; + + if (flag.starts_with("-L") && flag.size() > 2) { + return "-L" + absolute_path(std::string_view(flag).substr(2)).string(); + } + + constexpr std::string_view rpathPrefix = "-Wl,-rpath,"; + if (flag.starts_with(rpathPrefix) && flag.size() > rpathPrefix.size()) { + return std::string(rpathPrefix) + + absolute_path(std::string_view(flag).substr(rpathPrefix.size())).string(); + } + + return flag; + }; + + auto propagateLinkFlags = [&](const std::filesystem::path& depRoot, + const mcpp::manifest::Manifest& depManifest) + -> std::vector + { + // Word by word (SPEC-004 §8, #703): a search path is made absolute + // per word, and each word is written back as an element that reads as + // exactly that word, so the consumer's renderer reads the dependency's + // flags with the same reading its own flags receive, and an element + // that packs several tokens is several words on both sides. + std::vector added; + for (auto const& word : mcpp::manifest::flag_words(depManifest.buildConfig.ldflags)) { + auto normalized = mcpp::manifest::flag_element(normalizeDepLdflag(depRoot, word)); + state.m->buildConfig.ldflags.push_back(normalized); + added.push_back(std::move(normalized)); + } + return added; + }; + + auto removeLinkFlags = [&](const std::vector& flags) { + auto& ldflags = state.m->buildConfig.ldflags; + for (auto const& flag : flags) { + auto pos = std::find(ldflags.begin(), ldflags.end(), flag); + if (pos != ldflags.end()) ldflags.erase(pos); + } + }; + + auto package_source_files = []( + const std::filesystem::path& srcRoot, + const mcpp::manifest::Manifest& depManifest) + -> std::expected, std::string> + { + // Resolve the source globs against the original root, falling + // back to the convention default if the manifest didn't set any. + std::vector globs = depManifest.modules.sources; + if (globs.empty()) { + // Was a fourth hand-written copy of the convention default, and it + // had already drifted: all three assembly extensions were missing, + // so staging a dependency with .S/.s/.asm silently dropped them. + globs = mcpp::default_source_globs( + mcpp::extension_table_for(depManifest.buildConfig.moduleExtensions, + depManifest.buildConfig.deviceExtensions)); + } + // Glob exclusion (same as scan_one_into): `!` prefix removes. + std::set sourceFiles; + std::set excluded; + for (auto const& g : globs) { + if (!g.empty() && g[0] == '!') { + for (auto& p : mcpp::modgraph::expand_glob(srcRoot, g.substr(1))) + excluded.insert(p); + } else { + for (auto& p : mcpp::modgraph::expand_glob(srcRoot, g)) + sourceFiles.insert(p); + } + } + for (auto& p : excluded) sourceFiles.erase(p); + if (sourceFiles.empty()) { + return std::unexpected(std::format( + "stage: no source files found under '{}' (globs={})", + srcRoot.string(), globs.size())); + } + return sourceFiles; + }; + + // Stage a dep's source files into a fresh directory, rewriting their + // module / import declarations against `rename`. Used by the multi- + // version mangling fallback (Level 1) so two cross-major copies of + // the same package can coexist with distinct module names. + // + // Headers reached through `[build].include_dirs` are NOT staged — those + // keep pointing at the original install dir via absolutized include paths. + // + // HEADERS BESIDE A SOURCE ARE A DIFFERENT CASE, AND THEY ARE STAGED. + // + // `#include "detail.h"` is resolved relative to the directory of the file + // holding the directive, so moving the source moves the search. No + // `include_dirs` entry is involved and absolutizing one cannot help: the + // package never declared a path because it never needed one. Measured + // before this, on a package whose `src/time.cpp` includes `src/sbi.h`: + // + // target/.mangled/openkal-opensbi/__self__/src/time.cpp:44:10: + // fatal error: 'sbi.h' file not found + // + // THE DIAGNOSIS THIS PRODUCES POINTS AT THE WRONG THING. The path in it + // is a staging directory the author never wrote, for a header sitting + // exactly where the source expects it, and the build that triggered it + // asked for nothing unusual — two majors of one dependency is a supported + // arrangement, and this is its most ordinary consequence. + // + // What is copied is every file in a directory that contains a staged + // source and is not itself staged, verbatim: rewriting applies to module + // declarations, and a header has none. Directories with no staged source + // are not visited, so this stays proportional to what is being staged. + auto stage_with_rewrite = [&](const std::filesystem::path& srcRoot, + const std::filesystem::path& dstRoot, + const mcpp::manifest::Manifest& depManifest, + const std::map& rename) + -> std::expected + { + std::error_code ec; + std::filesystem::create_directories(dstRoot, ec); + if (ec) return std::unexpected(std::format( + "stage: cannot create '{}': {}", dstRoot.string(), ec.message())); + + auto sources = package_source_files(srcRoot, depManifest); + if (!sources) return std::unexpected(sources.error()); + + for (auto const& f : *sources) { + auto rel = std::filesystem::relative(f, srcRoot, ec); + if (ec) return std::unexpected(std::format( + "stage: cannot relativize '{}': {}", f.string(), ec.message())); + auto dst = dstRoot / rel; + std::filesystem::create_directories(dst.parent_path(), ec); + + std::ifstream is(f); + if (!is) return std::unexpected(std::format( + "stage: cannot read '{}'", f.string())); + std::stringstream buf; buf << is.rdbuf(); + std::string content = buf.str(); + + std::string out = mcpp::pm::rewrite_module_decls(content, rename); + std::ofstream os(dst); + if (!os) return std::unexpected(std::format( + "stage: cannot write '{}'", dst.string())); + os << out; + } + + // The files beside those sources, carried across unchanged so a quoted + // include still finds what it named. + std::set sourceDirs; + for (auto const& f : *sources) sourceDirs.insert(f.parent_path()); + for (auto const& dir : sourceDirs) { + for (auto const& entry : std::filesystem::directory_iterator(dir, ec)) { + if (ec) break; + if (!entry.is_regular_file()) continue; + if (sources->contains(entry.path())) continue; + auto rel = std::filesystem::relative(entry.path(), srcRoot, ec); + if (ec) continue; + auto dst = dstRoot / rel; + std::filesystem::create_directories(dst.parent_path(), ec); + std::filesystem::copy_file( + entry.path(), dst, + std::filesystem::copy_options::overwrite_existing, ec); + if (ec) return std::unexpected(std::format( + "stage: cannot copy '{}': {}", + entry.path().string(), ec.message())); + } + ec.clear(); + } + return {}; + }; + + auto declared_modules_for = [&](const std::filesystem::path& srcRoot, + const mcpp::manifest::Manifest& depManifest) + -> std::expected, std::string> + { + auto sources = package_source_files(srcRoot, depManifest); + if (!sources) return std::unexpected(sources.error()); + std::vector modules; + for (auto const& file : *sources) { + std::ifstream is(file); + if (!is) return std::unexpected(std::format( + "mangle: cannot read '{}'", file.string())); + std::stringstream buf; buf << is.rdbuf(); + for (auto& name : mcpp::pm::declared_module_roots(buf.str())) { + if (std::ranges::find(modules, name) == modules.end()) + modules.push_back(std::move(name)); + } + } + if (modules.empty()) return std::unexpected(std::format( + "mangle: package '{}' declares no named C++ module to rewrite", + depManifest.package.name)); + return modules; + }; + + // Stage 2a — feature-activated optional dependencies. Defined as local + // lambdas (NOT file-scope functions): keeping their std::map instantiations + // inside this implementation unit avoids polluting the exported module BMI, + // which otherwise trips a GCC-16 modules bug ("failed to load pendings for + // __normal_iterator") when other modules import std. + auto activateFeatures = [](const mcpp::manifest::Manifest& pm, + const std::vector& requested, + bool seedDefault = true) { + return feature_closure(pm, requested, seedDefault); // single shared implementation + }; + // Merge a manifest's active feature-deps into its `dependencies` map so the + // worklist below pulls them like any normal dep. A top-level dep of the same + // key is never overwritten; deps declared only under a feature appear only + // when that feature is active. `seedDefault` carries consumer-side + // `default-features = false` (#242): when a consumer opts out of this dep's + // default set, feature-deps behind the default pseudo-feature stay dormant. + // + // A RESTATEMENT NAMES THE SAME SOURCE OR IS REFUSED (#647 E4.2). The grammar + // asks a `[feature-deps]` entry to restate its dependency's source, and the + // merge below takes only the additive fields from it, so a restatement + // that names another path, repository or version was dropped without a + // word, under `--strict` too: the tool came from the declaration in effect + // while the manifest said it came from somewhere else. The comparison runs + // against `dependencies` after the conditional fold, so a row's replacement + // (#634 A1) is the declaration a restatement is held to. + // + // TWO SPELLINGS OF ONE SOURCE ARE ONE SOURCE. The comparison below decides + // whether a restatement names something else, so it has to be made on what + // the two declarations MEAN, not on their bytes: a path is normalised, and + // a version constraint is compared with its whitespace removed, because + // `">= 1.2.0"` and `">=1.2.0"` are one constraint and the manifest that + // spells them differently built on 2026.9.15.2. A gate added for #647 E4.2 + // must refuse a restatement that names another source, and nothing else. + auto dependencySourceOf = [](const mcpp::manifest::DependencySpec& s) { + if (s.inheritWorkspace) return std::string("workspace = true"); + if (s.isPath()) { + auto norm = std::filesystem::path(s.path).lexically_normal().generic_string(); + while (norm.size() > 1 && norm.back() == '/') norm.pop_back(); + return std::format("path = \"{}\"", norm); + } + if (s.isGit()) + return std::format("git = \"{}\", {} = \"{}\"", s.git, + s.gitRefKind.empty() ? "rev" : s.gitRefKind, s.gitRev); + return std::format("version = \"{}\"", s.version); + }; + // What the comparison is made on. The message shows the declaration as it + // was written; the judgement drops the whitespace inside a constraint, so + // the two declarations are compared on what they mean. + auto dependencySourceKey = [&](const mcpp::manifest::DependencySpec& s) { + auto spelled = dependencySourceOf(s); + if (!s.inheritWorkspace && !s.isPath() && !s.isGit()) + std::erase_if(spelled, [](char c) { return c == ' ' || c == '\t'; }); + return spelled; + }; + auto mergeActiveFeatureDeps = [&](mcpp::manifest::Manifest& pm, + const std::vector& requested, + bool seedDefault = true) + -> std::expected { + if (pm.featureDeps.empty()) return {}; + for (auto& f : activateFeatures(pm, requested, seedDefault)) { + auto it = pm.featureDeps.find(f); + if (it == pm.featureDeps.end()) continue; + for (auto& [k, spec] : it->second) { + auto [pos, fresh] = pm.dependencies.try_emplace(k, spec); + if (fresh) continue; + if (!pos->second.inheritWorkspace && !spec.inheritWorkspace) { + const auto inEffect = dependencySourceOf(pos->second); + const auto restated = dependencySourceOf(spec); + if (dependencySourceKey(pos->second) != dependencySourceKey(spec)) + return std::unexpected(std::format( + "[feature-deps.{}] of '{}' restates the dependency '{}' " + "with {}, while the declaration in effect on this row " + "names {}.\n" + " One dependency has one source, so the " + "restatement would be ignored.\n" + " fix: restate the same source ({}), or declare " + "'{}' only under the feature.", + f, pm.package.name, k, restated, inEffect, inEffect, k)); + } + // #359: the key already exists unconditionally, and dropping + // the feature's spec here loses REQUESTS the feature exists to + // make. gRPC is the shape: it depends on compat.protobuf + // always, and its `codegen` feature has to add + // `tools = ["protoc"], reexport = true` to that same edge — + // which is precisely what must NOT be paid for by a consumer + // who did not ask for codegen, so moving it to the + // unconditional entry is not an option either. + // + // Additive fields merge; identity fields (version/path/git) do + // not, keeping "a conditional section never silently + // overrides an unconditional one" intact. Same rule the + // per-edge feature request already follows. + auto& dst = pos->second; + for (auto const& t : spec.tools) + if (std::find(dst.tools.begin(), dst.tools.end(), t) + == dst.tools.end()) + dst.tools.push_back(t); + for (auto const& f2 : spec.features) + if (std::find(dst.features.begin(), dst.features.end(), f2) + == dst.features.end()) + dst.features.push_back(f2); + dst.hostModule = dst.hostModule || spec.hostModule; + dst.reexport = dst.reexport || spec.reexport; + } + } + return {}; + }; + + // #243: dep/feat forwarding. When a resolved package's feature F is active, + // it may forward features to its dependencies (Cargo `[features] F = + // ["dep/feat"]`). Injecting the forwarded feature into the child's request + // BEFORE the child is pushed onto the worklist makes BOTH consumption points + // observe it: resolution (mergeActiveFeatureDeps reads the child's + // spec.features) and activation (recordDependencyEdge stores spec.features on + // the P->D edge, which aggregatedRequest unions and apply() activates). + // Transitive forwarding rides the BFS forward edge (root -> mid -> leaf). + auto injectForwards = [](const mcpp::manifest::Manifest& parent, + const std::vector& parentActive, + const std::string& childKey, + mcpp::manifest::DependencySpec& childSpec) { + if (parent.featureForwards.empty()) return; + for (auto const& f : parentActive) { + auto it = parent.featureForwards.find(f); + if (it == parent.featureForwards.end()) continue; + for (auto const& [depKey, depFeat] : it->second) { + if (depKey != childKey) continue; + if (std::find(childSpec.features.begin(), childSpec.features.end(), + depFeat) == childSpec.features.end()) + childSpec.features.push_back(depFeat); + } + } + }; + // #243: a forward whose active feature targets a dependency that is not + // declared is a manifest bug — name it instead of silently dropping. Only + // active features' forwards are checked (lazy, like the + // unknown-requested-feature gate at ~2875). + // + // THE VALIDATOR ASKS WHAT THE FORWARD LANGUAGE DEFINES: IS THE KEY DECLARED + // IN ANY DEPENDENCY TABLE OF THIS MANIFEST, ON ANY ROW, UNDER ANY FEATURE + // (#647 E4.1). It used to look in `dependencies` and `devDependencies` + // only, while `injectForwards` applies a forward to the build-dependency + // edge as well, so a forward along `[build-dependencies]` was applied and + // reported as undeclared in the same run, and `--strict` refused a build + // whose forward had worked. A key declared only for another row, or only + // under an inactive feature, is declared: on this row the forward reaches + // no edge and does nothing, which is what a portable manifest means by it. + auto declaresDependencyKey = [](const mcpp::manifest::Manifest& pm, + const std::string& key) { + auto inFeatureDeps = [&](const auto& byFeature) { + for (auto const& [f, deps] : byFeature) + if (deps.contains(key)) return true; + return false; + }; + if (pm.dependencies.contains(key) || pm.devDependencies.contains(key) + || pm.buildDependencies.contains(key) || inFeatureDeps(pm.featureDeps)) + return true; + for (auto const& cc : pm.conditionalConfigs) + if (cc.dependencies.contains(key) || cc.devDependencies.contains(key) + || cc.buildDependencies.contains(key) + || inFeatureDeps(cc.featureDeps)) + return true; + return false; + }; + auto validateForwards = [&](const mcpp::manifest::Manifest& parent, + const std::vector& parentActive, + std::string_view parentName) + -> std::expected { + for (auto const& f : parentActive) { + auto it = parent.featureForwards.find(f); + if (it == parent.featureForwards.end()) continue; + for (auto const& [depKey, depFeat] : it->second) { + if (declaresDependencyKey(parent, depKey)) continue; + auto msg = std::format( + "feature '{}' of '{}' forwards to dependency '{}' (as " + "'{}/{}') which no dependency table declares ([dependencies], " + "[build-dependencies], [dev-dependencies] or [feature-deps], " + "on any row)", f, parentName, depKey, depKey, depFeat); + if (state.overrides.strict) return std::unexpected(msg); + mcpp::diag::warning("features/forwarding", msg); + } + } + return {}; + }; + + // Pull the root package's active feature-deps into its dependency set before + // seeding, so `mcpp build --features X` resolves X's optional deps. + state.rootReq = parse_feature_request(state.overrides.features); + if (auto fm = mergeActiveFeatureDeps(*state.m, state.rootReq); !fm) + return std::unexpected(fm.error()); + // #243: the root's active features may forward features to its direct deps. + std::vector rootActive = feature_closure(*state.m, state.rootReq, true); + if (auto fe = validateForwards(*state.m, rootActive, state.m->package.name); !fe) + return std::unexpected(fe.error()); + state.activeFeaturesByPackage.assign(1, rootActive); + + // `--features /` (#649 E8): a forward of the root, + // applied to the edges exactly as a `[features]` forward is and checked + // against the same tables. Named whether or not the root declares + // `[features]`: the token cannot be a macro of the root, so there is no + // "pure macro usage" to preserve for it. + std::vector> cliForwards; + for (auto const& tok : feature_forward_request_tokens(state.overrides.features)) { + auto fwd = mcpp::pm::split_feature_forward_token(tok); + std::string msg; + if (!fwd) + msg = std::format("--features requests '{}', which names neither a " + "feature nor `/`", tok); + else if (!declaresDependencyKey(*state.m, fwd->first)) + msg = std::format("--features requests '{}', and no dependency table " + "of '{}' declares '{}'", tok, state.m->package.name, + fwd->first); + if (!msg.empty()) { + if (state.overrides.strict) return std::unexpected(msg); + mcpp::diag::warning("features/request", msg); + continue; + } + cliForwards.push_back(std::move(*fwd)); + } + auto injectCliForwards = [&](const std::string& childKey, + mcpp::manifest::DependencySpec& childSpec) { + for (auto const& [depKey, depFeat] : cliForwards) + if (depKey == childKey + && std::ranges::find(childSpec.features, depFeat) + == childSpec.features.end()) + childSpec.features.push_back(depFeat); + }; + + // Seed the worklist from the main manifest. Dev-deps only when the + // caller wants them; they're never propagated transitively. + const std::string mainPkgLabel = state.m->package.name; + for (auto& [n, s] : state.m->dependencies) { + auto req = s; + injectForwards(*state.m, rootActive, n, req); + injectCliForwards(n, req); + state.worklist.push_back({n, req, mainPkgLabel, req.version, kMainConsumer, {}}); + } + if (state.includeDevDeps) { + for (auto& [n, s] : state.m->devDependencies) { + auto req = s; + injectForwards(*state.m, rootActive, n, req); + injectCliForwards(n, req); + state.worklist.push_back({n, req, mainPkgLabel + " (dev-dep)", + req.version, kMainConsumer, {}, /*devOnly=*/true}); + } + } + // `[build-dependencies]`. Parsed since 0.0.x, merged across workspace + // members, conditionalised by target predicate — and until now read by + // nothing that made a decision, so writing it produced a manifest that + // loaded, no diagnostic, and no effect. Seeded here, and unlike dev-deps + // it IS walked transitively: a build dependency's own dependencies are + // what make it work, and they inherit its build-only nature. + for (auto& [n, s] : state.m->buildDependencies) { + auto req = s; + injectForwards(*state.m, rootActive, n, req); + injectCliForwards(n, req); + state.worklist.push_back({n, req, mainPkgLabel + " (build-dep)", + req.version, kMainConsumer, {}, /*devOnly=*/false, + /*buildOnly=*/true}); + } + + // `ResolvedRecord::sourceRef` for a given declaration — see the field's + // comment. Computed from what was AUTHORED, not from a network round + // trip: a `branch` reference is compared by name here, and the two + // clones it may eventually resolve to are a question `resolveSemver`-style + // ANSWERING code, not this IDENTITY code, would have to ask. + auto sourceRefOf = [&](const std::string& kind, + const mcpp::manifest::DependencySpec& s, + const std::filesystem::path& resolveRoot, + const std::string& originalConstraint) -> std::string { + if (kind == "git") { + return std::format("{}#{}={}", s.git, s.gitRefKind, s.gitRev); + } + if (kind == "path") { + std::filesystem::path p = s.path; + auto base = resolveRoot.empty() ? *state.root : resolveRoot; + if (p.is_relative()) p = base / p; + std::error_code ec; + auto canon = std::filesystem::weakly_canonical(p, ec); + return (ec ? p : canon).lexically_normal().generic_string(); + } + // "version": the constraint as authored; empty means unconstrained, + // matching `addrset::unify`'s treatment of a bare-name claim. + return originalConstraint.empty() ? std::string("*") : originalConstraint; + }; + + while (!state.worklist.empty()) { + auto item = std::move(state.worklist.front()); + state.worklist.pop_front(); + + const auto& name = item.name; + auto& spec = item.spec; + + mcpp::pm::compat::normalize_nested_namespace( + spec.namespace_, spec.shortName, spec.legacyDottedKey); + if (spec.legacyDottedKey) { + spec.candidates = {{ + .namespace_ = spec.namespace_, + .shortName = spec.shortName, + }}; + } + + if (auto r = state.selectDependencyCandidate(spec, name); !r) { + return std::unexpected(r.error()); + } + if (item.consumerDepIndex == kMainConsumer) { + if (auto it = state.m->dependencies.find(name); it != state.m->dependencies.end()) { + it->second.namespace_ = spec.namespace_; + it->second.shortName = spec.shortName; + it->second.candidates = spec.candidates; + } + } + + // A `path` edge that stays inside a git clone this graph already + // resolved names the same git source at the same commit (#649 E7): + // a member's `spike.fw = { path = ".." }` reaches the repository the + // application pinned by revision, and is that package rather than a + // second, path-sourced declaration of it. Only the clone root itself + // and its `[workspace] members` are mapped; any other directory keeps + // being an ordinary path. + if (spec.isPath() && !state.gitCloneBySource.empty()) { + std::filesystem::path p = spec.path; + auto base = item.resolveRoot.empty() ? *state.root : item.resolveRoot; + if (p.is_relative()) p = base / p; + std::error_code ec; + auto canon = std::filesystem::weakly_canonical(p, ec); + if (ec) canon = p.lexically_normal(); + for (auto const& [src, clone] : state.gitCloneBySource) { + auto rel = canon.lexically_relative(clone.root).generic_string(); + if (rel.empty() || rel.starts_with("..")) continue; + bool mapped = rel == "."; + if (!mapped) { + if (auto rm = mcpp::manifest::load(clone.root / "mcpp.toml"); + rm && rm->workspace.present) + for (auto const& member : rm->workspace.members) + if (std::filesystem::path(member).lexically_normal() + .generic_string() == rel) + mapped = true; + } + if (!mapped) continue; + spec.path.clear(); + spec.git = clone.url; + spec.gitRefKind = clone.refKind; + spec.gitRev = clone.ref; + break; + } + } + + // Pin SemVer constraint before dedup/fetch. + if (auto r = state.resolveSemver(spec, name); !r) { + return std::unexpected(r.error()); + } + + ResolvedKey key{ + spec.namespace_, + spec.shortName.empty() ? name : spec.shortName, + }; + const std::string sourceKind = + spec.isPath() ? "path" + : spec.isGit() ? "git" + : "version"; + // The commit a `git` dependency resolved to, carried out of the clone + // branch below for the cache identity. + std::string sourceCommit; + // The repository member a `git` dependency selected; empty for the + // repository's root package (#649 E7). + std::string gitMember; + std::filesystem::path gitMemberCloneRoot; + + // A second key over a source that is already resolved takes the + // identity resolved there; its manifest is not loaded again. + if (sourceKind != "version") { + const auto source = sourceRefOf(sourceKind, spec, item.resolveRoot, + item.originalConstraint); + // A key naming another package of the same repository is that + // member, not a second key over the root's identity (#649 E7). + bool namesMember = false; + if (sourceKind == "git") + if (auto clone = state.gitCloneBySource.find(source); + clone != state.gitCloneBySource.end()) + namesMember = state.gitMemberDeclaring(clone->second.root, key).has_value(); + if (auto bySource = state.identityBySource.find(source); + !namesMember && bySource != state.identityBySource.end() + && !(bySource->second == key)) { + const auto& existing = state.resolved.at(bySource->second); + const auto& declaring = state.declaringManifest.at(bySource->second); + if (!declaring.namespaceDeclared) { + return std::unexpected(std::format( + "one source is reached as two packages: '{}' names it {} " + "and '{}' names it {}, and its manifest '{}' declares no " + "namespace, so each key gives it its own identity and " + "its modules would be compiled twice.\n" + " fix: declare `namespace` in '{}', or write the " + "same key in both places.", + existing.requestedBy, state.qualifiedKey(bySource->second), + item.requestedBy, state.qualifiedKey(key), + declaring.path, declaring.path)); + } + state.reportAdoption(item.requestedBy, name, key, bySource->second, + declaring.path); + key = bySource->second; + state.stateAdoptedIdentity(item, key); + } + } + + if (auto it = state.resolved.find(key); it != state.resolved.end()) { + // A package is dev-only until some non-dev consumer wants it. Order + // of arrival must not decide, so this is an AND over every request. + it->second.devOnly = it->second.devOnly && item.devOnly; + // Conflict detection: a KIND clash (`path`/`git`/`version` differ). + // Rows 4 and 5 of the decision table in the 2026-09-13-630 record + // §2.2. Two non-root requesters keep the outright refusal (row + // 5); when the root is a party, its declaration wins instead + // (row 4) — a whole-graph choice of WHICH checkout an identity + // resolves to is exactly the kind of decision + // `DependencySpec::linkage` already reserves to the root's own + // edges (dep_spec.cppm). + if (it->second.source != sourceKind) { + const bool existingIsRoot = it->second.fromRoot; + const bool incomingIsRoot = item.consumerDepIndex == kMainConsumer; + + if (!existingIsRoot && !incomingIsRoot) { + return std::unexpected(std::format( + "dependency '{}{}{}' is requested as both a {} dep " + "(by '{}') and a {} dep (by '{}'). Pick one.\n" + " declare '{}{}{}' in the root to settle it.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.source, it->second.requestedBy, + sourceKind, item.requestedBy, + key.ns, key.ns.empty() ? "" : ".", key.shortName)); + } + if (incomingIsRoot && !existingIsRoot) { + // FIFO SEEDING MAKES THIS UNREACHABLE. Every root-declared + // identity is pushed onto `worklist` before this loop + // starts; a transitive dependency's request is pushed + // onto the BACK of the same deque while the loop runs. + // The root's own entry for any identity is therefore + // always dequeued — and resolved — before any + // dependency's request for that identity can arrive. If + // this branch is ever reached, the invariant broke + // upstream (the seed reordered, or a new seed source was + // added after the loop starts): refusing and naming the + // invariant is safer than silently letting whichever side + // arrived first win, which is the accident #630 reports. + return std::unexpected(std::format( + "internal: dependency '{}{}{}': the root's " + "declaration arrived after '{}' had already resolved " + "it. This is unreachable under first-in-first-out " + "worklist seeding; please report this as an mcpp " + "engine defect.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.requestedBy)); + } + + // The root already holds this identity (existingIsRoot); the + // incoming, non-root declaration is overridden. When the + // OVERRIDDEN declaration is a version requirement, it is + // still a promise about the graph and is checked against + // what the root's checkout actually is — the same + // Holds/Violated test `addrset::unify` runs for a tool pin + // (address_set.cppm). + if (sourceKind == "version") { + const std::string winnerVersion = it->second.source == "version" + ? it->second.version + : (it->second.depIndex < state.dep_manifests.size() + ? state.dep_manifests[it->second.depIndex]->package.version + : std::string{}); + auto req = mcpp::version_req::parse_req(item.originalConstraint); + auto ver = mcpp::version_req::parse_version(winnerVersion); + // An unparseable requirement or checkout version is + // reported as an override below rather than refused: a + // refusal manufactured from ignorance is worse than the + // silent override it would be preventing (the same + // reasoning `addrset::check` states for an unparseable + // spelling). + if (req && ver && !mcpp::version_req::matches(*req, *ver)) { + return std::unexpected(std::format( + "'{}{}{}' is pinned to {} (version {}) by '{}', " + "and '{}' requires {}.\n" + " One checkout of a package is used, so the " + "two cannot both hold.\n" + " fix: relax the requirement, or point the " + "root's pin at a checkout satisfying it.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.sourceRef, winnerVersion, + it->second.requestedBy, + item.requestedBy, item.originalConstraint)); + } + } + + mcpp::diag::warning("dependency/source-override", std::format( + "'{}{}{}' is declared as a {} dep (by '{}', {}) and as a " + "{} dep (by '{}', {}); the root's declaration wins.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.source, it->second.requestedBy, it->second.sourceRef, + sourceKind, item.requestedBy, + sourceKind == "version" ? item.originalConstraint + : sourceRefOf(sourceKind, spec, + item.resolveRoot, + item.originalConstraint)), + std::format("declare '{}{}{}' in the root to choose the other.", + key.ns, key.ns.empty() ? "" : ".", key.shortName)); + + if (it->second.depIndex + 1 < state.packages.size()) { + recordDependencyEdge(item.consumerDepIndex, + it->second.depIndex + 1, + spec, item.buildOnly, name); + } + continue; + } + if (sourceKind == "version" && it->second.version != spec.version) { + // SemVer merge attempt: AND-combine the two original + // constraint strings and ask the index for a single version + // satisfying both. Same-major caret/tilde/exact pairs that + // overlap converge here; cross-major or otherwise + // unsatisfiable pairs fall through to a hard error (a future + // PR adds multi-version mangling as a Level-1 fallback). + auto cfg = state.get_cfg(true); + if (!cfg) return std::unexpected(cfg.error()); + + auto merged = mcpp::pm::try_merge_semver( + key.ns, key.shortName, + it->second.constraint, + item.originalConstraint, + state.index_route(*cfg), *state.targetPlatform); + if (!merged) { + // Level 1 fallback: multi-version mangling. Two + // versions can't be reconciled by SemVer, but they + // can coexist in the same build if we mangle the + // secondary copy's module name and rewrite the one + // consumer that asked for it. The primary keeps its + // authored module name so consumers that don't care + // about the secondary see no churn. + // + // MVP scope (these limits surface as clear errors): + // * The conflicting consumer must be a dep, not + // the main package — main-package mangling + // would mean rewriting user-authored sources, + // which is too surprising for a fallback path. + // * The secondary version must be a leaf (no own + // transitive deps) — recursive mangling is + // deferred to a follow-up. + if (item.consumerDepIndex == kMainConsumer) { + return std::unexpected(std::format( + "dependency '{}{}{}' has irreconcilable versions:\n" + " '{}' (constraint '{}') requested by '{}'\n" + " '{}' (constraint '{}') requested by '{}'\n" + "SemVer merge: {}\n" + "Multi-version mangling can't help here — the conflict " + "involves the main package directly. Pin one version " + "explicitly in your mcpp.toml.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.version, it->second.constraint, it->second.requestedBy, + spec.version, item.originalConstraint, item.requestedBy, + merged.error())); + } + + auto loaded = state.loadVersionDep(name, key.ns, key.shortName, spec.version); + if (!loaded) return std::unexpected(loaded.error()); + auto& [secondaryRoot, secondaryManifest] = *loaded; + + if (!secondaryManifest.dependencies.empty()) { + return std::unexpected(std::format( + "dependency '{}{}{}' has irreconcilable versions:\n" + " '{}' requested by '{}'\n" + " '{}' requested by '{}'\n" + "Multi-version mangling fallback only handles leaf " + "secondaries in 0.0.3 — but the secondary v{} declares " + "its own dependencies, which would need recursive " + "mangling. Pin one version explicitly, or wait for " + "the recursive-mangling extension.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.version, it->second.requestedBy, + spec.version, item.requestedBy, + spec.version)); + } + + // Module names are authored API and are not required to + // mirror package identity. Discover every provided module + // root from the secondary's source text, then rewrite the + // same map in both the secondary and its consumer. + auto moduleNames = declared_modules_for( + secondaryRoot, secondaryManifest); + // The two branches above name both versions and who asked + // for them; this one used to report only that the package + // declares no named C++ module, which is a true statement + // about a package the reader never asked to be staged. A + // C package -- compat.vulkan-runtime is one -- reaches + // here whenever a manifest pins one version of it and + // another dependency asks for a second, and the message + // has to say that before it says anything about modules. + if (!moduleNames) return std::unexpected(std::format( + "dependency '{}{}{}' has irreconcilable versions:\n" + " '{}' requested by '{}'\n" + " '{}' requested by '{}'\n" + "Multi-version mangling cannot separate them: {}.\n" + "A package with no named C++ module has nothing to " + "rewrite, so the two requests must agree. Align the " + "pin in your mcpp.toml with the version the other " + "dependency asks for.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.version, it->second.requestedBy, + spec.version, item.requestedBy, + moduleNames.error())); + std::map rename; + for (auto const& module : *moduleNames) { + rename.emplace(module, + mcpp::pm::mangle_name(module, spec.version)); + } + const auto& moduleName = moduleNames->front(); + const auto& mangledModule = rename.at(moduleName); + const std::string mangledPackage = mcpp::pm::mangle_name( + key.shortName, spec.version); + + // Stage layout: + // /target/.mangled//__/ ← rewritten secondary source + // /target/.mangled//__self__/ ← rewritten consumer source + auto& consumerManifest = *state.dep_manifests[item.consumerDepIndex]; + auto consumerRoot = state.packages[item.consumerDepIndex + 1].root; + // Under the write root, not the source root: the stage + // is build output, and BuildOverrides::work_dir promises + // that everything the build writes moves with it. + auto stageBase = state.workRoot / "target" / ".mangled" + / consumerManifest.package.name; + auto secStage = stageBase + / std::format("{}__{}", key.shortName, spec.version); + auto consumerStage = stageBase / "__self__"; + + if (auto r = stage_with_rewrite(secondaryRoot, secStage, + secondaryManifest, rename); !r) + return std::unexpected(r.error()); + if (auto r = stage_with_rewrite(consumerRoot, consumerStage, + consumerManifest, rename); !r) + return std::unexpected(r.error()); + + // Re-anchor the consumer's PackageRoot at its staged copy + // so the modgraph scanner picks up the rewritten imports. + state.packages[item.consumerDepIndex + 1].root = consumerStage; + + // Record the staged secondary as a brand-new dep entry + // under its mangled name, so future encounters of this + // exact (ns, mangled) pair dedup cleanly. The original + // primary entry (it->second) is untouched. + auto stagedManifest = secondaryManifest; + // Give the staged package a distinct atomic identity too; + // authored module names remain independent and are carried + // exclusively by the rename map above. + stagedManifest.package.name = mangledPackage; + if (stagedManifest.package.namespace_.empty()) { + stagedManifest.package.namespace_ = key.ns.empty() + ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; + } + stagedManifest.package.sourceProvenance = std::format( + "index+{}@{}", state.cache_index_name(key.ns), spec.version); + // Absolutize secondary's include_dirs against its original + // install root so the staged copy still finds headers. + for (auto& inc : stagedManifest.buildConfig.includeDirs) { + if (inc.is_relative()) inc = secondaryRoot / inc; + } + for (auto& inc : stagedManifest.buildConfig.includeDirsAfter) { + if (inc.is_relative()) inc = secondaryRoot / inc; + } + + state.dep_manifests.push_back( + std::make_unique(std::move(stagedManifest))); + state.dep_cache_identities.push_back({ + .indexName = state.cache_index_name(key.ns), + .packageName = mangledPackage, + .version = spec.version, + .sourceKind = "version", + }); + const auto depPackageIndex = state.packages.size(); + auto secPackage = makePackageRoot(secStage, *state.dep_manifests.back()); + if (!secPackage) return std::unexpected(secPackage.error()); + state.packages.push_back(std::move(*secPackage)); + recordDependencyEdge(item.consumerDepIndex, depPackageIndex, + spec, item.buildOnly, name); + auto linkFlagsAdded = propagateLinkFlags(secStage, *state.dep_manifests.back()); + + ResolvedKey mangledKey{key.ns, mangledPackage}; + state.resolved[mangledKey] = ResolvedRecord{ + .version = spec.version, + .constraint = item.originalConstraint, + .requestedBy = item.requestedBy, + .source = "version", + .sourceRef = item.originalConstraint.empty() + ? std::string("*") : item.originalConstraint, + // The mangling fallback refuses a main-package + // participant earlier (see the branch's comment + // above), so this record's requester is always a + // dependency. + .fromRoot = false, + .devOnly = item.devOnly, + .depIndex = state.dep_manifests.size() - 1, + .linkFlagsAdded = std::move(linkFlagsAdded), + }; + + mcpp::ui::info("Mangled", + std::format("{} v{} ↔ v{} → {} (cross-major fallback)", + moduleName, it->second.version, spec.version, + mangledModule)); + continue; + } + + // Combine the constraint strings so future merges AND with + // both. Empty originalConstraint means "any" — use "*". + const std::string& addCstr = + item.originalConstraint.empty() ? std::string("*") + : item.originalConstraint; + if (it->second.constraint.empty()) + it->second.constraint = addCstr; + else + it->second.constraint += "," + addCstr; + + if (*merged == it->second.version) { + // The existing pin already satisfies the new constraint — + // no re-fetch needed; just record this consumer edge. + recordDependencyEdge(item.consumerDepIndex, + it->second.depIndex + 1, + spec, item.buildOnly, name); + continue; + } + + // Merged version differs from the previously-pinned one. + // Re-fetch the dep at the merged version and replace the + // earlier slot in dep_manifests / packages so the build plan + // sees only one version. Old include_dir entries are evicted + // and the new manifest's entries are appended. + mcpp::ui::info("Merged", + std::format("{}{}{} {} ⨯ {} → v{}", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.version, spec.version, *merged)); + auto reloaded = state.loadVersionDep(name, key.ns, key.shortName, *merged); + if (!reloaded) return std::unexpected(reloaded.error()); + auto& [newRoot, newManifest] = *reloaded; + + // Name match against the re-loaded manifest. + { + const std::string& expectedShort = + spec.shortName.empty() ? name : spec.shortName; + // Also accept the fully-qualified form (ns.short) since + // synthesize_from_xpkg_lua may set package.name to the + // composite name for backward compat. + auto expectedComposite = spec.namespace_.empty() + ? std::string{} + : std::format("{}.{}", spec.namespace_, expectedShort); + const bool nameOk = + newManifest.package.name == expectedShort + || newManifest.package.name == name + || (!expectedComposite.empty() + && newManifest.package.name == expectedComposite); + if (!nameOk) { + return std::unexpected(std::format( + "dependency '{}' (merged to v{}) resolved to " + "package '{}' (mismatch with declared name '{}')", + name, *merged, newManifest.package.name, + expectedShort)); + } + } + if (newManifest.package.namespace_.empty()) { + newManifest.package.namespace_ = key.ns.empty() + ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; + } + newManifest.package.sourceProvenance = std::format( + "index+{}@{}", state.cache_index_name(key.ns), *merged); + + removeLinkFlags(it->second.linkFlagsAdded); + auto linkFlagsAdded = propagateLinkFlags(newRoot, newManifest); + + // Replace in dep_manifests + packages. depIndex is the slot + // in dep_manifests; packages = [main, dep_0, dep_1, …], so + // packages[depIndex+1] is the same dep. + *state.dep_manifests[it->second.depIndex] = std::move(newManifest); + auto mergedPackage = + makePackageRoot(newRoot, *state.dep_manifests[it->second.depIndex]); + if (!mergedPackage) return std::unexpected(mergedPackage.error()); + state.packages[it->second.depIndex + 1] = std::move(*mergedPackage); + recordDependencyEdge(item.consumerDepIndex, + it->second.depIndex + 1, + spec, item.buildOnly, name); + + it->second.version = *merged; + it->second.linkFlagsAdded = std::move(linkFlagsAdded); + if (it->second.depIndex < state.dep_cache_identities.size()) + state.dep_cache_identities[it->second.depIndex].version = *merged; + + // Walk the *new* manifest's deps so their constraints feed + // future merges. Already-resolved children dedup via the + // resolved map. + const std::string newLabel = std::format("{}{}{}@{}", + key.ns, key.ns.empty() ? "" : ".", + key.shortName, *merged); + for (auto& [child_name, child_spec] : + state.dep_manifests[it->second.depIndex]->dependencies) { + state.worklist.push_back({child_name, child_spec, newLabel, + child_spec.version, + it->second.depIndex, {}, item.devOnly}); + } + continue; + } + // SAME kind, possibly DIFFERENT reference: two `git` declarations + // of different rev/tag/branch, or two `path` declarations of + // different directories. Row 3 of the decision table (`version` + // vs `version` is handled above and never reaches here). Before + // this comparison existed, the second declaration's reference was + // never even read — the record kept no `path`/`gitRev`, so there + // was nothing to compare, and the winner was whichever request + // happened to be dequeued first (the #630 "accident of queue + // order"). + if (sourceKind != "version") { + const std::string incomingRef = + sourceRefOf(sourceKind, spec, item.resolveRoot, item.originalConstraint); + if (incomingRef != it->second.sourceRef) { + const bool existingIsRoot = it->second.fromRoot; + const bool incomingIsRoot = item.consumerDepIndex == kMainConsumer; + if (incomingIsRoot && !existingIsRoot) { + // See the identical comment in the kind-clash branch + // above: unreachable under FIFO seeding, and refused + // by name rather than silently swapped in. + return std::unexpected(std::format( + "internal: dependency '{}{}{}': the root's " + "declaration arrived after '{}' had already " + "resolved it. This is unreachable under " + "first-in-first-out worklist seeding; please " + "report this as an mcpp engine defect.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.requestedBy)); + } + // The already-resolved record wins either way: it is the + // root's (existingIsRoot) or it is simply the first one + // dequeued (neither party is the root). Both are "the + // first requester" in the sense row 3 states — the root + // is dequeued before any transitive request under FIFO + // seeding, so "the root wins" and "the first dequeued + // wins" never disagree about WHICH record already sits in + // `resolved`. + mcpp::diag::warning("dependency/source-override", std::format( + "'{}{}{}' is declared as {} '{}' (by '{}') and as {} " + "'{}' (by '{}'); {} wins.", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + sourceKind, it->second.sourceRef, it->second.requestedBy, + sourceKind, incomingRef, item.requestedBy, + existingIsRoot ? "the root's declaration" + : std::format("'{}', declared first", + it->second.requestedBy)), + std::format("declare '{}{}{}' in the root to choose " + "the other.", + key.ns, key.ns.empty() ? "" : ".", key.shortName)); + } + } + // Same key, same version (or compatible path/git) — already + // processed; still record the dependency edge before skipping. + // Usage propagation is per edge, not per unique package: two + // consumers can need the same dep's public surface even though + // the dep itself is fetched/scanned once. + if (it->second.depIndex + 1 < state.packages.size()) { + recordDependencyEdge(item.consumerDepIndex, + it->second.depIndex + 1, + spec, item.buildOnly, name); + } + continue; + } + + std::filesystem::path dep_root; + + if (spec.isPath()) { + // Path-based: resolve relative to the consumer's root dir. + // For top-level deps this is the project root; for transitive + // deps it's the parent dep's directory (stored in resolveRoot). + dep_root = spec.path; + auto base = item.resolveRoot.empty() ? *state.root : item.resolveRoot; + if (dep_root.is_relative()) dep_root = base / dep_root; + dep_root = std::filesystem::weakly_canonical(dep_root); + } else if (spec.isGit()) { + // Git-based (M4 #5): clone into ~/.mcpp/git// and treat + // as a path dep from there. + // + // Two independent questions, each answered by at most one network + // operation and therefore guarded by exactly one --offline gate: + // + // 1. WHICH COMMIT? `tag`/`rev` name one outright. A `branch` is + // floating: mcpp.lock answers it, else `git ls-remote` does. + // 2. IS IT ON DISK? The commit selects the cache directory, so a + // miss — or a clone parked on the wrong commit — is a clone. + // + // mcpp.lock is authoritative for (1), not a hint that (2) has to + // confirm: a recorded commit is used whether or not the clone + // survived, so evicting ~/.mcpp/git can never quietly move a build + // onto a newer branch tip. `mcpp update ` drops the entry and + // stays the one way a branch advances. + auto mcppHome = mcpp::home::root(); // single resolver (#311) + + const bool remoteIsLocal = is_local_git_remote(spec.git); + auto refuse_offline = [&](std::string_view need, + std::string_view why, + std::string_view verb) { + refusal::record(refusal::Code::OfflineDownloadRequired); + return std::unexpected(std::format( + "offline mode: git dependency '{}' needs {} of '{}'\n" + " {}\n" + " run without --offline (or unset MCPP_OFFLINE) to {} it", + name, need, spec.git, why, verb)); + }; + const bool offline = + !remoteIsLocal && mcpp::platform::env::offline_mode(); + + // ── 1. which commit ── + std::string resolvedGitRev = spec.gitRev; + bool fromLock = false; + if (spec.gitRefKind == "branch") { + auto it = state.gitLockAnchors.find(name); + if (it != state.gitLockAnchors.end() + && it->second.url == spec.git + && it->second.refKind == spec.gitRefKind + && it->second.ref == spec.gitRev + && it->second.resolvedCommit) { + resolvedGitRev = *it->second.resolvedCommit; + fromLock = true; + } else { + if (offline) + return refuse_offline("`git ls-remote`", + std::format("mcpp.lock records no commit for branch " + "'{}'", spec.gitRev), + "resolve"); + // The FIRST network step of a git dependency, and therefore + // the one a transient fault is most likely to meet. + auto r = run_with_network_retry(std::format( + "git ls-remote {} {} 2>&1", + mcpp::platform::shell::quote(spec.git), + mcpp::platform::shell::quote( + std::format("refs/heads/{}", spec.gitRev)))); + if (r.exit_code != 0) + return std::unexpected(std::format( + "git ls-remote of '{}' failed:\n{}", + spec.git, r.output)); + // Cleared first: `operator>>` leaves the target untouched + // when the stream is already at EOF, which would otherwise + // let the declared branch name pass the emptiness check. + resolvedGitRev.clear(); + std::istringstream is(r.output); + is >> resolvedGitRev; + if (resolvedGitRev.empty()) + return std::unexpected(std::format( + "git branch '{}' not found in '{}'", + spec.gitRev, spec.git)); + } + } + + // ── 2. is it on disk ── + // Cache key: hash(url + refkind + declared ref + resolved commit). + // For fixed rev/tag deps the declared ref is also the resolved ref. + // Deterministic across hosts: `std::hash` is not (see the note on + // mcpp::pm::index_package_digest). This key names the git cache + // directory AND the lock hash below, so a host-dependent hash made + // both the cache directory and mcpp.lock differ by platform. + auto H = [](std::string_view s) -> std::string { + return mcpp::toolchain::hash_string(s); + }; + auto gitRoot = mcppHome / "git" / H(spec.git + "|" + spec.gitRefKind + + "|" + spec.gitRev + "|" + resolvedGitRev); + std::error_code ec; + std::filesystem::create_directories(gitRoot.parent_path(), ec); + + // A branch's resolved rev is always a sha by now, so the clone can + // be checked against it — catching one killed between `git clone` + // and `git checkout`, which would otherwise serve the wrong commit + // from a correctly-named directory forever. tag/rev keep their ref + // name as the identity, so there is nothing to compare. + bool cachePresent = std::filesystem::exists(gitRoot / ".git"); + if (cachePresent && spec.gitRefKind == "branch" + && git_cache_head(gitRoot) != resolvedGitRev) { + std::filesystem::remove_all(gitRoot, ec); + cachePresent = false; + } + + // Reported before the clone, not instead of it: when the cache is + // gone this line is the whole explanation for why the build is on + // an older commit than the branch now points at. + if (fromLock) + mcpp::ui::info("Resolved", + std::format("{} (branch = {}) from mcpp.lock", + spec.git, spec.gitRev)); + + if (!cachePresent) { + if (offline) + return refuse_offline("a clone", + std::format("no cached clone at {}", gitRoot.string()), + "fetch"); + mcpp::ui::info("Cloning", + std::format("{} ({} = {})", spec.git, spec.gitRefKind, spec.gitRev)); + // A commit taken from the lock may sit behind the branch tip, + // and a tag/rev may sit anywhere in history — both need full + // history before the checkout. Only a tip just read from + // ls-remote is guaranteed present in a depth-1 clone. + // + // `git -C` rather than `cd &&`: on Windows `cd` does not + // change drive without /d, and the cache root routinely lives + // on a different one than the project. + auto cloneCmd = (spec.gitRefKind == "branch" && !fromLock) + ? std::format( + "git clone --depth 1 --branch {} {} {} && " + "git -C {} checkout --quiet {} 2>&1", + mcpp::platform::shell::quote(spec.gitRev), + mcpp::platform::shell::quote(spec.git), + mcpp::platform::shell::quote(gitRoot.string()), + mcpp::platform::shell::quote(gitRoot.string()), + mcpp::platform::shell::quote(resolvedGitRev)) + : std::format( + "git clone {} {} && git -C {} checkout --quiet {} 2>&1", + mcpp::platform::shell::quote(spec.git), + mcpp::platform::shell::quote(gitRoot.string()), + mcpp::platform::shell::quote(gitRoot.string()), + mcpp::platform::shell::quote(resolvedGitRev)); + // See `run_with_network_retry` for why, and for what the + // callback is removing between attempts. + auto r = run_with_network_retry(cloneCmd, [&] { + std::filesystem::remove_all(gitRoot, ec); + }); + if (r.exit_code != 0) { + std::filesystem::remove_all(gitRoot, ec); + return std::unexpected(std::format( + "git clone of '{}' failed:\n{}", spec.git, r.output)); + } + } + if (item.consumerDepIndex == kMainConsumer) { + // Only root deps are locked: the writer below walks the root + // manifest's [dependencies], so a transitive git branch dep + // has no anchor and still resolves over the network. + auto source = std::format("git+{}#{}={}", + spec.git, spec.gitRefKind, spec.gitRev); + if (spec.gitRefKind == "branch") source += "@" + resolvedGitRev; + state.root_git_lock_identities[name] = GitLockIdentity{ + .source = std::move(source), + .hash = "fnv1a:" + H(spec.git + "|" + + spec.gitRefKind + "|" + spec.gitRev + "|" + + resolvedGitRev), + }; + } + sourceCommit = resolvedGitRev; + dep_root = gitRoot; + state.gitCloneBySource.try_emplace( + sourceRefOf("git", spec, item.resolveRoot, item.originalConstraint), + GitClone{ gitRoot, spec.git, spec.gitRefKind, spec.gitRev }); + if (auto member = state.gitMemberDeclaring(gitRoot, key)) { + gitMember = *member; + gitMemberCloneRoot = gitRoot; + dep_root = gitRoot / *member; + } + } + // (version-source: dep_root + manifest are loaded together via + // loadVersionDep below since the index entry drives both.) + + // Manifest acquisition. + // - Path/git dep: dep_root is the source tree, mcpp.toml at root. + // - Version dep: delegate to loadVersionDep — the index entry's + // `mcpp` field decides where mcpp.toml lives (StringPath / + // TableBody / default lookup). + std::optional dep_manifest; + if (spec.isPath() || spec.isGit()) { + if (!std::filesystem::exists(dep_root / "mcpp.toml")) { + return std::unexpected(std::format( + "{} dependency '{}' (at '{}') has no mcpp.toml", + spec.isGit() ? "git" : "path", name, dep_root.string())); + } + // A MEMBER IS A MEMBER HOWEVER IT IS REACHED. + // + // A workspace member that omits `package.version` because + // `[workspace.package]` supplies it is legal — and it is reached + // here as a sibling's `path` dependency, which is the ordinary + // shape rather than an exotic one. Loading it as an anonymous path + // dependency would refuse it for a field the workspace does + // provide, and the message would name the member's manifest rather + // than the table that answers. + // + // `is_workspace_member` asks the workspace's own `members` list, so + // a vendored copy or an example living inside the tree is still + // refused for a missing version, exactly as before. + const bool depIsMember = + state.wsManifest && !state.runtimeWorkspaceRoot.empty() + && mcpp::project::is_workspace_member( + *state.wsManifest, state.runtimeWorkspaceRoot, dep_root); + auto dm = mcpp::manifest::load( + dep_root / "mcpp.toml", + {.insideWorkspace = depIsMember || !gitMember.empty()}); + if (!dm) { + return std::unexpected(std::format( + "dependency '{}' (at '{}'): {}", + name, dep_root.string(), dm.error().format())); + } + dep_manifest = std::move(*dm); + // A member reached as a dependency inherits here, at its load + // site; see `inherit_as_workspace_member`. A member of a + // git-hosted workspace inherits from ITS repository, anchored at + // the clone. + auto inheritAsMember = [&](const mcpp::manifest::Manifest& ws, + const std::filesystem::path& wsRoot) { + return inherit_as_workspace_member(*dep_manifest, ws, wsRoot, dep_root); + }; + if (depIsMember) { + if (auto bad = inheritAsMember(*state.wsManifest, state.runtimeWorkspaceRoot)) + return std::unexpected(*bad); + } else if (!gitMember.empty()) { + if (auto rm = mcpp::manifest::load(gitMemberCloneRoot / "mcpp.toml")) { + if (auto bad = inheritAsMember(*rm, gitMemberCloneRoot)) + 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 + // at all: their `[target.'cfg(...)'.build] sources` were parsed + // into `conditionalConfigs` but never folded into + // `buildConfig.sources` / `modules.sources`, so the modgraph scan + // never saw the file — link-time `undefined reference`. Must run + // BEFORE `propagateLinkFlags`/`makePackageRoot` below, which + // snapshot this manifest's flags/sources into `packages[]`. + if (!dep_manifest->conditionalConfigs.empty()) { + merge_conditional_config(*dep_manifest, + state.cfgCtx()); + } + report_flag_words_changes(*dep_manifest); + fold_build_defines_into_flags(dep_manifest->buildConfig); + // The root's `abi.threads` reaches this dependency's C translation + // units here, as it does for a version dependency. + if (state.abiThreadsRendered) state.add_once(dep_manifest->buildConfig.cflags, "-pthread"); + } else { + auto loaded = state.loadVersionDep(name, key.ns, key.shortName, spec.version); + if (!loaded) return std::unexpected(loaded.error()); + dep_root = std::move(loaded->first); + dep_manifest = std::move(loaded->second); + } + + // Name match via compat::resolve_package_name — handles both + // canonical (explicit namespace field) and legacy (dotted name) + // forms transparently. + { + auto resolved = mcpp::pm::compat::resolve_package_name( + dep_manifest->package.name, dep_manifest->package.namespace_); + const std::string& expectedShort = + spec.shortName.empty() ? name : spec.shortName; + const bool nameOk = + resolved.shortName == expectedShort + || dep_manifest->package.name == expectedShort + || dep_manifest->package.name == + mcpp::pm::compat::qualified_name(spec.namespace_, expectedShort); + if (!nameOk) { + return std::unexpected(std::format( + "dependency '{}' resolved to package '{}' (mismatch with declared name '{}')", + name, dep_manifest->package.name, expectedShort)); + } + } + + // The identity a `path` or `git` manifest declares is the package's, + // whatever key reached it (#634, A2). Before this, only the short name + // was compared, so `fw` reaching a manifest that declares `huxdemo.fw` + // resolved as `mcpplibs.fw` while every reader that builds a name from + // the manifest saw `huxdemo.fw`, and a second edge written + // `huxdemo.fw` put the same sources into the build twice. + const bool namespaceDeclared = !dep_manifest->package.namespace_.empty(); + const std::string manifestPath = sourceKind == "version" + ? std::string{} + : (dep_root / "mcpp.toml").lexically_normal().generic_string(); + if (sourceKind != "version" && namespaceDeclared) { + auto declaredName = mcpp::pm::compat::resolve_package_name( + dep_manifest->package.name, dep_manifest->package.namespace_); + ResolvedKey declared{ dep_manifest->package.namespace_, + declaredName.shortName }; + if (!(declared == key)) { + state.reportAdoption(item.requestedBy, name, key, declared, manifestPath); + state.stateAdoptedIdentity(item, declared); + if (state.resolved.contains(declared)) { + // Another source already resolved the declared identity, + // and the rules for two declarations of one identity + // decide (the #630 decision table, at the resolved-record + // hit above). The edge is queued again stating that + // identity, which sends it there. + item.spec.namespace_ = declared.ns; + item.spec.shortName = declared.shortName; + item.spec.candidates = {{ .namespace_ = declared.ns, + .shortName = declared.shortName }}; + item.spec.namespaceOmitted = false; + item.spec.legacyCandidateSearch = false; + item.spec.legacyDottedKey = false; + state.worklist.push_front(std::move(item)); + continue; + } + key = declared; + } + } + + // Stamp the identity with the resolver's exact coordinate and source. + // A descriptor that omitted namespace inherits the coordinate that + // answered it; otherwise two indices containing the same short name + // collapse in runtime provenance even though resolution distinguished + // them correctly. + if (dep_manifest->package.namespace_.empty()) { + dep_manifest->package.namespace_ = key.ns.empty() + ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; + } + if (sourceKind == "version") { + dep_manifest->package.sourceProvenance = std::format( + "index+{}@{}", state.cache_index_name(key.ns), spec.version); + } else if (sourceKind == "git") { + dep_manifest->package.sourceProvenance = std::format( + "git+{}#{}={}", spec.git, spec.gitRefKind, spec.gitRev); + } else { + dep_manifest->package.sourceProvenance = + "path+" + dep_root.lexically_normal().generic_string(); + } + + // Stage 2a: merge this dependency's active feature-deps into its own + // dependency set before its children are pushed, so a dep's feature can + // transitively pull a provider. `spec.features` = features the consumer + // requested for this dep. + if (auto fm = mergeActiveFeatureDeps(*dep_manifest, spec.features, + spec.defaultFeatures); !fm) + return std::unexpected(fm.error()); + + // A PACKAGE OF PROGRAMS HAS NOTHING TO LINK (#649 E6). Its tools are + // built by the tool sub-build, which resolves the package as its own + // root; in this graph it is a provider of tools and of its directory, + // and nothing more. Walking its dependencies here put a tool's own + // library into the application's link (a tool depending on `z` gave the + // 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 = state.isProgramOnlyPackage(*dep_manifest) + && spec.artifacts.empty(); + auto linkFlagsAdded = depProgramOnly + ? std::vector{} + : propagateLinkFlags(dep_root, *dep_manifest); + + // Move the manifest into stable storage so we can later look it up + // by depIndex (the SemVer merger needs to overwrite the slot). + state.dep_manifests.push_back( + std::make_unique(std::move(*dep_manifest))); + state.dep_cache_identities.push_back({ + .indexName = state.cache_index_name(key.ns), + .packageName = name, + .version = sourceKind == "version" + ? spec.version + : state.dep_manifests.back()->package.version, + .sourceKind = sourceKind, + .sourceRef = sourceKind == "git" ? sourceCommit + : sourceKind == "path" ? dep_root.string() + : std::string{}, + }); + const auto depPackageIndex = state.packages.size(); + auto depPackage = makePackageRoot(dep_root, *state.dep_manifests.back()); + if (!depPackage) return std::unexpected(depPackage.error()); + state.packages.push_back(std::move(*depPackage)); + recordDependencyEdge(item.consumerDepIndex, depPackageIndex, spec, + item.buildOnly, name); + + // Record this dep as resolved so future encounters of the same + // (ns, name) hit the fast path (skip / merge / conflict). + if (sourceKind != "version") { + state.identityBySource.emplace( + sourceRefOf(sourceKind, spec, item.resolveRoot, item.originalConstraint) + + (gitMember.empty() ? std::string{} : "#member=" + gitMember), + key); + state.declaringManifest[key] = DeclaringManifest{ manifestPath, namespaceDeclared }; + } + state.resolved[key] = ResolvedRecord{ + .version = sourceKind == "version" ? spec.version : "", + .constraint = sourceKind == "version" ? item.originalConstraint : "", + .requestedBy = item.requestedBy, + .source = sourceKind, + .sourceRef = sourceRefOf(sourceKind, spec, item.resolveRoot, + item.originalConstraint), + .fromRoot = item.consumerDepIndex == kMainConsumer, + .devOnly = item.devOnly, + .depIndex = state.dep_manifests.size() - 1, + .linkFlagsAdded = std::move(linkFlagsAdded), + }; + + // Recurse: the dep's own [dependencies] become new worklist items. + // dev-dependencies are intentionally NOT walked — those are + // private to the dep's test runs, not part of its public ABI. + // A package of programs is not walked at all; see `depProgramOnly`. + if (depProgramOnly) continue; + const std::string thisDepLabel = std::format( + "{}{}{}@{}", + key.ns, + key.ns.empty() ? "" : ".", + key.shortName, + sourceKind == "version" ? spec.version : sourceKind); + const std::size_t selfIdx = state.dep_manifests.size() - 1; + // #243: forward this dep's active features to ITS children before they + // are pushed (transitive dep->dep forwarding rides the BFS forward + // edge). Uses the SAME closure inputs as mergeActiveFeatureDeps above + // (this edge's spec.features, already carrying any forward injected by + // this dep's own consumer, + defaultFeatures), so activation agrees + // with resolution. + auto depActive = feature_closure(*state.dep_manifests.back(), spec.features, + spec.defaultFeatures); + if (auto fe = validateForwards(*state.dep_manifests.back(), depActive, + state.dep_manifests.back()->package.name); !fe) + return std::unexpected(fe.error()); + for (auto& [child_name, child_spec] : state.dep_manifests.back()->dependencies) { + auto childReq = child_spec; + injectForwards(*state.dep_manifests.back(), depActive, child_name, childReq); + state.worklist.push_back({child_name, childReq, thisDepLabel, + childReq.version, selfIdx, dep_root, + item.devOnly, item.buildOnly}); + } + // A dependency's own `[build-dependencies]` — the only channel through + // which a package can speak about what IT needs at build time. Both + // live channels (`tools`, `host-module`) are written by the CONSUMER + // on an edge, so before this a build rule could not request anything + // on its own behalf. That, and not a design decision, is why a rule + // was a leaf. + // + // These are build-only regardless of how this package was reached: a + // library's build dependency has no business in its consumer's binary + // either. + for (auto& [child_name, child_spec] : + state.dep_manifests.back()->buildDependencies) { + auto childReq = child_spec; + injectForwards(*state.dep_manifests.back(), depActive, child_name, childReq); + state.worklist.push_back({child_name, childReq, + thisDepLabel + " (build-dep)", + childReq.version, selfIdx, dep_root, + item.devOnly, /*buildOnly=*/true}); + } + } + + // ONE PLACE DETECTS A CYCLE OF PACKAGES, AND IT IS HERE, WHERE THE GRAPH + // IS RESOLVED (#649 E6). The build-cache key walk was the only reader that + // noticed, and it runs for the global cache only, so the same manifest was + // refused by default and built under `--cache=local`. Every edge counts, + // build-only ones included, as the key walk counts them. + { + // Named visitState, not state: `state` is this function's PrepareState + // parameter, which the loop below also reads. + std::vector visitState(state.packages.size(), 0); // 0 new / 1 on stack / 2 done + std::vector stack, cycle; + auto visit = [&](auto&& self, std::size_t u) -> bool { + visitState[u] = 1; + stack.push_back(u); + for (auto const& e : state.dependencyEdges) { + if (e.consumerPackageIndex != u) continue; + const auto v = e.dependencyPackageIndex; + if (v >= state.packages.size()) continue; + if (visitState[v] == 1) { + cycle.assign(std::ranges::find(stack, v), stack.end()); + cycle.push_back(v); + return true; + } + if (visitState[v] == 0 && self(self, v)) return true; + } + stack.pop_back(); + visitState[u] = 2; + return false; + }; + for (std::size_t i = 0; i < state.packages.size() && cycle.empty(); ++i) + if (visitState[i] == 0) (void)visit(visit, i); + if (!cycle.empty()) { + std::string path; + for (auto p : cycle) { + if (!path.empty()) path += " -> "; + path += std::format("'{}'", + mcpp::build::qualified_package_name(state.packages[p].manifest)); + } + refusal::record(refusal::Code::PackageCycle); + return std::unexpected(std::format( + "dependency cycle: {}.\n" + " A package cannot reach itself through its own dependencies.\n" + " fix: remove one of these edges. A program that depends on the " + "package requesting it builds without a cycle when its package " + "declares only `kind = \"bin\"` targets: it is then built by its " + "own tool sub-build.", path)); + } + } + + state.computeUsageRequirements(); + + return {}; +} + + +} // namespace mcpp::build diff --git a/src/build/prepare/graph_load.cpp b/src/build/prepare/graph_load.cpp new file mode 100644 index 000000000..59fb4ea80 --- /dev/null +++ b/src/build/prepare/graph_load.cpp @@ -0,0 +1,1114 @@ +// graph_load.cpp -- P4a: loading one dependency (git, path or index +// version) into the graph; the worklist in graph.cpp calls it. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.xlings.address_set; +import mcpp.build.version_floor; +import mcpp.platform.axis; +import mcpp.log; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.modgraph.glob; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.modgraph.validate; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.config; +import mcpp.xlings; +import mcpp.platform; +import mcpp.fetcher; +import mcpp.fetcher.progress; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.compat; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.ui; +import mcpp.log; +import mcpp.fallback.install_integrity; +import mcpp.project; + +namespace mcpp::build { + +std::expected phase4a_graph_load(PrepareState& state) { + // The features each package ends up built with, index-aligned with + // `packages`. Recorded at activation because the passes that run after it + // — `[feature-xlings]` provisioning among them — otherwise have no way to + // ask, and re-deriving it there would be a second copy of the aggregation + // rule. + + // WHICH VERSION OF EACH TOOL PACKAGE THIS BUILD USES, decided once. + // + // Keyed by `(namespace, name)` — the identity, with the version treated as + // a constraint on it. Filled by the first `graph_xlings_split()` below and + // read by `fillXpkgDirs`, because those two answer the same question from + // different ends: one decides what is installed, the other tells a build + // program where it landed. They used to derive it separately, and the + // failure that produced is the quiet one — installed A, answered B. + + // The split is computed HERE and reused by the late pass, so the two + // cannot disagree about what "the graph declared" means. + // + // ONE PACKAGE, ONE VERSION. This used to compare whole address strings, so + // `xim:cuda-nvcc@13.3.33` from the project and `xim:cuda-nvcc@>=12.9.86` + // from a rule package were two packages: both installed, gigabytes each, + // and `xpkg_dir` answered one of them. The unification below adjudicates + // (the declaration nearer the artifact wins) and validates (the winner must + // satisfy every requirement that lost) — see mcpp.xlings.address_set. + state.graph_xlings_split = [&]() -> std::expected< + std::pair, std::vector>, + std::string> { + namespace addrset = mcpp::xlings::addrset; + auto describe = [&](std::size_t i) { + auto const& pkg = state.packages[i].manifest.package; + return pkg.namespace_.empty() ? pkg.name + : pkg.namespace_ + ":" + pkg.name; + }; + std::vector claims; + for (auto const& spec : applicable_xlings_addresses( + *state.runtimeOwnerManifest, state.activeFeaturesByPackage.empty() + ? std::vector{} : state.activeFeaturesByPackage[0], + state.toolPurpose, /*isRoot=*/true)) + claims.push_back({spec, "this project", 0}); + // THE BUCKET IS DECIDED BY WHERE THE WINNING CLAIM SITS IN THIS LIST, + // not by its distance. The root's own pass provisions exactly the + // addresses collected above; anything else has to reach the graph pass + // or nothing installs it. Those two lists are the same one whenever the + // project is its own runtime owner, and differ under a workspace. + const std::size_t rootClaims = claims.size(); + for (std::size_t i = 0; i < state.packages.size(); ++i) { + const auto& man = state.packages[i].manifest; + const auto feats = i < state.activeFeaturesByPackage.size() + ? state.activeFeaturesByPackage[i] : std::vector{}; + for (auto const& spec : applicable_xlings_addresses( + man, feats, state.toolPurpose, /*isRoot=*/i == 0)) + claims.push_back({spec, describe(i), i == 0 ? 0 : 1}); + } + auto unified = addrset::unify(claims); + if (!unified) return std::unexpected(unified.error()); + std::vector rootSpecs, fromGraph; + for (auto const& w : unified->winners) { + (w.claim < rootClaims ? rootSpecs : fromGraph).push_back(w.address); + state.xlingsWinner[addrset::package_key(w.address)] = w.address; + } + // REPORTED, NOT INFERRED. An override that is only visible as "two + // versions were declared and one directory exists" is a fact the reader + // has to reconstruct from the filesystem. + // + // This lambda runs twice per build (the early pass and the late one), + // and the reader sees each note ONCE: `mcpp::diag` deduplicates by the + // whole payload, which is a designed property rather than an accident + // of where these two calls sit. + for (auto const& note : unified->overrides) + mcpp::diag::warning("xlings/version-override", note); + return std::pair{std::move(rootSpecs), std::move(fromGraph)}; + }; + state.packages.push_back({*state.root, *state.m}); + + // dep_manifests is kept around purely so the build plan can move it + // out at the end (PackageRoot stores a `Manifest` by value, so the + // unique_ptr is not load-bearing for liveness — it's a leftover from + // an earlier design and harmless). + state.cache_index_name = [](std::string_view ns) { + if (ns.empty()) return std::string(mcpp::pm::kDefaultNamespace); + return std::string(ns); + }; + + + // Sentinel for "the consumer is the main package" (no dep_manifests entry). + + // #634, A2. A `path` or `git` dependency's identity is the one its manifest + // declares (SPEC-001 §1.2), and the key a consumer wrote is one way of + // reaching it. `identityBySource` maps a canonical source (the directory, + // or the repository and reference) to the identity resolved from it, so a + // second key over the same source finds that record without loading the + // manifest again. `declaringManifest` holds the manifest each such + // identity came from, and records whether that manifest named its + // namespace: one that does not takes the key's, so two keys over it would + // be two identities over one source. + // A GIT DEPENDENCY NAMES A REPOSITORY, AND THE KEY NAMES WHICH PACKAGE OF + // IT (#649 E7). The root manifest's package is one; each `[workspace] + // members` entry of that manifest is another. Before this a git source + // always yielded the root package, so a repository holding a framework and + // its tools could be pinned by revision for the framework only. The clone + // of every git source resolved so far is kept with the reference it was + // resolved from, so a later key over the same source, and a member's + // `path` edge that stays inside the clone, find it. + // The member of the repository at `cloneRoot` whose manifest declares + // `want`, when the root manifest's package is not `want` itself. + state.gitMemberDeclaring = [&](const std::filesystem::path& cloneRoot, + const ResolvedKey& want) + -> std::optional { + auto declares = [&](const mcpp::manifest::Manifest& mm) { + auto rn = mcpp::pm::compat::resolve_package_name( + mm.package.name, mm.package.namespace_); + // A manifest that names no namespace takes the key's, as a path + // dependency's does (#634 A2), so only the short name is compared. + const bool nsDeclared = !mm.package.namespace_.empty() || rn.usedLegacySplit; + return rn.shortName == want.shortName + && (!nsDeclared || rn.namespace_ == want.ns); + }; + std::error_code ec; + if (!std::filesystem::exists(cloneRoot / "mcpp.toml", ec)) return std::nullopt; + auto rootManifest = mcpp::manifest::load(cloneRoot / "mcpp.toml"); + if (!rootManifest || declares(*rootManifest) + || !rootManifest->workspace.present) + return std::nullopt; + for (auto const& member : rootManifest->workspace.members) { + const auto path = cloneRoot / member / "mcpp.toml"; + if (!std::filesystem::exists(path, ec)) continue; + auto mm = mcpp::manifest::load(path, {.insideWorkspace = true}); + if (mm && declares(*mm)) + return std::filesystem::path(member).lexically_normal().generic_string(); + } + return std::nullopt; + }; + state.qualifiedKey = [](const ResolvedKey& k) { + return k.ns.empty() ? k.shortName : std::format("{}.{}", k.ns, k.shortName); + }; + // A root edge that adopted an identity states it on the root's own + // declaration too, which is what every later reader of the root manifest + // (the build banner, the resolution record) sees. + state.stateAdoptedIdentity = [&](const WorkItem& item, const ResolvedKey& declared) { + if (item.consumerDepIndex != kMainConsumer) return; + if (auto it = state.m->dependencies.find(item.name); it != state.m->dependencies.end()) { + it->second.namespace_ = declared.ns; + it->second.shortName = declared.shortName; + } + }; + // One warning per declaring edge: each names a line someone can correct. + state.reportAdoption = [&](const std::string& requestedBy, const std::string& written, + const ResolvedKey& normalised, const ResolvedKey& declared, + const std::string& manifestPath) { + if (!state.adoptionsReported.emplace(requestedBy, written).second) return; + mcpp::diag::warning("dependency/identity", std::format( + "'{}' declares the dependency '{}', which names {}; the manifest " + "'{}' declares {}, and that identity is used.", + requestedBy, written, state.qualifiedKey(normalised), manifestPath, + state.qualifiedKey(declared)), + std::format("write '{}' in '{}' to state the identity the " + "manifest declares.", + state.qualifiedKey(declared), requestedBy)); + }; + + + // Index routing — WHICH index answers for a namespace and how its + // descriptors are read — lives in mcpp.pm.index_route, shared with the + // `mcpp add` existence gate so the two cannot disagree about which + // packages are real (#305/#307). `cfg` is filled in per call: the route is + // rebuilt on demand because `root` moves when a workspace member is + // selected above. + state.index_route = [&](mcpp::config::GlobalConfig* cfg = nullptr) { + return mcpp::pm::IndexRoute{ &state.m->indices, *state.root, cfg }; + }; + state.findIndexForNs = [&](const std::string& ns) + -> const mcpp::pm::IndexSpec* + { + return state.index_route(nullptr).find_for_ns(ns); + }; + + // SemVer constraint resolver, shared across the worklist so transitive + // deps with caret/range constraints (`^1.0`) also get pinned to a + // concrete version before fetch. + state.resolveSemver = [&](mcpp::manifest::DependencySpec& s, + const std::string& depName) + -> std::expected + { + if (s.isPath() || s.isGit()) return {}; + if (!mcpp::pm::is_version_constraint(s.version)) return {}; + auto cfg = state.get_cfg(true); + if (!cfg) return std::unexpected(cfg.error()); + // 0.0.10+: use structured namespace from DependencySpec. The route (not + // a bare Fetcher) is what reaches a descriptor served by a project + // `[indices]` entry — see #308. + auto resolved = mcpp::pm::resolve_semver( + s.namespace_, s.shortName.empty() ? depName : s.shortName, + s.version, state.index_route(*cfg), *state.targetPlatform); + if (!resolved) return std::unexpected(resolved.error()); + mcpp::ui::info("Resolved", + std::format("{} {} → v{}", depName, s.version, *resolved)); + s.version = std::move(*resolved); + return {}; + }; + + // Acquire a version-source dep at a specific pinned version. Used both + // by the first-time walk and by the SemVer merger when a re-fetch at a + // different version is needed. Returns the dep's effective root (where + // mcpp.toml lives) and a fully loaded manifest. + using LoadedDep = std::pair; + // Identity-first candidate probe. A candidate is DISAMBIGUATED by the + // DECLARED (namespace, name) of whatever descriptor the index holds — never + // by whether a canonically-named file `..lua` happens to exist on + // disk. It routes through the same identity-verified readers the load path + // uses (`read_xpkg_lua*`, which gate every hit on the descriptor's declared + // identity and already cover non-canonical filenames), so candidate selection + // and loading can never disagree about what a candidate resolves to. + // + // SCOPE (#278, do not over-read the paragraph above): identity governs which + // hits are ACCEPTED, not which files are REACHED. Discovery is still bounded + // by the candidate-filename list from `compat::xpkg_lua_candidates` — there + // is no index-wide scan of `pkgs/*/*.lua` anywhere in mcpp, so a descriptor + // whose filename matches none of the candidates is simply not found. The + // `IdentityIndex` that would lift that bound was deferred with §5 of the + // 2026-06-26 design and is deliberately NOT being added: see + // .agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md + // §3.2/§4.2 for why bare-name discovery across arbitrary namespaces is a + // reproducibility hazard rather than a convenience. + // + // Before this, selection probed the canonical filename only, so a descriptor + // filed under a non-canonical name (e.g. `aimol.tensorvia-cpu` declared in the + // mcpplibs index as bare `pkgs/t/tensorvia-cpu.lua`) was invisible to its own + // peer-root candidate `(aimol, tensorvia-cpu)`, leaving the request pinned to + // the wrong front candidate `(mcpplibs.aimol, …)`. See + // .agents/docs/2026-06-26-identity-first-resolution-no-filename.md. + state.readStrictLuaForCandidate = + [&](const mcpp::pm::DependencyCoordinate& coord) + -> std::optional + { + auto cfg = state.get_cfg(true); + if (!cfg) return std::nullopt; + return state.index_route(*cfg).read(coord); + }; + + state.xpkgLuaMatchesCandidate = + [&](const mcpp::pm::DependencyCoordinate& coord, + std::string_view luaContent, + bool allowLegacyBareDefault) { + // Single source of truth: the descriptor identity gate lives in + // mcpp.manifest and is shared with the read_xpkg_lua family. A + // descriptor served by a declared project index inherits that + // index's namespace when package.namespace is omitted; preserve + // the same owner context during this second, stricter check. + const auto route = state.index_route(nullptr); + const auto* owner = route.find_for_ns(coord.namespace_); + const std::string_view ownerNs = owner + ? std::string_view{owner->name} : std::string_view{}; + return mcpp::manifest::xpkg_lua_identity_matches( + luaContent, coord.namespace_, coord.shortName, + allowLegacyBareDefault, ownerNs); + }; + + state.dependencyCoordinates = + [](const mcpp::manifest::DependencySpec& spec, + const std::string& depName) { + if (!spec.candidates.empty()) return spec.candidates; + std::vector out; + out.push_back({ + .namespace_ = spec.namespace_.empty() + ? std::string(mcpp::pm::kDefaultNamespace) + : spec.namespace_, + .shortName = spec.shortName.empty() ? depName : spec.shortName, + }); + return out; + }; + + + state.selectDependencyCandidate = + [&](mcpp::manifest::DependencySpec& spec, + const std::string& depName) -> std::expected + { + auto candidates = state.dependencyCoordinates(spec, depName); + if (candidates.empty()) { + return std::unexpected( + with_index_cause(std::format( + "dependency '{}' has no lookup candidates", depName))); + } + + // One release train of migration support for the former dotted + // candidate search. A lockfile records the identity an existing + // project already selected, so keep that identity stable until the + // user rewrites the selector explicitly. Without a lock anchor, never + // fall back: only diagnose a valid old-primary package and continue + // with the new exact coordinate. + if (spec.legacyCandidateSearch) { + const auto exact = candidates.front(); + bool lockExpressesIntent = false; + if (auto locked = state.packageIdentityLockAnchors.find(depName); + locked != state.packageIdentityLockAnchors.end()) { + lockExpressesIntent = true; + if (locked->second != exact.namespace_) { + mcpp::pm::DependencyCoordinate lockedCoordinate{ + .namespace_ = locked->second, + .shortName = exact.shortName, + }; + if (state.selectorMigrationWarnings.insert(depName).second) { + mcpp::ui::warning(std::format( + "dependency selector '{}' now means exact package " + "'{}', but mcpp.lock records '{}'; keeping the " + "locked identity for this migration release. " + "Write '{}' to keep it explicitly, or remove the " + "lock and keep '{}' to migrate", + depName, + mcpp::pm::format_package_selector(exact), + mcpp::pm::format_package_selector(lockedCoordinate), + mcpp::pm::format_package_selector(lockedCoordinate), + mcpp::pm::format_package_selector(exact))); + } + candidates.assign(1, std::move(lockedCoordinate)); + } + } + + if (!lockExpressesIntent && spec.isVersion()) { + if (auto old = mcpp::pm::legacy_prefixed_coordinate(exact)) { + auto oldLua = state.readStrictLuaForCandidate(*old); + if (oldLua && state.xpkgLuaMatchesCandidate( + *old, *oldLua, + /*allowLegacyBareDefault=*/false) + && state.selectorMigrationWarnings.insert(depName).second) { + mcpp::ui::warning(std::format( + "dependency selector '{}' now resolves exactly to " + "'{}'; an older mcpp would select the existing " + "package '{}'. Write '{}' to keep the old identity " + "or keep '{}' for the new exact identity", + depName, + mcpp::pm::format_package_selector(exact), + mcpp::pm::format_package_selector(*old), + mcpp::pm::format_package_selector(*old), + mcpp::pm::format_package_selector(exact))); + } + } + } + } + + auto selected = candidates.front(); + bool matched = false; + if (spec.isVersion()) { + for (auto& candidate : candidates) { + auto lua = state.readStrictLuaForCandidate(candidate); + if (!lua) continue; + if (auto violation = mcpp::manifest:: + xpkg_name_form_violation_from_lua(*lua)) { + return std::unexpected(std::format( + "dependency '{}': {}", depName, *violation)); + } + if (!state.xpkgLuaMatchesCandidate( + candidate, *lua, /*allowLegacyBareDefault=*/false)) { + continue; + } + + // INV-RESOLVE (#278) — the discovery rung `(∅, name)` is the + // "upstream package that declares no namespace" rung, NOT a + // cross-namespace wildcard. The identity gate is intentionally + // permissive here (`ns.empty() → name match is enough`, because + // `mcpp new --template X` legitimately discovers by short name), + // so the narrowing lives at THIS call site rather than in the + // gate — tightening the gate would break template discovery. + // + // Rejecting the hit keeps a third-party-namespaced package from + // being reachable by a bare name: resolution must not depend on + // which indices happen to be present, or adding an index could + // silently retarget an existing dependency (design §3.2). + auto declaredNs = + mcpp::manifest::extract_xpkg_namespace(*lua); + if (candidate.namespace_.empty() && !declaredNs.empty()) { + continue; + } + + // P3 (#278) — resolve the discovery rung to a REAL identity + // before anything downstream sees it. `selected.namespace_` + // used to be the CANDIDATE's namespace, so a discovery hit + // wrote an empty namespace into the spec and on into the + // lockfile and install layer. Read the DECLARED one instead. + // + // An empty `declaredNs` is a legal identity here, not a hole to + // fill: an upstream package with no `namespace` (xim `opencv`, + // `musl-gcc`) is keyed by its bare name, and the derived + // fqname == shortName is exactly right for it. Attributing such + // a descriptor to its owning index (`xim-pkgindex → xim`) is + // §4.1 of the 2026-06-26 design and is still unimplemented. + selected = candidate; + if (selected.namespace_.empty()) selected.namespace_ = declaredNs; + matched = true; + break; + } + + // One-release bare-name migration. Namespace omission means exactly + // `mcpplibs`, but every published `compat.*` package and every user + // manifest written before this release spells the dependency bare — + // `gtest = "1.15.2"`, `ftxui = "6.1.9"`. Making that an immediate + // hard error means an mcpp upgrade breaks builds against data that + // is already published and cannot be edited retroactively; the + // symmetric rule ("published data must not break the program") is + // why the index floor degrades instead of bricking. + // + // The defect #278 removed was the SILENCE, not the reach: mcpp used + // to continue with a namespace the user never wrote and never say + // so. A hit here is announced, is recorded downstream under its + // canonical identity, and names the exact edit that removes the + // warning. Only a selector whose namespace was OMITTED is eligible — + // `mcpplibs.gtest` states an identity and must still miss. + if (!matched && spec.isVersion() && spec.namespaceOmitted) { + for (auto& legacy : + mcpp::pm::legacy_bare_candidates(candidates.front())) { + auto lua = state.readStrictLuaForCandidate(legacy); + if (!lua) continue; + if (mcpp::manifest::xpkg_name_form_violation_from_lua(*lua)) + continue; + if (!state.xpkgLuaMatchesCandidate( + legacy, *lua, /*allowLegacyBareDefault=*/false)) + continue; + auto declaredNs = + mcpp::manifest::extract_xpkg_namespace(*lua); + // Same narrowing as the exact loop: the namespace-less rung + // is "upstream package that declares no namespace", not a + // cross-namespace wildcard. + if (legacy.namespace_.empty() && !declaredNs.empty()) + continue; + + selected = legacy; + if (selected.namespace_.empty()) + selected.namespace_ = declaredNs; + matched = true; + // Downstream — lock, install, cache label — must see the + // canonical identity, so the ambiguous spelling survives in + // exactly one place: the user's manifest, until they edit it. + candidates.assign(1, selected); + + if (state.selectorMigrationWarnings.insert(depName).second) { + mcpp::ui::warning(std::format( + "dependency '{}' resolved to '{}' through the " + "deprecated bare-name search; namespace omission " + "means `{}` only. Write the exact package:" + "\n [dependencies.{}]" + "\n {} = \"{}\"" + "\n (or run `mcpp add {}@{}`). This fallback is " + "removed in {}.", + depName, + mcpp::pm::format_package_selector(selected), + mcpp::pm::kDefaultNamespace, + selected.namespace_, selected.shortName, + spec.version, + mcpp::pm::format_package_selector(selected), + spec.version, + mcpp::pm::kBareNameFallbackRemovedIn)); + } + break; + } + } + + // A custom GIT index is cloned lazily by xlings during install, so + // at selection time its descriptors may legitimately not be on disk + // yet. "Not found" is therefore not conclusive for those namespaces + // — keep the historical fall-through rather than hard-failing on a + // package that would have materialized a moment later. Local path + // indices and the builtin index are both readable here, so they stay + // under the strict rule below. + bool anyLazyGitIndex = std::ranges::any_of(candidates, + [&](const mcpp::pm::DependencyCoordinate& c) { + return state.index_route(nullptr).lazy_git(c.namespace_); + }); + + // An exact coordinate that a readable index cannot serve fails at + // resolution. Never carry it into install-time compatibility + // retries, which would reintroduce cross-namespace guessing. + if (!matched && !anyLazyGitIndex) { + std::string tried; + for (auto& c : candidates) { + if (!tried.empty()) tried += ", "; + tried += c.namespace_.empty() + ? std::format("(no namespace, {})", c.shortName) + : std::format("({}, {})", c.namespace_, c.shortName); + } + + // T12 — did-you-mean. DIAGNOSTIC ONLY: the scan runs solely on + // this already-failed path and its result never leaves the + // error string (see Fetcher::scan_short_name_matches). + std::string hint; + if (auto cfg = state.get_cfg(true)) { + auto suggestions = mcpp::pm::cross_namespace_suggestions( + state.index_route(*cfg), candidates.front().shortName); + if (!suggestions.empty()) { + hint += "\n a package with this name exists under " + "another namespace:"; + for (auto& suggestion : suggestions) + hint += "\n " + suggestion.fqn + + suggestion.versions_label(); + if (auto suggested = mcpp::pm::parse_package_selector( + suggestions.front().fqn); suggested + && suggested->namespace_) { + hint += std::format( + "\n namespace omission means `{}`. write the " + "exact package:" + "\n [dependencies.{}]" + "\n {} = \"{}\"", + mcpp::pm::kDefaultNamespace, + *suggested->namespace_, suggested->name, + spec.version.empty() ? "" + : spec.version); + } + } + } + + // Advisory, never a gate (#315): now that a build only refreshes + // the index on a miss, "not found" and "your copy of the index + // is from last month" are easy to confuse. State which index + // answered and how old it is, so the next step is obvious + // instead of guessed at. + if (auto cfgA = state.get_cfg(true)) { + hint += std::format("\n index: {}\n hint: `mcpp index update` " + "if it was published recently", + mcpp::pm::staleness_note( + mcpp::config::make_xlings_env(**cfgA))); + } + // Offline with no local copy of the index at all, the miss says + // nothing about the package: nothing has been downloaded to look + // in. That is a download the run needs, not a wrong selector. + if (mcpp::platform::env::offline_mode()) { + if (auto cfgO = state.get_cfg(true); + cfgO && !mcpp::xlings::default_index_status( + mcpp::config::make_xlings_env(**cfgO), 0).present) { + hint += "\n offline: the package index has never been fetched; " + "run `mcpp index update` without --offline"; + refusal::record(refusal::Code::OfflineDownloadRequired); + } + } + return std::unexpected(with_index_cause(std::format( + "dependency '{}': no package found for exact selector" + "\n tried: {}{}", + depName, tried, hint))); + } + } + + spec.namespace_ = std::move(selected.namespace_); + spec.shortName = std::move(selected.shortName); + spec.candidates = std::move(candidates); + return {}; + }; + + // 0.0.10+: loadVersionDep accepts structured (ns, shortName) for + // namespace-aware lookup. depName is the map key (qualified or bare), + // kept for install() target formatting and error messages. + + state.loadVersionDep = [&](const std::string& depName, + const std::string& ns, + const std::string& shortName, + const std::string& version) + -> std::expected + { + auto cfg = state.get_cfg(true); + if (!cfg) return std::unexpected(cfg.error()); + mcpp::fetcher::Fetcher fetcher(**cfg); + + // ─── Routing: check if this dep's namespace maps to a custom index ── + auto* idxSpec = state.findIndexForNs(ns); + + const bool useProjectEnv = idxSpec && !idxSpec->is_builtin(); + + auto readLuaContent = [&]() -> std::optional { + if (idxSpec && idxSpec->is_local()) { + auto indexPath = mcpp::config::resolve_project_index_path(*state.root, *idxSpec); + return mcpp::fetcher::Fetcher::read_xpkg_lua_from_path( + indexPath, ns, shortName); + } + if (idxSpec && !idxSpec->is_builtin()) { + return mcpp::fetcher::Fetcher::read_xpkg_lua_from_project_data( + *state.root, ns, shortName); + } + return fetcher.read_xpkg_lua(ns, shortName); + }; + + auto luaContent = readLuaContent(); + if (idxSpec && idxSpec->is_local() && !luaContent) { + auto indexPath = mcpp::config::resolve_project_index_path(*state.root, *idxSpec); + return std::unexpected(with_index_cause(std::format( + "dependency '{}': not found in local index at '{}'", + depName, indexPath.string()))); + } + + auto findRawInstalled = [&]() -> std::optional { + if (useProjectEnv) { + if (auto p = mcpp::fetcher::Fetcher::install_path_from_project_data( + *state.root, ns, shortName, version)) { + return p; + } + } + return fetcher.install_path(ns, shortName, version); + }; + + auto installedLayoutMatchesIndex = [&](const std::filesystem::path& verRoot) -> bool { + if (!luaContent) return false; + + auto field = mcpp::manifest::extract_mcpp_field(*luaContent); + if (field.kind == mcpp::manifest::McppField::StringPath) { + return !mcpp::modgraph::expand_glob(verRoot, field.value).empty(); + } + if (field.kind == mcpp::manifest::McppField::TableBody) { + auto dm = mcpp::manifest::synthesize_from_xpkg_lua( + *luaContent, shortName, version, *state.targetPlatform); + if (!dm) return false; + for (auto const& [generatedPath, _] : dm->buildConfig.generatedFiles) { + if (!generatedPath.empty()) return true; + } + for (auto const& glob : dm->modules.sources) { + if (!glob.empty() && glob.front() == '!') continue; + if (!mcpp::modgraph::expand_glob(verRoot, glob).empty()) { + return true; + } + } + return false; + } + + for (auto pat : { "mcpp.toml", "*/mcpp.toml" }) { + if (!mcpp::modgraph::expand_glob(verRoot, pat).empty()) { + return true; + } + } + 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, *state.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); + return p; + } + mcpp::fallback::clean_incomplete_install(*p); + return std::nullopt; + }; + + auto markInstalled = [&](const std::filesystem::path& p) { + mcpp::fallback::mark_install_complete(p); + }; + + // For custom indices, try project-level xlings data roots first. + // Existing directories without the mcpp completion marker are treated + // as stale/incomplete on this active resolve path and reinstalled. + std::optional installed = findCompleteInstalled(); + + // #278 masking guard. The hard INV-NAME check lives on the install path + // below, so a machine that already has the package from an older index + // snapshot keeps building. That asymmetry is exactly the trap the issue + // names — local green, clean CI red — so make it visible here instead of + // letting it stay silent. + if (installed && luaContent) { + if (auto violation = mcpp::manifest:: + xpkg_name_form_violation_from_lua(*luaContent)) { + mcpp::ui::warning(std::format( + "dependency '{}': {}\n" + " resolving from the already-installed copy; a clean " + "environment (CI) will fail here", + depName, *violation)); + } + } + + if (!installed) { + if (luaContent) { + auto field = mcpp::manifest::extract_mcpp_field(*luaContent); + if (field.kind == mcpp::manifest::McppField::TableBody) { + auto depManifest = mcpp::manifest::synthesize_from_xpkg_lua( + *luaContent, shortName, version, *state.targetPlatform); + if (!depManifest) { + return std::unexpected(std::format( + "dependency '{}': {}", depName, depManifest.error().format())); + } + warn_unknown_xpkg_keys(*depManifest, depName); + + auto preinstallKey = std::format("{}:{}@{}", ns, shortName, version); + if (state.preinstallStack.contains(preinstallKey)) { + return std::unexpected(std::format( + "dependency '{}': cyclic mcpp.deps while preparing install hooks", + depName)); + } + + if (!state.preinstallDone.contains(preinstallKey)) { + state.preinstallStack.insert(preinstallKey); + for (auto [childName, childSpec] : depManifest->dependencies) { + mcpp::pm::compat::normalize_nested_namespace( + childSpec.namespace_, + childSpec.shortName, + childSpec.legacyDottedKey); + + if (auto r = state.selectDependencyCandidate( + childSpec, childName); !r) { + state.preinstallStack.erase(preinstallKey); + return std::unexpected(r.error()); + } + + if (auto r = state.resolveSemver(childSpec, childName); !r) { + state.preinstallStack.erase(preinstallKey); + return std::unexpected(r.error()); + } + + if (!childSpec.isVersion()) continue; + + ResolvedKey childKey{ + childSpec.namespace_, + childSpec.shortName.empty() ? childName : childSpec.shortName, + }; + if (auto child = state.loadVersionDep( + childName, + childKey.ns, + childKey.shortName, + childSpec.version); !child) { + state.preinstallStack.erase(preinstallKey); + return std::unexpected(child.error()); + } + } + state.preinstallStack.erase(preinstallKey); + state.preinstallDone.insert(preinstallKey); + } + } + } + + // The address xlings is asked for is `:` (SPEC-001 §6), and BOTH halves come from the + // descriptor the identity gate accepted — see + // `mcpp::manifest::xpkg_wire_address` for why splitting the two + // sources is the bug it is. + auto wireAddr = mcpp::manifest::xpkg_wire_address( + luaContent ? std::string_view(*luaContent) : std::string_view{}, + ns, shortName); + if (luaContent) { + if (auto violation = mcpp::manifest:: + xpkg_name_form_violation_from_lua(*luaContent)) { + return std::unexpected(std::format( + "dependency '{}': {}", depName, *violation)); + } + } + // Human-facing name stays the resolved identity `.` — + // that is what the user wrote in [dependencies], so it is what the + // progress line and errors should echo back. + auto displayName = ns.empty() ? shortName + : std::format("{}.{}", ns, shortName); + + // Offline (#315). Checked HERE, at the point of download, and not + // any earlier: everything above this line — reading descriptors, + // resolving versions, reusing an already-installed package — is + // local, and an offline build that has its dependencies must + // succeed. Only the download itself is refused, and it names the + // package rather than surfacing a socket error from three layers + // down. (The toolchain payload path has its own gate; this is the + // dependency path, which does not go through resolve_xpkg_path.) + if (mcpp::platform::env::offline_mode()) { + refusal::record(refusal::Code::OfflineDownloadRequired); + return std::unexpected(std::format( + "offline mode: dependency '{}' v{} is not installed and " + "cannot be downloaded\n" + " run without --offline (or unset MCPP_OFFLINE) to fetch it", + displayName, version)); + } + mcpp::ui::info("Downloading", std::format("{} v{}", displayName, version)); + + // #238: retain whatever error/warn text the child DID emit so we + // can fold it into a diagnostic if install_packages exits non-zero. + std::string capturedChildError; + // xlings' own error lines, after its structured summary (#614). + auto append_xlings_stderr = [](std::string& into, + const mcpp::xlings::CallResult& r) { + if (r.exitCode == 0) return; + for (auto const& line : r.stderrTail) + into += (into.empty() ? "" : "\n ") + std::string("xlings: ") + line; + }; + auto install_one = [&](std::string target) -> std::expected { + if (useProjectEnv) { + // Project/custom-index deps install into the project-local + // xlings data root (so a package's install hook can find + // sibling packages from the same index). The NDJSON + // interface honors this: in the pinned xlings the + // `install_packages` capability and the `install` CLI share + // `xim::cmd_install`, and the install destination is chosen + // by package *scope* (project vs global), not by transport. + // Using the interface (rather than the silenced direct CLI) + // restores the live `Downloading … [bar] X/Y Z/s` UI here, + // matching the toolchain and builtin-index paths. + auto projEnv = mcpp::config::make_project_xlings_env(**cfg, *state.root); + auto argsJson = std::format( + R"({{"targets":["{}"],"yes":true}})", target); + mcpp::fetcher::InstallProgressHandler progress; + auto r = mcpp::xlings::call( + projEnv, "install_packages", argsJson, &progress); + capturedChildError = progress.captured_error(); + if (!r) return std::unexpected(mcpp::pm::CallError{r.error()}); + append_xlings_stderr(capturedChildError, *r); + return *r; + } + std::vector targets{ std::move(target) }; + mcpp::fetcher::InstallProgressHandler progress; + auto r = fetcher.install(targets, &progress); + capturedChildError = progress.captured_error(); + if (r) append_xlings_stderr(capturedChildError, *r); + return r; + }; + // Target = `:@` (SPEC-001 §6). + // + // The colon prefix is xlings' *effective namespace*, matched against + // the descriptor's own `package.namespace` (xlings issue-381 design + // §2.2) — NOT the index name. mcpp's `[indices] = {...}` keys + // ARE namespaces, so the two coincide for a qualified request; for a + // bare one they do NOT, which is exactly why the namespace has to be + // read off the descriptor rather than off `ns`. + // + // A namespace-less upstream package (xim `opencv`) is addressed by + // its bare literal name, with no prefix. + auto target = std::format("{}@{}", wireAddr.target, version); + // Keep every address we actually put on the wire. Diagnosing the + // 2026-07-25 breakage needed MCPP_VERBOSE=1 to discover that mcpp + // had asked for `mcpplibs:gtest` — the error itself only named the + // dependency, which is the one thing nobody doubts. + std::vector attempted{ target }; + // #613: THE INSTALL HOOK'S ENVIRONMENT, under the names and the rule + // a build program gets: always emitted, empty when not applicable, + // so a hook never reads a value inherited from a parent process. + // Computed by `install_hook_env`, the function the build-program + // environment takes the same six values from. + // + // THE TOOLCHAIN VALUES ARE EMPTY HERE ON THE ORDINARY PATH. `tc` is + // resolved after the dependency graph (see its declaration: a + // package in the graph may supply a target-side layer), so when a + // dependency installs there is no resolved compiler or standard + // library to state, and a guessed one would be worse than none. + // Measured with tests/e2e/648. The target names are decided, and a + // package states the standard library it was built for with + // `requires = ["mcpp:c++-abi=..."]`, checked once `tc` exists. A + // hook must not build a variant into a store directory that does + // not name the variant, because the store is keyed by package and + // version. Scoped: restored when this dependency's install returns, + // compat retries below included. + mcpp::build::BuildProgramEnv hookEnv; + fill_target_build_env(hookEnv, *state.m, state.tc ? &*state.tc : nullptr, state.cfg_opt ? &*state.cfg_opt : nullptr); + hookEnv.targetTriple = state.overrides.target_triple; + // Six names, fixed by install_hook_env; one guard each. + const auto hookVars = mcpp::build::install_hook_env(hookEnv); + mcpp::platform::env::ScopedEnv hookVar0(hookVars.at(0).first, hookVars.at(0).second); + mcpp::platform::env::ScopedEnv hookVar1(hookVars.at(1).first, hookVars.at(1).second); + mcpp::platform::env::ScopedEnv hookVar2(hookVars.at(2).first, hookVars.at(2).second); + mcpp::platform::env::ScopedEnv hookVar3(hookVars.at(3).first, hookVars.at(3).second); + mcpp::platform::env::ScopedEnv hookVar4(hookVars.at(4).first, hookVars.at(4).second); + mcpp::platform::env::ScopedEnv hookVar5(hookVars.at(5).first, hookVars.at(5).second); + auto r = install_one(target); + if (r && r->exitCode != 0 && + (ns.empty() || ns == mcpp::pm::kDefaultNamespace)) { + // Compat retry for a bare/default-namespace request whose + // descriptor could not be read (no `wireAddr` to trust): the + // package may still be a `compat` one. Try BOTH spellings — a + // SPEC-001 index keys it `compat:`, a pre-SPEC-001 index + // keys it by the literal `compat.`. Sending only the + // latter is what left the retry pointing at a name the migrated + // index no longer has. + for (auto&& compatTarget : { + std::format("compat:{}@{}", shortName, version), + std::format("compat.{}@{}", shortName, version) }) { + if (compatTarget == target) continue; + mcpp::ui::info("Downloading", std::format("{} v{}", + compatTarget.substr(0, compatTarget.rfind('@')), version)); + attempted.push_back(compatTarget); + r = install_one(compatTarget); + if (!r || r->exitCode == 0) break; + } + } + if (!r) return std::unexpected(std::format( + "fetch '{}@{}': {}", depName, version, r.error().message)); + if (r->exitCode != 0) { + // #238: the opaque `fetch failed (exit 1)` hid the actionable + // context mcpp actually has. Reconstruct it: the target, the + // configured index repos (read back from the seeded + // .xlings.json — project scope when useProjectEnv, else the + // global xlings home), any child error text we captured, plus + // a hint about the known ≥2-repo xlings resolution gap. The + // real fix lives in openxlings/xlings; this only surfaces WHY. + auto xlingsJson = (useProjectEnv + ? (state.workRoot / ".mcpp") + : (*cfg)->xlingsHome()) + / ".xlings.json"; + auto indexRepos = mcpp::pm::read_seeded_index_repos(xlingsJson); + std::string childErr = capturedChildError; + if (r->error) { + if (!childErr.empty()) childErr += "; "; + childErr += r->error->message; + } + auto target = std::format("{}@{}", depName, version); + auto diag = mcpp::pm::format_install_failure_diagnostic( + target, r->exitCode, indexRepos, childErr); + std::string tried; + for (auto& a : attempted) { + if (!tried.empty()) tried += ", "; + tried += a; + } + diag += std::format("\n wire address{} tried: {}", + attempted.size() == 1 ? "" : "es", tried); + return std::unexpected(std::move(diag)); + } + // After install, check project data first for custom index packages. + installed = findRawInstalled(); + if (!installed) return std::unexpected(std::format( + "package '{}@{}' install path missing after fetch", depName, version)); + markInstalled(*installed); + } + std::filesystem::path verRoot = *installed; + + // Route xpkg.lua reading through the appropriate index. + if (!luaContent) { + luaContent = readLuaContent(); + } + if (!luaContent) return std::unexpected(with_index_cause(std::format( + "dependency '{}': index entry not found in local clone", depName))); + auto field = mcpp::manifest::extract_mcpp_field(*luaContent); + + // 0.0.6+: read explicit namespace from xpkg lua if present. + auto luaNs = mcpp::manifest::extract_xpkg_namespace(*luaContent); + + std::optional manifest; + std::filesystem::path effRoot = verRoot; + auto loadFrom = [&](const std::filesystem::path& mcppToml) + -> std::expected + { + // A manifest that is a member of a workspace inside the archive + // receives that workspace's inheritance, as it does from a git + // clone of the same commit (#690). + auto repoWorkspace = workspace_listing(mcppToml.parent_path(), verRoot); + auto dm = mcpp::manifest::load( + mcppToml, {.insideWorkspace = repoWorkspace.has_value()}); + if (!dm) return std::unexpected(std::format( + "dependency '{}' (at '{}'): {}", + depName, mcppToml.string(), dm.error().format())); + if (repoWorkspace) { + if (auto bad = inherit_as_workspace_member( + *dm, repoWorkspace->first, repoWorkspace->second, + mcppToml.parent_path())) + 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 {}; + }; + if (field.kind == mcpp::manifest::McppField::StringPath) { + auto matches = mcpp::modgraph::expand_glob(verRoot, field.value); + if (matches.empty()) return std::unexpected(std::format( + "dependency '{}': mcpp pointer '{}' did not match any " + "file under '{}'", depName, field.value, verRoot.string())); + if (matches.size() > 1) return std::unexpected(std::format( + "dependency '{}': mcpp pointer '{}' matched {} files " + "(expected exactly one)", depName, field.value, matches.size())); + if (auto r = loadFrom(matches.front()); !r) return std::unexpected(r.error()); + } else if (field.kind == mcpp::manifest::McppField::TableBody) { + auto dm = mcpp::manifest::synthesize_from_xpkg_lua( + *luaContent, shortName, version, *state.targetPlatform); + if (!dm) return std::unexpected(std::format( + "dependency '{}': {}", depName, dm.error().format())); + warn_unknown_xpkg_keys(*dm, depName); + manifest = std::move(*dm); + // effRoot stays as verRoot + } else { + std::vector matches; + for (auto pat : { "mcpp.toml", "*/mcpp.toml" }) { + matches = mcpp::modgraph::expand_glob(verRoot, pat); + if (!matches.empty()) break; + } + // Name the directory actually searched. `` was a literal + // placeholder, so the message could not distinguish "the package + // is Form B and you forgot the mcpp field" from "the verdir mcpp + // resolved is not this package's at all" — the second is what a + // cross-namespace install_path hit produces, and it sent this + // investigation down the wrong path for a while. + if (matches.empty()) return std::unexpected(std::format( + "dependency '{}': index entry has no `mcpp = ...` field, " + "and no mcpp.toml was found at '{}/mcpp.toml' or " + "'{}/*/mcpp.toml' — add an explicit `mcpp = \"\"` " + "or `mcpp = {{ ... }}` block to the .lua descriptor. " + "(If that directory belongs to a DIFFERENT package, the " + "install step resolved the wrong verdir.)", + depName, verRoot.string(), verRoot.string())); + if (matches.size() > 1) return std::unexpected(std::format( + "dependency '{}': default mcpp.toml lookup matched {} " + "files; pin one with explicit `mcpp = \"\"`.", + depName, matches.size())); + if (auto r = loadFrom(matches.front()); !r) return std::unexpected(r.error()); + } + // Propagate lua-level namespace into the loaded manifest when + // the manifest itself doesn't carry one (Form A descriptors + // whose upstream mcpp.toml predates the namespace field). + // Guard: if the manifest's name already starts with luaNs+"." + // (e.g. name="mcpplibs.tinyhttps" with luaNs="mcpplibs"), + // the namespace is already embedded in the name — don't inject + // it again or the scanner will produce a double-prefixed + // qualified name like "mcpplibs.mcpplibs.tinyhttps". + if (manifest->package.namespace_.empty() && !luaNs.empty()) { + auto prefix = luaNs + "."; + if (!manifest->package.name.starts_with(prefix)) { + manifest->package.namespace_ = luaNs; + } + } + + if (auto r = materialize_generated_files(effRoot, *manifest); !r) { + return std::unexpected(std::format( + "dependency '{}': {}", depName, r.error())); + } + + // Dependency-side L1 cfg merge (flags + sources): a descriptor's + // `target_cfg` / a dep mcpp.toml's [target.'cfg(...)'.build] must + // evaluate here too — before its globs expand. This is the version/ + // registry-dep half of the #229 funnel: every loadVersionDep() caller + // (the main per-dependency loop, the multi-version mangling + // secondary, and the SemVer-merge re-fetch) shares this one call site, + // so a version dep is merged exactly once regardless of which of the + // three paths loaded it. The path/git-dep half is the mirror-image + // call right after ITS manifest load (dependency-manifest-acquisition + // block below) — same function, same one-merge-per-package guarantee, + // just keyed off a different loading branch since path/git deps never + // pass through loadVersionDep. + if (!manifest->conditionalConfigs.empty()) { + merge_conditional_config(*manifest, + state.cfgCtx()); + } + report_flag_words_changes(*manifest); + fold_build_defines_into_flags(manifest->buildConfig); + // The root's `abi.threads` reaches a dependency's C translation units + // here; its C++ units already receive it through the dialect flag set. + if (state.abiThreadsRendered) state.add_once(manifest->buildConfig.cflags, "-pthread"); + + return std::pair{effRoot, std::move(*manifest)}; + }; + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/manifest.cpp b/src/build/prepare/manifest.cpp new file mode 100644 index 000000000..9ee6aec32 --- /dev/null +++ b/src/build/prepare/manifest.cpp @@ -0,0 +1,517 @@ +// manifest.cpp -- P0: the effective manifest and the workspace it belongs to, +// from the one loader every command uses. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.targetside; +import mcpp.diag; +import mcpp.build.version_floor; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.modgraph.glob; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.modgraph.validate; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.pack.abi_tag; // the tag a prebuilt dependency is checked against +import mcpp.pack.prebuilt; // …and the check itself +import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its manifest +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.xlings; +import mcpp.xlings.runtime_selection; +import mcpp.platform; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.ui; +import mcpp.project; + +namespace mcpp::build { + +std::expected phase0_manifest_and_workspace(PrepareState& state) { + // A refusal decided early and released late. `host_can_serve` answers + // "does a payload on this machine produce this target", which is knowable + // before dependency resolution and is only half the question: a package in + // the graph can supply the target's system, and the graph is not known + // here. Held until it is, and released only if nothing supplies it. + + // THE LOCATED APPLE SDK, RESOLVED ONCE AND READ ONCE. + // + // `xcrun` is a process. Calling it at the refusal below and again where + // the answer is stored would be two calls whose answers can differ -- the + // developer directory can be switched between them -- and this repository + // has a standing rule that a value crossing two sites is resolved at one. + // The iOS floor was not written and was taken from the located SDK. The + // refusal of a dependency's platform floor names where the value came + // from, and after the fill below the manifest no longer says. + state.iosFloorFromSdk = false; + // Non-empty when a target row's convention replaced a toolchain the user + // had set with `mcpp toolchain default`. Reported on the status line, + // because a substitution nobody is told about is a rule that can only be + // learned by experiment — writing the same value a second time in + // `[target.]` and observing that it works. + // THE HOST SPEC AS IT STOOD BEFORE A TARGET ROW'S CONVENTION REPLACED IT, + // whatever its origin. `build.mcpp` is compiled and run on this machine, + // so its compiler is a host fact; the row's pin is a target fact. Before + // this snapshot existed, `host_tc_for_build_program` read `tcSpec` after + // the row had overwritten it and resolved the row's payload "for the + // host" -- which works by accident for a payload whose compiler can also + // target the host (an NDK clang) and cannot work for one that cannot: + // `em++` produces WebAssembly under every invocation, and every project + // with a build program failed under `--target wasm32-emscripten` inside + // `emcc.py` (#622, measured by the dist-web member's first build). + // + // Empty when the row replaced nothing — no [toolchain], no global + // default, no [target.] entry existed before the row's pin applied. + // THIS IS NOT "the row's pin remains the only spec there is": on a + // fresh $HOME whose first-ever invocation names a hosted `--target` + // (nothing to be "before"), that reading resolved the SAME payload the + // row just picked — `em++` again — as the host compiler, which is the + // exact defect this field exists to close, just with no prior value to + // restore. `host_tc_for_build_program` resolves the platform's own + // native default in that case instead (`native_first_run_spec()`), the + // same one a plain `mcpp build` would have installed. + // THE PACKAGE WHOSE `requires` CHOSE THE COMPILER, AND WHAT IT ASKED FOR. + // + // Non-empty only when the graph's requirement actually changed the answer. + // Reported on the status line for the same reason `pinReplacedDefault` is: + // a compiler the user did not name is a decision they did not make, and one + // reported without its reason is a rule learned by experiment. + // The C library the target triple asked for, taken before the triple is + // canonicalised. Empty when the project declined to name one. + // The target as the project spelled it, when that differs from the + // canonical identity. Report only; empty means they coincide. + // The target row's toolchain convention, held until the graph is known. + // Empty when the row names none or the project named its own. + // AND WHETHER THAT PIN IS A CONVENTION OR A CAPABILITY, RECORDED AT + // THE SAME READ. + // + // A hosted row's pin answers "which payload supplies this target's C + // library", so a graph that supplies one instead makes it inapplicable. + // A freestanding row's pin answers a different question — the table says + // so in its own words: "the pin is llvm on every host because clang/lld + // are cross-compilers by construction". A host g++ cannot emit + // riscv64-none-elf at all, and no dependency changes that. + // + // Taken here rather than re-derived at the decision point, because the row + // is read exactly once and both facts come out of that read. + state.targetPinIsCapability = false; + // THE ROW'S PIN, KEPT EVEN WHEN THE PROJECT NAMED ITS OWN COMPILER — + // which is exactly when `targetPinCandidate` above is left empty. + // + // The candidate answers "should mcpp apply its convention"; this answers + // "what does the convention SAY", and the two differ precisely in the case + // that needs a diagnosis: a project that overrode the convention and has + // nothing supplying what the convention was there to supply. + // Whether the resolved toolchain spec names the machine's own Visual + // Studio. Decided inside `resolve_target_toolchain`, read by + // `host_tc_for_build_program`, which is why it is declared out here. + state.tcSpecIsMsvc = false; + + state.root = state.overrides.project_root.empty() + ? mcpp::project::find_manifest_root(std::filesystem::current_path()) + : std::optional(state.overrides.project_root); + if (!state.root) { + return std::unexpected("no mcpp.toml found in current directory or any parent"); + } + // THE PROJECT'S PATH IS PART OF EVERY DOCUMENT A BUILD WRITES, and those + // documents are UTF-8 text (build.ninja, compile_commands.json). A + // directory whose path has no UTF-8 spelling used to fail the first build + // with `internal: unhandled exception: [json.exception.type_error.316]` + // (#693, measured on Linux with a Latin-1 name and on a Windows code page + // 1252 host with a name that page can spell). It is refused here, by name. + if (!mcpp::modgraph::try_narrow(*state.root)) { + return std::unexpected(std::format( + "the project directory '{}' has no UTF-8 spelling.\n" + " {}\n" + " Every file a build writes names this directory in UTF-8; " + "rename or move it.", + mcpp::modgraph::escaped_spelling(*state.root), + mcpp::modgraph::no_utf8_spelling_reason())); + } + // NOTE: `workRoot` is deliberately NOT derived here. `root` is not final + // yet — the workspace block below reassigns it to the selected member + // (`root = memberDir`), and anchoring the write root to the pre-switch + // value puts a member's target/, mcpp.lock and .mcpp/ at the WORKSPACE + // root. See the derivation right after that block. + + // A registry package in `compat` form (Form B) ships NO mcpp.toml — its + // manifest is synthesized from the `.lua` descriptor by the resolver. So a + // nested build of such a package cannot re-read one off disk, and the + // caller hands over the manifest it already synthesized instead. + // + // Passing it in rather than re-deriving it is also the more correct of the + // two: re-deriving could produce a DIFFERENT manifest than the one the + // parent resolved against (the L1 cfg merge and feature-activated deps + // have already been folded in by then). + // THE EFFECTIVE MANIFEST, FROM THE ONE LOADER EVERY COMMAND USES. + // + // A command issued inside a member directory receives the member's + // manifest after workspace inheritance, exactly as `publish`, `pack`, + // `emit xpkg` and `toolchain list` do (#690, W4). A command at the + // workspace root receives the root manifest as written; the `-p ` + // switch below loads and inherits the member it names. + // + // A PRELOADED manifest (a host-tool sub-build) is already effective: the + // resolver loaded it at the dependency's load site, where a member + // inherits (see `inherit_as_workspace_member`). It is not inherited a second time; the + // workspace it belongs to is still recorded below, so that its own sibling + // dependencies inherit as members. + state.m = + std::unexpected(std::string{}); + if (state.overrides.preloaded_manifest) { + state.m = *state.overrides.preloaded_manifest; + } else { + auto loaded = mcpp::project::load_effective_manifest(*state.root); + if (!loaded) return std::unexpected(loaded.error()); + state.m = loaded->manifest; + state.effective = std::move(*loaded); + } + + // AND ONLY FOR THE ROOT. A layer name this engine does not know is a + // typo in the manifest the author is looking at, and a version gap in a + // dependency's. The reserved `mcpp:` prefix exists so the first is an error + // rather than a silently disabled behaviour; refusing the second as well + // meant the layer vocabulary could never be extended by a published package + // (`warn_unknown_xpkg_keys` carries that half). + if (!state.m->unknownCapabilities.empty()) { + auto const& cap = state.m->unknownCapabilities.front(); + auto why = mcpp::targetside::parse_capability(cap); + return std::unexpected(std::format( + "{}: {}", (*state.root / "mcpp.toml").string(), + why ? std::format("`{}` names no capability mcpp knows.", cap) + : why.error())); + } + + // A DISTRIBUTION package is not a source tree, and building "in" one is a + // failure that looks like a success: `interface/` holds declarations whose + // definitions are in the prebuilt archive, so the build compiles the + // declarations, produces a near-empty library, links nothing, and reports + // Finished. The archive it was supposed to carry never enters the picture. + // + // Only the ROOT is refused. As a dependency this is exactly what the + // package is for — the consumer compiles the interface and links the + // artifact, which is the whole design. + if (!state.overrides.preloaded_manifest && mcpp::pack::is_distribution_package(*state.m)) { + return std::unexpected(std::format( + "'{}' is a distribution package produced by `mcpp pack`, not a source tree.\n" + " Its sources are interface declarations; the definitions are in the\n" + " prebuilt artifacts beside them, so building here would produce an\n" + " empty library and say it succeeded.\n" + " Use it: add it to a project as a dependency —\n" + " [dependencies]\n" + " {} = {{ path = \"{}\" }}", + state.root->string(), state.m->package.name, state.root->string())); + } + + // ─── Workspace handling ──────────────────────────────────────────── + // If the manifest has [workspace] and is a virtual workspace (no [package]), + // or if -p filter is set, switch to the target member's manifest. + if (state.m->workspace.present) { + std::string targetMember; + + if (!state.overrides.package_filter.empty()) { + // -p : find matching member by directory basename or path + for (auto& mp : state.m->workspace.members) { + auto basename = std::filesystem::path(mp).filename().string(); + if (basename == state.overrides.package_filter || mp == state.overrides.package_filter) { + targetMember = mp; + break; + } + } + if (targetMember.empty()) { + return std::unexpected(std::format( + "workspace member '{}' not found in [workspace].members", + state.overrides.package_filter)); + } + } else if (state.m->package.name.empty()) { + // Virtual workspace: find a member with a program target ("is + // this the program", #622 A3's `is_program()`, so a member whose + // only target is `kind = "app"` is picked exactly as one whose + // target is `bin` is), or use last member. + for (auto& mp : state.m->workspace.members) { + auto memberDir = *state.root / mp; + auto mm = mcpp::manifest::load(memberDir / "mcpp.toml", + {.insideWorkspace = true}); + if (!mm) continue; + for (auto& t : mm->targets) { + if (t.is_program()) { + targetMember = mp; + break; + } + } + if (!targetMember.empty()) break; + } + if (targetMember.empty() && !state.m->workspace.members.empty()) { + targetMember = state.m->workspace.members.back(); + } + } + // else: rooted workspace with [package] — build root normally. Its own + // `x.workspace = true` entries name its own [workspace.dependencies]. + else if (state.m->workspace.present) + mcpp::project::merge_workspace_deps(*state.m, *state.m, *state.root); + + if (!targetMember.empty()) { + auto memberDir = *state.root / targetMember; + if (!std::filesystem::exists(memberDir / "mcpp.toml")) { + return std::unexpected(std::format( + "workspace member '{}' has no mcpp.toml", targetMember)); + } + state.runtimeWorkspaceRoot = *state.root; + state.wsManifest = std::move(*state.m); // preserve workspace manifest + auto memberManifest = mcpp::manifest::load(memberDir / "mcpp.toml", + {.insideWorkspace = true}); + if (!memberManifest) return std::unexpected(std::format( + "workspace member '{}': {}", targetMember, + memberManifest.error().format())); + state.m = std::move(*memberManifest); + + // ONE call, not a hand-copied list. `*root` is still the WORKSPACE + // root here (the `root = memberDir` reassignment below has not + // happened yet), which is what a relative `[indices].path` or + // `[workspace.dependencies] path` was written against (#224). + mcpp::project::inherit_workspace_config(*state.m, *state.wsManifest, *state.root); + if (auto bad = mcpp::project::workspace_inheritance_error(*state.m, memberDir)) + return std::unexpected(*bad); + + mcpp::ui::status("Workspace", std::format("building member '{}'", targetMember)); + state.root = memberDir; + } + } else { + // Not at workspace root: inside a member, the loader above has + // already inherited (#224 anchoring included). Only the workspace is + // recorded here, for the membership test of this member's own `path` + // dependencies. + if (state.effective && state.effective->member) { + state.runtimeWorkspaceRoot = state.effective->workspaceRoot; + state.wsManifest = std::move(*state.effective->workspace); + } else if (state.overrides.preloaded_manifest) { + auto wsRoot = mcpp::project::find_workspace_root(*state.root); + if (!wsRoot.empty()) { + if (auto wsm = mcpp::manifest::load(wsRoot / "mcpp.toml"); + wsm && wsm->workspace.present) { + state.runtimeWorkspaceRoot = wsRoot; + state.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 (state.wsManifest + && mcpp::project::is_workspace_member(*state.wsManifest, state.runtimeWorkspaceRoot, *state.root)) + mcpp::project::inherit_workspace_root_position( + *state.m, *state.wsManifest, state.runtimeWorkspaceRoot); + } + } + + if (auto bad = mcpp::project::unresolved_workspace_dependency_error(*state.m, *state.root)) + return std::unexpected(*bad); + + if (state.overrides.inherited_runtime_selection) { + state.runtimeSelection = *state.overrides.inherited_runtime_selection; + } else { + std::optional> wsRef; + if (state.wsManifest) wsRef = std::cref(*state.wsManifest); + auto selected = mcpp::xlings::runtime::select_runtime( + *state.m, wsRef, *state.root, state.runtimeWorkspaceRoot); + if (!selected) return std::unexpected(selected.error()); + state.runtimeSelection = std::move(*selected); + } + + // Where mcpp WRITES — derived here because `root` is only final now: the + // workspace block above may have moved it to the selected member. Defaults + // to the project root, so every existing invocation is byte-for-byte + // unchanged; the tool-provisioning pass points it at the tool store + // instead (BuildOverrides::work_dir). + state.workRoot = + state.overrides.work_dir.empty() ? *state.root : state.overrides.work_dir; + { + std::error_code wdEc; + std::filesystem::create_directories(state.workRoot, wdEc); + } + + if (state.m->package.sourceProvenance.empty()) { + state.m->package.sourceProvenance = + "path+" + state.root->lexically_normal().generic_string(); + } + + // A `compat`-form (Form B) package's sources live under a wrap directory + // inside the version dir, which is why its descriptor writes globs like + // `*/src/foo.cc` — the `*` stands for the tarball's top-level folder, + // whose name the descriptor cannot know. `[build] sources` has always + // expanded those; `targets..main` did NOT, so a bin target in such a + // package handed ninja a literal `*` and died with + // `missing and no known rule to make it`. + // + // Nothing could reach that path before #355 (a dependency's bin targets + // were never built), which is why it went unnoticed. Resolve it here, once + // the manifest is final and before anything reads `t.main`. + for (auto& t : state.m->targets) { + if (t.main.empty() || t.main.find('*') == std::string::npos) continue; + auto hits = mcpp::modgraph::expand_glob(*state.root, t.main); + if (hits.size() == 1) { + t.main = std::filesystem::relative(hits.front(), *state.root).generic_string(); + } else { + return std::unexpected(std::format( + "target '{}': `main = \"{}\"` matched {} files; it must name " + "exactly one entry source", + t.name, t.main, hits.size())); + } + } + + // Inject synthetic targets (e.g. test binaries from `mcpp test`). + for (auto& t : state.extraTargets) state.m->targets.push_back(t); + + // #540: a cfg() predicate mcpp cannot evaluate must say so. + // + // A PREDICATE THAT ANSWERS FALSE AND A PREDICATE THAT WAS NEVER + // UNDERSTOOD USED TO READ THE SAME. `cfgpred` returns false for an unknown + // key and for an unknown bareword, and a `[target..build]` section + // whose predicate is false is dropped without a word — so a typo, and every + // `cfg(c-abi = …)` section docs/14 documented before this release, produced + // a successful build configured as if the section had not been written. + // + // Reported here rather than in the manifest parser because the vocabulary + // lives with the evaluator, and a second copy of it in `toml.cppm` is the + // exact defect this release is fixing four other instances of. + // + // Scoped to the root manifest by where it sits, which matches the existing + // policy for every other schema warning: a dependency may adopt a predicate + // a consumer's older mcpp does not know, and its build stays quiet. + for (auto const& cc : state.m->conditionalConfigs) { + auto unknown = cfgpred::unknown_tokens(cc.predicate); + if (!unknown.empty()) { + std::string names; + for (auto const& u : unknown) { + if (!names.empty()) names += ", "; + names += '\'' + u + '\''; + } + state.m->schemaWarnings.push_back(std::format( + "[target.'{}'] names {} in its cfg() predicate, which mcpp does " + "not know, so the section never applies (ignored). {}", + cc.predicate, names, cfgpred::vocabulary_sentence())); + } + // A RESOLVED layer is answered AFTER dependency resolution, so a + // dependency selected by one would form a cycle with the resolution + // that produces the answer — docs/14 states this. The section's build + // inputs are honoured by the second pass; its dependencies cannot be, + // and saying so is the difference between a documented limit and a + // silent drop. + // + // `accelerator` is not one of these (see kCfgEarlyLayerKeys), so + // `[target.'cfg(accelerator = "cuda")'.dependencies]` is honoured and + // never reaches this warning: nothing about it is circular, because the + // accel is an input to the build rather than an answer from the graph. + if (cfgpred::uses_layer(cc.predicate) + && !(cc.dependencies.empty() && cc.devDependencies.empty() + && cc.buildDependencies.empty() && cc.featureDeps.empty())) { + state.m->schemaWarnings.push_back(std::format( + "[target.'{}'] conditions dependencies on a target-side layer " + "(ignored). A layer is resolved from the dependency graph, so a " + "dependency chosen by one would decide the answer it is asking " + "for. Build inputs under this predicate DO apply; move the " + "dependency to an unconditional [dependencies] entry, or " + "condition it on the triple instead.", + cc.predicate)); + } + // The same reason holds for a row's library form: whether a package + // is linked shared is decided while the graph is resolved, before a + // layer has an answer. + if (cfgpred::uses_layer(cc.predicate) && !cc.targetKinds.empty()) { + state.m->schemaWarnings.push_back(std::format( + "[target.'{}'] conditions a target's kind or linkage on a " + "target-side layer (ignored). A layer is resolved from the " + "dependency graph, and a library's form is decided while that " + "graph is resolved; condition the statement on the triple " + "instead.", + cc.predicate)); + } + } + + // Surface non-fatal manifest schema warnings (e.g. unsupported [targets.*] + // keys). Under --strict they become errors — same policy as the + // feature/platform schema checks below. + for (auto const& w : state.m->schemaWarnings) { + if (state.overrides.strict) return std::unexpected(w); + mcpp::diag::warning("manifest/schema", w); + } + + // Load mcpp.lock once, up front: it is a resolution input for git deps + // (#329), which decide the commit to build long before anything is + // fetched. Keyed by package name — the same key the writer at the end of + // this function emits, both taken from the root manifest's [dependencies]. + { + // Read where the project keeps it. A planning pass that writes + // elsewhere (plan_only) still resolves against the project's lock. + auto lockPath = (state.overrides.plan_only ? *state.root : state.workRoot) / "mcpp.lock"; + if (std::filesystem::exists(lockPath)) { + if (auto lock = mcpp::pm::load(lockPath); lock) { + for (auto const& p : lock->packages) { + if (!p.namespace_.empty()) + state.packageIdentityLockAnchors.emplace( + p.name, p.namespace_); + if (auto parsed = mcpp::pm::parse_git_source(p.source); parsed) + state.gitLockAnchors.emplace(p.name, std::move(*parsed)); + } + } else { + // Degraded, not a plain warning: the engine silently does less + // than asked — every git branch dep falls back to `ls-remote` + // and may advance past the commit the lock recorded. + mcpp::diag::degraded("lockfile", + std::format("mcpp.lock could not be read: {}", + lock.error().message), + "git branch dependencies are re-resolved over the network " + "and may move onto a newer commit than the one recorded", + "delete mcpp.lock and rebuild to regenerate it"); + } + } + } + + // Global-cache mode: --cache > MCPP_BUILD_CACHE > [build] cache > global. + // An unparseable value is a warning (error under --strict) and falls + // through to the next source rather than silently meaning "global" — a typo + // that quietly re-enabled the cache would be the hardest kind of surprise + // to attribute. + // Selection lives in resolve_cache_mode (above) so the fast paths settle it + // identically. This block only adds the diagnostics, which the fast paths + // have no business emitting: an unparseable value must be reported once, by + // the invocation that actually resolves the build. + state.cacheMode = resolve_cache_mode(*state.m, state.overrides.cache_mode); + { + const char* envMode = std::getenv("MCPP_BUILD_CACHE"); + for (auto [value, origin] : std::initializer_list< + std::pair>{ + {state.overrides.cache_mode, "--cache"}, + {envMode ? envMode : "", "MCPP_BUILD_CACHE"}, + {state.m->buildConfig.cacheMode, "[build] cache"}}) { + if (value.empty() || parse_cache_mode(value)) continue; + auto msg = std::format( + "{} has unknown cache mode '{}' (expected: global | local | off)", + origin, value); + if (state.overrides.strict) return std::unexpected(msg); + mcpp::diag::warning("build/cache-mode", msg); + } + } + + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/options.cpp b/src/build/prepare/options.cpp new file mode 100644 index 000000000..ac63fa7dd --- /dev/null +++ b/src/build/prepare/options.cpp @@ -0,0 +1,105 @@ +// options.cpp -- the invocation options prepare_build resolves: the MSVC +// guidance, the build-cache mode and the profile. Declared, with their +// documentation, in prepare.cppm. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.build.version_floor; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.msvc; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.toolchain.post_install; +import mcpp.platform; + +namespace mcpp::build { + +std::string msvc_unavailable_guidance(const mcpp::toolchain::Toolchain& tc) { + namespace pins = mcpp::toolchain::triple::pins; + const bool haveVcTools = tc.compiler == mcpp::toolchain::CompilerId::MSVC; + if (haveVcTools && mcpp::toolchain::msvc::find_msvc_tools_dir()) { + return std::format( + "msvc {} was detected at {}, but no Windows SDK was found —\n" + " cl.exe cannot compile without the UCRT/SDK headers.\n" + " Install the 'Windows 11 SDK' component via the Visual Studio\n" + " Installer (it is part of the Desktop development with C++\n" + " workload), then retry.", + tc.version, tc.binaryPath.string()); + } + return std::format( + "this build targets the MSVC ABI, which needs Visual Studio /\n" + " Build Tools (MSVC STL + Windows SDK) — neither was found.\n" + "\n" + " No Visual Studio? Use the self-contained MinGW-w64 toolchain\n" + " (no Visual Studio required, `import std` works):\n" + " mcpp toolchain default {} --target {}\n" + "\n" + " Have Visual Studio? Install the 'Desktop development with C++'\n" + " workload — it provides the MSVC STL and the Windows SDK.", + pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget); +} + +std::optional parse_cache_mode(std::string_view v) { + if (v == "global") return CacheMode::Global; + if (v == "local") return CacheMode::Local; + if (v == "off" || v == "none") return CacheMode::Off; + return std::nullopt; +} + +std::string_view cache_mode_name(CacheMode m) { + switch (m) { + case CacheMode::Local: return "local"; + case CacheMode::Off: return "off"; + default: return "global"; + } +} + +CacheMode resolve_cache_mode(const mcpp::manifest::Manifest& m, + std::string_view override_mode) { + if (auto v = parse_cache_mode(override_mode)) return *v; + if (const char* e = std::getenv("MCPP_BUILD_CACHE"); e && *e) + if (auto v = parse_cache_mode(e)) return *v; + if (auto v = parse_cache_mode(m.buildConfig.cacheMode)) return *v; + return CacheMode::Global; +} + +std::string resolve_profile_name(const mcpp::manifest::Manifest& m, + std::string_view override_name, + std::string_view fallback) { + if (!override_name.empty()) return std::string(override_name); + if (!m.buildConfig.defaultProfile.empty()) return m.buildConfig.defaultProfile; + return fallback.empty() ? std::string("dev") : std::string(fallback); +} + +std::string profile_override_from_flags(std::string_view profileOption, + bool release, bool dev) { + if (!profileOption.empty()) return std::string(profileOption); + if (release) return "release"; + if (dev) return "dev"; + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp new file mode 100644 index 000000000..2813db92a --- /dev/null +++ b/src/build/prepare/plan.cpp @@ -0,0 +1,2373 @@ +// plan.cpp -- P13: the BuildContext: the plan, prebuilt dependencies, +// assembly units, Windows resources, the global cache, mcpp.lock and +// resolution.json. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.build.version_floor; +import mcpp.home; +import mcpp.platform.axis; +import mcpp.libs.json; +import mcpp.log; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.toolchain.clang; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.cppfly; +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.linkage_form; // #519 — which form each dependency takes +import mcpp.build.plan; +import mcpp.build.schedule.policy; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.distribution; // dist::Role / dist::Contract to_string +import mcpp.platform.capacity; // the host fallback handed to schedule::decide +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.runtime_validation; // declared artifact -> identity verdict +import mcpp.build.cache_key; +import mcpp.pack.abi_tag; // the tag a prebuilt dependency is checked against +import mcpp.pack.prebuilt; // …and the check itself +import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its manifest +import mcpp.build.build_program; +import mcpp.build.resources; // #365 Windows resources: synthesise / scan / find rc +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.lockfile; +import mcpp.config; +import mcpp.xlings; +import mcpp.runtime.binding; +import mcpp.platform.runtime_search; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.build.runner_lookup; +import mcpp.fetcher; +import mcpp.fetcher.progress; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.compat; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.version_req; +import mcpp.ui; +import mcpp.log; +import mcpp.bmi_cache; + +namespace mcpp::build { + +std::expected phase13_finish(PrepareState& state) { + BuildContext ctx; + ctx.strict = state.overrides.strict; + ctx.manifest = *state.m; + ctx.tc = *state.tc; + ctx.fp = state.fp; + ctx.runtimeSelection = state.runtimeSelection; + ctx.runtimeBinding = state.runtimeBindingSnapshot; + ctx.profile = state.effectiveProfile; + ctx.activeFeatureRequest = state.overrides.features; + ctx.compilerChoice = { std::string(tc_origin_name(state.tcOrigin)), + state.graphCompilerRequiredBy, + state.graphCompilerReplaced.empty() ? state.pinReplacedDefault + : state.graphCompilerReplaced }; + ctx.cacheMode = state.cacheMode; + ctx.projectRoot= *state.root; + ctx.outputDir = target_dir(*state.tc, state.fp, state.workRoot); + { + std::error_code ec; + const bool firstPlan = !std::filesystem::exists(ctx.outputDir / "build.ninja", ec); + for (auto const& [what, hint] : pending_flag_words_notes()) + if (firstPlan) mcpp::diag::warning("build/flag-words", what, hint); + pending_flag_words_notes().clear(); + } + ctx.stdBmi = state.stdBmiPath; + ctx.stdObject = state.stdObjectPath; + // Copied, not moved: `describedStdModule` is read again once `ctx.plan` + // exists (below), to recover the standard-library units' commands onto + // it (StdModuleUnit, C5 / D5a-b). A `std::optional` move leaves the + // source engaged with a moved-from value, so a plain move here would + // hand build_database.cppm's render() a value and the plan an empty one. + ctx.stdModule = state.describedStdModule; + // Every directory a package payload may legitimately have been INSTALLED + // into. There is more than one: the global registry, plus the two + // project-local data roots a custom git index installs into + // (`config::project_xlings_data_roots`). make_plan uses these to anchor the + // cache address of a dependency source that lives outside its own package + // root, and the cacheability gate below uses the same list to decide + // whether a package's sources really came from a store. ONE definition, + // two uses — deriving the same fact twice is how the object layout and the + // cache key drifted apart in the first place (#344). + // Which source trees does the fast path have to watch besides this one? + // + // A package whose root is neither under `projectRoot` nor under a directory + // mcpp OWNS is a `path` dependency — the shape every workspace member takes + // towards its siblings — and its sources are read on every build. See + // BuildContext::depSourceRoots for what the list is for. + // + // WHAT IS EXCLUDED, AND WHY IT IS "WHO WROTE THE DIRECTORY" RATHER THAN + // "WHICH KIND OF DEPENDENCY". An xpkg payload under the store is written + // once at install time and never edited. A git checkout under + // `/git/` is a pinned revision in a hash-addressed + // directory: changing the revision changes the directory name, and the + // manifest that names it is already swept. Neither can change under a warm + // build, so sweeping them would buy nothing and cost a directory walk per + // dependency on every invocation — which is the fast path this whole change + // exists to keep. + // + // A `path` dependency is the opposite on both counts: it is the user's + // working tree, and editing it is the point. + { + std::vector owned = state.storeRoots; + owned.push_back(mcpp::home::root()); + std::vector roots; + // The same enumeration answers a second reader: which packages were + // read from an editable tree, with their source globs (the build + // database lists them as the inputs that change the plan). + auto qualified = [](const mcpp::manifest::Manifest& pm) { + return pm.package.namespace_.empty() + ? pm.package.name + : pm.package.namespace_ + "." + pm.package.name; + }; + for (std::size_t i = 0; i < state.packages.size(); ++i) { + const auto& pkgRoot = state.packages[i].root; + if (pkgRoot.empty()) continue; + if (i > 0 && mcpp::build::path_is_under_any(pkgRoot, owned)) continue; + auto normalized = pkgRoot.lexically_normal(); + const bool known = std::ranges::any_of(ctx.sourcePackages, + [&](const BuildContext::SourcePackage& sp) { + return sp.root.lexically_normal() == normalized; + }); + if (!known) + ctx.sourcePackages.push_back({qualified(state.packages[i].manifest), + normalized, + state.packages[i].manifest.modules.sources}); + if (i == 0 || normalized == state.root->lexically_normal()) continue; + if (std::find(roots.begin(), roots.end(), normalized) == roots.end()) + roots.push_back(std::move(normalized)); + } + ctx.depSourceRoots = std::move(roots); + } + // Where a runner may find the programs this project declared (#544). The + // same resolution `fillXpkgDirs` hands to build programs, kept as + // directories rather than env vars because the reader is mcpp's own + // lookup, not a child process. See BuildContext::xlingsDepBinDirs. + // + // AND EVERY PACKAGE IN THE GRAPH, NOT ONLY THE ROOT — WHICH IS THE + // CASE THIS FEATURE EXISTS FOR. + // + // A board-support package is precisely the thing that knows which emulator + // or probe reaches its machine, and it declares that emulator under its own + // `[xlings] deps`. Collecting only the ROOT's declarations meant a runner + // could name a program by bare name only when the CONSUMER had also + // declared it — which is the duplication the board package exists to + // remove. Measured on `mcpplibs/aarch64-virt-rt`: with the board naming + // `qemu-system-aarch64` bare, `mcpp run` searched PATH, found the shim or + // nothing, and reported a missing runner while the emulator sat installed + // in the payload the board had declared. + // + // Ordering is root-first: a consumer that declares its own payload gets to + // decide, and a dependency supplies the answer when the consumer said + // nothing. A payload that is declared but not installed contributes + // nothing, and the lookup continues to PATH. + // + // AND THE SET COLLECTED HERE IS ALSO THE SET PROVISIONED. Looking in a + // directory that nothing installed is a lookup that can only fail, and the + // engine had exactly that shape: a dependency's declaration was searched + // and never acted on. The two definitions are one expression below, so + // they cannot drift — the third of the three hazards §12.6 named. + { + // THE SAME SPLIT THE EARLY PASS USED. Written once, above, next to the + // provisioning that has to happen before build.mcpp; this site reads it + // for the records below. Two copies of "what did the graph declare" + // would be two definitions of the same word. + auto split = state.graph_xlings_split(); + if (!split) { + refusal::record(refusal::Code::ToolVersionConflict); + return std::unexpected(split.error()); + } + auto& xlingsSpecs = split->first; + auto& fromGraph = split->second; + // THE ROOT'S OWN PASS RAN LONG AGO, AND THIS ONE MUST NOT REPEAT IT. + // The stamp is keyed by the LIST, so provisioning root+graph together + // would key a different list than the early pass wrote and re-run an + // xlings round trip on every build. Only what the graph added is + // provisioned here, under its own key. + // + // Since the graph's pass moved above build.mcpp this call is normally a + // stamp hit. It is kept rather than deleted because the stamp is keyed + // by content: if the early pass did not run, or ran on a different + // list, this is still the site that makes the record true. + if (!fromGraph.empty()) { + if (auto cfg = state.get_cfg(true)) { + if (auto pv = provision_xlings_addresses( + **cfg, fromGraph, *state.root, + "[xlings.workspace] entries declared by dependencies"); + !pv) return std::unexpected(pv.error()); + } + } + xlingsSpecs.insert(xlingsSpecs.end(), fromGraph.begin(), fromGraph.end()); + // What a RUN would additionally have asked for. Recorded rather than + // installed: this verb is not running anything, and installing it + // anyway is the behaviour the tier exists to remove. + if (state.toolPurpose == ToolPurpose::Build) { + for (std::size_t i = 0; i < state.packages.size() && !ctx.runTierPending; ++i) { + const auto& man = state.packages[i].manifest; + const auto feats = i < state.activeFeaturesByPackage.size() + ? state.activeFeaturesByPackage[i] : std::vector{}; + for (auto const& spec : applicable_xlings_addresses( + man, feats, ToolPurpose::Run, /*isRoot=*/i == 0)) + if (std::ranges::find(xlingsSpecs, spec) == xlingsSpecs.end()) + { ctx.runTierPending = true; break; } + } + } + if (!xlingsSpecs.empty()) { + if (auto cfg = state.get_cfg(true)) { + auto xlEnv = mcpp::config::make_xlings_env(**cfg); + 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. + for (auto& d : + mcpp::build::runner_lookup::payload_search_dirs(*dir)) + ctx.xlingsDepBinDirs.push_back(std::move(d)); + } + } + } + } + } + // ─── Prebuilt dependencies: check before planning to link them ───── + // + // Here rather than at each place a dependency manifest is loaded, because + // there are three of those and the check needs the RESOLVED toolchain, + // which only exists by now. One pass over the assembled package list is + // also the only spelling under which a package cannot be checked twice + // with two different answers. + // + // The current tag's SHAPE follows the package's: a package that publishes + // a triple-only tag is saying its interface is `extern "C"`, and comparing + // it against a full tag would refuse a combination it explicitly allows. + // `tag_check` already treats an unnamed dimension as don't-care, so one + // full tag on this side is correct for both. + { + const auto canonicalTriple = state.tc->targetTriple.empty() + ? mcpp::toolchain::triple::host_triple().str() + : [&] { + auto t = mcpp::toolchain::triple::parse(state.tc->targetTriple); + return t ? t->str() : state.tc->targetTriple; + }(); + auto currentTag = mcpp::pack::cxx_surface_tag( + *state.tc, canonicalTriple, state.m->cppStandard.level); + // What THIS build targets on the device axis. Absent means it asks for + // no accelerator, and every artifact then satisfies it vacuously — + // which is correct, and is why a descriptor lists its CPU-only variant + // first: the first accepted artifact wins. + currentTag.accel = mcpp::pack::parse_accel(state.resolvedAccel()); + for (std::size_t i = 1; i < state.packages.size(); ++i) { + auto const& pkg = state.packages[i]; + if (!mcpp::pack::is_distribution_package(pkg.manifest)) continue; + mcpp::pack::PrebuiltCheck chk{ + .packageRoot = pkg.root, + .packageLabel = mcpp::manifest::package_id(pkg.manifest.package).canonical(), + .current = currentTag, + }; + if (auto ok = mcpp::pack::check_prebuilt(pkg.manifest, chk); !ok) + return std::unexpected(ok.error()); + } + } + + // ── #519: the form each dependency takes, APPLIED ────────────────────── + // + // The answers were computed before the root build program (see there). + // + // MATERIALISED AS A TARGET KIND, on purpose. A dependency resolved to + // the shared form becomes an ordinary `SharedLibrary` target, so every + // emitter mcpp already has applies to it unchanged — the ELF soname and + // `$ORIGIN`, the PE import library and generated `.def`, the Mach-O + // install name. That is the whole reason this axis needs no new backend + // code on any of the three formats. It also means `make_plan` READS the + // answer instead of deriving it a second time. + { + namespace lf = mcpp::build::linkage_form; + + // A non-root edge that writes the key gets its request IGNORED, and + // says so — a silently dropped knob is how a knob becomes decoration. + for (std::size_t i = 1; i < state.packages.size(); ++i) + for (auto const& [depName, spec] : state.packages[i].manifest.dependencies) + if (!spec.linkage.empty()) + mcpp::diag::warning("build/dependency-linkage", std::format( + "'{}' asks for dependency '{}' to be linked as '{}'; only " + "the root project decides link forms, so this is ignored", + state.packages[i].manifest.package.name, depName, spec.linkage)); + + for (auto const& [i, form] : state.dependencyLinkForms) { + auto const& answer = form.answer; + auto const& facts = form.facts; + + if (!answer.diagnostic.empty()) + mcpp::diag::degraded("build/dependency-linkage", answer.diagnostic, + "this dependency is linked in the other form, which changes " + "whether its code travels inside the images that use it"); + // An explicit request honoured against the package's own default + // (#642 E1): the build did what was asked, so this is information, + // and it names both statements. + if (!answer.note.empty()) + mcpp::ui::info("Linkage", answer.note); + + if (answer.linkage != lf::DepLinkage::Shared) continue; + if (facts.isDistribution) continue; // nothing here to build + // A package that ALREADY declares a shared target has decided + // for itself, and its remaining library targets are not part of + // that decision. Flipping them would change what such a package + // builds under the DEFAULT request, which is the one property this + // axis promises never to touch. (No package in mcpp-index has both + // shapes at once — compat.vulkan's `lib` is overridden to `shared` + // on Linux rather than joined by it — but "unreachable today" is + // how the last few of these got in.) + if (facts.declaredShared) continue; + for (auto& t : state.packages[i].manifest.targets) + if (t.kind == mcpp::manifest::Target::Library) + t.kind = mcpp::manifest::Target::SharedLibrary; + } + } + + auto planResult = mcpp::build::make_plan(*state.m, *state.tc, state.fp, state.scan.graph, state.report.topoOrder, + state.packages, *state.root, ctx.outputDir, + state.stdBmiPath, state.stdObjectPath, state.storeRoots); + if (!planResult) return std::unexpected(planResult.error()); + ctx.plan = std::move(*planResult); + // Resolved far above, where the dependency graph first exists. It is + // attached here rather than threaded through `make_plan` because nothing + // that function does depends on it: the flag assembly that does reads the + // plan, and every reader of `compute_flags` runs after this line. + ctx.plan.targetSide = state.resolvedTargetSide; + + // C5 / D5a-b (design 2026-09-26 §3.5): the standard-library units this + // configuration's build compiles, when it imports `std`. Recovered here, + // once, from the SAME command derivation `ensure_built` and + // `describe_std_module` both read (mcpp.toolchain.stdmod), and carried on + // the plan (BuildPlan::stdModuleUnits) so compile_commands.json, + // `emit --spec compile-commands` and the S1 document render the exact + // same record and cannot disagree (P1, mcpp.build.compile_commands). + if (state.describedStdModule) { + const auto& sm = *state.describedStdModule; + auto add_std_unit = [&](const std::filesystem::path& source, + const std::vector& commands, + const std::filesystem::path& object, + const std::filesystem::path& bmi, + std::string_view module, + std::vector requiresModules) { + if (source.empty() || commands.empty()) return; + auto inv = mcpp::build::recover_invocation( + commands, source, state.tc->binaryPath, sm.cacheDir, + mcpp::platform::is_windows); + if (!inv) { + state.planNotes.push_back({"MCPP_BUILD_DATABASE_STD_UNIT_UNDESCRIBED", + std::format("no command that builds the {} module names its " + "source '{}'; the unit is not listed", + module, source.string())}); + return; + } + ctx.plan.stdModuleUnits.push_back(mcpp::build::StdModuleUnit{ + .source = source, + .workDirectory = std::move(inv->workDirectory), + .arguments = std::move(inv->arguments), + .object = object, + .bmi = bmi, + .module = std::string(module), + .requiresModules = std::move(requiresModules), + }); + }; + add_std_unit(state.tc->stdModuleSource, sm.stdCommands, sm.objectPath, + sm.bmiPath, "std", {}); + add_std_unit(state.tc->stdCompatSource, sm.compatCommands, sm.compatObjectPath, + sm.compatBmiPath, "std.compat", {"std"}); + } + + // A DEPENDENCY'S C++ SHARED LIBRARY OVER A C++ RUNTIME THAT IS A PACKAGE + // (#641, item 5). + // + // The runtime package is linked like every other static package: its + // objects go into the program. A dependency's shared library is linked from + // its own package's objects, and `-nostdlib++` withholds the driver's + // runtime, so the library has no C++ runtime at all. A private copy does + // not come for free either: `llvm.libcxx` compiles its classes with hidden + // visibility, so no image resolves against another's copy, and each image + // then holds its own type information for the library's classes. Measured + // on x86_64 Linux, an exception of `std::runtime_error` thrown in such a + // library is not caught by that type in the program; libc++ documents the + // same identity split for hidden types on arm64 Apple. + // + // So the private copy is linked only when the manifest states it for + // shared libraries (`cxx_runtime = { shared = "self-contained" }`, the key + // that already means a private runtime in each shared library for the + // payload's runtime), and every other case is refused here, before + // anything compiles. Each build this refuses failed at link before. + if (state.resolvedTargetSide.cxx.fromGraph() && state.cxxLayerProviderIndex + && *state.cxxLayerProviderIndex < state.packages.size()) { + namespace dist = mcpp::build::dist; + auto const& provider = state.packages[*state.cxxLayerProviderIndex].manifest; + const auto providerName = mcpp::build::qualified_package_name(provider); + auto const& bc = ctx.plan.manifest.buildConfig; + const auto format = dist::format_for( + state.tc->targetTriple, + mcpp::platform::is_windows ? dist::Format::Pe + : mcpp::platform::is_macos ? dist::Format::MachO + : dist::Format::Elf); + const bool privateCopy = + dist::stated_shared_library_contract(bc.cxxRuntime, bc.cxxRuntimeShared, + bc.staticStdlib, format) + == dist::Contract::SelfContained; + // A refused library, and the statement that makes it shared when its + // own package makes it so: an edge's `linkage = "static"` cannot change + // a form the package constrains (`declaredShared`), so that remedy is + // offered only where a request or the package's default decided. + struct Refused { std::string name; std::string statedBy; }; + std::vector withoutRuntime; + const auto runtimeObjects = mcpp::build::package_link_objects(ctx.plan, providerName); + for (auto& lu : ctx.plan.linkUnits) { + if (lu.kind != mcpp::build::LinkUnit::SharedLibrary || !lu.dependencyOwned) + continue; + if (!mcpp::build::link_unit_holds_cxx(ctx.plan, lu)) continue; + if (privateCopy) { + for (auto const& o : runtimeObjects) + if (std::ranges::find(lu.objects, o) == lu.objects.end()) + lu.objects.push_back(o); + continue; + } + Refused r{ lu.targetName, {} }; + for (auto const& [i, form] : state.dependencyLinkForms) { + if (!form.facts.declaredShared || i >= state.packages.size()) continue; + for (auto const& t : state.packages[i].manifest.targets) + if (t.name == lu.targetName) + r.statedBy = form.facts.declaredSharedBy.empty() + ? std::string("its manifest declares a shared library target") + : form.facts.declaredSharedBy; + } + withoutRuntime.push_back(std::move(r)); + } + if (!withoutRuntime.empty()) { + std::string names, constrained; + bool anyRequested = false; + for (auto const& r : withoutRuntime) { + names += (names.empty() ? "'" : ", '") + r.name + "'"; + if (r.statedBy.empty()) anyRequested = true; + else constrained += std::format( + " '{}' states its form itself ({}), so its edge cannot " + "link it static.\n", r.name, r.statedBy); + } + const std::string staticRemedy = anyRequested + ? " Link the dependency static, on its edge in [dependencies]:\n" + "\n" + " = { ..., linkage = \"static\" }\n" + "\n" + " or give each shared library a private copy of the runtime:\n" + : " Give each shared library a private copy of the runtime:\n"; + refusal::record(refusal::Code::SharedLibraryCxxRuntime); + return std::unexpected(std::format( + "{} {} linked as a shared library, and this graph's C++ runtime is " + "the package '{}@{}', whose objects are linked into the program.\n" + " A shared library built here would have no C++ runtime: the " + "package compiles its runtime\n" + " with hidden visibility, so one image cannot use another " + "image's copy.\n" + "{}{}" + "\n" + " [build]\n" + " cxx_runtime = {{ shared = \"self-contained\" }}\n" + "\n" + " With a private copy, an exception of a standard library class " + "thrown in the shared\n" + " library is not caught by that class in the program, because " + "each copy has its own\n" + " type information.", + names, withoutRuntime.size() == 1 ? "is" : "are", + providerName, provider.package.version, constrained, staticRemedy)); + } + } + + // ONE PROCESS, ONE C++ RUNTIME; ONE STATIC PACKAGE, ONE IMAGE (#646). + // + // Both are decided by `make_plan` and the contract table; this is where a + // decision that cannot be delivered stops the build before it compiles. + { + namespace dist = mcpp::build::dist; + auto const& bc = ctx.plan.manifest.buildConfig; + const auto format = dist::format_for( + state.tc->targetTriple, + mcpp::platform::is_windows ? dist::Format::Pe + : mcpp::platform::is_macos ? dist::Format::MachO + : dist::Format::Elf); + const dist::CxxSharedLoad load{ + .program = mcpp::build::image_loads_cxx_shared_library( + ctx.plan, mcpp::build::LinkUnit::Binary), + .tests = mcpp::build::image_loads_cxx_shared_library( + ctx.plan, mcpp::build::LinkUnit::TestBinary), + }; + const auto contracts = dist::role_contracts( + dist::ContractStatement{ + .cxxRuntime = bc.cxxRuntime, + .cxxRuntimeTests = bc.cxxRuntimeTests, + .cxxRuntimeShared = bc.cxxRuntimeShared, + .staticStdlib = bc.staticStdlib, + }, + format, load); + // F3a. A stated self-contained program over a coupled C++ shared + // library of this build: the program would carry a static C++ runtime + // and the library would load a shared one. The unstated case needs no + // refusal, because `role_contracts` then gives the program the + // library's contract. + if (auto role = dist::runtime_split(contracts, format, load)) { + std::string libraries; + for (auto const& lu : ctx.plan.linkUnits) { + if (lu.kind != mcpp::build::LinkUnit::SharedLibrary) continue; + if (!mcpp::build::link_unit_holds_cxx(ctx.plan, lu)) continue; + libraries += (libraries.empty() ? "'" : ", '") + lu.targetName + "'"; + } + const bool tests = *role == dist::Role::Test; + refusal::record(refusal::Code::ProgramCxxRuntimeSplit); + return std::unexpected(std::format( + "this build's {} state a self-contained C++ runtime and load the C++ " + "shared library {}, which is linked against the {} C++ runtime.\n" + " The process would hold two C++ runtimes: the program exports the " + "runtime symbols the\n" + " library references, the library binds some of them there and keeps " + "the rest, and the two\n" + " halves disagree about shared state (measured: a string formatted in " + "the library aborts\n" + " with std::bad_cast).\n" + " Remove the self-contained statement for {} (the `{}` value of " + "[build] cxx_runtime), and\n" + " they take the shared library's contract, or give the shared library " + "a private copy of the\n" + " runtime:\n" + "\n" + " [build]\n" + " cxx_runtime = {{ shared = \"self-contained\" }}", + tests ? "tests" : "programs", + libraries.empty() ? std::string("'(unnamed)'") : libraries, + dist::to_string(contracts.shared), + tests ? "tests" : "programs", tests ? "tests" : "default")); + } + + // MACH-O: EVERY IMAGE CARRIES ITS OWN HIDDEN libc++ (#646 F2). + // + // The Mach-O default is self-contained for every role, and each image + // embeds the payload's `libc++.a` through `-load_hidden`, so the type + // information of a standard library class exists once per image and libc++ + // compares it by address. Measured on macos-15 for this release: with the + // default, a `std::runtime_error` thrown in a dylib is NOT caught by its + // class in the program and two `std::error_code` categories compare + // unequal; with `cxx_runtime = "host-coupled"` for every role, both hold. + // The default is not changed here, because it is what every macOS build + // ships today and changing it is its own record; a build that would meet + // the split is told, once, what it is and how to avoid it. + if (format == dist::Format::MachO && (load.program || load.tests) + && contracts.shared == dist::Contract::SelfContained) { + std::string libraries; + for (auto const& lu : ctx.plan.linkUnits) { + if (lu.kind != mcpp::build::LinkUnit::SharedLibrary) continue; + if (!mcpp::build::link_unit_holds_cxx(ctx.plan, lu)) continue; + libraries += (libraries.empty() ? "'" : ", '") + lu.targetName + "'"; + } + mcpp::diag::degraded("build/cxx-runtime-identity", + std::format("this build's program and the C++ shared library {} each " + "carry a private copy of the C++ runtime", + libraries.empty() ? std::string("'(unnamed)'") : libraries), + "on Mach-O every image embeds the payload's libc++ with hidden " + "visibility, so the type information of a standard library class exists " + "once per image: measured on macOS, an exception of such a class thrown " + "in the library is not caught by that class in the program, and two " + "error categories compare unequal", + "state one runtime for the process, for example [build] cxx_runtime = " + "\"host-coupled\", when objects cross the boundary as exceptions or as " + "libc++ values compared by identity"); + } + + // F1. A static package that several images reach. Refused where the + // build cannot work (Mach-O and PE resolve every reference at link + // time; Android's Java host loads an application's shared library + // before anything that could supply the package), reported on other + // ELF rows, where the library binds to the program's copy at run time + // as it always has. + if (!ctx.plan.staticPlacementConflicts.empty()) { + const bool applicationRow = std::ranges::any_of(ctx.plan.linkUnits, + [](auto const& lu) { + return lu.kind == mcpp::build::LinkUnit::SharedLibrary + && !lu.dependencyOwned && lu.entryMain.has_value(); + }); + const bool refuse = format == dist::Format::MachO + || format == dist::Format::Pe || applicationRow; + std::string listing; + for (auto const& c : ctx.plan.staticPlacementConflicts) { + std::string reachers; + if (c.program) reachers = "the program"; + for (auto const& image : c.images) + reachers += (reachers.empty() ? "'" : ", '") + image + "'"; + listing += std::format(" '{}' is reached by {}\n", c.package, reachers); + } + const std::string first = ctx.plan.staticPlacementConflicts.front().package; + const std::string remedy = std::format( + " Link the package shared, so that every image loads one copy: on its " + "edge in [dependencies],\n" + "\n" + " {} = {{ ..., linkage = \"shared\" }}\n" + "\n" + " or as the package's own default, in its manifest:\n" + "\n" + " [targets.]\n" + " linkage = \"shared\"", first); + if (refuse) { + refusal::record(refusal::Code::StaticPackageInTwoImages); + return std::unexpected(std::format( + "a static package is linked into more than one image of this build, " + "and on this target\n" + " an image cannot use another image's copy:\n{}{}", + listing, remedy)); + } + mcpp::diag::degraded("build/static-placement", + std::format("a static package is reachable from more than one image of " + "this build and is linked into the program only:\n{}", + listing), + "the shared libraries bind to the program's copy at run time, which only " + "an ELF process whose program links the package can do; the same graph " + "is refused on Mach-O, PE and the Android application row", + std::format("give the package the shared form, e.g. {} = {{ ..., linkage " + "= \"shared\" }}", first)); + } + } + + // The module graph outlives the plan for one consumer: `mcpp pack`, which + // has to know which units are INTERFACE (published as source) and which + // are implementation (published only as an object). The plan flattens that + // away — a CompileUnit records what to compile, not what it provides — so + // the packer would otherwise have to scan the tree a second time and could + // then disagree with the build about what the package even contains. + ctx.graph = std::move(state.scan.graph); + // mcpp#407. Both callers that produce a non-plain graph arrive here the + // same way: dev-dependencies enabled, synthetic test targets appended. The + // resulting `default` line names the test binaries and omits the package's + // own target, and the output directory is shared with plain builds because + // the fingerprint covers neither input. Stamping it on the plan is what + // lets the graph say so about itself. + ctx.plan.graphShape = (state.includeDevDeps || !state.extraTargets.empty()) + ? mcpp::build::GraphShape::WithTests + : mcpp::build::GraphShape::Normal; + // The device variant an override chose is stamped for the same reason: the + // fast path runs without overrides, so a graph written under one must not + // be the graph it replays. + ctx.plan.accelOverridden = !state.overrides.accel.empty(); + + // THE MACHINE'S JOB DEFAULT, resolved unconditionally and never fatally. + // + // `get_cfg` is lazy, so by this point the config may or may not have been + // loaded -- a project with no dependencies can reach here without touching + // it. Asking for it here rather than reading whatever `cfg_opt` happens to + // hold is the point: otherwise the same project would honour + // `[build] default_jobs` or ignore it depending on whether it has + // dependencies, which is an answer that depends on an unrelated axis. + // + // A failure is discarded. This value is a concurrency hint, and a build + // must not fail because the machine's preferred job count could not be + // read; every other consumer of the config already reports its own + // failures with a diagnostic that fits what it needed the config FOR. + // `requireBootstrap=false` because nothing here needs the bootstrap + // toolchain. + int globalDefaultJobs = 0; + if (auto c = state.get_cfg(/*requireBootstrap=*/false)) + globalDefaultJobs = static_cast((*c)->defaultJobs); + ctx.globalDefaultJobs = globalDefaultJobs; + + // Resolve the module-edge schedule ONCE, here, where both the toolchain and + // the manifest are in hand. The backend writes the graph in this shape, the + // graph records the tag, and `mcpp build --verbose` prints the reason — all + // three read this, none of them re-derives it. + { + const auto decision = mcpp::build::schedule::decide( + ctx.plan.toolchain, + // Warned HERE and not at the fingerprint call above, which reads the + // same switch a few hundred lines earlier: both get the normalised + // value, only one of them says anything, so a typo produces exactly + // one warning rather than two identical ones. + mcpp::build::schedule::requested_switch(*state.m, [](std::string_view bad) { + mcpp::ui::warning(std::format( + "ignoring invalid bmi_schedule '{}' (expected \"auto\", \"on\" or \"off\")", bad)); + }), + mcpp::build::schedule::resolve_jobs(*state.m, [](std::string_view bad) { + mcpp::ui::warning(std::format( + "ignoring invalid job count '{}' (expected a positive number or 'auto')", bad)); + }, globalDefaultJobs), + // What this machine would pick if asked. Impure, so it is resolved + // here and handed to the pure `decide`. Only DetachCodegen uses it, + // and only when the user gave no job count — without it that + // strategy ships `sched_cap = 0`, which disables the semaphore that + // is its ONLY bound on how many compilers run at once. + mcpp::platform::capacity::recommended_jobs( + mcpp::platform::capacity::host_capacity())); + ctx.plan.scheduleTag = std::string(mcpp::build::schedule::to_string(decision.strategy)); + ctx.plan.scheduleNinjaJobs = decision.ninjaJobs; + ctx.plan.scheduleCompilerCap = decision.compilerCap; + mcpp::log::verbose("build", std::format("schedule: {} — {}", + ctx.plan.scheduleTag, decision.reason)); + + } + ctx.plan.runtimeBinding = state.runtimeBindingSnapshot; + mcpp::build::merge_runtime_binding_contract( + ctx.plan, state.runtimeBindingSnapshot); + ctx.plan.compileDbPath = state.workRoot / "compile_commands.json"; + // GCC: a clean `*link:` for this build, so the payload's specs cannot + // inject other homes' rpath entries into the artifact. AFTER the plan is + // moved in — an earlier assignment was silently overwritten by that move, + // which produced a generated file that nothing ever passed to the driver. + // Generated here rather than in compute_flags, which runs twice per build. + // A link input only: a plan that builds nothing (`plan_only`) neither reads + // it nor runs the driver to produce it, and its compile arguments are the + // same without it. + if (state.tc->compiler == mcpp::toolchain::CompilerId::GCC && !state.overrides.plan_only) + ctx.plan.gccCleanSpecs = mcpp::toolchain::write_clean_link_specs( + state.tc->binaryPath, ctx.outputDir); + + // ── Declared build-graph nodes → the plan ─────────────────────────────── + // + // Collected here rather than inside make_plan because the engine-variable + // vocabulary an action may reference includes values that only exist once + // the plan does (outputDir is fingerprint-derived; a target's file name is + // a link unit's output). + // + // The vocabulary is CLOSED on purpose. An action's command is an argv, not + // a shell string, and the only interpolations are these four — which is + // what makes an action portable (Windows has no shell to assume) and + // cacheable (nothing can smuggle in ambient state). + { + // An engine variable that resolves to nothing must be an ERROR, not an + // empty string: `${mcpp.target_file:tpyo}` would otherwise silently + // 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, + // and the tool then reads the build directory root -- which exists, so + // the mistake produces a plausible artifact instead of a diagnostic. + // Section 2 of the design record measured that shape: a valid, empty, + // 52 KB installer with nothing said about it. + std::set stageDirNoPass, stageDirWrongRole; + // Carried from `state.overrides` so the refusal below can say WHY there is + // no tree, which is a different sentence from "you are not packaging". + std::string stageDirWhy; + // WHETHER *THIS* ACTION REFERENCED THE STAGED TREE, and deliberately a + // flag rather than a set keyed on the action's id: an id is unique + // within the package that declared it and nothing more, so two packages + // may each submit a `dist` action called `package`. A set would then + // hand one package's implicit dependency to the other's edge -- the + // shape where a predicate is right and the object is wrong, which does + // not fail, it answers about something else. + // + // The diagnostic sets below stay keyed by id because a diagnostic + // NAMES ids and a collision there costs a duplicate line, not a wrong + // edge. + bool thisActionUsesStageDir = false; + const bool stagePass = !state.overrides.pack_stage_dir.empty(); + auto substitute = [&](std::string s, const char* actionId, + mcpp::manifest::BuildAction::Role role) { + auto rep = [&](std::string_view what, const std::string& with) { + for (std::size_t p; (p = s.find(what)) != std::string::npos; ) + s.replace(p, what.size(), with); + }; + rep("${mcpp.out_dir}", ctx.plan.outputDir.string()); + rep("${mcpp.bin_dir}", (ctx.plan.outputDir / "bin").string()); + rep("${mcpp.compile_db}", ctx.plan.compileDbPath.string()); + // The engine's own executable, absolute (2026.9.13.1+). An action + // whose command is an argv with no shell has no portable way to + // copy, touch or compare a file, and the engine is the one + // program present wherever a build runs -- the reason a `check` + // is wrapped with `mcpp __action-stamp` (ninja_backend.cppm). This + // token lets a build program say the same thing: `${mcpp.self} + // stage --verify content --output ` is the copy every + // `stage_file` edge already performs. The same caveat as the + // wrapper's: a version change regenerates build.ninja, and a + // binary moved under an unchanged version leaves a stale path, + // exactly as it would for the compiler. + rep("${mcpp.self}", mcpp::platform::fs::self_exe_path().string()); + // ABSOLUTE, unlike `${mcpp.target_file:}` and for the same reason + // stated the other way round: the staged tree lives outside the + // build directory and no ninja edge produces it, so there is no + // edge-declared spelling to agree with. `${mcpp.out_dir}` above is + // absolute on the same grounds. + if (s.find("${mcpp.stage_dir}") != std::string::npos) { + if (!stagePass) { + stageDirNoPass.insert(actionId); + stageDirWhy = state.overrides.pack_stage_reason; + } else if (role != mcpp::manifest::BuildAction::Role::Artifact) { + stageDirWrongRole.insert(actionId); + } else { + thisActionUsesStageDir = true; + } + rep("${mcpp.stage_dir}", state.overrides.pack_stage_dir.string()); + } + constexpr std::string_view kTf = "${mcpp.target_file:"; + for (std::size_t p; (p = s.find(kTf)) != std::string::npos; ) { + auto close = s.find('}', p); + if (close == std::string::npos) break; + auto name = s.substr(p + kTf.size(), close - p - kTf.size()); + // The link unit's BUILD-DIR-RELATIVE output, not an absolute + // path. ninja identifies a file by the string an edge declares, + // and the link edge declares `bin/app`; an absolute reference + // to the same bytes is a DIFFERENT node, which ninja reports as + // "missing and no known rule to make it". Commands run with + // cwd = the build dir, so the relative form is also what the + // tool being invoked should receive. + std::string resolved; + for (auto const& lu : ctx.plan.linkUnits) + if (lu.targetName == name) + resolved = lu.output.generic_string(); + 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) { + // The declaring package, recorded here because this is the only + // place that knows it: the build program emitted the action, and a + // program has no idea which package the engine loaded it for. + // mcpp#534's ordering edge is scoped to this name. + auto owner = mcpp::build::qualified_package_name(mm); + for (auto a : mm.buildConfig.actions) { + thisActionUsesStageDir = false; + const auto sub = [&](std::string v) { + return substitute(std::move(v), a.id.c_str(), a.role); + }; + for (auto& x : a.inputs) x = sub(x); + for (auto& x : a.outputs) x = sub(x); + for (auto& x : a.command) x = sub(x); + // Same closed vocabulary as outputs — a depfile commonly + // wants to live at `${mcpp.out_dir}/.d`, beside the + // output it describes, and `prepare_actions` above + // 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 + // shared library while the program's own bytes did not would + // leave the previous distributable in place, reported as + // up to date. + if (thisActionUsesStageDir) { + a.consumesStageDir = true; + a.inputs.push_back( + mcpp::pack::stage_manifest_path(state.overrides.pack_stage_dir).string()); + } + a.packageName = owner; + ctx.plan.actions.push_back(std::move(a)); + } + // Every package's declaration, on every pass. Sorted and de-duplicated + // below so the refusal's list reads the same whatever order resolution + // walked the graph in. + for (auto const& f : mm.buildConfig.packFormats) + ctx.plan.providedPackFormats.push_back(f); + }; + collect(*state.m); + for (std::size_t i = 1; i < state.packages.size(); ++i) + collect(state.packages[i].manifest); + std::ranges::sort(ctx.plan.providedPackFormats); + ctx.plan.providedPackFormats.erase( + std::ranges::unique(ctx.plan.providedPackFormats).begin(), + ctx.plan.providedPackFormats.end()); + ctx.plan.packFormat = state.overrides.pack_format; + if (!stageDirNoPass.empty()) { + std::string ids; + for (auto const& n : stageDirNoPass) ids += (ids.empty() ? "" : ", ") + n; + if (!stageDirWhy.empty()) { + return std::unexpected(std::format( + "build.mcpp action(s) [{}] reference ${{mcpp.stage_dir}}, and no " + "tree could be staged for this target.\n" + " {}\n" + " The format was requested and the provider was reached; what is " + "missing is the staged\n" + " closure itself. A member that names a built file with " + "${{mcpp.target_file:}} instead\n" + " of reading the tree is unaffected on this target.", + ids, stageDirWhy)); + } + return std::unexpected(std::format( + "build.mcpp action(s) [{}] reference ${{mcpp.stage_dir}}, and this " + "build is not packaging.\n" + " The staged tree is produced by `mcpp pack` after the link, so " + "it does not exist during\n" + " a plain build and there is nothing for the placeholder to name.\n" + " Gate the submission on the format you provide:\n" + " mcpp::provides_pack_format(\"\"); // always\n" + " if (std::string_view(mcpp::pack_format()) == \"\") " + "// then submit\n" + " and reach the tree with `mcpp pack --format `.", ids)); + } + if (!stageDirWrongRole.empty()) { + std::string ids; + for (auto const& n : stageDirWrongRole) ids += (ids.empty() ? "" : ", ") + n; + return std::unexpected(std::format( + "build.mcpp action(s) [{}] reference ${{mcpp.stage_dir}} with a role " + "other than \"artifact\".\n" + " Only an artifact action runs after the link, and the staged tree " + "is a link output's\n" + " successor: a source, object or check action is scheduled before " + "there is anything to stage.\n" + " use: role = \"artifact\"", ids)); + } + if (!unresolvedTargets.empty()) { + std::string bad, known; + for (auto const& n : unresolvedTargets) bad += (bad.empty() ? "" : ", ") + n; + for (auto const& lu : ctx.plan.linkUnits) + known += (known.empty() ? "" : ", ") + lu.targetName; + return std::unexpected(std::format( + "build.mcpp action references unknown target(s) via " + "${{mcpp.target_file:...}}: {}\n" + " targets in this build: [{}]\n" + " (a target gated by required_features is absent unless those " + "features are active)", + 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. + // + // The strings are pushed VERBATIM. ninja identifies a file by the string + // an edge declares, and the action edge declares whatever + // prepare_actions produced (an absolute path); handing the link edge a + // prettier relative spelling of the same bytes creates a second node and + // "missing and no known rule to make it" — the same trap + // ${mcpp.target_file:} documents just above. + std::set unknownObjectTargets; + for (auto const& a : ctx.plan.actions) { + if (a.role != mcpp::manifest::BuildAction::Role::Object) continue; + + // Validate EVERY named target, not just the case where none of them + // matched. Gating the check on "nothing attached" meant + // `.target("app").target("aap")` attached to `app` and dropped the + // typo without a word — while both the type comment and the docs + // promise an unknown name is an error. A per-name check is also the + // only one that scales: the failure it catches is a target that + // exists in one configuration and not another. + for (auto const& t : a.targets) { + bool known = false; + for (auto const& lu : ctx.plan.linkUnits) + if (lu.targetName == t) { known = true; break; } + if (!known) unknownObjectTargets.insert(t); + } + + bool attached = false; + for (auto& lu : ctx.plan.linkUnits) { + // Empty targets = every LINKED IMAGE, and a test binary is one. + // Excluding it made `mcpp build` succeed while `mcpp test` died + // with `undefined symbol` on the very symbol the action exists + // to provide — the library code under test links the same + // objects, so a blob/`.def`/pre-built `.o` has to reach it too. + // Naming the test target instead is not a workaround: test link + // units are DISCOVERED from tests/*.cpp, so their names are not + // in mcpp.toml and a build.mcpp that spells one stops building + // under plain `mcpp build`, where that unit does not exist. + // (`[resources]` makes the opposite call on purpose: an icon + // belongs to what ships, not to a test runner.) + // + // A STATIC LIBRARY IS ONE OF THEM, and leaving it out was + // the whole of what C-6 needed. A package whose device code is + // its point -- ggml's CUDA backend is 305 `.cu` files behind a + // `kind = "lib"` target -- emitted its actions, watched every + // one of them be dropped with a warning, and produced an + // archive with no device code in it. The archive rule already + // consumes `lu.objects`, so the objects an action produced + // belong there for exactly the reason a compiled `.cpp`'s do: + // the target's content is what it was told to contain. + // + // AND NOT A DEPENDENCY'S IMAGE. "Every linked image" means + // every image THIS PACKAGE produces; a `kind = "shared"` + // dependency contributes a link unit to this plan and is not + // one of them. Without the qualifier the SYCL example's device + // island was linked into `compat:opencl`'s ICD loader as well + // -- a C library carrying `saxpy_device` -- and the process + // held two copies of it. An action that means to reach a + // dependency's target cannot: it is not this package's to + // fill, and naming it explicitly already fails as unknown. + const bool image = !lu.dependencyOwned + && (lu.kind == mcpp::build::LinkUnit::Binary + || lu.kind == mcpp::build::LinkUnit::SharedLibrary + || lu.kind == mcpp::build::LinkUnit::StaticLibrary + || lu.kind == mcpp::build::LinkUnit::TestBinary); + const bool wanted = a.targets.empty() + ? image + : std::find(a.targets.begin(), a.targets.end(), + lu.targetName) != a.targets.end(); + if (!wanted) continue; + for (auto const& o : a.outputs) lu.objects.emplace_back(o); + attached = true; + } + + // No consumer at all. The edge is excluded from `actionDefaults` + // (its outputs are supposed to be reachable through a link edge), so + // this is not "builds but unused" — the command never runs and the + // build says nothing. Same shape, and same diagnostic, as + // `resources/no-image`. + if (!attached && a.targets.empty()) { + mcpp::diag::degraded("action/no-target", std::format( + "build.mcpp action '{}' has role = \"object\" but this build " + "produces no target to put its outputs into", + a.id.empty() ? "" : a.id), + "the action never runs and its outputs are never produced", + "add a [targets.] — a bin, a lib, a shared lib or a " + "test all take one — or name the targets explicitly with " + ".target(\"…\")"); + } + } + if (!unknownObjectTargets.empty()) { + std::string bad, known; + for (auto const& n : unknownObjectTargets) bad += (bad.empty() ? "" : ", ") + n; + for (auto const& lu : ctx.plan.linkUnits) + known += (known.empty() ? "" : ", ") + lu.targetName; + return std::unexpected(std::format( + "build.mcpp action with role = \"object\" names unknown " + "target(s): {}\n" + " targets in this build: [{}]\n" + " (a target gated by required_features is absent unless those " + "features are active; test binaries exist only under `mcpp " + "test`, so name none and the outputs reach every target " + "including them)", + bad, known.empty() ? std::string("none") : known)); + } + } + ctx.plan.stdCompatBmiPath = state.stdCompatBmiPath; + ctx.plan.stdCompatObjectPath = state.stdCompatObjectPath; + + // Clang: discover clang-scan-deps for P1689 dyndep scanning. + if (mcpp::toolchain::is_clang(*state.tc)) { + if (auto sd = mcpp::toolchain::clang::find_scan_deps(*state.tc)) { + ctx.plan.scanDepsPath = *sd; + } + } + + // ─── Assembly units: validate + resolve the assembler ───────────── + // .S/.s ride the C driver (GAS) — the MSVC dialect has no such path. + // .asm is NASM: x86-family only, and the binary is resolved LAZILY — + // only when the plan actually contains .asm units — as a hard failure, + // never a silent skip (a dropped .o surfaces as undefined references + // much later; fail here with the real cause instead). + { + bool hasGas = false, hasNasm = false; + for (auto& cu : ctx.plan.compileUnits) { + if (cu.kind == mcpp::SourceKind::GasAsm) hasGas = true; + else if (cu.kind == mcpp::SourceKind::NasmAsm) hasNasm = true; + } + if (hasGas && mcpp::toolchain::dialect_for(*state.tc).id == "msvc") { + return std::unexpected(std::string( + "GAS assembly sources (.S/.s) are not supported by the MSVC " + "toolchain; use NASM syntax (.asm) or a MinGW/LLVM toolchain, " + "or `!`-exclude them in [build].sources")); + } + if (hasNasm) { + auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + auto fmt = trip.nasm_format(); + if (!fmt) { + return std::unexpected(std::format( + "NASM sources (.asm) are x86-only, but the target is {}; " + "gate them off non-x86 targets (a feature, or a " + "`!`-exclude glob in [build].sources)", trip.str())); + } + ctx.plan.nasmFormat = *fmt; + + // #232: nasm used to go through a bespoke `ensure_nasm` path + // whose `if (cfgNasm)` guard silently swallowed a `get_cfg()` + // bootstrap failure (misreporting it as "no nasm"), and whose + // install fallback never refreshed the package index and + // downgraded a failed install to a warning. Surface the real + // config error, then provision through the SAME synchronous + // gate the compiler toolchain uses (index refresh before + // install, blocking install, hard error on failure) — see the + // toolchain resolution block above (~line 872-899). + auto cfgNasm = state.get_cfg(true); + if (!cfgNasm) return std::unexpected(cfgNasm.error()); + + std::optional nasmBin = + mcpp::xlings::find_usable_nasm(mcpp::config::make_xlings_env(**cfgNasm)); + if (!nasmBin) { + mcpp::fetcher::Fetcher nasmFetcher(**cfgNasm); + mcpp::fetcher::InstallProgressHandler nasmProgress; + auto nasmTarget = std::format("xim:nasm@{}", + mcpp::xlings::pinned::kNasmVersion); + auto payload = nasmFetcher.resolve_xpkg_path( + nasmTarget, /*autoInstall=*/true, &nasmProgress); + if (!payload) { + return std::unexpected(std::format( + "NASM sources (.asm) present but nasm provisioning " + "failed: {}", payload.error().message)); + } + nasmBin = mcpp::xlings::find_sandbox_nasm( + mcpp::config::make_xlings_env(**cfgNasm)); + } + if (!nasmBin) { + return std::unexpected(std::string( + "NASM sources (.asm) present but no usable nasm (>= 2.16) " + "was found or installable; install one via `xlings install " + "nasm` or your system package manager")); + } + // A HOST TOOL THAT REACHES A BUILD IS NAMED THERE. The sandbox + // copy is tried first (mcpp.xlings::find_usable_nasm), so this + // fires only where that route could not serve: an offline machine + // that already has an assembler. Saying nothing would leave two + // machines assembling the same source with different tools and + // no line in either build recording which. + if (mcpp::xlings::nasm_is_from_host( + mcpp::config::make_xlings_env(**cfgNasm), *nasmBin)) { + mcpp::diag::degraded("build/nasm-from-host", std::format( + "the assembler for this build is the host's ('{}'), not " + "the one this engine pins", nasmBin->string()), + "two machines can assemble the same source with different " + "assemblers, and the build records only this line", + "run `xlings install nasm` so the pinned copy is used"); + } + ctx.plan.nasmPath = *nasmBin; + } + } + + // ─── Windows resources: [resources] → a tracked link input (mcpp#365) ── + // + // Four rules, in this order: + // 1. Only the ROOT package's [resources] is read. A dependency's version + // resource would fight its consumer's for ordinal 1, and a dependency + // that produces no PE image of its own has nothing to embed into. + // 2. A DECLARED FILE THAT DOES NOT EXIST IS AN ERROR — on EVERY target. + // Whether a path exists is a fact about the working tree, not about + // the target; gating it on is_pe() meant a Linux or macOS CI could not + // see a typo in `icon = …` at all and only the Windows job went red, + // which is the same "find out late" failure the hard error exists to + // remove. Existence is checked everywhere; only COMPILATION is PE-only. + // 3. On a non-PE target nothing is compiled — no units, no warning, + // byte-identical build. This is what makes `cfg(windows)` unnecessary + // (and it could not be used anyway: the conditional channel carries + // BuildInputs only). + // 4. Nothing to embed into (an archive-only package) → say so and stop. + // + // The same pipeline carries the application manifest of `windows_code_page` + // (#693). A PE executable embeds one that makes its process ANSI code page + // UTF-8 when its target says `windows_code_page = "utf-8"`, or, with nothing + // said, when it is built as a host tool (D6): such a tool receives mcpp's + // UTF-8 paths on its command line. `legacy` opts out, and an ordinary target + // that says nothing embeds nothing (M6: the program's encoding is its own). + // + // The host-tool default yields to a manifest the package embeds itself + // through `[resources] files`: both would sit at ordinal 1, the package + // said nothing about code pages, and its own manifest is the one it ships. + // A DECLARED `utf-8` beside such a manifest is refused below instead. + const bool hostToolBuild = state.overrides.tool_depth > 0; + const bool ownManifest = hostToolBuild + && std::ranges::any_of(state.m->resources.files, [&](const auto& f) { + const auto abs = (f.is_absolute() ? f : (*state.root / f)).lexically_normal(); + return mcpp::build::resources::scan_rc(abs).declaresManifest; + }); + auto codePageOf = [&](const mcpp::manifest::Target& t) -> std::string_view { + if (!t.windowsCodePage.empty()) return t.windowsCodePage; + return (hostToolBuild && t.is_program() && !ownManifest) ? "utf-8" : "legacy"; + }; + const bool anyUtf8Image = std::ranges::any_of(state.m->targets, [&](const auto& t) { + return t.is_program() && codePageOf(t) == "utf-8"; + }); + if (state.m->resources.declared() || anyUtf8Image) { + namespace rsrc = mcpp::build::resources; + const auto& R = state.m->resources; + + // Rule 2 — target-independent, so it runs before the is_pe() gate. + auto resolve_declared = [&](const std::filesystem::path& p, + std::string_view key) + -> std::expected + { + // Lexical, not weakly_canonical: canonicalising resolves symlinks, + // and a symlinked source tree would then bake a different path into + // the generated script than the one the user wrote. (Same reason + // mcpp#344 made the cache anchor lexical.) + auto abs = (p.is_absolute() ? p : (*state.root / p)).lexically_normal(); + std::error_code ec; + if (!std::filesystem::is_regular_file(abs, ec)) + return std::unexpected(std::format( + "[resources] {} = \"{}\" does not exist (looked at {}).\n" + " A declared resource is a build input like any other " + "source: mcpp will not quietly ship a binary without it. " + "Remove the key if the resource is not wanted.", + key, p.generic_string(), abs.generic_string())); + return abs; + }; + + std::filesystem::path iconAbs; + if (!R.icon.empty()) { + auto r = resolve_declared(R.icon, "icon"); + if (!r) return std::unexpected(r.error()); + iconAbs = *r; + } + std::vector extraInputs; + for (auto const& e : R.extraInputs) { + auto r = resolve_declared(e, "extra-inputs"); + if (!r) return std::unexpected(r.error()); + extraInputs.push_back(*r); + } + std::vector scriptFiles; + for (auto const& f : R.files) { + auto r = resolve_declared(f, "files"); + if (!r) return std::unexpected(r.error()); + scriptFiles.push_back(*r); + } + + const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + + // Rules 3 and 4 are early returns rather than nesting: the body below is + // ~150 lines and an `else` around all of it reads as an accident. + auto plan_resources = [&]() -> std::expected { + const auto dialectId = mcpp::toolchain::dialect_for(*state.tc).id; + const bool msvcStyle = (dialectId == "msvc"); + const std::string_view outExt = msvcStyle ? ".res" : ".o"; + const auto resDir = ctx.plan.outputDir / "res"; + std::error_code mkEc; + std::filesystem::create_directories(resDir, mkEc); + + // Which link units embed resources: images, not archives. A `.res` + // inside a static library is dropped by every linker that reads one. + // Test binaries are images too, but deliberately excluded: an icon + // and an OriginalFilename belong to what the project SHIPS, and a + // test executable is not that. (`role = "object"` makes the opposite + // call, for the opposite reason — see its note above.) + 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); + } + // Nothing to embed into. Compiling the scripts anyway would leave + // orphan edges nothing depends on, and demanding a resource + // compiler for them would fail a build that has no use for one. + // A degradation, not a warning: the user asked for something and + // got nothing, so `--strict` should see it. + if (peUnits.empty()) { + mcpp::diag::degraded("resources/no-image", std::format( + "[resources] is declared but '{}' produces no executable or " + "shared library for {}", state.m->package.name, trip.str()), + "nothing embeds the icon or the version metadata", + "add a [targets.] with kind = \"bin\" or \"shared\", " + "or drop the [resources] section"); + return {}; + } + + // Two scripts with the same stem in different directories would + // otherwise write the same artifact — a silent "multiple rules + // generate" that ninja reports far from the cause. + std::set usedStems; + auto add_unit = [&](const std::filesystem::path& src, + std::string_view stem, + std::vector inputs, + std::size_t attachTo) + -> std::expected + { + if (!usedStems.insert(std::string(stem)).second) + return std::unexpected(std::format( + "[resources] two resource scripts are named '{}.rc'; " + "they would produce the same artifact. Rename one.", stem)); + mcpp::build::ResourceUnit ru; + ru.source = src; + ru.output = std::filesystem::path("res") / + (std::string(stem) + std::string(outExt)); + ru.implicitInputs = std::move(inputs); + ctx.plan.resourceUnits.push_back(std::move(ru)); + const auto& out = ctx.plan.resourceUnits.back().output; + if (attachTo == static_cast(-1)) { + for (auto i : peUnits) ctx.plan.linkUnits[i].objects.push_back(out); + } else { + ctx.plan.linkUnits[attachTo].objects.push_back(out); + } + return {}; + }; + + // Author-written scripts: compiled once, linked into every image. + for (auto const& rcSrc : scriptFiles) { + auto scan = rsrc::scan_rc(rcSrc); + if (scan.versionInfoNamedByString) { + // The mcpp#365 silent failure, caught on the way in. A + // degradation rather than a warning: the impact is exactly + // the thing this feature exists to remove — a shipped binary + // whose version metadata Windows cannot read — so a build + // that asked for `--strict` must not pass over it. + mcpp::diag::degraded("resources/versioninfo", std::format( + "{}: `{} VERSIONINFO` names the version resource '{}' " + "instead of ordinal 1", + rcSrc.filename().generic_string(), scan.versionInfoName, + scan.versionInfoName), + "Windows will not find it — GetFileVersionInfo looks up " + "MAKEINTRESOURCE(1) and every field comes back empty, " + "while every tool that prints the resource TYPE still " + "says it is fine", + "VS_VERSION_INFO is a macro from ; add " + "`#include ` to the script, or write " + "`1 VERSIONINFO`"); + } + for (auto const& g : scan.gaps) { + mcpp::diag::degraded("resources/inputs", + std::format("{}: `{}` names its file through a macro, so " + "mcpp cannot track it", + rcSrc.filename().generic_string(), g), + "editing that file will not trigger a rebuild", + "list it in [resources] extra-inputs = [...]"); + } + if (scan.declaresManifest && anyUtf8Image) + return std::unexpected(std::format( + "[resources] {} embeds an application manifest, and " + "`windows_code_page = \"utf-8\"` embeds another at the same " + "ordinal (1).\n Keep one: add `" + "UTF-8` to your manifest and set " + "`windows_code_page = \"legacy\"`, or drop your manifest.", + rcSrc.filename().generic_string())); + auto inputs = std::move(scan.inputs); + inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); + if (auto a = add_unit(rcSrc, rcSrc.stem().string(), + std::move(inputs), + static_cast(-1)); !a) + return std::unexpected(a.error()); + } + + // The synthesised script: per image, because OriginalFilename and + // the version block belong to a specific artifact, and the + // manifest to a specific executable. + 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 : state.m->targets) + if (t.name == lu.targetName) + return t.is_program() && codePageOf(t) == "utf-8"; + return false; + }; + if (!iconAbs.empty() || synthVersion || anyUtf8Image) { + // A version key mcpp cannot order (an upstream build number) + // leaves FILEVERSION's four numeric fields at zero while the + // string fields keep the real text. Say so — the properties + // dialog will disagree with `[package].version` and nothing + // else would explain why. + if (synthVersion && !state.m->package.version.empty() + && !mcpp::version_req::parse_version(state.m->package.version)) { + mcpp::diag::degraded("resources/version", + std::format("[package].version = \"{}\" has no numeric " + "form", state.m->package.version), + "the embedded FILEVERSION / PRODUCTVERSION fields are " + "0,0,0,0 (the string fields keep the real version)", + "set [resources.version-info] explicitly, or use a " + "dotted numeric version"); + } + for (auto i : peUnits) { + const auto& lu = ctx.plan.linkUnits[i]; + const bool utf8 = wantsUtf8(lu); + if (iconAbs.empty() && !synthVersion && !utf8) continue; + std::filesystem::path manifestAbs; + if (utf8) { + manifestAbs = resDir / (lu.targetName + ".mcpp.manifest"); + const auto manifestText = rsrc::utf8_code_page_manifest(); + std::string had; + if (std::ifstream in(manifestAbs, std::ios::binary); in) + had.assign(std::istreambuf_iterator(in), {}); + if (had != manifestText) { + std::ofstream os(manifestAbs, std::ios::binary); + if (!os) return std::unexpected(std::format( + "cannot write the application manifest '{}'", + manifestAbs.string())); + os << manifestText; + } + } + // A script synthesised for the manifest alone carries + // nothing else: a package that declares no [resources] + // asked for no version resource. + mcpp::manifest::Resources forScript = R; + if (!synthVersion) forScript.versionInfo = false; + auto text = rsrc::synthesize_rc( + state.m->package, forScript, lu.output.filename().string(), + iconAbs, manifestAbs); + if (!text) return std::unexpected(text.error()); + // A stable path, so `cp` + `files = [...]` reproduces the + // same resource byte for byte (the L0→L1 escape hatch). + auto rcPath = resDir / (lu.targetName + ".mcpp.rc"); + // Write only on change: rewriting unconditionally would + // relink on every build. + std::string existing; + if (std::ifstream in(rcPath, std::ios::binary); in) + existing.assign(std::istreambuf_iterator(in), {}); + if (existing != *text) { + std::ofstream os(rcPath, std::ios::binary); + if (!os) return std::unexpected(std::format( + "cannot write generated resource script '{}'", + rcPath.string())); + os << *text; + } + std::vector inputs; + if (!iconAbs.empty()) inputs.push_back(iconAbs); + if (!manifestAbs.empty()) inputs.push_back(manifestAbs); + inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); + if (auto a = add_unit(rcPath, lu.targetName + ".mcpp", + std::move(inputs), i); !a) + return std::unexpected(a.error()); + } + } + + if (ctx.plan.resourceUnits.empty()) return {}; + + // Lazy + hard failure, exactly like nasm: a dropped resource + // surfaces as "where did my icon go", which is unattributable. + auto tool = rsrc::find_rc_tool(*state.tc, dialectId); + if (!tool) { + return std::unexpected(std::format( + "[resources] needs a Windows resource compiler for the " + "{} toolchain targeting {}, and none was found next to " + "{}.\n Expected {} in the toolchain's own bin directory " + "(mcpp does not search PATH for build tools).", + dialectId, trip.str(), state.tc->binaryPath.string(), + msvcStyle ? "rc.exe or llvm-rc" + : "-windres, windres or llvm-windres")); + } + ctx.plan.rcPath = tool->path; + ctx.plan.rcStyle = tool->style; + + // UTF-8 input, always. `[package]` metadata is user text and + // routinely non-ASCII; without this llvm-rc refuses the script + // outright ("Non-ASCII 8-bit codepoint can't be interpreted in + // the current codepage") rather than mangling it, so a project + // with a Chinese description could not build at all. + ctx.plan.rcFlags.push_back(msvcStyle ? "/C" : "--codepage=65001"); + if (msvcStyle) ctx.plan.rcFlags.push_back("65001"); + + // Include search: the project first, then whatever the toolchain + // puts on INCLUDE. llvm-rc preprocesses but does NOT read INCLUDE + // (rc.exe does), so the SDK dirs have to be spelled out for it — + // that is what makes `#include ` work, and it is the + // supported way to get VS_VERSION_INFO defined. + const std::string ip = msvcStyle ? "/I" : "-I"; + ctx.plan.rcFlags.push_back(ip + state.root->string()); + for (auto const& d : state.m->buildConfig.includeDirs) { + auto abs = d.is_absolute() ? d : (*state.root / d); + ctx.plan.rcFlags.push_back(ip + abs.string()); + } + if (msvcStyle && tool->name().find("llvm-rc") != std::string::npos) { + for (auto const& ev : state.tc->envOverrides) { + if (ev.key != "INCLUDE") continue; + // Shared splitter: `;` only. See rsrc::split_env_list — + // the drive colon is not a separator. + for (auto dir : rsrc::split_env_list(ev.value)) + ctx.plan.rcFlags.push_back(ip + std::string(dir)); + } + } + return {}; + }; + + if (trip.is_pe()) + if (auto r = plan_resources(); !r) return std::unexpected(r.error()); + } + + // ─── Global dependency cache: per-package keys, hit → stage edges ── + // + // Every index package gets a key over the axes that actually reach its + // compiler command lines (mcpp.build.cache_key), computed bottom-up so a + // package's key includes its direct dependencies' keys. A hit marks that + // package's compile units `servedFromCache`, and the ninja backend emits + // `stage_file` edges instead of compile edges for them — which is the only + // way ninja will accept a cached artifact. A miss records a populate task + // for after the build. + // + // `--cache=local|off` skips this block entirely: nothing is read and, in + // run_build_plan, nothing is written. + auto cfg2 = state.get_cfg(true); + if (cfg2 && ctx.cacheMode == CacheMode::Global) { + std::error_code mkEc; + std::filesystem::create_directories(ctx.outputDir, mkEc); + + // NOTE (mcpp#344): there is deliberately no local "derive the entry + // address from the object path" helper here any more. There used to be + // one, and it was the SECOND derivation of a fact plan.cppm already + // owns — it stripped `obj/` off the consumer's build path, so the entry + // layout followed the consumer's package mix while the key did not. + // `CompileUnit::packageObjectRel` is now the only answer to "where does + // this object live inside a cache entry", and it is computed in exactly + // one place. Do not reintroduce a second one. + + // ── Per-package keys, bottom-up ────────────────────────────────── + // Axes A/B/C are whole-graph, so they are computed once. Axes D/E are + // per package. Axis F is each direct dependency's key, which forces a + // bottom-up order: `dependencyEdges` is a DAG (the modgraph validator + // rejects cycles), so a simple memoized recursion suffices — with an + // explicit in-progress guard so a cycle that slipped past validation + // fails loudly instead of recursing until the stack dies. + namespace ck = mcpp::build::cache_key; + auto axes = ck::build_axes( + *state.tc, *state.m, state.stdFlagAndDialect, + mcpp::toolchain::cppfly::effective_dialect_flags( + *state.tc, state.m->cppStandard.experimental, + mcpp::manifest::dialect_flags(state.m->buildConfig)), + // ONE SLOT, BOTH PLATFORMS. See `min_platform_version`: a target + // is either Apple or Android, and the level selects which bionic + // symbols are visible, so two levels must be two build + // directories. + [&] { + auto tt = mcpp::toolchain::triple::parse(state.tc->targetTriple); + return tt ? min_platform_version(*state.m, *tt, state.tc->binaryPath) + : std::string{}; + }(), + // The GLOBAL registry root — the same one `fill_package_config` + // relativizes against below, so both halves of the key describe + // payload paths the same way. + state.storeRoots.empty() ? std::filesystem::path{} : state.storeRoots.front(), + // The bit `make_plan` decided and `compute_flags` emits. Reading + // it here rather than re-deriving is what keeps the objects a + // cache entry HOLDS and the objects a build ASKS FOR describable + // by one sentence. + ctx.plan.needsPic); + + // Sources belonging to each package, package-root-relative and sorted. + std::vector> pkgSources(state.packages.size()); + for (auto& cu : ctx.plan.compileUnits) { + // Longest matching root wins. Package roots can nest — a workspace + // member lives under the workspace root — and taking the first match + // would file the member's sources under the outer package, putting + // them in the wrong key. (Index payloads live in the xpkgs store and + // cannot be shadowed this way, so no cached entry is affected today; + // resolving it by specificity rather than by iteration order is what + // keeps that true if roots ever move.) + std::size_t best = state.packages.size(); + std::size_t bestLen = 0; + std::string bestRel; + for (std::size_t p = 0; p < state.packages.size(); ++p) { + std::error_code ec; + auto rel = std::filesystem::relative(cu.source, state.packages[p].root, ec); + if (ec || rel.empty()) continue; + auto rels = rel.generic_string(); + if (rels.starts_with("..")) continue; + auto len = state.packages[p].root.generic_string().size(); + if (best == state.packages.size() || len > bestLen) { + best = p; bestLen = len; bestRel = std::move(rels); + } + } + if (best != state.packages.size()) pkgSources[best].push_back(std::move(bestRel)); + } + for (auto& v : pkgSources) std::ranges::sort(v); + + std::vector pkgKeys(state.packages.size()); + std::vector pkgInputs(state.packages.size(), + nlohmann::json::object()); + std::vector keyState(state.packages.size(), 0); // 0 new/1 busy/2 done + std::string keyCycleError; + // Does this package's own transitive upstream contain anything that is + // not an immutable index payload? If so it cannot be cached either, even + // when the package itself is an index package. + // + // A key covers an upstream package by folding in that package's KEY, and + // a local package's key covers its file list but not its file CONTENTS — + // nothing could, without hashing a tree that may change between the hash + // and the compile. So editing a local upstream's source would leave a + // downstream entry looking valid. No index descriptor can declare a path + // dependency today, which makes this shape unreachable in practice; it is + // enforced structurally anyway, because "unreachable today" is how the + // transitive path-dep leak got in. + std::vector localTaint(state.packages.size(), 0); + auto compute_key = [&](auto&& self, std::size_t idx) -> const std::string& { + static const std::string kEmpty; + if (keyState[idx] == 2) return pkgKeys[idx]; + if (keyState[idx] == 1) { + if (keyCycleError.empty()) { + keyCycleError = std::format( + "dependency cycle through package '{}' while computing " + "its build-cache key", state.packages[idx].manifest.package.name); + } + return kEmpty; + } + keyState[idx] = 1; + + ck::PackageAxes pa; + if (idx > 0 && idx - 1 < state.dep_cache_identities.size()) { + pa.indexName = state.dep_cache_identities[idx - 1].indexName; + pa.packageName = state.dep_cache_identities[idx - 1].packageName; + pa.version = state.dep_cache_identities[idx - 1].version; + } + if (pa.packageName.empty()) { + // The root package, or a package with no resolution identity. + // It is never cached, but its key still has to exist because + // downstream packages fold it in via axis F. + pa.packageName = state.packages[idx].manifest.package.namespace_.empty() + ? state.packages[idx].manifest.package.name + : std::format("{}.{}", state.packages[idx].manifest.package.namespace_, + state.packages[idx].manifest.package.name); + } + if (pa.version.empty()) pa.version = state.packages[idx].manifest.package.version; + // The GLOBAL registry root — index 0 by construction above. Include + // dirs are relativized against it so a key survives a different + // MCPP_HOME; a project-local payload falls back to the `` + // prefix inside fill_package_config and is equally stable. + ck::fill_package_config(pa, state.packages[idx], + state.storeRoots.empty() ? std::filesystem::path{} + : state.storeRoots.front()); + pa.sources = pkgSources[idx]; + const bool selfIsIndex = idx > 0 + && idx - 1 < state.dep_cache_identities.size() + && state.dep_cache_identities[idx - 1].sourceKind == "version"; + if (!selfIsIndex) localTaint[idx] = 1; + for (auto& e : state.dependencyEdges) { + if (e.consumerPackageIndex != idx) continue; + auto& up = self(self, e.dependencyPackageIndex); + if (!up.empty()) pa.upstreamKeys.push_back(up); + if (localTaint[e.dependencyPackageIndex]) localTaint[idx] = 1; + for (auto& f : e.requestedFeatures) pa.features.push_back(f); + } + std::ranges::sort(pa.upstreamKeys); + pa.upstreamKeys.erase(std::unique(pa.upstreamKeys.begin(), + pa.upstreamKeys.end()), + pa.upstreamKeys.end()); + std::ranges::sort(pa.features); + pa.features.erase(std::unique(pa.features.begin(), pa.features.end()), + pa.features.end()); + + pkgKeys[idx] = ck::key_hex(axes, pa); + pkgInputs[idx] = ck::to_json(axes, pa); + keyState[idx] = 2; + return pkgKeys[idx]; + }; + for (std::size_t i = 0; i < state.packages.size(); ++i) + (void)compute_key(compute_key, i); + if (!keyCycleError.empty()) return std::unexpected(keyCycleError); + + for (std::size_t i = 1; i < state.packages.size(); ++i) { // skip [0] = main + const auto& pkgRoot = state.packages[i]; + const auto* depIdent = i - 1 < state.dep_cache_identities.size() + ? &state.dep_cache_identities[i - 1] + : nullptr; + // Only index ("version") packages are cacheable, and the identity + // recorded at resolution time is the ONLY admissible evidence. + // + // The predicate this replaces looked the package up in the ROOT + // manifest's dependencies/dev-dependencies and skipped it when the + // spec was path/git. A transitively-reached package is in neither + // map, so `specIt == end()` left skipCache false and local sources + // were cached — with `indexName` falling back to defaultIndex, so a + // workspace member `B` landed on disk as `mcpplibs/B@0.1.0`. Its + // sources can then change without changing name@version, i.e. the + // cache key cannot see the change. + // + // Note the direction of the judgment: `mcpp add`'s existence gate + // admits anything it cannot disprove. A build cache must do the + // opposite — anything it cannot prove came from the immutable + // xpkgs store stays out, because the failure mode here is a + // silently wrong object rather than a rejected command. + if (!depIdent || depIdent->sourceKind != "version") continue; + // ...and neither may anything it was built against be local. + if (localTaint[i]) continue; + // ...and the package's sources must ACTUALLY be in the immutable + // store, not merely labelled as coming from it. + // + // The rule stated three paragraphs up is about provenance on disk; + // `sourceKind` is a label recorded at resolution time, which is a + // weaker proxy — and there is already a case where the two + // disagree. Multi-version mangling re-anchors a consumer package's + // root at `/target/.mangled//__self__` and REWRITES + // its sources (module/import declarations renamed) while leaving + // `sourceKind == "version"` and `localTaint` clear. Nothing about + // that copy is immutable or shareable. It stays out of the cache + // today only because axis F happens to fold in the mangled + // secondary's differing key — one axis away from serving objects + // compiled against renamed modules, which is the silent-wrong-`.o` + // failure this gate exists to prevent. + // + // Judge the location, not the label. + // + // LEXICALLY, not via std::filesystem::relative. `relative()` runs + // weakly_canonical on both sides, which RESOLVES SYMLINKS — and a + // store whose entries are symlinks into another store is ordinary + // (tests/e2e/_inherit_toolchain.sh builds exactly that, and so do + // CI caches that link a warm payload tree into a fresh + // MCPP_HOME). Canonicalizing turns + // `/registry/data/xpkgs/` into wherever the link points + // and the package stops looking like a store package at all. The + // question here is where the payload was INSTALLED, which is a + // statement about the path, not about the inode. + if (!mcpp::build::path_is_under_any(pkgRoot.root, state.storeRoots)) + continue; + + const auto& depName = depIdent->packageName; + const auto& depVer = depIdent->version.empty() + ? pkgRoot.manifest.package.version + : depIdent->version; + + auto bmiT = mcpp::toolchain::bmi_traits(*state.tc); + mcpp::bmi_cache::CacheKey key { + .cacheRoot = mcpp::home::cache_root(), + .indexName = depIdent->indexName, + .packageName = depName, + .version = depVer, + .keyHex = pkgKeys[i], + .inputs = pkgInputs[i], + .bmiDirName = std::string(bmiT.bmiDir), + .manifestTag = std::string(bmiT.manifestPrefix), + }; + + // The artifacts this package contributes, and the compile units + // that produce them. Collected together so a hit can mark exactly + // those units — the artifact list alone would not say which edges + // must stop being compile edges. + mcpp::bmi_cache::DepArtifacts arts; + std::vector unitIdx; + bool addressable = true; + for (std::size_t u = 0; u < ctx.plan.compileUnits.size(); ++u) { + auto& cu = ctx.plan.compileUnits[u]; + std::error_code ec; + auto rel = std::filesystem::relative(cu.source, pkgRoot.root, ec); + if (ec || rel.empty()) continue; + auto rels = rel.string(); + if (rels.starts_with("..")) continue; // not under depRoot + + // ALL OR NOTHING. A unit plan.cppm could not give a + // machine-independent entry address to takes its whole package + // out of the cache, rather than leaving the package half + // staged. Mixing cached and freshly built artifacts within one + // package is the case GCC reports as a BMI CRC mismatch in a + // consumer three edges away, which is far harder to read than + // one extra compile. + if (cu.packageObjectRel.empty()) { addressable = false; break; } + + if (!cu.providesModule.empty()) { + std::string bmi; + for (char c : cu.providesModule) + bmi.push_back(c == ':' ? '-' : c); + bmi += std::string(bmiT.bmiExt); + arts.bmiFiles.push_back(std::move(bmi)); + } + arts.objFiles.push_back({cu.packageObjectRel.generic_string(), + cu.object}); + unitIdx.push_back(u); + } + if (!addressable) continue; + + // Validate the entry against THIS build's artifact list, not + // against the entry's own (mcpp#344). Anything short of a full + // match is a miss — never a failure: the stage edges below are + // simply not emitted and the units compile normally. + auto probe = mcpp::bmi_cache::probe_cached(key, arts); + if (probe.ok) { + // Mark the units. The backend turns each into a stage_file + // edge; nothing is copied here. Copying behind ninja's back is + // exactly what made the old cache a no-op: the staged file was + // still declared as a compile edge's output, and an output with + // no .ninja_log command-line record is dirty, so every unit was + // recompiled while the CLI printed "Cached". + for (auto u : unitIdx) { + auto& cu = ctx.plan.compileUnits[u]; + cu.servedFromCache = true; + cu.cachedObject = mcpp::bmi_cache::cached_obj_path( + key, cu.packageObjectRel.generic_string()); + if (!cu.providesModule.empty()) { + std::string bmi; + for (char c : cu.providesModule) + bmi.push_back(c == ':' ? '-' : c); + bmi += std::string(bmiT.bmiExt); + cu.cachedBmi = mcpp::bmi_cache::cached_bmi_path(key, bmi); + } + } + mcpp::bmi_cache::touch_accessed(key); + ctx.cachedDeps.push_back({depName, depVer, unitIdx.size()}); + continue; // no populate task; it is already cached + } + // A valid entry that does not hold what we asked for means the + // entry and this build disagree about the layout under one key. + // After #344 that is unreachable; say so out loud if it ever + // happens again, because the alternative presentation is "the + // cache silently never hits", and a cache that lies about its own + // effectiveness went unnoticed for three months once already. + if (!probe.layoutMismatch.empty()) { + mcpp::ui::warning(std::format( + "build cache entry for {}@{} [{}] does not contain the " + "artifacts this build needs ({} of {} missing, e.g. `{}`); " + "treating it as a miss. Run `mcpp cache verify` for details.", + depName, depVer, key.keyHex, + probe.layoutMismatch.size(), + arts.bmiFiles.size() + arts.objFiles.size(), + probe.layoutMismatch.front())); + } + ctx.depsToPopulate.push_back({ std::move(key), std::move(arts) }); + } + } + // ────────────────────────────────────────────────────────────────── + + // Write/update mcpp.lock for any version-based deps that succeeded. + // Path deps are intentionally NOT locked — their source is local filesystem. + // + // mcpp#363: the version entries come from `resolved` — what the walk + // actually picked — not from `m->dependencies`, which still holds the + // constraint the user wrote and only covers DIRECT deps. Reading the input + // instead of the output made the lock record `^1.92.8` (a range locks + // nothing) and omit the transitive graph entirely. Git entries deliberately + // stay on `m->dependencies`: their lock line is read back as a resolution + // anchor (#329), keyed by the root manifest's map key, and that contract is + // unchanged here. + { + mcpp::lockfile::Lockfile lock; + lock.schemaVersion = 2; + + // The lock key for a dep the ROOT declares is the map key it declared + // it under (`compat.imgui`, `gtest`) — that is the key #329's git anchor + // lookup uses, and changing it would silently unpin every branch dep. + // A dep reached only transitively has no such key, so it is written + // under its fully-qualified identity. + auto lock_name_for = [&](const ResolvedKey& k) -> std::string { + for (auto const& [n, s] : state.m->dependencies) { + const std::string sn = s.shortName.empty() ? n : s.shortName; + if (s.namespace_ == k.ns && sn == k.shortName) return n; + } + return mcpp::pm::compat::qualified_name(k.ns, k.shortName); + }; + + // Lock custom index shas from manifest [indices] section. + for (auto const& [idxName, spec] : state.m->indices) { + if (spec.is_local() || spec.is_builtin()) continue; + mcpp::lockfile::LockedIndex li; + li.name = idxName; + li.url = spec.url; + li.rev = spec.rev; // may be empty if not yet resolved + lock.indices.push_back(std::move(li)); + } + + // Git deps: root-declared only, unchanged (see the note above). + for (auto const& [name, spec] : state.m->dependencies) { + if (!spec.isGit()) continue; + mcpp::lockfile::LockedPackage lp; + lp.name = name; + lp.version = spec.gitRev; + auto gitIt = state.root_git_lock_identities.find(name); + if (gitIt == state.root_git_lock_identities.end()) { + lp.source = std::format("git+{}#{}={}", + spec.git, spec.gitRefKind, spec.gitRev); + lp.hash = "fnv1a:" + mcpp::toolchain::hash_string(lp.source); + } else { + lp.source = gitIt->second.source; + lp.hash = gitIt->second.hash; + } + lock.packages.push_back(std::move(lp)); + } + + // Version deps: the whole resolved graph, at the versions actually + // chosen. `resolved` is an ordered map, so the file is deterministic. + for (auto const& [key, rec] : state.resolved) { + if (rec.source != "version") continue; // path / git handled elsewhere + if (rec.version.empty()) continue; + // See ResolvedRecord::devOnly: `mcpp test` resolves dev-deps and + // `mcpp build` does not, so writing them would make the file depend + // on which command ran last. + if (rec.devOnly) continue; + mcpp::lockfile::LockedPackage lp; + lp.name = lock_name_for(key); + lp.namespace_ = key.ns; + lp.version = rec.version; + // Use the namespace and resolved version as the source identifier. + // For custom indices, include the index name for traceability. + auto sourceIndex = lp.namespace_.empty() + ? std::string(mcpp::pm::kDefaultNamespace) + : lp.namespace_; + lp.source = std::format("index+{}@{}", sourceIndex, lp.version); + // Use a deterministic hash based on namespace + name + version. + // A future PR can replace this with a real content hash from the + // xpkg.lua's declared sha256 or from the install plan. + // + // NOT `std::hash`: its output is implementation-defined + // (MSVC FNV-1a, libstdc++/libc++ MurmurHash), so the same dependency + // used to hash differently on Windows and Linux while the `fnv1a:` + // prefix claimed otherwise. `index_package_digest` is FNV-1a on + // every host. + lp.hash = mcpp::pm::index_package_digest(sourceIndex, lp.name, lp.version); + lock.packages.push_back(std::move(lp)); + } + if (!lock.packages.empty() || !lock.indices.empty()) { + auto lockPath = state.workRoot / "mcpp.lock"; + // `--locked` ASSERTS THAT THIS RESOLUTION IS THE RECORDED ONE. + // + // The file has always been written after the walk and never read + // back as a constraint; its own header says so ("does not yet pin + // future builds"). Making it an input to resolution is a change to + // the resolver. Making it an ASSERTION is not, and it is the half + // that reproducibility actually needs: a release build, a CI job or + // an audit can demand that what resolved today is what was recorded, + // and find out when it is not. + // + // THE FAILURE NAMES THE DIFFERENCE. "The lock is out of date" is + // true and useless; which package moved, from which version to + // which, is what the reader does something about. + if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") { + auto prior = mcpp::lockfile::load(lockPath); + if (!prior) { + return std::unexpected(std::format( + "--locked was given and there is no readable mcpp.lock at {}\n" + " Run the same command without --locked once to record " + "this resolution, then commit mcpp.lock.", + lockPath.string())); + } + auto key = [](const mcpp::lockfile::LockedPackage& p) { + return p.namespace_.empty() ? p.name + : p.namespace_ + "." + p.name; + }; + std::map was, now; + for (auto const& p : prior->packages) was[key(p)] = p.version; + for (auto const& p : lock.packages) now[key(p)] = p.version; + std::vector drift; + for (auto const& [k, v] : now) { + auto it = was.find(k); + if (it == was.end()) drift.push_back(k + " " + v + " (not in the lock)"); + else if (it->second != v) drift.push_back(k + " " + it->second + " -> " + v); + } + for (auto const& [k, v] : was) + if (!now.contains(k)) drift.push_back(k + " " + v + " (no longer resolved)"); + if (!drift.empty()) { + std::string msg = "--locked was given and this resolution " + "differs from mcpp.lock:"; + for (auto const& d : drift) msg += "\n " + d; + msg += "\n Re-run without --locked to update the lock, " + "or pin the dependency that moved."; + return std::unexpected(msg); + } + } + (void)mcpp::lockfile::write(lock, lockPath); + } + + // Same data, second consumer: the "Compiling v" banner. + // It reads this rather than re-deriving from the manifest, so the banner + // and the lock cannot disagree about what was built. + for (auto const& [key, rec] : state.resolved) { + if (rec.source != "version" || rec.version.empty()) continue; + ctx.resolvedVersions[lock_name_for(key)] = rec.version; + } + } + + // Apply [runtime.] provider = "" overrides. Canonical + // identity wins; the old short spelling is accepted only when it denotes + // exactly one provider. A same-short-name collision is never guessed. + for (auto& [capKey, prov] : ctx.manifest.runtimeConfig.providerOverrides) { + std::vector candidates; + for (auto const& entry : ctx.plan.runtimeProviders) { + if (!entry.capability.starts_with(capKey)) continue; + const auto withoutVersion = entry.provider.namespace_.empty() + ? entry.provider.name + : entry.provider.namespace_ + "." + entry.provider.name; + if (entry.provider.canonical() == prov || withoutVersion == prov) + candidates = {entry.provider}; + } + if (candidates.empty()) { + for (auto const& entry : ctx.plan.runtimeProviders) { + if (entry.capability.starts_with(capKey) + && entry.provider.name == prov) + candidates.push_back(entry.provider); + } + } + std::ranges::sort(candidates); + candidates.erase(std::ranges::unique(candidates).begin(), candidates.end()); + if (candidates.empty()) { + return std::unexpected(std::format( + "[runtime.{}] provider = \"{}\" does not name a provider in " + "the resolved dependency graph", capKey, prov)); + } + if (candidates.size() != 1) { + std::string choices; + for (auto const& candidate : candidates) + choices += (choices.empty() ? "" : ", ") + candidate.canonical(); + return std::unexpected(std::format( + "[runtime.{}] provider = \"{}\" is ambiguous; use one exact " + "canonical identity: [{}]", capKey, prov, choices)); + } + const auto selected = candidates.front(); + std::stable_partition(ctx.plan.runtimeProviders.begin(), + ctx.plan.runtimeProviders.end(), + [&](const auto& pr) { + return pr.capability.starts_with(capKey) && pr.provider == selected; + }); + } + + // Capability-driven ABI enforcement, dimensional (see src/toolchain/abi.cppm + // and .agents/docs/2026-06-27-abi-compat-model-single-pr-design.md). Each + // dependency may constrain specific toolchain dimensions via `abi:` + // capabilities (libc / cxxstdlib / arch / os / cxxabi); UNSPECIFIED + // DIMENSIONS ARE DON'T-CARE. The legacy bare form `abi:glibc` maps to the + // libc dimension only — so a glibc *C library* (glfw) builds fine under a + // clang+libc++ toolchain on `*-linux-gnu` (libc is still glibc), which the + // previous single-axis check wrongly rejected. The toolchain is resolved + // before the dep graph, so this enforces/diagnoses rather than reselects — + // abi-driven reselection is a resolution-ordering follow-up. + { + const auto prof = mcpp::toolchain::abi_profile(ctx.tc); + std::vector constraints; + for (auto& cap : ctx.plan.runtimeCapabilities) { + std::string provider; + for (auto& [c, p] : ctx.plan.runtimeProviders) + if (c == cap) { provider = p.canonical(); break; } + if (auto con = mcpp::toolchain::parse_abi_capability( + cap, provider.empty() ? std::string_view{"?"} : std::string_view{provider})) + constraints.push_back(std::move(*con)); + } + if (auto mismatches = mcpp::toolchain::abi_check(prof, constraints); + !mismatches.empty()) { + const auto& mm = mismatches.front(); + return std::unexpected(std::format( + "ABI incompatibility: dependency '{}' requires {}={}, but the " + "resolved toolchain '{}' provides {}={}.\n" + " fix: select a {}-compatible toolchain " + "(e.g. gcc@16.1.0 for glibc) or set [toolchain] in mcpp.toml.", + mm.source, mcpp::toolchain::dim_name(mm.dim), mm.need, + ctx.tc.label(), mcpp::toolchain::dim_name(mm.dim), mm.got, + mm.need)); + } + } + + // Per-build resolution manifest: the durable, provider-neutral facts that + // `mcpp why runtime` interprets without resolving again or probing the + // current host. The post-link validator replaces `validation.pending` + // with the exact artifact verdict produced at the link seam. + { + const std::string tcAbi = + ctx.tc.targetTriple.find("musl") != std::string::npos ? "musl" + : ctx.tc.stdlibId == "libc++" ? "libc++" + : ctx.tc.compiler == mcpp::toolchain::CompilerId::MSVC ? "msvc" + : "glibc"; + auto package_json = [](const mcpp::manifest::PackageId& id) { + return nlohmann::json{ + {"canonical", id.canonical()}, + {"namespace", id.namespace_}, + {"name", id.name}, + {"version", id.version}, + {"source", id.sourceProvenance}, + }; + }; + auto path_array = [](auto const& paths) { + nlohmann::json values = nlohmann::json::array(); + for (auto const& path : paths) + values.push_back(path.lexically_normal().generic_string()); + return values; + }; + nlohmann::json j; + j["schema_version"] = 2; + j["toolchain"] = { + {"spec", ctx.tc.label()}, {"abi", tcAbi}, + {"triple", ctx.tc.targetTriple}, {"stdlib", ctx.tc.stdlibId}, + }; + nlohmann::json dirs = nlohmann::json::array(); + for (auto& d : ctx.plan.runtimeLibraryDirs) dirs.push_back(d.string()); + nlohmann::json legacyCaps = nlohmann::json::array(); + nlohmann::json providers = nlohmann::json::array(); + for (auto& [cap, prov] : ctx.plan.runtimeProviders) + { + legacyCaps.push_back({{"capability", cap}, + {"provider", prov.canonical()}}); + providers.push_back({{"capability", cap}, + {"provider", package_json(prov)}}); + } + nlohmann::json requirements = nlohmann::json::array(); + for (auto const& requirement : ctx.plan.runtimeRequirements) { + requirements.push_back({ + {"kind", requirement.kind}, + {"value", requirement.value}, + {"phase", requirement.phase}, + {"requester", package_json(requirement.requester)}, + {"required", requirement.required}, + }); + } + nlohmann::json artifacts = nlohmann::json::array(); + for (auto const& artifact : ctx.plan.runtimeArtifacts) { + artifacts.push_back({ + {"role", artifact.role}, + {"provider", package_json(artifact.provider)}, + {"path", artifact.path.lexically_normal().generic_string()}, + {"provenance", artifact.provenance}, + {"abi", artifact.abi}, + {"digest", artifact.digest}, + {"host_fingerprint", artifact.hostFingerprint}, + // A requirement must land on a THING, and the thing must be + // the one that was declared. mcpp already enforces this for + // the private libc; recording it per artifact makes a stale + // binding visible instead of leaving `providers:` naming + // something nobody checked. + {"identity", std::string( + mcpp::build::runtime_validation::to_string( + mcpp::build::runtime_validation + ::artifact_identity_verdict(artifact)))}, + }); + } + nlohmann::json binding = nlohmann::json::parse( + mcpp::platform::runtime::serialize_runtime_binding( + ctx.plan.runtimeBinding), nullptr, false); + if (binding.is_discarded()) binding = nlohmann::json::object(); + + // ASKED OF THE PARSED TRIPLE, with the substring test kept only for a + // spelling `parse` rejects. This field is the SECOND copy of a + // derivation `mcpp.build.dist::format_for` already owns, and it had + // the same defect: mcpp's canonical `aarch64-macos` contains neither + // "apple" nor "darwin", so an explicit `--target aarch64-macos` + // recorded `"elf"` while the native build on the same machine recorded + // `"macho"` -- one report contradicting the other about one machine. + std::string format = "elf"; + if (auto t = mcpp::toolchain::triple::parse(ctx.tc.targetTriple)) { + format = std::string(mcpp::toolchain::triple::to_string(t->object_format())); + std::ranges::transform(format, format.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (format == "mach-o") format = "macho"; + } else { + auto triple = ctx.tc.targetTriple; + std::ranges::transform(triple, triple.begin(), + [](unsigned char c) { return std::tolower(c); }); + const bool pe = triple.find("windows") != std::string::npos + || triple.find("mingw") != std::string::npos; + const bool macho = triple.find("darwin") != std::string::npos + || triple.find("apple") != std::string::npos; + format = pe ? "pe" : macho ? "macho" : "elf"; + } + // The ORDERED run-time search closure with provenance. Order is + // semantics here, not presentation: it is what the loader will walk, + // and the mutable SubOS farm sitting last is the invariant that keeps + // libc resolving from the pinned payload. Recorded so "why does my GL + // program find its driver" is answerable without readelf, and so a + // regression in the ordering is visible to CI and to `mcpp why`. + nlohmann::json closure = nlohmann::json::array(); + for (auto const& dir : ctx.plan.runtimeSearch) { + closure.push_back({ + {"path", dir.path.generic_string()}, + {"origin", std::string( + mcpp::platform::search::to_string(dir.origin))}, + {"machine_local", + mcpp::platform::search::is_machine_local(dir.origin)}, + }); + } + nlohmann::json search = { + {"format", format}, + {"link_library", format == "pe" ? "libpath" : "library_path"}, + {"transitive_needed", format == "elf" ? "rpath_link" : "none"}, + {"runtime", format == "pe" ? "deploy" + : format == "macho" ? "loader_rpath" : "runpath"}, + {"closure", closure}, + }; + // #418 — the contract each ROLE actually got, after any downgrade. + // + // `CompileFlags::contractByRole` was written and never read: a valuable + // observation with no way out of the process. Since #414 the shared + // library role can legitimately end up on a different contract from the + // binaries beside it, so "which one did my .so actually get?" is a + // question a user has, and the only answer available was to run + // `readelf` and infer. + // + // Recorded as the RESOLVED value, not the requested one — a request + // that was downgraded is exactly the case worth being able to see. + // `compute_flags` is pure in the plan; prepare does not otherwise hold + // the result, and threading it through just for this would widen a + // signature for one field. + const auto roleFlags = mcpp::build::compute_flags(ctx.plan); + nlohmann::json contracts = nlohmann::json::object(); + for (std::size_t i = 0; i < mcpp::build::dist::kRoleCount; ++i) { + contracts[std::string(mcpp::build::dist::to_string( + static_cast(i)))] = + std::string(mcpp::build::dist::to_string(roleFlags.contractByRole[i])); + } + + // #634, X: the resolved dependency graph. One entry per package, the + // root first: its identity as `runtime` records identities, every + // request that reached it with the key as written and the table that + // declared it, and for a library the link form with its reason. It is + // what `mcpp why deps` prints, and what a test of a resolution rule + // reads instead of a warning's wording. + { + nlohmann::json graphPackages = nlohmann::json::array(); + for (std::size_t i = 0; i < state.packages.size(); ++i) + graphPackages.push_back(state.graph_package_entry(i, /*forBuildProgram=*/false)); + j["graph"] = { {"packages", std::move(graphPackages)} }; + } + + j["runtime"] = { + {"cxx_runtime_by_role", contracts}, + {"library_dirs", dirs}, + {"dlopen_libs", ctx.plan.runtimeDlopenLibs}, + {"capabilities", legacyCaps}, + {"binding", binding}, + {"requirements", requirements}, + {"artifacts", artifacts}, + {"providers", providers}, + {"link_intent", { + {"libraries", ctx.plan.linkIntent.libraries}, + {"link_library_dirs", + path_array(ctx.plan.linkIntent.linkLibraryDirs)}, + {"transitive_needed_dirs", + path_array(ctx.plan.linkIntent.transitiveNeededDirs)}, + {"runtime_search_dirs", + path_array(ctx.plan.linkIntent.runtimeSearchDirs)}, + {"frameworks", ctx.plan.linkIntent.frameworks}, + {"deploy_files", path_array(ctx.plan.linkIntent.deployFiles)}, + {"deploy", [&] { + auto a = nlohmann::json::array(); + for (auto const& d : ctx.plan.linkIntent.deploy) + a.push_back({{"from", d.from.generic_string()}, + {"to", d.to}}); + return a; + }()}, + }}, + {"search", search}, + {"validation", { + {"status", format == "elf" ? "pending" : "not_exercised"}, + {"source", "post_link"}, + {"artifacts", nlohmann::json::array()}, + }}, + }; + // THE MSVC SYSROOT OF THE CLANG ROW: which toolset and SDK the build + // compiled against, and where each came from. Absent on every other + // row, so a reader can tell "not this row" from "not recorded". + if (!ctx.plan.toolchain.msvcToolsDir.empty()) { + const auto& tcr = ctx.plan.toolchain; + j["msvc_toolset"] = { + {"version", tcr.msvcToolsVersion}, + {"origin", tcr.msvcOrigin}, + {"product", tcr.msvcProduct}, + {"root", tcr.msvcToolsDir.generic_string()}, + }; + j["windows_sdk"] = { + {"version", tcr.windowsSdkVersion}, + {"root", tcr.windowsSdkRoot.generic_string()}, + }; + } + std::error_code ec; + std::filesystem::create_directories(ctx.plan.outputDir, ec); + auto path = ctx.plan.outputDir / "resolution.json"; + auto tmp = path; + tmp += ".tmp"; + if (std::ofstream js(tmp); js) { + js << j.dump(2) << "\n"; + js.close(); + std::filesystem::rename(tmp, path, ec); + if (ec) { + ec.clear(); + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(tmp, path, ec); + } + } + } + + // ── A link unit with no inputs is not a build (mcpp#533) ──────────────── + // + // Checked HERE, last, because objects arrive from three places and each + // one is legitimate: the compile set, a `role = "object"` action + // (`lu.objects.emplace_back` above), and a Windows resource unit. A check + // placed before any of them would refuse a unit that was about to be + // filled. If a fourth source is ever added, it must land before this line. + // + // WHY THIS IS AN ERROR AND NOT A WARNING. The two library kinds fail + // differently and BOTH failures are worse than this message: + // + // shared — `$cc -shared` over an empty response file. Measured on + // gcc 16.1.0: `gcc: fatal error: no input files`, which names + // the driver and not the target. Before `cc` was emitted + // unconditionally it was `/bin/sh: 1: -shared: not found`, + // which names neither. + // static — `ar rcs libfoo.a` with no members. Measured: exit 0, an + // 8-byte archive, and a build that REPORTS SUCCESS. Every + // consumer then fails with undefined symbols, one repository + // further from the cause. + // + // The silent one is why this is not merely a nicer diagnostic. mcpp#533 + // reached here because a dependency's `install()` was skipped over a + // package-identity collision, leaving a version directory with no source + // tree; the shape is the same for any package whose sources fail to + // materialise, which is why the check is on the link unit rather than on + // the install path. + for (auto const& lu : ctx.plan.linkUnits) { + if (!lu.objects.empty()) continue; + const char* kindName = + lu.kind == mcpp::build::LinkUnit::SharedLibrary ? "shared library" + : lu.kind == mcpp::build::LinkUnit::StaticLibrary ? "static library" + : lu.kind == mcpp::build::LinkUnit::TestBinary ? "test binary" + : "binary"; + return std::unexpected(std::format( + "target '{}' ({}) has no inputs to link\n" + " no translation unit and no `role = \"object\"` action " + "output reached it, and an empty link is not a build: `ar` writes " + "an empty archive and reports success, so this would otherwise " + "surface as undefined symbols in whatever consumes '{}'\n" + " if '{}' is an installed dependency, its package directory " + "has no sources — reinstall it and check that its descriptor's " + "install step ran", + lu.targetName, kindName, lu.output.generic_string(), + lu.targetName)); + } + + ctx.planNotes = std::move(state.planNotes); + return ctx; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp new file mode 100644 index 000000000..88be45488 --- /dev/null +++ b/src/build/prepare/scan.cpp @@ -0,0 +1,787 @@ +// scan.cpp -- P11 and P12: the module scan and its validation, the +// standard-module gate, and the fingerprint. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.build.version_floor; +import mcpp.platform.axis; +import mcpp.log; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.modgraph.glob; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.modgraph.validate; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.cppfly; +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.freestanding.target; // the target sysroot layout (libdir) +import mcpp.freestanding.linkline; // the ISA profile, for the std module command +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.plan; +import mcpp.build.schedule.policy; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.xlings; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.platform.macos; +import mcpp.log; + +namespace mcpp::build { + +std::expected phase11_scan(PrepareState& state) { + + // mcpp#225 (E2): observability marker for the source-discovery phase — + // `mcpp run`'s fast path (build_run_target/try_fast_run in execute.cppm) + // skips prepare_build ENTIRELY on a cache hit, so this line's absence + // under MCPP_VERBOSE=1 on a second `mcpp run` is the "did we re-scan" + // signal the e2e test asserts on (tests/e2e/114_run_scan_scope.sh). + mcpp::log::verbose("scan", "scanning module sources"); + + // Modgraph: regex scanner by default; opt-in to compiler-driven P1689 + // scanner via env var MCPP_SCANNER=p1689 (see docs/27). + // + // A dependency whose declared targets are all programs compiles nothing in + // this build (#649 E6): its programs come from the tool sub-build, which + // scans it as its own root. The root itself is always scanned. + std::vector scannedPackages; + scannedPackages.reserve(state.packages.size()); + for (std::size_t i = 0; i < state.packages.size(); ++i) + if (state.compilesHere(i)) + scannedPackages.push_back(state.packages[i]); + state.scan = [&] { + const char* sel = std::getenv("MCPP_SCANNER"); + if (sel && std::string_view(sel) == "p1689") { + auto tmp = std::filesystem::temp_directory_path() + / std::format("mcpp_p1689_{}", std::random_device{}()); + std::filesystem::create_directories(tmp); + return mcpp::modgraph::scan_packages_p1689(scannedPackages, *state.tc, tmp, + state.stdFlagAndDialect); + } + return mcpp::modgraph::scan_packages(scannedPackages); + }(); + if (!state.scan.errors.empty()) { + std::string msg = "scanner errors:\n"; + for (auto& e : state.scan.errors) msg += " " + e.format() + "\n"; + return std::unexpected(msg); + } + for (auto& w : state.scan.warnings) { + mcpp::diag::warning("modgraph/scan", w.format()); + } + + state.report = mcpp::modgraph::validate(state.scan.graph, *state.m, *state.root); + for (auto& w : state.report.warnings) { + if (w.path.empty()) mcpp::diag::warning("modgraph/validate", w.message); + else mcpp::diag::warning("modgraph/validate", + std::format("{}: {}", w.path.string(), w.message)); + } + if (!state.report.ok()) { + std::string msg = "validation errors:\n"; + for (auto& e : state.report.errors) { + if (e.path.empty()) msg += " " + e.message + "\n"; + else msg += " " + e.path.string() + ": " + e.message + "\n"; + } + return std::unexpected(msg); + } + + bool needsStdModule = graph_or_targets_import_std(state.scan.graph, *state.m, *state.root); + + // A DEPENDENCY THAT DECLARED A HIGHER STANDARD THAN THE GRAPH IS BUILT AT. + // + // A C++ module graph has ONE standard — cross-level BMIs are hard + // incompatible — so the root package's level is imposed graph-wide, and a + // dependency's `standard` is parsed and then discarded. That is correct and + // is not the defect. The defect is the silence: a package that declared + // c++26 because it needs c++26 is compiled at whatever the consumer says, + // and fails — if it fails at all — with a compiler error inside a + // translation unit the user does not own, naming neither package nor the + // mechanism. + // + // SCOPED TO MANIFESTS THE PROJECT AUTHOR CONTROLS, and that scope is the + // whole reason this check is shippable. The cpp20 design doc's §9-Q3 + // declined it because the default and a declaration were indistinguishable; + // `standardDeclared` fixes that for `mcpp.toml`, and NOT for the index: + // measured over the local registry, every descriptor with an mcpp segment + // declares `language` (782 of 782), and 756 of those 774 packages are C + // libraries with `import_std = false` carrying a boilerplate "c++23". A + // check that trusted declaredness everywhere would fire against essentially + // the whole index for any root at c++20 — exactly the outcome §9-Q3 + // refused, reached through a different door. + // + // DEGRADED, NOT AN ERROR. The condition is not a proven failure: a package + // declaring c++26 compiles perfectly well at c++23 whenever it happens not + // to use a C++26 construct, and that is a working configuration today for + // anyone who wrote the key aspirationally. `--strict` promotes it. + { + const auto graphLevel = state.m->cppStandard.level; + for (std::size_t i = 1; i < state.packages.size(); ++i) { + auto const& pkg = state.packages[i]; + if (!pkg.manifest.package.standardDeclared) continue; + // A C++-layer provider's declaration IS applied, to every unit of + // it that neither provides nor imports a module (`make_plan`), so + // "is not applied" would be false for exactly the package whose + // sources need the level. Its module units stay at the graph's + // level, as every module unit does. + if (mcpp::manifest::cxx_layer_implementation_standard(pkg.manifest)) + continue; + // The scope gate. A package whose root is under a store directory + // arrived from an index and its declaration was written by a + // descriptor generator, not by the person reading this diagnostic. + if (mcpp::build::path_is_under_any(pkg.root, state.storeRoots)) + continue; + auto declared = mcpp::manifest::normalize_cpp_standard( + pkg.manifest.package.standard); + if (!declared || declared->level <= graphLevel) continue; + mcpp::diag::degraded( + "build/standard", + std::format("dependency `{}` declares standard = \"{}\", and " + "this graph is built at {}", + pkg.manifest.package.name, + declared->canonical, state.m->cppStandard.canonical), + "a C++ module graph has one standard, so the dependency's " + "declaration is not applied and its sources are compiled at the " + "graph's level", + std::format( + "raise the consumer's standard to \"{}\", or declare it " + "once for every member:\n\n [workspace.package]\n " + "standard = \"{}\"", declared->canonical, declared->canonical)); + } + } + + // A DIALECT FLAG THAT REACHES EVERY TU AND NOT THE `import std` PREBUILD + // IS A BUILD THAT CANNOT SUCCEED, AND MCPP KNOWS IT BEFORE COMPILING. + // + // `[build] cxxflags = ["-fno-exceptions"]` is applied to each translation + // unit; the std BMI in `stdFlagAndDialect` is precompiled without it, + // because only `dialect_flags()` rides that channel. Every importer then + // fails inside a file mcpp generated: + // + // std: error: language dialect differs 'C++23', expected + // 'C++23/no-exceptions' + // std: error: failed to read compiled module: Bad file data + // + // The message names the mechanism and not the key, so the way out + // (`dialect_cxxflags`, which IS applied to the prebuild, the scan and every + // TU) is not discoverable from it. Both facts are known here: whether the + // graph imports `std`, and which flags reached the prebuild. + // + // REFUSED RATHER THAN WARNED, and that is the same rule the host-dependence + // diagnostics follow from the other side: this build provably cannot + // succeed, so there is no user decision to respect. Contrast + // `[toolchain] system`, which builds and runs and is therefore warned about. + // + // GATED ON `needsStdModule` — without `import std` in the graph there is no + // prebuilt BMI to disagree with, and `-fno-exceptions` is then an ordinary + // per-unit flag that works. A check that refused in both cases would have + // stopped testing the condition it claims to test. + if (needsStdModule) { + const auto prebuilt = mcpp::toolchain::cppfly::effective_dialect_flags( + *state.tc, state.m->cppStandard.experimental, + mcpp::manifest::dialect_flags(state.m->buildConfig)); + // THE ROOT PACKAGE ONLY, and the narrowing is a correctness bound + // rather than a shortcut. + // + // A dependency carrying the same flag fails identically — but only if + // ITS OWN translation units import `std`. `needsStdModule` is a + // property of the whole graph: a C++ wrapper package that uses no std + // module can carry `-fno-exceptions` in its `[build] cxxflags` and + // compile perfectly well inside a graph whose ROOT imports std. + // Refusing there would stop a build that works, which is the one thing + // a refusal must never do — the rule is "provably cannot build", and + // for a dependency this evidence does not prove it. + // + // Extending it needs a per-package answer to "does this package import + // std", which the scan graph holds and does not expose in that shape. + // Recorded here so the next person meets the reason and not the gap. + for (auto const& pkg : std::span{state.packages}.first(1)) { + const auto words = mcpp::manifest::flag_words(pkg.manifest.buildConfig.cxxflags); + auto missing = mcpp::manifest::dialect_flags_missing_from_prebuild(words, prebuilt); + if (missing.empty()) continue; + std::string list; + for (auto const& f : missing) { + if (!list.empty()) list += ", "; + list += '`'; list += f; list += '`'; + } + // NAMES THE FLAG, NOT THE TABLE IT CAME FROM. The same flag + // reaches the compile line from `[build] cxxflags`, from + // `[profile.] cxxflags` and from a `[target.…]` / `cfg(...)` + // block; by the time it is read here they have been merged, and + // asserting one of them would be wrong two times in three. + return std::unexpected(std::format( + "{} changes the language dialect{}, but the `import std` BMI is " + "precompiled without it, so every importing translation unit " + "will fail with \"language dialect differs\".\n" + " Declare it as a dialect flag instead — that channel is " + "applied to the std BMI prebuild, the module scan and every TU " + "in the graph:\n" + "\n" + " [build]\n" + " dialect_cxxflags = [{}]\n" + "\n" + " It belongs in `[build]` and not in a profile or a " + "per-target block: a dialect the standard library was not built " + "with cannot be held by one package or one profile alone.", + list, std::string{}, + [&] { + std::string q; + for (auto const& f : missing) { + if (!q.empty()) q += ", "; + q += '"'; q += f; q += '"'; + } + return q; + }())); + } + } + + // A standard library that came from a PACKAGE brings its own module + // source, because the compiler cannot be asked for one it does not have. + // + // `-print-library-module-manifest-path' is the right question when the + // standard library is the compiler's own. It is the wrong question when + // the library was configured by a package for a target the compiler + // knows nothing about: the source exists, and the compiler has never + // heard of it. So the package says where it is, and what it needs --- + // its include path and its own __config_site, neither of which the + // compiler would find. + // + // Both are read only from a package that ALSO provides the capability + // below. A package that named a std module without supplying the + // library would be describing something it does not have. + for (auto& pkg : state.packages) { + if (pkg.manifest.stdModule.empty()) continue; + // Either spelling of the C++ layer (see `provides_cxx_layer`). The + // same predicate decides which package's implementation units keep + // their own standard in `make_plan`, so the two cannot name different + // packages as the standard library. + if (!mcpp::manifest::provides_cxx_layer(pkg.manifest)) continue; + auto src = pkg.root / pkg.manifest.stdModule; + if (!std::filesystem::exists(src)) { + return std::unexpected(std::format( + "package '{}' declares [package].std-module = '{}', and there " + "is no such file under '{}'", + pkg.manifest.package.name, pkg.manifest.stdModule, + pkg.root.string())); + } + state.tc->stdModuleSource = src; + // AND THE COMPAT MODULE, FROM THE SAME PACKAGE OR NOT AT ALL. + // + // `std.compat` is a second module over the SAME library. Leaving it + // pointing at the toolchain's copy does not fail where it is set — it + // fails later, in that copy's own headers, against a configuration that + // was never generated for this target. Measured on a macOS cross: + // + // error: std module precompile failed (rc=1): + // …/xim-x-llvm/22.1.8/share/libc++/v1/std.compat.cppm + // …/include/c++/v1/__config:13: '__config_site' file not found + // + // — which reads as a broken toolchain payload and says nothing about + // the two libraries having been mixed. A package that supplies one + // module supplies both, or the pair is not offered. + if (!pkg.manifest.stdCompatModule.empty()) { + auto csrc = pkg.root / pkg.manifest.stdCompatModule; + if (!std::filesystem::exists(csrc)) { + return std::unexpected(std::format( + "package '{}' declares [package].std-compat-module = '{}', " + "and there is no such file under '{}'", + pkg.manifest.package.name, pkg.manifest.stdCompatModule, + pkg.root.string())); + } + state.tc->stdCompatSource = csrc; + } else { + state.tc->stdCompatSource.clear(); + } + state.tc->targetCxxRuntime = true; + state.tc->hasImportStd = true; + state.tc->importStdMinLevel = 20; // libc++'s own floor; see clang.cppm + // The target, first. On a freestanding target that means the whole ISA + // profile --- `--target', `-march', `-mabi', `-mcmodel' --- because a + // module built without them disagrees with every unit that imports it, + // and clang reports that as an ABI mismatch naming a .pcm file rather + // than the flag that split them. On a hosted one it is the triple alone. + std::string flags; + if (auto fs = mcpp::toolchain::triple::parse(state.tc->targetTriple); + fs && fs->is_freestanding()) { + if (auto spec = mcpp::freestanding::resolve(*fs)) + flags += mcpp::freestanding::compile_prefix(*spec, true); + } else if (!state.tc->crossTargetFlag.empty()) { + // `crossTargetFlag` and not `targetTriple`. The triple is mcpp's + // vocabulary (`aarch64-macos`); the flag carries the spelling a + // compiler takes (`arm64-apple-macos14.0`). Measured: emitting the + // first produced `--target=aarch64-macos`, which clang accepts as a + // triple it has never heard of and then treats as a bare-metal + // aarch64 — the module and its importers would agree with each + // other and with nothing else. + flags += " " + state.tc->crossTargetFlag; + // AND THE SECOND CHANNEL. `hostflags.cppm` reaches every ordinary + // translation unit; this command is assembled here instead, so a + // `std.pcm` built with SEH would be imported by units built with + // DWARF. Same function, not a second copy of the decision. + for (auto& f : mcpp::toolchain::graph_runtime_compile_flags(*state.tc)) + flags += " " + f; + } + // `__OPENKAL__` AND THE REALISED [c-abi] ENVIRONMENT REACH THE STD + // MODULE TOO (design §3.4: "环境作用于目标侧的全部编译单元... 以及图中 + // 所有普通包"). The std module's own command is assembled here rather + // than through `mcpp.toolchain.hostflags`'s shared string (see the + // comment above), so it needs the same broadcast the ordinary + // per-package loop gives every other unit — this is that same rule, + // stated once more at the one site it cannot reach on its own. + if (state.tc->kernelAbiIsOpenkal) flags += " -D__OPENKAL__"; + if (pkg.manifest.cEnvironment != "platform") { + for (auto& t : state.tc->cEnvTokens) flags += " " + t; + for (auto& t : state.tc->cEnvBuiltinsTokens) flags += " " + t; + } + // Everything up to here says which machine the module is for; what + // follows says where its headers are. The codegen step needs only the + // first — see Toolchain::stdModuleTargetFlags. + state.tc->stdModuleTargetFlags = flags; + for (auto& f : pkg.manifest.buildConfig.stdModuleFlags) { + // A flag naming a path is relative to the package that named it, + // for the same reason the module source is. + auto candidate = pkg.root / f; + flags += " " + mcpp::xlings::shq( + std::filesystem::exists(candidate) ? candidate.string() : f); + } + // AND THE HEADERS THIS PACKAGE ITSELF IS BUILT AGAINST. + // + // The std module source is one of this package's translation units in + // every way that matters, and it reaches the C library's headers the + // same way the rest of them do --- through the requirements the packages + // BENEATH this one publish. A package cannot name those in its own + // manifest: they belong to its dependencies, and their paths are known + // only after resolution. + // + // Measured: without them the module compiles until libc++ includes + // , which is the C library's, and stops there. + // publicUsage rather than privateBuild: the module is compiled once and + // imported by consumers, so the headers it must see are the ones the + // package PUBLISHES, not the ones it happens to build itself against. + // The two differ, and the difference is not cosmetic --- a package's own + // build path carries directories that exist for its .cpp files and that + // shadow the library's headers when a module is compiled against them. + // + // AND IT IS `targetSideUsage`, NOT THIS PACKAGE'S `publicUsage`. + // + // The two are the same set whenever one package supplies every layer, + // which is the arrangement this block was written for — so reading the + // package directly was correct and stayed correct until a second + // provider appeared. `openkal-llvm-runtime` supplies the C++ runtime + // while `openkal-musl` supplies the C library, and the std module needs + // both: libc++'s own headers reach ``, which is the C + // library's. + // + // Reading the assembled set also makes this site and every compile + // edge read ONE value. Deriving it here a second time is the shape + // #233/#240/#242/#344 each cost a release, and the same set has to + // reach both or the `std` BMI describes a different world than the + // units importing it — which is mcpp#514 exactly. + for (auto& d : state.targetSideUsage.includeDirs) + flags += " -isystem " + mcpp::xlings::shq(d.string()); + for (auto& d : state.targetSideUsage.includeDirsAfter) + flags += " -idirafter " + mcpp::xlings::shq(d.string()); + // And the definitions, for the same reason as the directories: a C + // library's headers show a different library depending on which feature + // macros are set, and the ones this package is built with are the ones + // its own translation units see. Measured: without them the module + // reaches musl's and stops on `clockid_t', a name that header + // declares only under the macro the package carries. + // THE PREBUILT C LIBRARY'S OWN TOKENS, FROM THE PRODUCER EVERY UNIT + // USES. A package that supplies the C++ layer over a prebuilt C + // library (`llvm.libcxx` over glibc, or over an Apple SDK) has no + // way to name that library's headers in its manifest, and the + // target-side broadcast below carries only graph layers. Without + // these the precompile reads whatever the driver finds on its own: + // on Linux the runner's `/usr/include` rather than the payload's + // glibc, a host dependency no report showed; on macOS nothing, and + // the precompile stops on `mbstate_t`; on the iOS rows nothing, and + // it stopped on the same name. Measured on 2026-09-14 across the + // three. `host_compile_tokens` is what every translation unit of the + // build gets, asked with the C++ layer marked as the graph's so that + // it withholds the payload's libc++ and emits the rest: the cfg + // bypass, the C library's directories, the SDK and the deployment + // floor. Same function, not a second copy. + // + // AND LAST ON THE COMMAND, after the package's own directories: + // `-isystem` order is search order, and libc++'s headers must precede + // the C library's, which libc++ states in as many words (`` + // stops the build if it reaches a `` that is not its own). + // Emitted ahead of them, glibc's `` shadowed libc++'s wrapper + // and `` failed on `std::__builtin_isnan` (measured). + if (state.tc->cAbiPrebuilt) { + mcpp::toolchain::HostFlagOptions hopt; + hopt.cfgBypass = mcpp::toolchain::HostFlagOptions::CfgBypass::Always; + hopt.cAbiPrebuilt = true; + hopt.cxxFromGraph = true; + hopt.appleSdkRoot = state.tc->appleSdkRoot; + // Target-keyed, not host-keyed (#685) — see `min_platform_version`. + const bool cAbiTargetIsMacos = [&] { + auto cAbiTt = mcpp::toolchain::triple::parse(state.tc->targetTriple); + return cAbiTt && cAbiTt->os == "macos"; + }(); + hopt.macosDeploymentTarget = mcpp::platform::macos::deployment_target( + cAbiTargetIsMacos, state.m->buildConfig.macosDeploymentTarget); + for (auto& t : mcpp::toolchain::host_compile_tokens( + *state.tc, hopt, mcpp::toolchain::no_escape)) { + const auto q = " " + mcpp::xlings::shq(t); + if (flags.find(q) == std::string::npos) flags += q; + } + for (auto& t : mcpp::toolchain::apple_float_macro_words(*state.tc)) { + const auto q = " " + mcpp::xlings::shq(t); + if (flags.find(q) == std::string::npos) flags += q; + } + } + // The same words the package's own units receive from this list + // (mcpp.manifest.flag_words), one quoted word each. + for (auto& w : mcpp::manifest::flag_words(state.targetSideUsage.cxxflags)) + flags += " " + mcpp::xlings::shq(w); + state.tc->stdModuleFlags = flags; + break; + } + + // AN APPLE CROSS TARGET WITHOUT A GRAPH C++ RUNTIME LINKS THE SDK'S + // libc++ (the Mach-O cell in distribution.cppm), AND THE HEADERS FOLLOW + // THE RUNTIME. The payload's `std.cppm` and headers describe libc++ 22; + // the SDK's dylib is libc++ 19 (Xcode 16.4, measured), and Apple's SDKs + // ship no module sources of their own (no `usr/share/libc++/v1` on the + // macOS 15.5 and iOS 18.5 SDKs). So: + // + // - a graph that does not import `std` takes the SDK's headers + // (hostflags.cppm, `appleSdkCxxHeaders`): one libc++ on every line, + // and the payload's module, unused, is withdrawn; + // - a graph that imports `std` keeps the payload's module and headers + // over the SDK's dylib. That pairing links until an inline path in + // the newer headers names an export the older dylib lacks + // (`__hash_memory`, `__atomic_notify_all_global_table`, measured), + // and it is what every iOS program built before this release got. + // It is REPORTED ONCE rather than refused: refusing would break a + // program that built yesterday, and the report names the two lines + // that make the hazard disappear. + if (state.tc && !state.tc->appleSdkRoot.empty() && state.targetSideResolved + && !state.resolvedTargetSide.cxx.fromGraph()) { + if (!needsStdModule) { + state.tc->appleSdkCxxHeaders = true; + state.tc->hasImportStd = false; + state.tc->stdModuleSource.clear(); + state.tc->stdCompatSource.clear(); + } else { + mcpp::diag::degraded("target/cxx-runtime", std::format( + "{} links the SDK's libc++ under the toolchain payload's " + "libc++ headers and std module, which are a different " + "release of the library", state.tc->targetTriple), + "the program links while no inline path in the newer headers " + "names an export the SDK's dylib lacks; `std::unordered_map` " + "over `std::string` and `std::atomic::notify_all` are two " + "that do, and they fail at link with `__hash_memory` or " + "`__atomic_notify_all_global_table` undefined", + "declare the C++ standard library as a package, which brings " + "its headers, its module and its objects as one release: " + "[target.'cfg(os = \"ios\")'.dependencies] " + "llvm.libcxx = \"22.1.8.1\" (and " + "llvm.compiler-rt-builtins = \"22.1.8.5\" beside it)"); + } + } + + if (needsStdModule && !state.tc->hasImportStd) { + // A freestanding target reaches here for a reason the generic message + // gets wrong. Nothing is missing from the toolchain — libc++'s std + // module is right there — it is that `std` is ONE module over the whole + // library, threads and filesystem and iostreams included, so there is + // no subset of it to build without an OS. Saying "provides no std + // module source" sends the reader to look for a broken payload. + // + // The line below is copy-pasteable, and that is a PROMISE: it has + // to resolve today. It briefly did not — an earlier version of this + // message named `mcpplibs.std.freestanding` before any such package + // existed, so following the advice failed at the very next command + // with "package not found" and sent the reader off to debug their + // index. The package is published now (103 of libc++'s 110 headers, + // measured; the 7 that fail fail on a hosted x86_64 too), so the line + // is back. If it is ever removed from the index, this must change with + // it. + // + // And the VERSION is part of the promise, not decoration — which is + // how the same defect recurred in a second form. The line said "0.1.0" + // after 0.2.0 superseded it in the index, and 0.1.0 is not published, + // so pasting it produced + // + // E_NOT_FOUND: package 'compat.std-freestanding@0.1.0' not found + // in the synced index + // + // measured 2026-08-20 while documenting this message. A floor would + // not fix it either: the request has to name a version the index + // actually carries. Publishing a new std-freestanding means updating + // this literal in the same change. + // THE QUESTION IS WHETHER A HOSTED STANDARD LIBRARY IS PRESENT, NOT + // WHETHER THE TARGET IS FREESTANDING. + // + // Those were the same question for as long as no one had built one for + // such a target, and they stopped being the same when someone did: + // `mcpplibs/openkal-llvm-runtime' configures libc++, libc++abi and + // libunwind for a machine with no operating system, and a program above + // it has the library this refusal says it cannot have. + // + // The refusal is kept, because it is right in every case where nothing + // supplies one --- which is still the ordinary case, and the advice + // below is still the advice. What changes is that a package can now say + // otherwise, and it says so the way every other capability is declared: + // + // provides = ["hosted-standard-library"] + // + // A capability rather than a triple, because the fact is a property of + // the graph and not of the target, and because dependency resolution is + // the earliest time at which it is known. + const bool hostedStdProvided = + state.capProviders.find("hosted-standard-library") != state.capProviders.end(); + if (auto ft = mcpp::toolchain::triple::parse(state.tc->targetTriple); + ft && ft->is_freestanding() && !hostedStdProvided) + { + return std::unexpected(std::format( + "`import std;` is not available on '{}' — a freestanding target " + "has no hosted standard library.\n" + " `std` is one module over the entire library (threads, " + "filesystem, iostreams\n" + " included), so there is no subset of it to build without " + "an OS underneath.\n" + " Use the freestanding subset instead — an ordinary " + "dependency carrying\n" + " the parts of the library that need no OS " + "(array, span, optional, atomic,\n" + " string_view, ranges, expected, charconv, coroutines):\n" + "\n" + " [dependencies]\n" + " std-freestanding = \"0.2.0\"\n" + "\n" + " then `import mcpplibs.std.freestanding;` in place of " + "`import std;`.\n" + " The target's C library itself comes from the BOARD " + "package (riscv-virt-rt\n" + " exports `mcpplibs.riscv_virt_rt`).", + state.tc->targetTriple)); + } + return std::unexpected(std::format( + "source imports std but toolchain '{}' provides no std module source", + state.tc->label())); + } + // `import std` availability is two-dimensional once C++20 is a legal level: + // having a std module source is not the same as being able to build it at + // the project's level. Every toolchain mcpp ships answers 20; only an MSVC + // STL older than microsoft/STL#3977 answers 23, and those users would + // otherwise get an error from inside std.ixx. + if (needsStdModule && state.tc->importStdMinLevel > 0 + && state.m->cppStandard.level < state.tc->importStdMinLevel) { + return std::unexpected(std::format( + "source imports std but toolchain '{}' provides the std module only " + "from {} up, while [package].standard resolves to '{}'; raise the " + "standard or drop `import std;`", + state.tc->label(), + mcpp::manifest::cpp_standard_level_name(state.tc->importStdMinLevel), + state.m->package.standard)); + } + + // Compute fingerprint (no lockfile in M1 → empty hash) + mcpp::toolchain::FingerprintInputs fpi; + fpi.toolchain = *state.tc; + fpi.cppStandard = state.m->package.standard; + // Target-keyed, not host-keyed (#685): the fingerprint must fold + // `macos_deployment_target` whenever THIS BUILD's resolved toolchain + // targets macOS, whether mcpp itself is running on Linux, Windows or + // macOS — see the discriminator comment on `min_platform_version` and + // on `canonical_compile_flags`. + const bool fpTargetIsMacos = [&] { + auto fpTt = mcpp::toolchain::triple::parse(state.tc->targetTriple); + return fpTt && fpTt->os == "macos"; + }(); + fpi.compileFlags = canonical_compile_flags(*state.m, fpTargetIsMacos) + + canonical_package_build_metadata(state.packages, fpTargetIsMacos); + // [c-abi] REALISATION AND `__OPENKAL__` PARTICIPATE IN THE FINGERPRINT + // (design 2026-09-18 §3.4, gap #4 of the design's own self-review). Two + // builds whose C library declares `data-model = "lp64"` and `"llp64"` + // compile the SAME source, against the SAME manifest, into objects whose + // `long` disagrees in width — sharing an output directory between them is + // exactly the silent ABI mismatch §3.4 exists to rule out. Appended only + // when non-empty (`tc->cEnvTokens` is empty whenever no `[c-abi]` block + // resolved), so a graph that declares nothing keeps the directory it + // already had. + if (state.tc->kernelAbiIsOpenkal) fpi.compileFlags += " openkal-kernel-abi"; + for (auto& t : state.tc->cEnvTokens) fpi.compileFlags += " cenv:" + t; + for (auto& t : state.tc->cEnvBuiltinsTokens) fpi.compileFlags += " cenv:" + t; + // A package opting OUT via `c-environment = "platform"` (§3.4) still + // changes what ITS OWN objects contain, relative to a graph where it + // did not opt out — so the opt-out is folded in too, named by the + // package rather than by its flags, since the flags it now keeps are + // simply the ones already covered above. + // + // GATED ON THE REALISATION ACTUALLY BEING ACTIVE (`cEnvTokens` or + // `cEnvBuiltinsTokens` non-empty) — NOT unconditional. `cEnvironment == + // "platform"` is true for every `mcpp:kernel-abi=` provider now + // (it is INFERRED, this same revision), in every graph that uses one, + // whether or not that graph's C library declares `[c-abi]` at all. An + // unconditional loop here folded `cenv-platform:` into the + // fingerprint of EVERY project using openkal-windows (say) even when + // nothing about the realised environment was active — moving every + // such project's output directory on upgrade for a string that + // describes an opt-out from a realisation that never ran. There is + // nothing to opt OUT of when there is nothing being realised, so the + // opt-out changes nothing about that package's own objects and must + // not move the fingerprint either — the same "declares nothing, byte + // identical" guarantee the rest of this block already gives, which this + // loop had broken on its own. + if (!state.tc->cEnvTokens.empty() || !state.tc->cEnvBuiltinsTokens.empty()) { + for (auto& pkg : state.packages) + if (pkg.manifest.cEnvironment == "platform") + fpi.compileFlags += " cenv-platform:" + pkg.manifest.package.name; + } + // The module-edge schedule changes the SHAPE of build.ninja, and the fast + // path replays that file without a plan to compare against. Folding the + // switch into the fingerprint puts a differently-scheduled build in a + // different directory, which makes replaying the wrong shape structurally + // impossible instead of merely guarded. Only appended when non-default, so + // existing build directories keep their identity. + if (const auto sched = mcpp::build::schedule::requested_switch(*state.m); + sched != "auto") { + fpi.compileFlags += " #schedule="; + fpi.compileFlags += sched; + } + // The device axis decides which sources compile and which cfg sections + // apply, so two builds that differ only in it are two builds. Appended + // only when set, so a project that asks for no accelerator keeps the + // build directory it has. + if (const auto accel = state.resolvedAccel(); !accel.empty()) { + fpi.compileFlags += " #accel="; + fpi.compileFlags += accel; + } + if (state.m->cppStandard.experimental) { + // c++fly gate flags are derived (not manifest-declared): fold them in + // so a cppfly table change across mcpp versions re-fingerprints. + for (auto& f : mcpp::toolchain::cppfly::resolve(*state.tc).flags) { + fpi.compileFlags += ' '; + fpi.compileFlags += f; + } + } + fpi.dependencyLockHash = ""; // M2 + fpi.stdBmiHash = ""; // updated after stdmod build (chicken/egg ok for M1) + state.fp = mcpp::toolchain::compute_fingerprint(fpi); + + // Pre-build std module only when the source graph actually imports it. + if (needsStdModule) { + // The std BMI must be compiled with the SAME dialect set its + // importers use (issue #210: -freflection gates libstdc++'s — + // a std BMI built without it structurally lacks std::meta). Both + // pieces were already in the fingerprint; this fixes the COMMAND + // construction the fingerprint promised (stdFlagAndDialect above). + // #422: the CRT model reaches the std module too. Derived from the + // SAME expression the project's TUs use (flags.cppm), through the one + // helper, so the two cannot drift. A GNU dialect yields "-static" or "" + // here, and the gcc and clang std module builders do not read it, so + // their commands are unchanged; clang on the MSVC ABI is given no CRT + // model at all (see `MechanismInput::msvcCrtModelEmitted`). + const auto& stdDialect = mcpp::toolchain::dialect_for(*state.tc); + const auto stdCrt = mcpp::toolchain::msvc_crt_flag( + stdDialect, mcpp::toolchain::msvc_wants_static_crt( + state.m->buildConfig.linkage, state.m->buildConfig.cxxRuntime)); + // Whether THIS build's resolved toolchain targets macOS — the same + // target-not-host discriminator `min_platform_version` uses, parsed + // locally because `tc` (not a `triple::Triple`) is what is in scope + // here (#685). + const bool stdTargetIsMacos = [&] { + auto stdTt = mcpp::toolchain::triple::parse(state.tc->targetTriple); + return stdTt && stdTt->os == "macos"; + }(); + if (state.overrides.plan_only) { + // Described, not compiled: the paths and commands are the ones + // ensure_built would use, from the one derivation in stdmod.cppm. + auto described = mcpp::toolchain::describe_std_module( + *state.tc, state.m->package.standard, state.stdFlagAndDialect, + mcpp::platform::macos::deployment_target( + stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), + mcpp::toolchain::default_cache_root(), stdCrt); + if (!described) { + refusal::record(refusal::Code::StdModulePrecompile); + return std::unexpected(described.error().message); + } + state.stdBmiPath = described->bmiPath; + state.stdObjectPath = described->objectPath; + state.stdCompatBmiPath = described->compatBmiPath; + state.stdCompatObjectPath = described->compatObjectPath; + state.describedStdModule = std::move(*described); + } else { + auto sm = mcpp::toolchain::ensure_built( + *state.tc, state.m->package.standard, state.stdFlagAndDialect, + mcpp::platform::macos::deployment_target( + stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), + mcpp::toolchain::default_cache_root(), stdCrt); + if (!sm) { + // THE ONE CODE IN THE TAXONOMY THAT NOTHING WROTE. + // + // `Code::StdModulePrecompile` has existed, with a name and a + // comment, since the taxonomy was written; `grep` for it found the + // declaration and the `name()` arm and no third site. So every + // std-module refusal reported `other`, which is the bucket + // refusal.cppm defines as "a refusal that has not been given a code + // yet" -- a visible admission, and one nobody had cashed. + // + // Measured: `tests/matrix/expected.tsv` carried exactly ONE `other` + // row out of 176, `x86_64-windows-msvc x llvm@22.1.8` in graph mode, + // and `scan.sh` printed it under "无名拒绝" on every Windows run. + // The sentence was right and the classification was missing -- + // the same shape `Code::HostToolToolchain` was added for. + refusal::record(refusal::Code::StdModulePrecompile); + return std::unexpected(sm.error().message); + } + state.stdBmiPath = sm->bmiPath; + state.stdObjectPath = sm->objectPath; + state.stdCompatBmiPath = sm->compatBmiPath; + state.stdCompatObjectPath = sm->compatObjectPath; + // C5 / D5a (design 2026-09-26 §3.5): compile_commands.json and the + // S1 document list the standard-library units too, so the plan + // needs the commands mcpp ran to build them (§13396 below), not + // only their output paths. `describe_std_module` is the pure + // derivation `ensure_built` itself reads before running anything + // (mcpp.toolchain.stdmod's header); calling it again here starts + // no process and cannot name a different command or directory. + // A failure here is not this build's failure -- `ensure_built` + // above already succeeded with the same inputs -- so it only + // means the description is unavailable for the plan, silently. + auto described = mcpp::toolchain::describe_std_module( + *state.tc, state.m->package.standard, state.stdFlagAndDialect, + mcpp::platform::macos::deployment_target( + stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), + mcpp::toolchain::default_cache_root(), stdCrt); + if (described) state.describedStdModule = std::move(*described); + } + } + + if (state.print_fingerprint) { + std::println("Toolchain: {}", state.tc->label()); + std::println("Fingerprint: {}", state.fp.hex); + for (std::size_t i = 0; i < state.fp.parts.size(); ++i) { + std::println(" [{}] {}", i + 1, state.fp.parts[i]); + } + } + + return {}; +} + + +} // namespace mcpp::build diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm new file mode 100644 index 000000000..3ea84182c --- /dev/null +++ b/src/build/prepare/state.cppm @@ -0,0 +1,578 @@ +// mcpp.build.prepare:state -- the implementation partition holding +// PrepareState and the phase functions' declarations. Imported only by +// this module's own implementation units (driver.cpp and every +// phaseN.cpp): never by the primary interface, which must not import any +// partition (see the file-header comment in prepare.cppm for why -- a +// GCC 16.1 constraint). An implementation partition rather than an +// interface partition for the same reason. +module mcpp.build.prepare:state; + +// A partition sees only what it explicitly imports -- unlike an +// implementation unit of this module, it does not implicitly see the +// primary interface. PrepareState needs BuildOverrides, BuildContext, +// PlanNote, CacheMode, TcOrigin and ToolPurpose, all exported from there. +import mcpp.build.prepare; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.targetside; +import mcpp.build.version_floor; +import mcpp.platform.axis; +import mcpp.libs.json; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.modgraph.glob; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.modgraph.validate; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.linkage_form; // #519 — which form each dependency takes +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.directives; // directive table: mark / fold_private_tail +import mcpp.build.dep_graph; // queries over the resolved edge graph +import mcpp.build.provisions; // #359 build-time provisions: table + propagation +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.config; +import mcpp.xlings; +import mcpp.xlings.runtime_selection; +import mcpp.runtime.binding; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.project; + +namespace mcpp::build { + + +// The platform key used to read `[toolchain].` — a pure constant, +// so it lives at file scope rather than in PrepareState: nothing about it +// depends on any phase's state, and every phase that reads it (P1's +// `tcSpecSource` included) can just name it. +constexpr std::string_view kCurrentPlatform = mcpp::platform::name; + +// Namespace aliases used across several phases (P4's own declarations and +// P7/P9's reuse of its helpers); hoisted here so every phase function sees +// them under the short spelling instead of each repeating the full path. +namespace prov = mcpp::build::provisions; +namespace dg = mcpp::build::dep_graph; + +// Sentinel for "the consumer is the main package" (no dep_manifests entry). +// A pure constant; used by both halves of the P4 split (graph_load, graph). +constexpr std::size_t kMainConsumer = static_cast(-1); + + struct DepCacheIdentity { + std::string indexName; + std::string packageName; + std::string version; + // "version" | "path" | "git". Only "version" is cacheable: an index + // package's payload lives in the immutable xpkgs store under a + // version-keyed directory, so name@version identifies its sources. + // Path and git checkouts can change under an unchanged identity. + std::string sourceKind; + // What identifies the SOURCES when the version does not: the resolved + // commit for `git`, the package root for `path`, empty for an index + // package. Read by the tool store, whose key must hold everything + // that can change a built tool's bytes (#630, item 6). + std::string sourceRef; + }; + + struct GitLockIdentity { + std::string source; + std::string hash; + }; + + struct DependencyEdge { + std::size_t consumerPackageIndex = 0; + std::size_t dependencyPackageIndex = 0; + mcpp::modgraph::DependencyVisibility visibility = + mcpp::modgraph::DependencyVisibility::Public; + // #242/#243: the per-edge feature request that THIS consumer made of + // THIS dependency. Feature activation must consume these off the edge + // graph (union over all incoming edges) rather than re-scanning only + // the root manifest's direct deps — otherwise a transitive dep's + // requested features and its consumer's `default-features = false` are + // silently dropped (resolution honors them per-edge; activation did not). + std::vector requestedFeatures; + bool defaultFeatures = true; + // #355: HOST tools this consumer asked the dependency for. Aggregated + // off the edge graph exactly like requestedFeatures — a transitive + // 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? + bool hostModule = false; + bool reexport = false; + // Did this edge come from `[build-dependencies]`? Such an edge serves + // the BUILD and never the target, and the property is inherited by + // everything reachable through it. It is a property of the edge and + // not of the package: the same package may be an ordinary dependency + // of someone else in the same build, and then it does reach the + // target. + bool buildOnly = false; + }; + + struct GraphRequest { + std::size_t consumerPackageIndex = 0; + std::size_t dependencyPackageIndex = 0; + std::string key; // the dependency key as the requester wrote it + std::string table; // `DependencySpec::declaredIn` + }; + + struct DependencyLinkForm { + mcpp::build::linkage_form::PackageFacts facts; + mcpp::build::linkage_form::Resolution answer; + // The package has a library form to report: a package of programs or + // rules has none, and is neither recorded nor offered to a program. + bool recorded = false; + }; + + struct ResolvedKey { + std::string ns; + std::string shortName; + auto operator<=>(const ResolvedKey&) const = default; + }; + + struct ResolvedRecord { + std::string version; // empty for path/git deps + std::string constraint; // AND-combined original constraints (version src only) + std::string requestedBy; // human-readable for error messages + std::string source; // "version" | "path" | "git" — for type-clash check + // The declaration's identity beyond `source`, so a SECOND declaration + // of the same (ns, name) can be compared for "the same reference" + // rather than merely "the same kind". `git`: "#=", + // from the DECLARED ref (never the resolved commit — comparing two + // branch names must not need a network round trip to decide whether + // they conflict). `path`: the canonical absolute directory. `version`: + // the original constraint string ("*" for none). See the + // `dependency/source-override` decision at the resolve hit (2026-09-13 + // #630 record, §2.2). + std::string sourceRef; + // True when this record's declaration came from the root manifest's + // own [dependencies]/[dev-dependencies]/[build-dependencies] + // (`item.consumerDepIndex == kMainConsumer` at the time the record + // was created). Bounds the root's privilege to override a + // conflicting declaration of the SAME identity the way + // `DependencySpec::linkage` is honoured only on the root's own + // edges — see dep_spec.cppm. + bool fromRoot = false; + // Reached ONLY through [dev-dependencies]. mcpp.lock excludes these: + // dev-deps are resolved under `mcpp test` and not under `mcpp build`, so + // recording them makes a VCS-committed file depend on which command ran + // last and ping-pong between the two. The lock must be a function of the + // MANIFEST, not of the command. Cleared the moment a non-dev consumer + // asks for the same package. + bool devOnly = false; + std::size_t depIndex = 0; // index into dep_manifests/packages-1 (for in-place re-fetch) + std::vector linkFlagsAdded; // entries appended to m->buildConfig.ldflags by this dep + }; + + struct WorkItem { + std::string name; // dep map key as written + mcpp::manifest::DependencySpec spec; // copy (we may mutate version) + std::string requestedBy; // who asked for it + std::string originalConstraint; // spec.version BEFORE pinning (for SemVer merge) + std::size_t consumerDepIndex; // dep_manifests slot of who pushed this child; kMainConsumer for main + std::filesystem::path resolveRoot; // base dir for relative path deps (empty = use project root) + bool devOnly = false; // seeded from [dev-dependencies]; inherited by children + // Seeded from `[build-dependencies]`, and inherited by children the + // same way `devOnly` is. It answers "does this serve the build or the + // target", which is a different question from "which build-time + // product do I want" — that one is answered per edge by `tools` and + // `host-module`, and the two are orthogonal. A package linked into the + // target that also provides a tool is written once, in + // `[dependencies]`, with a `tools` request on it. + bool buildOnly = false; + }; + + struct DeclaringManifest { + std::string path; + bool namespaceDeclared = false; + }; + + struct GitClone { + std::filesystem::path root; + std::string url, refKind, ref; + }; + +// PrepareState carries prepare_build's working state across the phases it +// decomposes into (see the layout comment at the top of prepare.cppm). Each +// phase is an ordinary function taking `PrepareState&`; the state itself is +// constructed once, in driver.cpp's prepare_build(), and lives for the +// whole call — including across a phase that stores a closure for a LATER +// phase to call (resolve_target_toolchain, defined in toolchain.cpp's P2 and +// called from its own P5): such a closure captures `state` itself rather +// than individual locals, so it stays valid no matter which phase's stack +// frame created it. +// +// A member exists here because some phase after the one that computes it +// still reads it, or because a stored closure needs it to remain valid past +// its own phase — several members exist ONLY for that second reason and are +// never read by name from another phase (bootstrap_checked, kMainConsumer's +// siblings). A value read and written within a single phase stays an +// ordinary local in that phase's function body; it does not move here. +// +// Not copied: copying this by value would copy every dependency-graph and +// plan structure prepare_build ever builds, silently, at whichever call +// happened to pass it by value instead of by reference. +struct PrepareState { + PrepareState(bool print_fingerprint_, bool includeDevDeps_, + std::vector extraTargets_, + BuildOverrides overrides_) + : print_fingerprint(print_fingerprint_), + includeDevDeps(includeDevDeps_), + extraTargets(std::move(extraTargets_)), + overrides(std::move(overrides_)), + // Which tool tiers this invocation needs. Named once so the two + // provisioning passes cannot disagree — a `mcpp build` that + // installed the run tier and a `mcpp run` that did not would be the + // same defect twice. + toolPurpose(overrides.will_run ? ToolPurpose::Run : ToolPurpose::Build) {} + + PrepareState(const PrepareState&) = delete; + PrepareState& operator=(const PrepareState&) = delete; + + // ── prepare_build's parameters, unchanged for every phase ────────────── + bool print_fingerprint; + bool includeDevDeps; + std::vector extraTargets; + BuildOverrides overrides; + const ToolPurpose toolPurpose; + + // ── P0: manifest and workspace resolution ─────────────────────────────── + std::string unservedTargetDiagnosis; + std::optional appleSdkLocated; + bool iosFloorFromSdk = false; + std::string pinReplacedDefault; + std::optional hostSpecBeforeRowPin; + std::string graphCompilerRequiredBy; + std::string graphCompilerFamily; + std::string graphCompilerReplaced; + std::string requestedCAbi; + std::string targetDisplayName; + std::string targetPinCandidate; + bool targetPinIsCapability = false; + std::string targetRowPin; + std::string targetRowName; + bool tcSpecIsMsvc = false; + std::optional root; + std::optional effective; + std::expected m = std::unexpected(std::string{}); + std::optional wsManifest; // keep workspace manifest alive + std::filesystem::path runtimeWorkspaceRoot; + mcpp::xlings::runtime::RuntimeSelection runtimeSelection; + std::filesystem::path workRoot; + std::vector planNotes; + std::map gitLockAnchors; + std::map packageIdentityLockAnchors; + CacheMode cacheMode{}; + + // ── P1: toolchain spec resolution, target axis, L1 cfg merge ──────────── + // `get_cfg`, `provide_runtime_payload`, `report_fixup`, + // `msvc_usable_either_origin`, `native_first_run_spec`, `tcSpecSource`, + // `resolvedAccel`, `cfgCtx` and `add_once` are closures that outlive P1 + // (later phases call them by name): each captures `state` itself, not the + // individual fields below, so it stays valid regardless of which phase's + // stack frame created it (see the PrepareState comment above). + std::filesystem::path explicit_compiler; + std::optional cfg_opt; + bool bootstrap_checked = false; + std::function(bool)> get_cfg; + mcpp::platform::runtime::RuntimeBinding runtimeBindingSnapshot; + std::string projectSubosBin; + std::string runtimePayload; + std::filesystem::path runtimeLibDir; + bool runtimePayloadProvided = false; + std::function provide_runtime_payload; + std::function report_fixup; + std::string effectiveProfile; + std::vector storeRoots; + std::optional tcSpec; + TcOrigin tcOrigin{}; + bool tcFromCommandLine = false; + bool tcFromConsumer = false; + std::function tcSpecSource; + std::function msvc_usable_either_origin; + std::function native_first_run_spec; + bool windowsGnuFirstRun = false; + std::function resolvedAccel; + std::function cfgCtx; + std::optional targetPlatform; + bool abiThreadsRendered = false; + std::function&, std::string_view)> add_once; + std::optional tc; + bool firstRunNeedsTargetPass = false; + bool targetPassDone = false; + + // ── P2: the toolchain resolver, defined here and called from P5 ───────── + // A std::function, not auto, because the first-run branch inside calls + // back into it (see the comment at its definition). Captures `state` + // itself, like every other stored closure here. + std::function()> resolve_target_toolchain; + + // ── P3: xlings payload before the dependency graph is built ───────────── + std::function host_spec_for_build_program; + std::optional> hostTcCache; + std::function, std::string>()> + host_tc_for_build_program; + std::string resolvedTargetCanonical; + // See its assignment: a pointer because a PrepareState member cannot be a + // reference, not because ownership is in question -- it always aliases + // either wsManifest or m, both of which outlive every phase. + const mcpp::manifest::Manifest* runtimeOwnerManifest = nullptr; + + // ── P4: the dependency graph ───────────────────────────────────────────── + // Escaping closures called again from P6/P7/P13 (fillXpkgDirs, + // fillDepDirs, adoptActionOutputs, markDirectiveTail, computeUsageRequirements, + // graph_xlings_split), plus the closures THEY call that are themselves + // referenced by reference from inside those (compilesHere, + // bareBindingsFor, isProgramOnlyPackage, isArtifactPackage, + // publishedNamesFor, appendUniqueFlags, appendUniquePaths, + // appendUniquePath): every one of the latter is a local lambda that would + // otherwise be captured by reference into one of the former and dangle + // the moment that former's phase returns, which the compiler cannot + // detect (nothing outside the escaping closure's own body names them). + std::vector packages; + std::vector> activeFeaturesByPackage; + std::map xlingsWinner; + std::vector> dep_manifests; + std::vector dep_cache_identities; + std::map root_git_lock_identities; + std::vector dependencyEdges; + std::vector graphRequests; + std::map dependencyLinkForms; + std::map> hostModuleProvidersByConsumer; + std::string runnerProvider; + std::map namedRunnerProvider; + std::vector rootReq; + mcpp::build::provisions::Propagation provisionGraph; + + std::function, std::vector>, + std::string>()> graph_xlings_split; + std::function computeUsageRequirements; + std::function adoptActionOutputs; + std::function fillXpkgDirs; + std::function*)> fillDepDirs; + std::function + markDirectiveTail; + std::function(std::size_t)> + bareBindingsFor; + std::function compilesHere; + std::function isProgramOnlyPackage; + std::function&, const std::vector&)> + appendUniqueFlags; + std::function&, + const std::vector&)> appendUniquePaths; + std::function&, const std::filesystem::path&)> + appendUniquePath; + std::function isArtifactPackage; + std::function(std::size_t, + const std::map&)> + publishedNamesFor; + + // ── the graph-loading half of P4 (graph_load.cpp), called from the + // worklist engine (graph.cpp): the same escaping-closure pattern as + // everything above, one level deeper. loadVersionDep is defined once + // (with these helpers as its own captures) and called repeatedly from + // the worklist loop, well after its defining call returns. ──────────── + std::deque worklist; + std::map identityBySource; + std::map declaringManifest; + std::set> adoptionsReported; + std::map gitCloneBySource; + std::set selectorMigrationWarnings; + std::set preinstallStack; + std::set preinstallDone; + std::function cache_index_name; + std::function(const std::filesystem::path&, + const ResolvedKey&)> gitMemberDeclaring; + std::function qualifiedKey; + std::function stateAdoptedIdentity; + std::function reportAdoption; + std::function index_route; + std::function findIndexForNs; + std::function(mcpp::manifest::DependencySpec&, + const std::string&)> resolveSemver; + std::function(const mcpp::pm::DependencyCoordinate&)> + readStrictLuaForCandidate; + std::function + xpkgLuaMatchesCandidate; + std::function( + const mcpp::manifest::DependencySpec&, const std::string&)> dependencyCoordinates; + std::function(mcpp::manifest::DependencySpec&, + const std::string&)> selectDependencyCandidate; + std::function, + std::string>(const std::string&, const std::string&, + const std::string&, const std::string&)> + loadVersionDep; + + std::map resolved; + std::map>> + toolEnvByConsumer; + std::map> + hostModulesByConsumer; + std::function + foldDirectiveTailIntoPrivateBuild; + + // ── P6-P8: feature activation, capability/ABI accumulation, target side, + // host tool provisioning ───────────────────────────────────────────────── + std::set activeRootFeatures; + std::map> capProviders; + std::vector> capRequires; + std::vector> abiRequires; + std::vector> abiRequiresExceptions; + std::map> capExclusive; + std::map> deviceSourcesByPackage; + std::function()> checkVersionFloors; + mcpp::targetside::TargetSide resolvedTargetSide; + std::optional cxxLayerProviderIndex; + bool targetSideResolved = false; + mcpp::modgraph::UsageRequirements targetSideUsage; + + // ── P9/P11: the module scan, its validation, and the fingerprint they + // feed; read again by P13 (the plan, resolution.json) ─────────────────── + mcpp::toolchain::Fingerprint fp; + std::filesystem::path stdBmiPath; + std::filesystem::path stdObjectPath; + std::filesystem::path stdCompatBmiPath; + std::filesystem::path stdCompatObjectPath; + std::optional describedStdModule; + std::string stdFlagAndDialect; + std::function graph_package_entry; + mcpp::modgraph::ScanResult scan; + mcpp::modgraph::ValidateReport report; +}; + +// Phase declarations. Defined across manifest.cpp, toolchain.cpp, +// xlings.cpp, graph_load.cpp, graph.cpp, features.cpp, target_side.cpp, +// scan.cpp and plan.cpp; called in order from driver.cpp's +// prepare_build(). Ordinary (non-static) module-linkage declarations -- +// static would give each definition internal linkage, invisible outside +// its own file. +std::expected phase0_manifest_and_workspace(PrepareState& state); +std::expected phase1_toolchain_spec_and_axes(PrepareState& state); +std::expected phase2_define_toolchain_resolver(PrepareState& state); +std::expected phase3_xlings_before_graph(PrepareState& state); +std::expected phase4a_graph_load(PrepareState& state); +std::expected phase4b_graph_worklist(PrepareState& state); +std::expected phase5_toolchain_after_graph(PrepareState& state); +std::expected phase6_features_and_host_tools(PrepareState& state); +std::expected phase9_target_side(PrepareState& state); +std::expected phase11_scan(PrepareState& state); +std::expected phase13_finish(PrepareState& state); + +// ── Helpers the phases share, defined in the files named below ───────────── + +// config.cpp: manifest conditional merges, build flags and defines, workspace inheritance, feature requests, std-module detection +void warn_unknown_xpkg_keys(const mcpp::manifest::Manifest& dm, + std::string_view depLabel); +std::expected +materialize_generated_files(const std::filesystem::path& root, + const mcpp::manifest::Manifest& manifest, + std::vector* stale = nullptr); +bool same_dependency_identity(const mcpp::manifest::DependencySpec& a, + const mcpp::manifest::DependencySpec& b); +void replace_dependencies( + std::map& into, + const std::map& from); +std::vector>& pending_flag_words_notes(); +void report_flag_words_changes(const mcpp::manifest::Manifest& m); +std::optional +inherit_as_workspace_member(mcpp::manifest::Manifest& member, + const mcpp::manifest::Manifest& workspace, + const std::filesystem::path& workspaceRoot, + const std::filesystem::path& memberDir); +std::optional> +workspace_listing(const std::filesystem::path& memberDir, + const std::filesystem::path& bound); +bool merge_layer_conditional_config(mcpp::manifest::Manifest& m, + const cfgpred::Ctx& ctx); +std::vector feature_closure(const mcpp::manifest::Manifest& pm, + const std::vector& requested, + bool seedDefault = true); +bool is_std_module(std::string_view name); +bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, + const mcpp::manifest::Manifest& manifest, + const std::filesystem::path& projectRoot); + +// toolchain_env.cpp: target rows, sysroots, the MSVC binding, build-program environments +const mcpp::manifest::TargetEntry* +find_target_entry(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::triple::Triple& t); +const std::string* +sysroot_override(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::triple::Triple& t); +std::expected +bind_msvc_sysroot(mcpp::toolchain::Toolchain& tc, + const mcpp::manifest::Manifest& m, + const std::function()>& cfgOf); +std::expected +check_cl_row_sysroot(const mcpp::toolchain::Toolchain& tc, + const mcpp::manifest::Manifest& m); +void fill_package_build_env(mcpp::build::BuildProgramEnv& e, + const mcpp::manifest::Manifest& m); +void fill_target_build_env(mcpp::build::BuildProgramEnv& e, + const mcpp::manifest::Manifest& m, + const mcpp::toolchain::Toolchain* tc, + const mcpp::config::GlobalConfig* cfg); +std::string min_platform_version(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::triple::Triple& t, + const std::filesystem::path& compilerPath); + +// fetch.cpp: git remotes, network retries, xlings addresses and their provisioning +std::string git_cache_head(const std::filesystem::path& gitRoot); +mcpp::platform::process::RunResult run_with_network_retry( + std::string_view command, + const std::function& between = {}); +std::vector +applicable_xlings_addresses(const mcpp::manifest::Manifest& man, + const std::vector& activeFeatures, + ToolPurpose purpose, bool isRoot); +std::expected +provision_xlings_addresses(const mcpp::config::GlobalConfig& cfg, + const std::vector& declaredDeps, + const std::filesystem::path& legacyStampRoot, + std::string_view label); +std::string with_index_cause(std::string msg); + +} // namespace mcpp::build diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp new file mode 100644 index 000000000..d06b55f32 --- /dev/null +++ b/src/build/prepare/target_side.cpp @@ -0,0 +1,1932 @@ +// target_side.cpp -- P9 and P10: the target side resolved against the +// graph, and the form each dependency is linked in. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.targetside; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.build.version_floor; +import mcpp.home; +import mcpp.platform.axis; +import mcpp.libs.json; +import mcpp.log; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.modgraph.glob; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.modgraph.validate; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.cenv; // [c-abi] declaration → compiler configuration (design 2026-09-18) +import mcpp.toolchain.cenv_probe; // [c-abi] declaration is checked, not trusted (design §3.2) +import mcpp.toolchain.predefines; // the macros this engine defines: contract and emission in one module +import mcpp.toolchain.cppfly; +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.freestanding.target; // the target sysroot layout (libdir) +import mcpp.freestanding.linkline; // the ISA profile, for the std module command +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.linkage_form; // #519 — which form each dependency takes +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.pack.abi_tag; // the tag a prebuilt dependency is checked against +import mcpp.pack.prebuilt; // …and the check itself +import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its manifest +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.ui; +import mcpp.log; +import mcpp.wire; // Severity, for PlanNote (#699 item 2, E3) + +namespace mcpp::build { + +std::expected phase9_target_side(PrepareState& state) { + // ── THE TARGET SIDE, RESOLVED ONCE ─────────────────────────────────────── + // + // HERE AND NOT EARLIER, AND THAT IS THE WHOLE POINT. + // + // mcpp serves two ways of supplying a target's platform interface, C + // library and C++ runtime, and the moment each becomes knowable is + // opposite: a prebuilt directory is known before dependency resolution, a + // set of packages only after it. Until now three separate derivations ran + // at the earlier moment and guessed the later answer — the family name in + // this file, `graphTargetSide` in flags, `graphCxxRuntime` in the contract + // — and they disagreed on the case none of them was written for. Measured: + // + // ld64.lld: error: …/lib/x86_64-unknown-linux-gnu/libc++.so: + // unhandled file type + // + // for a pure C program crossed to macOS, whose graph supplies a C library + // and no C++ runtime at all. + // + // Placing the resolution after capability binding and before the root + // build.mcpp means every later consumer reads one value, and a build + // program can be told what was resolved rather than re-deriving it. + { + namespace tsd = mcpp::targetside; + + // Scan the graph once for every layer. A package declares the layer it + // supplies and, optionally, the interface name it answers to: + // + // provides = ["mcpp:kernel-abi=openkal"] + // + // The engine knows the five layer names and nothing about the + // implementations that fill them. `hosted-standard-library` is accepted + // for the C++ layer as the spelling that shipped before this one, so an + // existing package keeps working unchanged. + // + // ONE SUPPLIER PER LAYER, AND TWO IS AN ERROR RATHER THAN A PICK. + // A C library, a kernel interface and a C++ runtime are mutually + // exclusive choices; the same rule already governs `[build] runner` for + // the same reason. Until this scan collected candidates instead of + // keeping the first acceptable one, two suppliers resolved by graph + // traversal order — an order the author neither writes nor can predict — + // and the loser's `[build]` section still reached the command line. + // `index` — WHICH PACKAGE this candidate is, not just its name. + // + // Needed once resolution is done: a layer supplied from the graph + // publishes an include set the WHOLE build must see (see + // `targetSideUsage` below), and reaching that package by name would be + // a second lookup of something already in hand. + struct Candidate { tsd::Provider p; bool direct; std::size_t index = 0; }; + std::map> byLayer; + std::vector requirements; + + const auto& rootDeps = state.m->dependencies; + auto is_direct = [&](std::string_view name) { + for (auto const& [k, _] : rootDeps) { + if (k == name) return true; + // Selectors are `.` or a bare tail; a tail + // match is what the author sees in their own manifest. + if (k.size() > name.size() && k.ends_with(name) + && k[k.size() - name.size() - 1] == '.') + return true; + } + return false; + }; + + for (std::size_t pkgIndex = 0; pkgIndex < state.packages.size(); ++pkgIndex) { + auto const& pkg = state.packages[pkgIndex]; + const auto pkgId = pkg.manifest.package.version.empty() + ? pkg.manifest.package.name + : std::format("{}@{}", pkg.manifest.package.name, + pkg.manifest.package.version); + + // EVERY PACKAGE KIND, NOT ONLY THE ONES WITH AN XPKG + // DESCRIPTOR. `warn_unknown_xpkg_keys` reaches a dependency + // resolved through the index; a path or git dependency carries a + // manifest of its own and reached no warning at all, so a layer + // this engine does not know went by in silence. This loop sees + // every package in the graph. + for (auto const& cap : pkg.manifest.unknownCapabilities) { + if (&pkg == &state.packages.front()) continue; // root: already refused + // The same text the root's refusal carries, including the list + // of layers that do exist. A warning that says less than the + // error it replaced would be a worse diagnostic wearing a + // milder severity. + auto why = tsd::parse_capability(cap); + mcpp::ui::warning(std::format( + "package '{}': {}\n" + " Ignored, and this build proceeds without that layer. " + "A newer mcpp may resolve it.", + pkgId, + why ? std::format("`{}` names no capability mcpp knows.", cap) + : why.error())); + } + + for (auto const& entry : pkg.manifest.provides) { + std::optional decl; + if (auto parsed = tsd::parse_capability(entry); parsed && *parsed) + decl = **parsed; + else if (entry == "hosted-standard-library") + decl = tsd::CapDecl{ tsd::CapLayer::CxxAbi, {} }; + if (!decl) continue; + if (!tsd::layer_is_suppliable_by_package(decl->layer)) { + return std::unexpected(std::format( + "package '{}' declares `provides = [\"{}\"]`, and the " + "compiler is not a layer a package can supply.\n" + " A compiler is a payload this engine installs and " + "drives; the differences between families are things the " + "engine must know rather than data a package can " + "describe.\n" + " A package may REQUIRE one: `requires = " + "[\"mcpp:compiler=\"]`.", + pkgId, entry)); + } + + tsd::Provider p; + p.name = pkg.manifest.package.name; + p.version = pkg.manifest.package.version; + p.interfaceName = decl->interfaceName; + p.hasStdModule = !pkg.manifest.stdModule.empty(); + p.cAbiDecl = pkg.manifest.cAbiDecl; + + auto& slot = byLayer[static_cast(decl->layer)]; + // A package may carry both spellings during the transition, and + // the array order is the author's, not a preference. Two entries + // from the SAME package are one supplier; the current spelling + // names the interface and the older one cannot, so the entry + // that carries an interface name wins. + auto same = std::find_if(slot.begin(), slot.end(), + [&](const Candidate& c){ return c.p.name == p.name; }); + if (same != slot.end()) { + // `index` MOVES WITH `p` AND NOT ON ITS OWN. The two + // describe one package, and this branch is reached only + // from the same `pkgIndex` today — a package carrying both + // spellings — so they cannot differ yet. Tying them keeps + // it that way if a second package ever reaches here. + if (same->p.interfaceName.empty() && !p.interfaceName.empty()) { + same->p = p; + same->index = pkgIndex; + } + } else { + slot.push_back({ p, is_direct(p.name), pkgIndex }); + } + } + + // `requires` — the symmetric half. An entry naming a layer this + // engine does not know is an error for the same reason a `provides` + // one is: a typo would otherwise disable a check silently. + for (auto const& entry : pkg.manifest.requires_) { + auto parsed = tsd::parse_capability(entry); + // An unknown layer name is reported where the manifest was + // read — as an error for the root and a warning for a + // dependency — so it is skipped rather than refused twice. + if (!parsed || !*parsed) continue; + requirements.push_back({ pkgId, (*parsed)->layer, + (*parsed)->interfaceName }); + } + } + + for (auto const& [layerInt, slot] : byLayer) { + if (slot.size() < 2) continue; + tsd::Conflict c; + c.layer = static_cast(layerInt); + c.first = slot[0].p.id(); + c.firstVia = slot[0].direct ? "" : "a transitive dependency"; + c.second = slot[1].p.id(); + c.secondVia = slot[1].direct ? "" : "a transitive dependency"; + return std::unexpected(tsd::format_conflict(c)); + } + + auto provider_of = [&](tsd::CapLayer want) + -> std::optional { + auto it = byLayer.find(static_cast(want)); + if (it == byLayer.end() || it->second.empty()) return std::nullopt; + return it->second.front().p; + }; + + tsd::Inputs in; + if (state.tc) { + if (auto tt = mcpp::toolchain::triple::parse(state.tc->targetTriple)) { + in.llvmTriple = tt->llvm_triple( + min_platform_version(*state.m, *tt, state.tc->binaryPath)); + in.targetOs = tt->os; + in.targetEnv = tt->env; + in.freestandingTarget = tt->is_freestanding(); + // NOT `tt->envExplicit`. By this line the triple has been + // canonicalised, and the canonical form of `x86_64-linux` is + // `x86_64-linux-gnu` — re-parsing it reports a segment the + // project never wrote. The request was captured upstream, where + // the distinction still existed. + in.requestedCAbi = state.requestedCAbi; + if (!state.requestedCAbi.empty()) { + auto bare = *tt; bare.env.clear(); + in.requestFreeTarget = bare.str(); + } + // The segment names a different axis on each platform, and + // saying WHICH lets the report gloss it instead of merely + // withholding a warning. Only the C-library case can contradict + // what the graph resolved; the other two are simply a different + // question, and the report says so. + in.envAxis = + tt->os == "linux" ? tsd::EnvAxis::CLibrary + : tt->os == "windows" ? tsd::EnvAxis::ObjectAbi + : tt->is_freestanding() ? tsd::EnvAxis::ObjectFormat + : tsd::EnvAxis::Unknown; + + // `sysroot = ""` and "no sysroot key" are different answers and + // must not be collapsed: the first says this project wants no + // prebuilt C library, the second says it did not say. + // On an MSVC-ABI row the key names the MSVC toolset, which the + // toolchain binding consumes (`bind_msvc_sysroot`); it is not a + // C library package for this model to report as one. + if (tt->is_msvc_env()) + ; + else if (auto const* ovr = sysroot_override(*state.m, *tt); ovr && ovr->empty()) + in.sysrootDeclaredEmpty = true; + else + in.sysrootXpkg = mcpp::toolchain::triple::effective_sysroot( + *tt, sysroot_override(*state.m, *tt)); + } + in.payloadLibcRef = state.tc->targetSysrootPkg; + in.payloadCxxInterface = state.tc->stdlibId; + // The compiler is a layer, and it is the one layer no package can + // supply. It enters here so that a requirement has something to be + // checked against and so the report can show the whole stack. + in.compilerFamily = std::string(state.tc->compiler_family()); + in.compilerVersion = state.tc->version; + // WHETHER THE PAYLOAD HAS A COMPILER RUNTIME FOR AN APPLE CROSS + // TARGET, read from the payload's own resource directory. Clang's + // Darwin driver adds `libclang_rt..a` from there when + // the file exists and continues silently when it does not, and + // the official payload builds only the macOS archive (measured, + // 22.1.8: `lib/clang/22/lib/darwin/` holds `libclang_rt.osx.a` and + // no `ios` or `iossim`). The consequence without this line is + // `__isPlatformVersionAtLeast` undefined at link with nothing + // said earlier (mcpp#630). The engine never looks in Xcode for the + // archive: a compiler runtime the payload lacks is a graph + // package, as it is on the bare rows. + if (!state.tc->appleSdkRoot.empty()) { + if (auto tt = mcpp::toolchain::triple::parse(state.tc->targetTriple); + tt && tt->is_ios()) { + const std::string archive = std::format( + "libclang_rt.{}.a", tt->is_ios_simulator() ? "iossim" : "ios"); + const auto payloadRoot = + state.tc->binaryPath.parent_path().parent_path(); + bool found = false; + std::error_code ec; + for (auto const& ver : std::filesystem::directory_iterator( + payloadRoot / "lib" / "clang", ec)) { + if (std::filesystem::exists( + ver.path() / "lib" / "darwin" / archive, ec)) { + found = true; + break; + } + } + in.payloadCompilerRuntimeAbsent = !found; + } + } + } + in.compilerRuntime = provider_of(tsd::CapLayer::CompilerRuntime); + in.kernelAbi = provider_of(tsd::CapLayer::KernelAbi); + in.cAbi = provider_of(tsd::CapLayer::CAbi); + in.cxxAbi = provider_of(tsd::CapLayer::CxxAbi); + + // A ROW THAT LINKS THROUGH lld DIRECTLY, ON A PAYLOAD WITH NO lld. + // + // `x86_64-none-elf` is the only row carrying an `lldEmulation`, and its + // column comment in mcpp.freestanding.target says why the driver is + // bypassed for it: the driver "would hand the link to a host `g++` that + // cannot take our linker's path". + // + // MEASURED TWICE ON windows-2022, AND THE SECOND TIME WAS MY OWN + // FALLBACK. First, an empty `resolve_lld` left linker vocabulary on a + // driver line: + // + // clang++: error: unknown argument: '-m' + // + // Then, falling back to the driver line reproduced exactly what the + // bypass exists to prevent: + // + // clang++: error: linker (via gcc) command failed + // collect2.exe: error: ld returned 1 exit status + // + // There is no third shape. The row needs lld by name; when the + // payload has none, the answer is a refusal at the decision, not a + // different link. + if (auto fsT = state.tc.has_value() + ? mcpp::toolchain::triple::parse(state.tc->targetTriple) + : std::nullopt; + fsT && fsT->is_freestanding()) { + auto fsSpec = mcpp::freestanding::resolve(*fsT); + if (fsSpec && !fsSpec->lldEmulation.empty() + && mcpp::freestanding::resolve_lld(state.tc->binaryPath).empty()) { + refusal::record(refusal::Code::LldRequiredAbsent); + return std::unexpected(std::format( + "target '{}' links through lld directly, and this toolchain " + "payload ships none.\n" + " The row carries an lld emulation ('{}'), which means " + "the compiler driver is\n" + " bypassed — for this target it would hand the link to " + "a host linker that\n" + " cannot take a freestanding ELF.\n" + " install a toolchain whose payload contains ld.lld, " + "or build this target\n" + " from a host that has one.", + fsT->str(), fsSpec->lldEmulation)); + } + } + + state.resolvedTargetSide = tsd::resolve(in); + state.targetSideResolved = true; + + // `__OPENKAL__` — design 2026-09-18 §2.1, §3.4. Read from the + // resolved LAYER's interface name, never from a package name, so a + // second implementation of `mcpp:kernel-abi=openkal` needs no engine + // change. Applies to every target-side unit unconditionally — even + // one that declares `c-environment = "platform"`, because the + // exception in §3.4 is about the C ENVIRONMENT a package sees, not + // about whether `kal_*` may be called from it. + if (state.tc) state.tc->kernelAbiIsOpenkal = + state.resolvedTargetSide.kernelAbi.interfaceName == "openkal"; + + // [c-abi] REALISATION — design §3.2-§3.4. Everything below is + // skipped, and every command line unchanged, for the graph this + // engine has always built. + // + // THE TEST IS `declared`, NOT THE OPTIONAL. `TargetSide::cAbiDecl` is + // also set by a `[c-abi-absent]` table on a provider that wrote no + // `[c-abi]` block, and those absences are diagnostic data with + // nothing in them to realise — `cenv::realise` requires `declared` + // (cenv.cppm) and would be reading fields nobody wrote. + if (state.tc && state.resolvedTargetSide.cAbiDecl + && state.resolvedTargetSide.cAbiDecl->declared) { + // `cenv::realise` FIRST, THE COMPILER-FAMILY GATE SECOND — not + // the other way around (coordinator report, openkal-musl 0.15.0 + // regression: GCC on Linux refused for a declaration + // `presents = "posix", data-model = "arch-default", wchar = 32, + // builtins = "iso"` that Linux/x86_64's own default ALREADY + // satisfies, needing no substitution at all — a gratuitous + // refusal that lost the package for every GCC user on Linux). + // `cenv::realise` is a pure function of the declaration and the + // TARGET, not of the compiler (`mcpp.toolchain.cenv`'s own + // module header) — computing it before asking anything about the + // compiler is what lets an EMPTY realisation answer "does this + // compiler need to be Clang" correctly: no. The Clang-specific + // mechanisms (`--target=` substitution, `-f[no-]short-wchar`, + // the `builtins = "iso"` flags) are only needed when realisation + // actually produces tokens; when it produces none, the target's + // own default already IS the declaration, and any compiler that + // can run the verification probe below (`-E -dM`, not a + // Clang-specific flag) can be trusted to have gotten there — + // which is exactly what that probe then confirms rather than + // assumes. The Windows/GCC case is UNCHANGED by this: there the + // realisation is non-empty (the Cygwin-flavoured substitution), + // and MinGW's `long` is 32-bit regardless of flags (openkal-musl + // measured), so the gate below still refuses it. + auto tt = mcpp::toolchain::triple::parse(state.tc->targetTriple); + auto realised = mcpp::toolchain::cenv::realise( + *state.resolvedTargetSide.cAbiDecl, tt ? tt->os : std::string{}, + tt ? tt->arch : std::string{}, tt && tt->is_freestanding()); + if (!realised) { + refusal::record(refusal::Code::CEnvUnrealisable); + return std::unexpected(realised.error()); + } + if ((!realised->tokens.empty() || !realised->builtinsTokens.empty()) + && !mcpp::toolchain::is_clang(*state.tc)) { + refusal::record(refusal::Code::CEnvUnrealisable); + return std::unexpected(std::format( + "the C library ('{}', {}) declares [c-abi] whose " + "realisation for this target requires Clang-specific " + "substitution, and this build's compiler ('{}') cannot " + "carry it out.\n" + " [c-abi] is realised, for targets that need " + "anything at all, with Clang-specific mechanisms — a " + "`--target=` substitution and `-f[no-]short-wchar` — so " + "a Clang toolchain is required for this target while " + "this declaration is in the graph.\n" + " Select one: [toolchain] default = \"llvm@\", " + "or [target.] toolchain = \"llvm@\".", + state.resolvedTargetSide.cAbi.interfaceName, + state.resolvedTargetSide.cAbi.impl, state.tc->compiler_family())); + } + state.tc->cEnvTokens = realised->tokens; + state.tc->cEnvBuiltinsTokens = realised->builtinsTokens; + state.tc->cEnvExpectWcharBits = realised->expectWcharBits; + state.tc->cEnvExpectLongBytes = realised->expectLongBytes; + state.tc->cEnvExpectDefined = realised->expectDefined; + state.tc->cEnvExpectUndefined = realised->expectUndefined; + + // VERIFICATION, NOT TRUST (design §3.2). The probe's argv is the + // IDENTITY-AFFECTING SUBSET of the real command line — the + // `--target=` substitution and the `-U`/`-f[no-]short-wchar` + // tokens `cenv::realise` just produced — because those are the + // only tokens that change what a compiler predefines; include + // paths and library search flags do not, and leaving them out + // is what makes this probe cheap AND cacheable across every + // package that shares this build's target side. + // + if (state.tc->cEnvExpectWcharBits != 0 || state.tc->cEnvExpectLongBytes != 0 + || !state.tc->cEnvExpectDefined.empty() + || !state.tc->cEnvExpectUndefined.empty()) { + // THE FREESTANDING TARGET HAD NEVER REACHED THE PROBE, AND + // THAT IS WHY 2026.9.18.3 MEASURED THE HOST (mcpp#674 + // review, 2026-09-20). `Toolchain::crossTargetFlag` is set + // for a HOSTED target only — the assignment above states the + // reason: a freestanding target carries its own `--target` + // together with the ISA flags that must accompany it, and a + // second one there would be the same decision in two places. + // That other place is `mcpp.freestanding.linkline`, which the + // real compile goes through and this probe did not. + // `cenv::realise` adds no `--target` for a freestanding + // target either, so the probe ran with NO target selection at + // all and clang answered for the machine it was running on. + // + // Measured: the probe's own argv shape on a Linux host + // (`-D__unix__ -fno-short-wchar -ffreestanding -x c++ -E -dM + // -`) reports `__linux__`; with `--target=riscv64-none-elf` + // it reports `__riscv`, no `__linux__`, and + // `__SIZEOF_WCHAR_T__` 4. A Windows host answered `_WIN32` + // and 2 for the same reason, and 2026.9.18.3 read that as the + // `--target=` substitution failing to strip host predefines. + // Clang's predefines follow the target; there was no + // substitution to fail. + // + // `hostStripMacros` is therefore GONE, and its removal is the + // point rather than a tidy-up: `-U_WIN32 -U_WIN64 + // -U__MINGW32__ -U__MINGW64__` deleted the one piece of + // evidence that said the probe was measuring the wrong + // machine. A future host leak, if one exists, must reach the + // mismatch report rather than be undefined before it can. + // + // THE ASSEMBLY REFUSES THE OMISSION rather than this site + // remembering not to make it — `cenv_probe::assemble_argv` + // holds the invariant, and the unit tests reach it without a + // cross toolchain. + std::vector freestandingFlags; + if (tt && tt->is_freestanding()) { + auto spec = mcpp::freestanding::resolve(*tt); + if (spec) { + freestandingFlags.push_back( + "--target=" + std::string(spec->triple)); + for (auto const& f : mcpp::freestanding::compile_flags(*spec)) + freestandingFlags.push_back(f); + } + } + auto assembled = mcpp::toolchain::cenv_probe::assemble_argv( + state.tc->crossTargetFlag, freestandingFlags, + state.tc->cEnvTokens, state.tc->cEnvBuiltinsTokens, + tt && tt->is_freestanding(), state.tc->targetTriple); + if (!assembled) { + refusal::record(refusal::Code::CEnvUnrealisable); + return std::unexpected(assembled.error()); + } + const auto& probeArgv = *assembled; + auto probe = mcpp::toolchain::cenv_probe::verify( + state.tc->binaryPath, probeArgv, + state.tc->cEnvExpectWcharBits, state.tc->cEnvExpectLongBytes, + state.tc->cEnvExpectDefined, state.tc->cEnvExpectUndefined, + mcpp::home::cache_root()); + if (!probe) { + refusal::record(refusal::Code::CEnvUnrealisable); + return std::unexpected(probe.error()); + } + if (!probe->mismatches.empty()) { + refusal::record(refusal::Code::CEnvVerificationMismatch); + std::string lines; + for (auto& mm : probe->mismatches) + lines += std::format( + "\n {:<24} declared {:<10} measured {}", + mm.fact, mm.declared, mm.measured); + return std::unexpected(std::format( + "the C library's [c-abi] declaration does not match " + "what the compiler actually produced for '{}'.{}\n" + " A declaration is checked, never trusted " + "(design 2026-09-18 §3.2) — the mismatch above was " + "measured from the compiler's own predefined macros, " + "compiled with the exact tokens this build derived " + "from the declaration.", + state.tc->targetTriple, lines)); + } + } + } + + // REPORTED, NOT REFUSED (mcpp#662, D4). `mcpp.toolchain.hostflags` + // closes the compiler's own C-library search with `-nostdlibinc` + // when a package supplies the target's C library (M1) — but only on + // a Clang-family driver; GCC has no one-token equivalent + // (`hostflags.cppm`'s own note on the shape it would need). Silently + // building unisolated was ruled out once already: the resolver + // refusing the combination outright was ALSO tried and reverted — + // it fired before `format_report` below and broke three existing + // e2e fixtures (268, 282, 303) that use a synthetic C-library + // provider on this host's native, GCC-default target to assert + // something else entirely, none of them about isolation. A + // degradation is the third option: it changes no command line + // (this branch decides nothing `hostflags.cppm` does not already + // decide on its own), and it does not stop a build the previous + // release would have allowed — it names, once, the gap the previous + // release left silent. + if (state.tc && state.resolvedTargetSide.cAbi.fromGraph() + && !mcpp::toolchain::is_clang(*state.tc)) { + mcpp::diag::degraded("target/c-abi-isolation", std::format( + "the target's C library ('{}', {}) comes from the " + "dependency graph, and the resolved compiler ('{}') has no " + "way to stop its own driver from also searching the host's " + "C library headers", + state.resolvedTargetSide.cAbi.interfaceName, + state.resolvedTargetSide.cAbi.impl, state.tc->compiler_family()), + "a host header can still satisfy an #include the graph's " + "own headers do not, silently — a Clang-family toolchain " + "closes that search entirely (docs/22 'Adaptation To The " + "Resolved Target Side')", + "add [toolchain] default = \"llvm@\", or for one " + "target only [target.] toolchain = \"llvm@\""); + } + + // REPORTED ONCE, NOT REFUSED. A program that never reaches an + // availability check links and runs without the archive; refusing it + // would trade a diagnosed hazard for a regression. The degradation + // names the platform, the file and the package that supplies it. + if (state.resolvedTargetSide.compilerRuntime.absent() && state.tc) { + auto tt = mcpp::toolchain::triple::parse(state.tc->targetTriple); + mcpp::diag::degraded("target/compiler-runtime", std::format( + "the toolchain payload carries no compiler runtime for {} " + "(no libclang_rt.{}.a under its lib/clang/*/lib/darwin), and no " + "package in the graph provides mcpp:compiler-runtime", + state.tc->targetTriple, + tt && tt->is_ios_simulator() ? "iossim" : "ios"), + "a program that reaches an availability check " + "(`__builtin_available`, or a system header that uses it) fails " + "at link with `__isPlatformVersionAtLeast` undefined", + "declare `llvm.compiler-rt-builtins` under the target's " + "[target.'cfg(os = \"ios\")'.dependencies]"); + } + + // RECORDED ON THE TOOLCHAIN THE MOMENT IT IS KNOWN, because three + // producers of a compile line need it and only one of them can see + // `resolvedTargetSide`. + // + // `flags.cppm` reads `plan.targetSide` directly; the std module build + // (`mcpp.toolchain.stdmod`) and the build.mcpp host helper cannot — + // they are in the toolchain layer and take a `Toolchain`. Giving them a + // second way to derive the answer is exactly the shape this release + // exists to remove, so the answer travels on the value they already + // share. + // + // HERE AND NOT LATER: `ensure_built` runs at :7368 and every compile + // line is assembled after it. A std BMI built against a different C + // library than its importers is what e2e 181 catches. + if (state.tc) state.tc->cAbiPrebuilt = state.resolvedTargetSide.cAbi.prebuilt(); + + // ── The target side's include set is a property of the BUILD ───────── + // + // IT WAS ALREADY COMPUTED, AND IT REACHED EXACTLY ONE TRANSLATION + // UNIT. + // + // A package that supplies a target-side layer publishes the headers the + // whole target is built against — libc++'s, the C library's, the + // architecture's. Those travel today as an ordinary `publicUsage`, + // which propagates ALONG DEPENDENCY EDGES. So a workspace member that + // depends on the provider receives them and a SIBLING DEPENDENCY + // PACKAGE does not: `nlohmann.json` is not downstream of + // `openkal-llvm-runtime`, it is beside it. + // + // The result is two flavours of BMI in one build — `std` compiled over + // the target's libc++ (correct: the block at :7232 hands it exactly + // this set) and the dependency packages compiled over the payload's. + // Any unit importing both fails at the first template instantiation + // that touches a declaration present in both header sets: + // + // istream:1245: error: reference to 'space' is ambiguous + // note: candidate … xim-x-llvm/…/__locale:321 + // note: candidate … openkal-llvm-runtime/…/__locale:302 + // + // mcpp#514. Reproduced in twenty lines with no openkal at all: a path + // package declaring `provides = ["mcpp:c++-abi=libc++"]` and one + // `include_dirs` entry reaches the root and its own units, and reaches + // no sibling dependency package. + // + // THE FIX IS THE ONE `mcpp.targetside` OPENS WITH: resolve once, + // after the graph is known, and have every consumer read that one + // value. A `publicUsage` describes what a library asks of ITS USERS; a + // target side is beneath everything. Modelling the second as the first + // is what made it edge-scoped. + // + // ONLY LAYERS THE GRAPH SUPPLIES. `Layer::fromGraph()` is the whole + // condition. A payload-supplied layer already reaches every unit + // through `mcpp.toolchain.hostflags`, and emitting it twice would put + // the ordering of one decision in two places. + { + std::set layerProviderIndices; + auto note_layer = [&](tsd::CapLayer which, const tsd::Layer& resolved) { + if (!resolved.fromGraph()) return; + auto it = byLayer.find(static_cast(which)); + if (it != byLayer.end() && !it->second.empty()) { + layerProviderIndices.insert(it->second.front().index); + if (which == tsd::CapLayer::CxxAbi) + state.cxxLayerProviderIndex = it->second.front().index; + } + }; + note_layer(tsd::CapLayer::CompilerRuntime, state.resolvedTargetSide.compilerRuntime); + note_layer(tsd::CapLayer::KernelAbi, state.resolvedTargetSide.kernelAbi); + note_layer(tsd::CapLayer::CAbi, state.resolvedTargetSide.cAbi); + note_layer(tsd::CapLayer::CxxAbi, state.resolvedTargetSide.cxx); + + for (auto idx : layerProviderIndices) { + if (idx >= state.packages.size()) continue; + auto const& provider = state.packages[idx]; + state.appendUniquePaths(state.targetSideUsage.includeDirs, + provider.publicUsage.includeDirs); + state.appendUniquePaths(state.targetSideUsage.includeDirsAfter, + provider.publicUsage.includeDirsAfter); + state.appendUniqueFlags(state.targetSideUsage.cflags, + provider.publicUsage.cflags); + state.appendUniqueFlags(state.targetSideUsage.cxxflags, + provider.publicUsage.cxxflags); + } + + // Into `privateBuild` and NOT into `publicUsage`. + // + // It is visible to the whole graph already, so it needs no further + // propagation; and writing it into `publicUsage` would fold the + // target side into the usage requirements of any library this + // build packages — a promise about a different machine. + // + // APPENDED, so a package's own directories keep coming first. + // The target side only has to precede the DRIVER's own defaults, + // and those are always searched last. + if (!state.targetSideUsage.includeDirs.empty() + || !state.targetSideUsage.includeDirsAfter.empty() + || !state.targetSideUsage.cflags.empty() + || !state.targetSideUsage.cxxflags.empty()) { + for (auto& p : state.packages) { + state.appendUniquePaths(p.privateBuild.includeDirs, + state.targetSideUsage.includeDirs); + state.appendUniquePaths(p.privateBuild.includeDirsAfter, + state.targetSideUsage.includeDirsAfter); + state.appendUniqueFlags(p.privateBuild.cflags, + state.targetSideUsage.cflags); + state.appendUniqueFlags(p.privateBuild.cxxflags, + state.targetSideUsage.cxxflags); + } + } + } + + // `__OPENKAL__` AND THE REALISED [c-abi] ENVIRONMENT — design + // 2026-09-18 §2.1, §3.2-§3.4. Broadcast into every package's OWN + // `privateBuild`, the same channel `targetSideUsage` just used above: + // it reaches that package's C/C++ compiles AND its dependency scan + // (`mcpp.modgraph.scanner` reads `privateBuild.cflags`/`cxxflags`), + // and it is APPENDED, so it follows every flag the package wrote for + // itself and the driver's own defaults still come last. + // + // `c-environment = "platform"` (§3.4) opts a package OUT of the + // [c-abi] REALISATION ONLY — `__OPENKAL__` still reaches it, because + // the exception is about the C environment a package's headers see, + // not about whether its own code may call `kal_*`. The base command + // line these tokens are appended to is untouched either way, which is + // what keeps a package that declares neither field byte-identical to + // a build before this feature existed. + // + // ALSO INTO `privateBuild.asmflags` (openkal-musl spike, post-review): + // the environment is a property of the TARGET, so it has to reach + // every translation unit built for that target, assembly (.S/.s) + // included — assembly is preprocessed with the same macros, and real + // code selects on them (openkal-musl's own `okm_setjmp.S`; upstream + // libunwind's `assembly.h`). `cflags`/`cxxflags` do not reach a .S + // file wholesale (`mcpp.build.compile_commands::unit_asm_flags` keeps + // only their -D/-U/-I words, on purpose — a -std= or -O token meant + // for the C compiler has no meaning for GAS), so the object-format + // and wchar-width tokens have to be named again here, into the + // channel `unit_asm_flags` passes through UNFILTERED. `__OPENKAL__` + // needs no second copy: it is a -D, and the -D/-U/-I filter already + // carries it from `cflags` into every assembly unit. + // + // Every token in `cEnvTokens`/`cEnvBuiltinsTokens` was checked against + // clang's GAS (`-x assembler-with-cpp`) front end before this was + // written (`--target=`, `-f[no-]short-wchar`, + // `-fno-builtin-memset_pattern16`) and none is rejected — so nothing + // here is filtered a second time; if a future token IS GAS-hostile, + // `cenv::realise` is where to split it, not this broadcast. + // THE MACROS THIS ENGINE DEFINES --- the contract, the rules for + // reading them and the reason each one exists rather than a manifest + // key, are `mcpp.toolchain.predefines`. That module is the + // specification and the implementation of the same thing, so this + // site decides only WHERE the tokens go, never WHICH they are. + if (state.tc) { + std::string targetOs; + if (auto ttOs = mcpp::toolchain::triple::parse(state.tc->targetTriple)) + targetOs = ttOs->os; + const auto engineDefines = + mcpp::toolchain::predefines::define_tokens( + targetOs, state.tc->kernelAbiIsOpenkal); + // Into `cflags`/`cxxflags` only: these are all `-D`, and the + // channel that builds an assembly unit's flags keeps the -D/-U/-I + // words of those two (`compile_commands::unit_asm_flags`), so a + // second copy here would put each one on a `.S` line twice. + for (auto& p : state.packages) { + state.appendUniqueFlags(p.privateBuild.cflags, engineDefines); + state.appendUniqueFlags(p.privateBuild.cxxflags, engineDefines); + } + } + + if (state.tc && (state.tc->kernelAbiIsOpenkal || !state.tc->cEnvTokens.empty() + || !state.tc->cEnvBuiltinsTokens.empty())) { + for (auto& p : state.packages) { + // `__OPENKAL__` is emitted above, with the rest of the + // engine's own defines; it is NOT subject to the + // `c-environment = "platform"` exception below, because that + // exception is about which C environment a package's headers + // see, not about whether its code may call `kal_*`. + if (p.manifest.cEnvironment == "platform") continue; + state.appendUniqueFlags(p.privateBuild.cflags, state.tc->cEnvTokens); + state.appendUniqueFlags(p.privateBuild.cxxflags, state.tc->cEnvTokens); + state.appendUniqueFlags(p.privateBuild.asmflags, state.tc->cEnvTokens); + state.appendUniqueFlags(p.privateBuild.cflags, state.tc->cEnvBuiltinsTokens); + state.appendUniqueFlags(p.privateBuild.cxxflags, state.tc->cEnvBuiltinsTokens); + state.appendUniqueFlags(p.privateBuild.asmflags, state.tc->cEnvBuiltinsTokens); + } + } + + // INTERFACE ENUMERATION — THE RESOLUTION-TIME HALF OF THE CAPABILITY + // MODEL (design 2026-09-20 §5.5; openkal SPEC 0.14 §3.3, §6.2). + // + // A package states which interfaces of the `kernel-abi` layer it uses; + // the package that supplies the layer states which it provides. This + // engine compares the two sets and knows no member of either: the + // names belong to the specification that owns the layer, and one may + // be added to it without a release of this engine. + // + // THE QUESTION IS ANSWERED HERE BECAUSE HERE IS WHERE THE ANSWER FIRST + // EXISTS. §6.2 tabulates three times and states that each is the + // earliest at which its information exists; "may this program be built + // against this implementation" is the first of them. Source asking the + // same question with `#ifdef` asks it during preprocessing, earlier + // than any answer, which is why each macro-shaped answer to it has had + // to be replaced by the next one. + // + // SILENT WHEN NOTHING DECLARES ANYTHING. A graph in which no package + // writes `[kernel-abi]` reaches neither loop below, so this addition + // changes no command line and no diagnostic for every project built + // before it. + { + // THE LIST COMES FROM THE PACKAGE THAT RESOLVED AS THE LAYER, NOT + // FROM THE FIRST ONE IN THE GRAPH THAT STATED ONE. A graph may + // carry more than one candidate for a layer — a workspace member + // beside a dependency, a second implementation reached through a + // feature that did not activate — and only one of them is the + // provider this build resolved. Reading whichever came first in + // `packages` would compare a consumer's requirements against an + // implementation the build is not using, which is a wrong answer + // rather than a missing one. + std::vector providedInterfaces; + std::string providerId; + for (auto& pkg : state.packages) { + if (pkg.manifest.kernelAbiProvidesInterfaces.empty()) continue; + // `impl` is `name@version`; the name is what precedes the + // separator. A substring test would match `openkal` against + // `openkal-linux@0.15.0` and read one implementation's list + // as another's. + if (!state.resolvedTargetSide.kernelAbi.impl.empty()) { + auto const& impl = state.resolvedTargetSide.kernelAbi.impl; + const auto at = impl.find('@'); + const auto implName = at == std::string::npos + ? impl : impl.substr(0, at); + if (implName != pkg.manifest.package.name) continue; + } + providedInterfaces = pkg.manifest.kernelAbiProvidesInterfaces; + providerId = pkg.manifest.package.name; + break; + } + // A REQUIREMENT NOBODY ANSWERED IS SAID SO, because otherwise + // "yes" and "never asked" are the same reading. + // + // Three situations exist and two of them build: the provider + // states a list and it contains the requirement (build); it + // states a list and does not (refuse, below); it states nothing + // at all (build, and until this note, in silence). The third is + // deliberate --- `provides-interfaces` is younger than the + // implementations that exist, and a graph that has not adopted it + // must keep building --- but a consumer reading a green build + // cannot tell it from the first. One line closes that, and it + // costs nothing to a graph where the provider does declare. + std::size_t uncheckedRequirements = 0; + for (auto& pkg : state.packages) { + const auto& need = pkg.manifest.kernelAbiRequiresInterfaces; + if (need.empty()) continue; + if (providerId.empty()) { + uncheckedRequirements += need.size(); + continue; + } + auto missing = mcpp::targetside::interfaces_not_provided( + need, providedInterfaces); + if (missing.empty()) continue; + refusal::record(refusal::Code::InterfaceNotProvided); + std::string names; + for (auto const& mI : missing) { + names += "\n "; + names += mI; + } + // THE CODE IS PRINTED, THE WAY E0006 IS, BECAUSE SOMETHING + // READS THIS. A refusal that only a person can recognise + // forces every machine consumer to match prose --- and prose + // that a package's own compile error could coincidentally + // contain. The mcpp-index compatibility measurement + // distinguishes "this graph does not supply what the member + // asked for" from "the member did not build" on exactly this + // token, and that distinction decides whether a member counts + // against a compatibility figure. + // THE LABEL SAYS WHICH IMPLEMENTATION WAS RESOLVED, NOT + // "provided by". The missing names are listed immediately + // above it, and `provided by fakekernel` under `openkal.space` + // reads as the statement that fakekernel provides it --- the + // exact opposite of what this refusal is about. Read once, + // rendered, which is the only way that kind of defect is + // visible: every assertion on this message matches an + // identifier inside it, and an identifier is in the right + // place under either wording. + return std::unexpected(std::format( + "'{}' requires interfaces the resolved implementation does " + "not provide. [interface-not-provided]{}\n" + " the resolved implementation is {} ({} interface{}), " + "and none of those listed above is among them.\n" + " This is refused before anything is compiled " + "because dependency resolution is the earliest time the " + "question can be answered. Select an implementation that " + "provides them, or remove them from [kernel-abi] " + "requires-interfaces in '{}'.", + pkg.manifest.package.name, names, providerId, + providedInterfaces.size(), + providedInterfaces.size() == 1 ? "" : "s", + pkg.manifest.package.name)); + } + + if (uncheckedRequirements > 0) { + // THE IMPLEMENTATION IS NAMED FROM THE RESOLVED LAYER, not + // from whichever package happened to be first: the note has + // to say WHOSE silence this is, or a reader cannot act on it. + const auto& impl = state.resolvedTargetSide.kernelAbi.impl; + mcpp::ui::info("note", std::format( + "kernel-abi interfaces: {} states none, {} requirement{} " + "unchecked", + impl.empty() ? std::string("the resolved implementation") + : impl, + uncheckedRequirements, + uncheckedRequirements == 1 ? "" : "s")); + } + } + + if (auto why = tsd::check_layering(state.resolvedTargetSide)) { + refusal::record(refusal::Code::LayerOrdering); + return std::unexpected(*why); + } + // REQUIREMENTS ARE CHECKED BEFORE ANYTHING IS COMPILED, WHICH IS THE + // WHOLE POINT OF DECLARING THEM. The combination this rejects — a C++ + // runtime configured for one compiler family being handed to another — + // otherwise fails inside that runtime's own headers, in a message that + // names a file the reader has never opened and no decision mcpp made. + // The origin travels with the check: reaching a compiler-layer refusal + // now means the project stated its own compiler, and the remedy has to + // name that statement rather than a global default it is not using. + if (auto why = tsd::check_requirements( + state.resolvedTargetSide, requirements, + tc_origin_is_user_explicit(state.tcOrigin) ? tc_origin_name(state.tcOrigin) + : std::string_view{})) { + refusal::record(refusal::Code::LayerRequirement); + return std::unexpected(*why); + } + // A WARNING, NOT A REFUSAL. The graph decides the C library either + // way, so the segment is ignored rather than violated and the artifact + // is the same with or without it. Refusing was tried and broke every + // project spelling the host target `x86_64-linux-gnu` — which is what + // `mcpp toolchain list` prints, and therefore what people write. + if (auto why = tsd::check_request(state.resolvedTargetSide)) + mcpp::diag::warning("target", *why); + + // The refusal held since toolchain resolution, released now that the + // other half of its question has an answer. A payload on this machine + // does not produce this target; if the graph does not supply the + // target's system either, then nothing does and the diagnosis stands. + if (!state.unservedTargetDiagnosis.empty() + && !state.resolvedTargetSide.system_from_graph()) { + refusal::record(refusal::Code::HostCannotServe); + return std::unexpected(state.unservedTargetDiagnosis); + } + + // THE TARGET AND THE COMPILER ARE NOT BOUND TOGETHER, AND THE + // TARGET ROW'S CONVENTION IS A FALLBACK RATHER THAN A RULE. + // + // A row pins a toolchain because the payload that toolchain belongs to + // is what supplies THAT TARGET'S C library. A project whose target side + // comes from its dependency graph does not use that payload, so the + // substitution was unnecessary — and this is the first line at which + // that is knowable, because it is the first line at which the graph + // exists. + // + // The decision itself is NOT revised here. `tc` has been read and + // mutated at 39 sites between its resolution and this point — the + // effective triple, the cross flag, the target sysroot, the MSVC + // runtime contract — and re-resolving it here would redo all of them + // out of order. Deferring the CHOICE the way the target side itself was + // deferred is the structural fix and is its own change; until then the + // user is told what happened and how to state the preference once. + // + // AND NOT FOR A ROW WHOSE PIN IS A CAPABILITY, WHERE BOTH HALVES OF + // THIS SENTENCE ARE FALSE. + // + // The warning says the default "would have served" the target and then + // tells the reader to declare it. On a capability row neither holds: + // nothing but the pinned payload can emit the target at all, and the + // declaration it suggests is REFUSED by the capability gate a few + // hundred lines above -- so following the advice replaces a warning + // with an error. + // + // Measured on `openkal-linux` built for `x86_64-linux-android`, whose + // target side does come from the graph: + // + // warning: ... so gcc@16.1.0 would have served x86_64-linux-android. + // State the preference: [target.x86_64-linux-android] + // toolchain = "gcc@16.1.0" + // $ (declaring exactly that) + // error: target 'x86_64-linux-android' cannot be emitted by + // 'gcc@16.1.0'. + // + // The first claim is false on its own terms too: this gcc payload + // cannot emit an Android object whatever the graph supplies. `graph` + // answers "who supplies the SYSTEM", and a capability pin answers "who + // can emit the FORMAT AND THE SYSTEM" -- two questions, and only the + // second one decides whether a substitution was avoidable. + const bool pinIsCapability = [&] { + auto tt = mcpp::toolchain::triple::parse(state.resolvedTargetCanonical); + return tt && tt->pin_is_capability(); + }(); + if (!state.pinReplacedDefault.empty() + && state.resolvedTargetSide.system_from_graph() + && !pinIsCapability) { + mcpp::diag::warning("toolchain", std::format( + "this project's target side comes from its dependency graph, so " + "{} would have served {}.\n" + " mcpp used the target row's convention because the graph " + "is not known when the\n" + " toolchain is chosen. State the preference for this " + "target to skip the substitution:\n" + " [target.{}]\n" + " toolchain = \"{}\"", + state.pinReplacedDefault, state.resolvedTargetCanonical, + state.resolvedTargetCanonical, state.pinReplacedDefault)); + } + + // A request that cannot be honoured is said so rather than dropped. + // + // Measured 2026-08-23: `linkage = "dynamic"` on a project whose system + // comes from the graph produced a statically linked artifact and + // printed nothing. The outcome is correct — the graph supplies its + // libraries as objects compiled into this build, and there is no shared + // object for a loader to resolve at run time — but a directive that has + // no effect and no diagnostic is indistinguishable from one that was + // never read. + // + // THE C LIBRARY IS THE LAYER THIS DEPENDS ON, NOT "THE SYSTEM". + // `system_from_graph()` spans two layers, and the arrangement that + // separates them is real: a backend running ON a platform takes its + // kernel interface from the graph while the C library stays the + // payload's. Measured 2026-08-25 on exactly that project — the + // predicate was true, this warning printed "The artifact is static", + // and the artifact had three DT_NEEDED entries including `libc.so.6`. + // The reason the message gives is a property of the C library alone: + // a payload libc has a shared object, so `dynamic` is honoured and + // there is nothing to warn about. Same shape as the three defects this + // release fixes — see `TargetSide::system_from_graph`'s own note. + if (state.resolvedTargetSide.cAbi.fromGraph() + && state.m->buildConfig.linkage == "dynamic") + mcpp::ui::warning( + "`linkage = \"dynamic\"` has no effect when the " + "target's system comes from the dependency graph: those " + "packages are compiled into this build as objects, and there " + "is no shared object to link against. The artifact is static."); + + // Reported, and reported HERE rather than recorded in a manifest field. + // + // A line a project writes states an intention, and it goes stale the + // moment the packages beneath it change — a program that names its C + // library by name is naming a transitive dependency it did not choose. + // This states the outcome, so it cannot be stale, and it answers a + // question that until now had no answer at all: reading every manifest + // in the graph did not tell anyone what would end up on the link line, + // because three places derived it separately and could disagree. + // + // AND IT PRINTS ONLY WHAT IS NOT ORDINARY. A zero-configuration build + // resolves all five layers from one compiler payload, and five lines + // reading `(payload)` answer a question nobody asked. `MCPP_VERBOSE` + // prints them all; a diagnostic always does. + // THE REQUESTED TARGET AND THE RESOLVED ONE MUST NAME THE SAME + // OPERATING SYSTEM, AND UNTIL THIS LINE NOTHING CHECKED. + // + // Measured 2026-08-25 in CI, on a machine that had installed only a + // native gcc — the report itself said it, and the build carried on: + // + // Target x86_64-windows-gnu → x86_64-unknown-linux-gnu + // … + // src/stream.cpp:68:9: error: 'GetFileType' was not declared + // + // Two operating systems on one line. The cross payload was absent, so + // resolution fell back to the host compiler, and Windows sources were + // compiled for Linux; the failure surfaced a hundred lines later as an + // undeclared identifier, naming a symbol rather than the decision. + // openkal-uefi hit the same fallback at the linker + // (`ld: unrecognized option '--subsystem'`). + // + // THE REPORT ALREADY HELD THE EVIDENCE — this asserts on it rather + // than deriving the question again. A refusal here costs one line; the + // alternative is a message about a Win32 function, in a file the reader + // did not write, for a decision made in this one. + // + // Scope is deliberately the OS and not the whole triple: an ABI or + // vendor difference between `x86_64-windows-gnu` and + // `x86_64-w64-windows-gnu` is the normalisation this very line reports, + // and refusing on it would reject every correct cross build. + // THE NAME THE REPORT PRINTS, DERIVED ONCE AND USED BY BOTH. + // + // The first version of this guard read `resolvedTargetCanonical` + // directly while the report below chose among three sources. They + // agreed on the machine it was written on and disagreed in CI, where + // the canonical string was empty and the report still named the target + // from `targetDisplayName` — so the report showed the mismatch and the + // guard, asking a different variable, saw nothing to refuse. One fact, + // derived twice: the shape this whole release exists to remove. + const std::string reportedTargetName = + !state.targetDisplayName.empty() + ? state.targetDisplayName + : (state.resolvedTargetCanonical.empty() + ? (state.tc ? state.tc->targetTriple : std::string{}) + : state.resolvedTargetCanonical); + if (!state.resolvedTargetSide.llvmTriple.empty() + && !reportedTargetName.empty()) { + auto want = mcpp::toolchain::triple::parse(reportedTargetName); + auto got = mcpp::toolchain::triple::parse( + state.resolvedTargetSide.llvmTriple); + // The inputs, when asked for. A guard that declines to fire and a + // guard that was never reached read the same from outside. + if (mcpp::log::is_verbose()) + mcpp::ui::info("Target", std::format( + "same-OS check: '{}'(os={}) vs '{}'(os={})", + reportedTargetName, want ? want->os : "", + state.resolvedTargetSide.llvmTriple, got ? got->os : "")); + if (want && got && !want->os.empty() && !got->os.empty() + && want->os != got->os) { + refusal::record(refusal::Code::OsMismatch); + return std::unexpected(std::format( + "target '{}' resolved to a toolchain for '{}'.\n" + " Those are different operating systems, so nothing " + "built here would be for\n" + " the target that was asked for. No payload on this " + "host produces '{}',\n" + " and mcpp will not substitute the host's.\n" + " install one with `mcpp toolchain install " + "`, or name it\n" + " explicitly with `[target.{}] toolchain = \"…\"`.", + reportedTargetName, state.resolvedTargetSide.llvmTriple, + reportedTargetName, reportedTargetName)); + } + } + mcpp::ui::info("Target", tsd::format_report( + state.resolvedTargetSide, reportedTargetName, mcpp::log::is_verbose())); + + // CLOSURE VISIBILITY — design §6. Distinct from the five-layer + // report above: a platform dependency is not a LAYER (no engine + // vocabulary names it, and `mcpp.targetside` — the pure, layer-only + // module the report above comes from — stays that way), it is an + // ORDINARY package that happens to declare `provides = + // ["platform-sdk"]`. That is the precise, machine-checkable + // definition this build uses: a package brings a platform + // dependency if and only if it says so, the same way a package + // states any other capability (docs/22, "provides"). Nothing infers + // this from header paths or link flags, because inference here would + // have exactly the silent-typo failure mode the reserved `mcpp:` + // prefix exists to avoid for the five layers — except this + // capability is deliberately UNPREFIXED, because it names no layer + // this engine resolves, only a fact a package states about itself. + std::vector platformDeps; + for (auto& pkg : state.packages) { + if (std::ranges::find(pkg.manifest.provides, "platform-sdk") + == pkg.manifest.provides.end()) + continue; + platformDeps.push_back(pkg.manifest.package.version.empty() + ? pkg.manifest.package.name + : std::format("{}@{}", pkg.manifest.package.name, + pkg.manifest.package.version)); + } + if (!platformDeps.empty() || mcpp::log::is_verbose()) { + std::string joined; + for (auto& d : platformDeps) { + if (!joined.empty()) joined += ", "; + joined += d; + } + mcpp::ui::info("Target", std::format( + " {:<17} {}", "platform-deps", + joined.empty() ? std::string("—") : joined)); + } + if (!platformDeps.empty() + && state.m->buildConfig.platformDependencies == "refuse") { + refusal::record(refusal::Code::PlatformDependency); + std::string joined; + for (auto& d : platformDeps) { + if (!joined.empty()) joined += ", "; + joined += d; + } + return std::unexpected(std::format( + "[build] platform-dependencies = \"refuse\", and the " + "dependency graph brings {}: {}.\n" + " This build asked to be a closure entirely on its " + "kernel-abi implementation and nothing else (design " + "2026-09-18 §6).\n" + " Remove the dependency, remove the feature that " + "pulled it in, or drop the refusal to allow it.", + platformDeps.size() == 1 ? "a platform dependency" + : "platform dependencies", + joined)); + } + } + + // ── L1b: conditional sections whose predicate names a target-side layer ── + // + // The second half of the conditional axis, and it runs HERE for the same + // reason the root build.mcpp below does: the target side is now resolved, + // and from this point on everything that consumes build inputs — the P1689 + // scan, the `stdModuleFlags` collection, the fingerprint, `compute_flags` — + // reads `packages[]` and `*m`, both of which are still writable. + // + // EVERY PACKAGE, NOT JUST THE ROOT. The build.mcpp mirror below patches + // `packages[0]`, which is right for build.mcpp because a build program + // speaks for its own package and the dep loop already handled the others. + // Here the motivating case IS a dependency — a package supplying one C++ + // runtime over several C libraries — so patching only the root would leave + // the one package this feature exists for unserved. + // + // `*m` as well as the snapshots: `canonical_compile_flags(*m)` feeds the + // fingerprint, so a contribution reaching the snapshots and not the + // manifest would compile with flags the fingerprint does not describe. + // MUTATING `pkg.manifest` IS NOT ENOUGH, AND THAT IS THE WHOLE + // DIFFICULTY OF A LATE PRODUCER. `makePackageRoot` snapshots the manifest's + // build inputs into `privateBuild` / `linkUsage`, and the compile and link + // edges read THOSE. The build.mcpp tail below solves the identical problem + // with `directives::mark` + `fold_private_tail`, so this uses the same two + // helpers rather than a second mechanism — measured first: writing only + // `pkg.manifest.buildConfig` produced a build in which every layer + // predicate matched and no flag reached the compiler. + if (state.targetSideResolved) { + auto layerCtx = state.cfgCtx(); + layerCtx.layersKnown = true; + layerCtx.compiler = state.resolvedTargetSide.compiler.interfaceName; + layerCtx.compilerRuntime = state.resolvedTargetSide.compilerRuntime.interfaceName; + layerCtx.kernelAbi = state.resolvedTargetSide.kernelAbi.interfaceName; + layerCtx.cAbi = state.resolvedTargetSide.cAbi.interfaceName; + layerCtx.cxxAbi = state.resolvedTargetSide.cxx.interfaceName; + // The root manifest feeds `canonical_compile_flags`, and therefore the + // fingerprint: a contribution reaching the snapshots but not `*m` would + // compile with flags the fingerprint does not describe, and the next + // build would call that a cache hit. + merge_layer_conditional_config(*state.m, layerCtx); + for (auto& pkg : state.packages) { + const auto mark = state.markDirectiveTail(pkg.manifest); + const auto ldN = pkg.manifest.buildConfig.ldflags.size(); + const auto privN = pkg.manifest.buildConfig.privateIncludeDirs.size(); + if (!merge_layer_conditional_config(pkg.manifest, layerCtx)) continue; + // cflags / cxxflags / include_dirs / include_dirs_after. + state.foldDirectiveTailIntoPrivateBuild(pkg, pkg.manifest, mark); + // ldflags: the link reads linkUsage. + pkg.linkUsage.ldflags.insert( + pkg.linkUsage.ldflags.end(), + pkg.manifest.buildConfig.ldflags.begin() + + static_cast(ldN), + pkg.manifest.buildConfig.ldflags.end()); + // private_include_dirs: expanded at makePackageRoot and folded into + // privateBuild.includeDirs, which is what keeps them OUT of + // publicUsage. A conditional entry has to take the same route or a + // vendored header overlay would reach every consumer — the blast + // radius e2e 304 exists to hold. + for (auto it = pkg.manifest.buildConfig.privateIncludeDirs.begin() + + static_cast(privN); + it != pkg.manifest.buildConfig.privateIncludeDirs.end(); ++it) { + if (it->is_absolute()) { + auto n = *it; n.make_preferred(); + if (std::ranges::find(pkg.privateBuild.includeDirs, n) + == pkg.privateBuild.includeDirs.end()) + pkg.privateBuild.includeDirs.push_back(std::move(n)); + continue; + } + for (auto& dir : mcpp::modgraph::expand_dir_glob( + pkg.root, it->generic_string())) + if (std::ranges::find(pkg.privateBuild.includeDirs, dir) + == pkg.privateBuild.includeDirs.end()) + pkg.privateBuild.includeDirs.push_back(dir); + } + } + } + + // ── #519: which FORM does each dependency take in this build ──────────── + // + // The decision itself lives in `mcpp.build.linkage_form`, which is a pure, + // table-driven function with no filesystem and no manifest knowledge. What + // happens here is only the two halves that need this scope: collecting the + // facts, and MATERIALISING the answer. + // + // COMPUTED HERE, APPLIED AFTER THE SCAN (#642 E2). A build program that + // generates a loader entry, or `dllimport` definitions, needs the form a + // dependency takes, and the root's program runs next, before the scan. + // Every input is final at this point: the requests are the root manifest's, + // which no directive changes; the target facts are resolved; each + // dependency's own build program has run, so its `ldflags` are complete; + // and the layer-conditional sections above have been folded. What the scan + // used to contribute, whether a package has sources of its own, is read + // from the scanner's own selection (`package_source_files`), so the two + // cannot disagree. The answers are stored in `dependencyLinkForms`; the + // application after the scan and the root program's environment both read + // them, and nothing resolves a second time. + { + namespace lf = mcpp::build::linkage_form; + + lf::Request request; + if (auto parsed = lf::parse(state.m->buildConfig.dependencyLinkage)) + request.whole = *parsed; + request.wholeIsExplicit = !state.m->buildConfig.dependencyLinkage.empty(); + // ONLY THE ROOT MANIFEST'S EDGES. See DependencySpec::linkage — a + // package deep in the graph imposing a whole-image layout on its + // consumer is a supply-chain property, not a convenience. + for (auto const& [depName, spec] : state.m->dependencies) { + if (spec.linkage.empty()) continue; + if (auto parsed = lf::parse(spec.linkage)) { + request.perPackage[depName] = *parsed; + auto shortKey = spec.shortName.empty() ? depName : spec.shortName; + request.perPackage.emplace(shortKey, *parsed); + } + } + + lf::TargetFacts targetFacts; + if (auto t = mcpp::toolchain::triple::parse(state.tc->targetTriple)) + targetFacts.hasLoader = !t->is_freestanding(); + // The libc axis. Spelled exactly as `compute_flags` spells it, because + // the two must agree about what `-static` means: an image linked that + // way has no interpreter, so no shared object can ever be loaded into + // it. Two keys with `linkage` in the name, and they are NOT independent. + targetFacts.fullStaticLibc = + state.m->buildConfig.linkage == "static" + && mcpp::toolchain::target_supports_full_static( + state.tc->targetTriple, mcpp::platform::supports_full_static); + + for (std::size_t i = 1; i < state.packages.size(); ++i) { + auto const& pkg = state.packages[i].manifest; + const std::string fq = pkg.package.namespace_.empty() + ? pkg.package.name + : std::format("{}.{}", pkg.package.namespace_, pkg.package.name); + + lf::PackageFacts facts; + facts.label = std::format("{}@{}", fq, pkg.package.version); + facts.hasSources = !mcpp::modgraph::package_source_files( + state.packages[i].root, pkg).empty(); + facts.carriesForeignLinkInputs = + lf::carries_foreign_link_inputs( + mcpp::manifest::flag_words(pkg.buildConfig.ldflags)); + facts.isDistribution = mcpp::pack::is_distribution_package(pkg); + for (auto const& artifact : pkg.runtimeConfig.artifacts) { + if (artifact.role == "static-library") facts.shipsStatic = true; + if (artifact.role == "shared-library") facts.shipsShared = true; + } + bool hasLibraryTarget = false; + for (auto const& t : pkg.targets) { + if (t.kind == mcpp::manifest::Target::SharedLibrary + && !facts.declaredShared) { + facts.declaredShared = true; + facts.declaredSharedBy = t.kindDeclaredBy; + facts.declaredSharedByRow = t.kindFromRow; + } + if (t.kind == mcpp::manifest::Target::Library) { + hasLibraryTarget = true; + // One package, one form: the first library target that + // states a default speaks for the package, as the first + // `kind = "shared"` does for the constraint. + if (!facts.defaultLinkage && !t.linkageDefault.empty()) { + facts.defaultLinkage = lf::parse(t.linkageDefault); + facts.defaultDeclaredBy = t.linkageDeclaredBy; + } + } + } + + // A consumer addresses a dependency by whatever it wrote in + // `[dependencies]` — the fully-qualified name or the bare one — + // while every message wants the version too. Rather than swapping + // the label to whichever spelling matches (which drops the version + // from every refusal), make the request answer to the descriptive + // label as well. + for (auto const& key : { fq, pkg.package.name }) { + if (auto it = request.perPackage.find(key); + it != request.perPackage.end()) { + request.perPackage.emplace(facts.label, it->second); + break; + } + } + auto allowed = lf::admissible(facts, targetFacts); + DependencyLinkForm form; + form.answer = lf::resolve(facts, allowed, request); + // Recorded for a package that has a library to link; a package of + // programs or rules has no form to report. + form.recorded = facts.isDistribution || facts.declaredShared + || hasLibraryTarget; + form.facts = std::move(facts); + state.dependencyLinkForms.emplace(i, std::move(form)); + } + } + + // ── The resolved graph, one derivation for two readers (#634 X, #647 E1) ── + // + // `resolution.json`'s `graph` section and the document the root build + // program reads (`mcpp::graph_file()`) describe the same packages, and they + // are built by this one function so they cannot disagree. Each entry holds + // the package's identity, every request that reached it (the key as + // written and the table that declared it) and, for a library, its link + // form with the reason. The build program's entries add what a program + // needs to act on a package: its manifest directory, the features it is + // built with, its targets, and its `[package.metadata]` verbatim. + // + // The link form is read from `dependencyLinkForms`, which is computed once, + // before this point, for exactly this program (#642 E2). + state.graph_package_entry = [&](std::size_t i, bool forBuildProgram) { + auto const& pm = state.packages[i].manifest; + const auto id = mcpp::manifest::package_id(pm.package); + nlohmann::json entry = { + {"package", { + {"canonical", id.canonical()}, + {"namespace", id.namespace_}, + {"name", id.name}, + {"version", id.version}, + {"source", id.sourceProvenance}, + }}, + {"root", i == 0}, + }; + nlohmann::json requests = nlohmann::json::array(); + for (auto const& r : state.graphRequests) { + if (r.dependencyPackageIndex != i) continue; + requests.push_back({ + {"requester", mcpp::manifest::package_id( + state.packages[r.consumerPackageIndex].manifest.package).canonical()}, + {"key", r.key}, + {"table", r.table}, + }); + } + entry["requested_by"] = std::move(requests); + if (auto form = state.dependencyLinkForms.find(i); + form != state.dependencyLinkForms.end() && form->second.recorded) + entry["link"] = { + {"form", std::string(mcpp::build::linkage_form::to_string( + form->second.answer.linkage))}, + {"reason", form->second.answer.reason}, + }; + if (!forBuildProgram) return entry; + + std::error_code ec; + auto dir = std::filesystem::absolute(state.packages[i].root, ec).lexically_normal(); + entry["manifest_dir"] = dir.string(); + nlohmann::json feats = nlohmann::json::array(); + if (i < state.activeFeaturesByPackage.size()) + for (auto const& f : state.activeFeaturesByPackage[i]) feats.push_back(f); + entry["features"] = std::move(feats); + nlohmann::json targets = nlohmann::json::array(); + for (auto const& t : pm.targets) { + using K = mcpp::manifest::Target::Kind; + const std::string_view kind = + t.kind == K::Library ? "lib" + : t.kind == K::Binary ? "bin" + : t.kind == K::SharedLibrary ? "shared" + : t.kind == K::TestBinary ? "test" + : "app"; + targets.push_back({{"name", t.name}, {"kind", std::string(kind)}}); + } + entry["targets"] = std::move(targets); + entry["metadata"] = pm.packageMetadataJson.empty() + ? nlohmann::json::object() + : nlohmann::json::parse(pm.packageMetadataJson, nullptr, + /*allow_exceptions=*/false); + if (entry["metadata"].is_discarded()) entry["metadata"] = nlohmann::json::object(); + return entry; + }; + + // ── L3: ROOT build.mcpp (moved after dependency resolution, design §3.1 + // item 4) ──────────────────────────────────────────────────────────────── + // Runs HERE — after dep resolution + feature activation (so the contract + // env can expose MCPP_DEP__DIR exactly like the dep loop above does) + // and BEFORE the modgraph scan / flag canonicalization / fingerprint (so + // its generated=/source= sources and flag directives are fully visible). + // Ordering invariants preserved relative to the pre-move call site: + // materialize_generated_files (may produce build.mcpp itself) and the L1 + // cfg merge still run earlier — ONLY this call moved later. + // + // One wrinkle the old ordering hid: back then apply() mutated *m BEFORE + // `packages[0] = makePackageRoot(*root, *m)` snapshotted buildConfig into + // privateBuild/manifest — the copies the scan and per-TU flag assembly + // actually read. Now the snapshot (and root feature activation on it) + // already happened, so mirror the directive TAILS into packages[0] + // explicitly, the same way the dep loop does for its package. + // A package with no `build.mcpp` still runs one when a rule dependency + // described it: `run_build_program` writes that program into the build + // directory. This guard therefore asks the same question the function does, + // and a guard that asked only about the file left the synthesis unreachable + // -- the shaders went uncompiled and the refusal named the missing program + // rather than the guard. Measured. + if (std::filesystem::exists(*state.root / "build.mcpp") + || !state.m->buildConfig.ruleModules.empty()) { + auto host = state.host_tc_for_build_program(); + if (!host) return std::unexpected(host.error()); + mcpp::build::BuildProgramEnv bpEnv; + bpEnv.targetTriple = state.resolvedTargetCanonical; + // Everything the engine already knows and a build program would + // otherwise hardcode: the payload ROOT (not the driver), the target's + // C library, which compiler and which C++ standard library resolved, + // and the three answers that keep a board package from naming a + // toolchain. One call — see fill_target_build_env. + fill_target_build_env(bpEnv, *state.m, state.tc ? &*state.tc : nullptr, state.cfg_opt ? &*state.cfg_opt : nullptr); + bpEnv.toolsBin = state.projectSubosBin; + bpEnv.profile = state.effectiveProfile; + bpEnv.accel = state.resolvedAccel(); + fill_package_build_env(bpEnv, *state.m); + bpEnv.packFormat = state.overrides.pack_format; + bpEnv.packStageDir = state.overrides.pack_stage_dir; + bpEnv.languageModules = state.m->language.modules; + bpEnv.ruleModules = state.m->buildConfig.ruleModules; + if (auto dit = state.deviceSourcesByPackage.find(state.root->string()); dit != state.deviceSourcesByPackage.end()) + bpEnv.deviceSources = dit->second; + // Set explicitly rather than relying on build_dir()'s root-relative + // default: under BuildOverrides::work_dir the package root is shared + // and may be read-only, and the default would write the compiled + // helper straight into it. Same value as the default when work_dir is + // unset, so an ordinary build is unchanged. + bpEnv.artifactsDir = state.workRoot / "target" / ".build-mcpp"; + // Root mode keeps genBase empty: a relative `generated=` from the ROOT + // package resolves against the package root (the documented contract), + // not against OUT_DIR. + // Same expression as the pre-move call site (and same order), so the + // contract hash — and therefore the build.mcpp cache — is unchanged + // across the move for feature-identical builds. + bpEnv.features = feature_closure(*state.m, parse_feature_request(state.overrides.features)); + // mcpp#241 (root): consumer index 0, same owner as the dep loop. + // + // AND THE LINK FORM OF EACH DEPENDENCY (#642 E2), to this program only. + // The root decides every dependency's form, and when this program runs + // every input of that decision is final: the requests are the root + // manifest's, and each dependency's own program has already run. A + // DEPENDENCY's program is not offered the forms. It runs in discovery + // order, before the programs of packages discovered after it, and those + // programs supply facts the answer depends on (a `-L` they add makes a + // package static-only), so the value it could be given would be a guess. + { + std::map rootLinkForms; + for (auto const& [idx, form] : state.dependencyLinkForms) + if (form.recorded) + rootLinkForms.emplace(idx, std::string( + mcpp::build::linkage_form::to_string(form.answer.linkage))); + state.fillDepDirs(bpEnv, 0, &rootLinkForms); + } + state.fillXpkgDirs(bpEnv, *state.m, 0); + // #355: the host tools the ROOT package requested (consumer index 0). + if (auto tit = state.toolEnvByConsumer.find(0u); tit != state.toolEnvByConsumer.end()) + bpEnv.toolPaths = tit->second; + bpEnv.hostModules = state.hostModulesByConsumer.count(0u) + ? state.hostModulesByConsumer.at(0u) + : decltype(bpEnv.hostModules){}; + // #649 E5: the packaging pass's strip decision, beside its format. + bpEnv.packStrip = state.overrides.pack_strip; + bpEnv.packDebugSymbolsDir = state.overrides.pack_debug_symbols_dir; + // #647 E1: THE RESOLVED GRAPH, FOR THE ROOT'S PROGRAM ONLY. + // + // Every package, dependencies before the packages that request them + // (ties in discovery order), so a program that merges what libraries + // contribute can apply them in override order without a sort of its + // own. The root decides the graph, and every input of that decision is + // final here -- the same reason `dep_linkage` is offered to this + // program alone. A file, not variables: a graph with metadata does not + // fit an environment block (`MAX_ARG_STRLEN`, the Windows limit). + // + // ITS DIGEST JOINS THE RE-RUN KEY. Editing a dependency's + // `[package.metadata]` changes what this program would answer, so it + // must run again; editing that dependency's sources does not, and the + // document does not change. + { + std::vector order; + std::vector placed(state.packages.size(), false); + while (order.size() < state.packages.size()) { + std::size_t pick = state.packages.size(); + for (std::size_t i = 0; i < state.packages.size() && pick == state.packages.size(); ++i) { + if (placed[i]) continue; + bool ready = true; + for (auto const& r : state.graphRequests) + if (r.consumerPackageIndex == i && r.dependencyPackageIndex != i + && r.dependencyPackageIndex < state.packages.size() + && !placed[r.dependencyPackageIndex]) { ready = false; break; } + if (ready) pick = i; + } + // A cycle leaves nothing ready; its first member in discovery + // order is taken so the document is still complete. + if (pick == state.packages.size()) + for (std::size_t i = 0; i < state.packages.size(); ++i) + if (!placed[i]) { pick = i; break; } + placed[pick] = true; + order.push_back(pick); + } + nlohmann::json doc; + doc["kind"] = "mcpp.graph"; + doc["version"] = 1; + nlohmann::json list = nlohmann::json::array(); + for (auto i : order) list.push_back(state.graph_package_entry(i, true)); + doc["packages"] = std::move(list); + const auto text = doc.dump(2) + "\n"; + const auto graphPath = bpEnv.artifactsDir / "graph.json"; + std::error_code gec; + std::filesystem::create_directories(graphPath.parent_path(), gec); + const auto tmp = graphPath.string() + ".tmp"; + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + out << text; + } + std::filesystem::rename(tmp, graphPath, gec); + if (gec) + return std::unexpected(std::format( + "cannot write the graph document '{}': {}", + graphPath.string(), gec.message())); + bpEnv.graphFile = graphPath; + bpEnv.graphDigest = mcpp::toolchain::hash_string(text); + } + auto& bcRoot = state.m->buildConfig; + const auto mark = state.markDirectiveTail(*state.m); + const auto rldN = bcRoot.ldflags.size(), rsrcN = bcRoot.sources.size(), + rmodN = state.m->modules.sources.size(); + const auto ractN = bcRoot.actions.size(); + // #622 A4: how many `[runtime] deploy` entries existed before this + // program ran — the manifest-sourced ones, already in `packages[0]`'s + // snapshot. Anything past this index is a `mcpp::deploy()` residue + // that needs the same mirror the flag/source tails get below. + const auto rdeployN = state.m->runtimeConfig.linkIntent.deploy.size(); + // Same reason, one field wide: `mcpp::runtime_search_dir()` residue + // needs the same mirror `deploy` does, or `resolve_runtime_contract` + // (which reads `packages[0]`'s snapshot, not `*m`) never sees it. + const auto rsearchDirN = state.m->runtimeConfig.linkIntent.runtimeSearchDirs.size(); + // What the dependencies supplied as runners, before the root's program + // speaks. The root's emissions are appended to the same slots, so a + // name both supply becomes one argv joining the two (#634, §9 item 8, + // measured: `run-A.sh run-B.sh `). + const auto runnerBeforeRoot = bcRoot.runner; + const auto namedBeforeRoot = bcRoot.namedRunners; + auto bp = mcpp::build::run_build_program( + *state.m, *state.root, host->first, host->second, + state.m->cppStandard, bpEnv); + if (!bp && !state.overrides.plan_only) { + return std::unexpected(bp.error()); + } + // #699 item 2 (E3): under `emit build-database` (`plan_only`), a + // failing root build program describes the package without its + // directives rather than costing the whole plan. Every mirror below + // reads what the program would have added to `*m`, so skipping + // straight past it (nothing runs on this path) is what "without its + // directives" means; a later failure that follows from the gap + // fails the member under the ordinary rule (E1). + if (!bp) { + state.planNotes.push_back({"MCPP_BUILD_DATABASE_PROGRAM_FAILED", + bp.error(), mcpp::wire::Severity::Error, + (*state.root / "build.mcpp").string()}); + } + if (bp) { + // THE SAME RULE THE DEPENDENCIES ARE HELD TO, WITH THE ROOT AS A PARTY. + // Two suppliers of one runner are refused naming both, and the + // manifest is the way to choose: a `[target.]` runner the + // project writes outranks every supplied one where the runner is + // looked up, so a name the manifest declares is not refused here. + { + const auto rowKey = [&]() -> std::string { + if (!state.tc) return {}; + auto t = mcpp::toolchain::triple::parse(state.tc->targetTriple); + return t ? t->str() : state.tc->targetTriple; + }(); + const auto row = state.m->targetOverrides.find(rowKey); + const auto manifestNames = [&](std::string_view name) { + if (row == state.m->targetOverrides.end()) return false; + if (name.empty()) return !row->second.runner.empty(); + return row->second.namedRunners.contains(std::string(name)); + }; + if (!state.runnerProvider.empty() && !runnerBeforeRoot.empty() + && bcRoot.runner.size() > runnerBeforeRoot.size() + && !manifestNames({})) { + return std::unexpected(std::format( + "the dependency '{}' and this project's build program both " + "supply the runner for this target, and the two would be " + "joined into one argv.\n" + " Drop one of them, or state the runner in " + "[target.{}].runner.", + state.runnerProvider, rowKey)); + } + for (auto const& [name, nr] : bcRoot.namedRunners) { + auto before = namedBeforeRoot.find(name); + auto who = state.namedRunnerProvider.find(name); + if (before == namedBeforeRoot.end() || before->second.argv.empty() + || who == state.namedRunnerProvider.end() || who->second.empty()) + continue; + if (nr.argv.size() <= before->second.argv.size()) continue; + if (manifestNames(name)) continue; + return std::unexpected(std::format( + "the dependency '{}' and this project's build program both " + "supply a runner named '{}' for this target, and the two " + "would be joined into one argv.\n" + " Drop one of them, or state it in " + "[target.{}.runners].{}.", + who->second, name, rowKey, name)); + } + } + auto& pkg0 = state.packages[0]; + // Compile-visible tail → privateBuild: the shared fold (same owner + // as the dep loop; the root's TUs read privateBuild). + state.foldDirectiveTailIntoPrivateBuild(pkg0, *state.m, mark); + // Before the source residues are mirrored below: adopting an action's + // outputs APPENDS to bcRoot.sources, and those appends must be inside + // the tail that gets copied into the packages[0] snapshot the scan reads. + state.adoptActionOutputs(*state.m, *state.root, ractN); + // The root's build program has spoken; a floor it stated is checked + // now, with the facts every package (it included) established. + if (auto err = state.checkVersionFloors(); err) return std::unexpected(*err); + // Root residues — apply() mutated *m, but packages[0].manifest is a + // value-copy snapshot taken at makePackageRoot, so everything the + // scan/fingerprint read from the snapshot needs the tail mirrored: + // sources → the scan walks packages[0].manifest, not *m. + pkg0.manifest.buildConfig.sources.insert( + pkg0.manifest.buildConfig.sources.end(), + bcRoot.sources.begin() + rsrcN, bcRoot.sources.end()); + pkg0.manifest.modules.sources.insert( + pkg0.manifest.modules.sources.end(), + state.m->modules.sources.begin() + rmodN, state.m->modules.sources.end()); + // Fingerprint metadata (canonical_package_build_metadata folds + // packages[].manifest.buildConfig) — mirror the flag/include tails, + // as the old pre-snapshot ordering implicitly did. + pkg0.manifest.buildConfig.cflags.insert( + pkg0.manifest.buildConfig.cflags.end(), + bcRoot.cflags.begin() + static_cast(mark.cflags), + bcRoot.cflags.end()); + pkg0.manifest.buildConfig.cxxflags.insert( + pkg0.manifest.buildConfig.cxxflags.end(), + bcRoot.cxxflags.begin() + static_cast(mark.cxxflags), + bcRoot.cxxflags.end()); + pkg0.manifest.buildConfig.includeDirs.insert( + pkg0.manifest.buildConfig.includeDirs.end(), + bcRoot.includeDirs.begin() + static_cast(mark.includeDirs), + bcRoot.includeDirs.end()); + pkg0.manifest.buildConfig.includeDirsAfter.insert( + pkg0.manifest.buildConfig.includeDirsAfter.end(), + bcRoot.includeDirsAfter.begin() + + static_cast(mark.includeDirsAfter), + bcRoot.includeDirsAfter.end()); + // Link flags → the final link reads *m (already applied); keep the + // linkUsage snapshot and fingerprint metadata equivalent too. + pkg0.linkUsage.ldflags.insert(pkg0.linkUsage.ldflags.end(), + bcRoot.ldflags.begin() + rldN, bcRoot.ldflags.end()); + pkg0.manifest.buildConfig.ldflags.insert( + pkg0.manifest.buildConfig.ldflags.end(), + bcRoot.ldflags.begin() + rldN, bcRoot.ldflags.end()); + // #622 A4: `mcpp::deploy()` residue → `packages[0].manifest`, the + // object `resolve_runtime_contract` (plan.cppm) actually reads. + // Without this mirror a directive-sourced deploy entry lands in `*m` + // and nowhere the planner looks — the same gap this block already + // closes for sources/flags, one more field wide. + pkg0.manifest.runtimeConfig.linkIntent.deploy.insert( + pkg0.manifest.runtimeConfig.linkIntent.deploy.end(), + state.m->runtimeConfig.linkIntent.deploy.begin() + static_cast(rdeployN), + state.m->runtimeConfig.linkIntent.deploy.end()); + // `mcpp::runtime_search_dir()` residue → `packages[0].manifest`, the + // same object and the same reason as the `deploy` mirror above: without + // it a directive-sourced entry lands in `*m` and `resolve_runtime_contract` + // never looks there. + pkg0.manifest.runtimeConfig.linkIntent.runtimeSearchDirs.insert( + pkg0.manifest.runtimeConfig.linkIntent.runtimeSearchDirs.end(), + state.m->runtimeConfig.linkIntent.runtimeSearchDirs.begin() + + static_cast(rsearchDirN), + state.m->runtimeConfig.linkIntent.runtimeSearchDirs.end()); + } + } + + // ── Every device source must reach some action ───────────────────────── + // + // A device-kind file is the one source the engine has no compile rule for. + // It is handed to the package's build program (MCPP_DEVICE_SOURCES) and + // comes back as an action, or it is not compiled at all. Nothing checked + // that it came back. Two ways it does not, both silent until now: + // + // - the package has no `build.mcpp`. The engine computed the list and + // dropped it. Both run sites above are guarded on that file existing, + // so there was not even a program to ignore it. + // - a program runs but no imported rule claims the extension. A project + // with a `.cu` and a `.comp` that imports only `mcpp.rules.spirv` is + // this case, and it is the ordinary case for a project with two + // backends: a rule takes the extensions it knows and leaves the rest. + // + // What they produce today is an undefined reference at the link, naming a + // symbol and never the file that would have defined it -- and for a + // `kind = "lib"` target not even that, because an archive is not resolved. + // A device source that compiles nothing is never what was meant, so it is + // refused here, where both halves of the fact are still in hand. + // + // THE CRITERION IS THE ACTION INPUTS, not "a build program ran": a program + // that ran and consumed nothing is exactly the second case. It is also the + // condition an action needs anyway -- one that compiles a file it does not + // declare as an input does not rerun when that file changes -- so a rule + // that satisfies it is a rule that rebuilds correctly. + for (std::size_t i = 0; i < state.packages.size(); ++i) { + auto const& pkg = state.packages[i]; + auto dit = state.deviceSourcesByPackage.find(pkg.root.string()); + if (dit == state.deviceSourcesByPackage.end() || dit->second.empty()) continue; + auto const& mm = (i == 0) ? *state.m : pkg.manifest; + std::set consumed; + for (auto const& a : mm.buildConfig.actions) + for (auto const& in : a.inputs) { + std::filesystem::path ip(in); + consumed.insert((ip.is_absolute() ? ip : pkg.root / ip).lexically_normal()); + } + std::string orphans; + for (auto const& rel : dit->second) + if (!consumed.contains((pkg.root / rel).lexically_normal())) + orphans += " " + rel + "\n"; + if (orphans.empty()) continue; + std::error_code hasEc; + const bool hasProgram = std::filesystem::exists(pkg.root / "build.mcpp", hasEc) + || !pkg.manifest.buildConfig.ruleModules.empty(); + refusal::record(refusal::Code::DeviceSourceUnconsumed); + return std::unexpected(std::format( + "`{}`: device sources that no action compiles:\n{}" + " A device-kind source is compiled by this package's build program\n" + " and by nothing else -- the engine has no rule for these extensions\n" + " and never will.\n" + "{}", + mm.package.name, orphans, + hasProgram + ? " `build.mcpp` ran but declared no action taking them as inputs.\n" + " fix: import the rule package that claims these extensions and\n" + " call it, or drop them from `[build] sources`. A rule that\n" + " compiles a file must also declare it as an action input, or the\n" + " action will not rerun when the file changes." + : " This package has no `build.mcpp`, so nothing was ever offered\n" + " them.\n" + " fix: add a `build.mcpp` importing the rule for these files (e.g.\n" + " `mcpp.rules.cuda` for `.cu`, `mcpp.rules.spirv` for shaders), or\n" + " drop them from `[build] sources`.")); + } + + // ── R1.3: a re-run input inside a `prepare` directory (SPEC-007 §3) ───── + // + // A build program's re-run set is declared BEFORE anything is built + // (`rerun_if_changed`/`rerun_if_changed_glob`), and a `prepare` action's + // directory is filled AFTER a build program has already run once for + // this build — it is a ninja edge, scheduled after `mcpp build`'s + // configure step ends. A program that also names a file or a glob inside + // such a directory as its own re-run input reads a CONSTRUCTION RESULT + // while it configures: correct on the SECOND build, once a previous + // build's `prepare` action has populated the directory, and wrong on the + // first — the exact pattern of a plugin placing a first installation's + // libraries on the NEXT plan (design §5.2's route on 2026.9.26.1, which + // this directive and role exist to remove). + // + // WARNED, NOT REFUSED: the program still configures correctly today (its + // FIRST run sees what the tree already held), and R1.2 already asks a + // program to say what it could not find with `mcpp::warning`. This is the + // engine naming an author obligation SPEC-007 states (R1.3), not a build + // it can complete no differently. + // + // `declared_program_inputs` reads back what every package's build.mcpp + // just declared (or, on a cache hit, declared on its last run) from the + // caches under `/target/.build-mcpp`, so no extra plumbing is + // needed to carry the re-run set out of `run_build_program`. + { + std::map ownerName; + std::vector> prepareDirs; + for (std::size_t i = 0; i < state.packages.size(); ++i) { + auto const& mm = (i == 0) ? *state.m : state.packages[i].manifest; + ownerName.emplace(state.packages[i].root.lexically_normal(), mm.package.name); + for (auto const& a : mm.buildConfig.actions) { + if (a.role != mcpp::manifest::BuildAction::Role::Prepare) continue; + if (a.outputDir.empty()) continue; + prepareDirs.emplace_back(mm.package.name, + std::filesystem::path(a.outputDir).lexically_normal()); + } + } + if (!prepareDirs.empty()) { + // `p` reaches strictly inside `dir`: equal paths and a sibling + // that merely shares a prefix (`lexically_relative` starting with + // `..`) both do not count. + auto isUnder = [](const std::filesystem::path& p, + const std::filesystem::path& dir) { + auto rel = p.lexically_relative(dir); + if (rel.empty()) return false; + auto s = rel.generic_string(); + return s != "." && s.compare(0, 2, "..") != 0; + }; + for (auto const& decl : mcpp::build::declared_program_inputs(state.workRoot)) { + std::vector watched(decl.files); + for (auto const& pattern : decl.globs) { + // The glob's fixed prefix — everything before its first + // wildcard character — is enough to answer whether the + // PATTERN reaches into a `prepare` directory; resolving it + // into the file set it matches is not needed for that. + auto wildcard = pattern.find_first_of("*?["); + auto fixed = wildcard == std::string::npos + ? pattern : pattern.substr(0, wildcard); + watched.push_back((decl.root / fixed).lexically_normal()); + } + auto ownerIt = ownerName.find(decl.root.lexically_normal()); + const std::string declName = + ownerIt != ownerName.end() ? ownerIt->second : decl.root.string(); + for (auto const& w : watched) { + for (auto const& [pkgName, dir] : prepareDirs) { + if (!isUnder(w, dir)) continue; + mcpp::ui::warning(std::format( + "{}'s build.mcpp re-runs on '{}', which is inside " + "'{}', the directory package '{}' declared with a " + "`prepare` action's output_dir. That directory is " + "populated at BUILD time, after build.mcpp has " + "already configured, so this program sees the " + "PREVIOUS build's contents, never the current " + "one's (SPEC-007 R1.3).", + declName, w.string(), dir.string(), pkgName)); + } + } + } + } + } + + // [targets.*] required_features gate: a target is emitted only when ALL its + // required features are active in this build; otherwise it is silently + // skipped. A pure build-selection knob — it runs before the modgraph/plan + // so gated-out targets cost nothing. + std::erase_if(state.m->targets, [&](const mcpp::manifest::Target& t) { + for (auto const& rf : t.requiredFeatures) + if (!state.activeRootFeatures.contains(rf)) return true; + return false; + }); + + // The dialect-complete standard flag: spelled per-dialect and carrying + // the module-graph-global dialect flags (issue #210). ONE string shared + // by the p1689 scan and the std BMI prebuild so scan-time, prebuild-time + // and compile-time dialect provably agree. Both this and make_plan go + // through the same cppfly merge, so the c++fly gates (and the + // c++latest/c++fly per-toolchain std spelling) stay graph-consistent. + state.stdFlagAndDialect = mcpp::toolchain::cppfly::std_flag( + *state.tc, state.m->cppStandard.canonical, state.m->cppStandard.level); + if (state.m->cppStandard.experimental) { + // c++fly is best-effort by design: say exactly what this toolchain + // got and what it lacks (the value's contract, design §5.4). + auto fly = mcpp::toolchain::cppfly::resolve(*state.tc); + std::string enabled, skipped; + for (auto& f : fly.features) { + auto& dst = f.enabled ? enabled : skipped; + if (!dst.empty()) dst += ", "; + dst += f.name; + if (f.enabled && !f.flags.empty()) dst += std::format(" ({})", f.flags); + if (!f.enabled) dst += std::format(" ({})", f.reason); + } + std::println("c++fly on {}: {}; enabled: {}; skipped: {}", + state.tc->label(), state.stdFlagAndDialect, + enabled.empty() ? "(none)" : enabled, + skipped.empty() ? "(none)" : skipped); + } + for (auto& f : mcpp::toolchain::cppfly::effective_dialect_flags( + *state.tc, state.m->cppStandard.experimental, + mcpp::manifest::dialect_flags(state.m->buildConfig))) { + state.stdFlagAndDialect += ' '; + state.stdFlagAndDialect += f; + } + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp new file mode 100644 index 000000000..111164196 --- /dev/null +++ b/src/build/prepare/toolchain.cpp @@ -0,0 +1,2094 @@ +// toolchain.cpp -- P1 and P2: the toolchain specification and the target +// axis, and the definition of the toolchain resolver that P5 calls once the +// dependency graph exists. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.build.version_floor; +import mcpp.platform.axis; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.msvc; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.freestanding.target; // the target sysroot layout (libdir) +import mcpp.freestanding.linkline; // the ISA profile, for the std module command +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.pack.abi_tag; // the tag a prebuilt dependency is checked against +import mcpp.pack.prebuilt; // …and the check itself +import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its manifest +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.config; +import mcpp.xlings; +import mcpp.xlings.runtime_selection; +import mcpp.runtime.binding; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.platform.macos; +import mcpp.fetcher; +import mcpp.fetcher.progress; +import mcpp.ui; + +namespace mcpp::build { + +std::expected phase1_toolchain_spec_and_axes(PrepareState& state) { + // ─── Toolchain resolution (docs/21) ──────────────────────────────── + // + // THE WHOLE CHAIN, in the order it is applied. It was documented twice, as + // "3 steps" here and "4 steps" further down, and neither list had been + // true for a long time — between them they named five of the nine inputs + // below and disagreed about two. A comment that undercounts the inputs to + // a decision is worse than none: it tells the next reader they have seen + // the whole thing. + // + // The WHAT (which spec) is settled first, then the HOW (which binary). + // Anything that WRITES `tcSpec` also writes `tcOrigin`, and that is the + // invariant this table rests on — the enumerator names below are real, so + // this comment cannot quietly stop matching the code. + // + // WHICH SPEC tcOrigin + // 1. mcpp.toml [toolchain]. / .default ManifestToolchain + // 2. global config.toml [toolchain] default GlobalDefault + // 3. mcpp.toml [target.].toolchain TargetSection + // (--target / [build] target / config default + // select the section; 3 outranks 1 and 2) + // 4. the target vocabulary's pin (triple.cppm) TargetPin + // — a convention, and it stands down when a + // REMEMBERED target would overrule a spec the + // user wrote down + // 5. the platform's first-run default, installed FirstRun + // and persisted by this very invocation + // + // WHICH BINARY, from the spec settled above + // 6. `msvc@system` → probe the machine (no xim package exists) + // 7. `@` → xim payload; msvc resolves through + // resolve_managed_msvc, everything else through + // the bin/-shaped frontend lookup + // 8. bare `system` → the PATH compiler. A deliberate escape hatch, and + // the ONLY host-compiler route: there is no + // `gcc@system` (see parse_toolchain_spec) + // 9. offline / MCPP_NO_AUTO_INSTALL → hard error rather than a silent + // ~800 MB download + // + // AND ONE REVISION, after 1-9 have produced a toolchain: a spec targeting + // the MSVC ABI on a machine with no usable MSVC is switched to MinGW-w64 + // — but only when `tc_origin_is_user_explicit` says mcpp chose it itself. + state.bootstrap_checked = false; + state.get_cfg = [&](bool requireBootstrap = true) -> std::expected { + if (!state.cfg_opt) { + auto c = mcpp::config::load_or_init(/*quiet=*/false, + mcpp::fetcher::make_bootstrap_progress_callback()); + if (!c) return std::unexpected(c.error().message); + state.cfg_opt = std::move(*c); + } + // Commands that need bootstrap tools (build, run, toolchain install) + // pass requireBootstrap=true to get an early, clear error. + if (requireBootstrap && !state.bootstrap_checked) { + state.bootstrap_checked = true; + auto problem = mcpp::config::check_base_init(*state.cfg_opt); + if (!problem.empty()) { + return std::unexpected(std::format( + "{}\n hint: run `mcpp self init --force` to reset and re-initialize", + problem)); + } + } + return &*state.cfg_opt; + }; + + // Resolve one exact runtime contract before resolving/fixing a toolchain. + // The fixup is itself a consumer of RuntimeBinding: doing it first would + // recreate #392 by letting directory order choose a libc and only later + // discovering what the project selected. + if (state.overrides.inherited_runtime_binding) { + state.runtimeBindingSnapshot = *state.overrides.inherited_runtime_binding; + } else { + auto cfgRuntime = state.get_cfg(true); + if (!cfgRuntime) return std::unexpected(cfgRuntime.error()); + auto resolved = mcpp::platform::runtime::resolve_runtime_binding( + state.runtimeSelection, {}, (**cfgRuntime).xlingsHome()); + if (!resolved) return std::unexpected(resolved.error()); + state.runtimeBindingSnapshot = std::move(*resolved); + // A degradation that nobody prints is indistinguishable from no + // degradation, which is the failure this whole area keeps paying for. + // A note is not a warning: nothing is wrong with the build, some facts + // are simply unavailable — so it is reported once, at info level. + if (!state.runtimeBindingSnapshot.note.empty()) + mcpp::ui::info("Runtime", state.runtimeBindingSnapshot.note); + } + // THE `bin` THIS PROJECT'S BUILD PROGRAMS SEE FIRST — derived ONCE, + // here, from the selection that has just been resolved. + // + // Empty unless the manifest declared `[xlings].subos`. That is deliberate: + // prepending the SHARED `subos/default/bin` would make what a build sees + // depend on what else has been installed on this machine, so a project + // that has not asked for an environment of its own gets the `PATH` mcpp + // was started with, byte for byte. + // + // NOT RE-DERIVED AT THE TWO DELIVERY SITES BELOW, AND NOT FROM + // `[xlings] deps`. `mcpp::xlings::runtime` is the sole runtime-selection + // policy and `RuntimeBinding::subosDir` is its resolved answer; a second + // derivation is how a build ends up with two subos and no way to say which + // one it used. The per-package payload paths a program may also need are + // already answered, separately, by `MCPP_XPKG_*_DIR`. + state.projectSubosBin = [&]() -> std::string { + using Mode = mcpp::xlings::runtime::RuntimeSelection::Mode; + if (state.runtimeBindingSnapshot.selection.mode != Mode::NamedSubos) + return {}; + auto bin = state.runtimeBindingSnapshot.subosDir / "bin"; + std::error_code ec; + if (!std::filesystem::is_directory(bin, ec)) return {}; + return bin.string(); + }(); + + state.runtimePayload = state.runtimeBindingSnapshot.libc; + state.runtimeLibDir = state.runtimeBindingSnapshot.libraryDirs.empty() + ? std::filesystem::path{} : state.runtimeBindingSnapshot.libraryDirs.front(); + + // THE DECLARED RUNTIME PAYLOAD IS PROVIDED BEFORE THE FIRST FIXUP THAT + // CONSUMES IT (mcpp#660), and not earlier: a build whose toolchain needs + // no C runtime payload must not download one. At most once per build. An + // inherited binding is not exempt, because the parent build may have used + // a toolchain that needed no payload. The two values derived above are + // refreshed with the binding, because detection and the fingerprint read + // them after the fixups. + state.runtimePayloadProvided = false; + state.provide_runtime_payload = [&](const mcpp::toolchain::XimToolchainPackage& pkg) { + if (state.runtimePayloadProvided) return; + if (mcpp::toolchain::post_install_fixup_kind(pkg).empty()) return; + state.runtimePayloadProvided = true; + auto cfgP = state.get_cfg(true); + if (!cfgP) return; + if (!mcpp::toolchain::ensure_declared_runtime(**cfgP, state.runtimeBindingSnapshot)) + return; + state.runtimePayload = state.runtimeBindingSnapshot.libc; + state.runtimeLibDir = state.runtimeBindingSnapshot.libraryDirs.empty() + ? std::filesystem::path{} : state.runtimeBindingSnapshot.libraryDirs.front(); + }; + + // mcpp#427: a toolchain fixup that could not run is a DEGRADATION, not a + // failure — the build continues without it. But it has to be said, or the + // eventual `stdlib.h: No such file or directory` arrives with no way to + // connect it to its cause. + // + // Deduplicated by payload: `ensure_post_install_fixup` is called from up + // to four seams in one build (manifest toolchain, default toolchain, + // MinGW first-run, build.mcpp host toolchain) and they routinely resolve + // the SAME payload. Saying it once is the rule mcpp#417 already paid for. + auto fixupNoticed = std::make_shared>(); + state.report_fixup = [fixupNoticed]( + const mcpp::toolchain::FixupOutcome& outcome, + const std::filesystem::path& payloadRoot) { + if (outcome.skippedReason.empty()) return; + if (!fixupNoticed->insert(payloadRoot.generic_string()).second) return; + // Only the fact this line ADDS. The `Runtime` note above already gave + // the cause and the remedy for the same absence; repeating them here + // would be the second copy of one message, which is the habit mcpp#417 + // exists to break. + mcpp::ui::info("Toolchain", std::format( + "used as installed — not patched against a C runtime ({})", + outcome.skippedReason)); + }; + + + // Toolchain resolution priority: see the table at the top of this + // function. Stated once, where `tcOrigin` is introduced — this used to be + // a second, shorter and differently-wrong list of the same thing. + // + // Resolve the build profile, overlaid by any [profile.] from the + // manifest → buildConfig. `effectiveProfile` outlives the block: the + // build.mcpp env contract exposes it as MCPP_PROFILE. + { + auto& pname = state.effectiveProfile; + // Precedence lives in resolve_profile_name (above) so execute.cppm's + // fast paths settle it identically without running prepare_build. + // Release is opt-in via --release / --profile release; a project that + // wants its plain `mcpp build` optimized sets + // [build].default-profile = "release" (mcpp's own mcpp.toml does this, + // so the released binary stays -O2). + pname = resolve_profile_name(*state.m, state.overrides.profile, state.overrides.profile_fallback); + mcpp::manifest::Profile pr; + if (pname == "dev" || pname == "debug") { pr.optLevel = "0"; pr.debug = true; } + else if (pname == "dist") { pr.optLevel = "3"; pr.strip = true; } + // (built-in dist intentionally leaves lto off: several packaged gcc + // payloads ship without the LTO plugin; enable via [profile.dist].) + else { pr.optLevel = "2"; } // release + if (auto it = state.m->profiles.find(pname); it != state.m->profiles.end()) pr = it->second; + // #519 — a profile may override the whole-graph form. OPTIONAL, so a + // profile that does not mention it leaves `[build]` standing; a plain + // value would reset it, because the block above REPLACES `pr` wholesale + // with the declared profile. + if (pr.dependencyLinkageDeclared) + state.m->buildConfig.dependencyLinkage = pr.dependencyLinkage; + state.m->buildConfig.optLevel = pr.optLevel; + state.m->buildConfig.debug = pr.debug; + state.m->buildConfig.lto = pr.lto; + state.m->buildConfig.strip = pr.strip; + state.m->buildConfig.cflags.insert(state.m->buildConfig.cflags.end(), + pr.cflags.begin(), pr.cflags.end()); + state.m->buildConfig.cxxflags.insert(state.m->buildConfig.cxxflags.end(), + pr.cxxflags.begin(), pr.cxxflags.end()); + state.m->buildConfig.ldflags.insert(state.m->buildConfig.ldflags.end(), + pr.ldflags.begin(), pr.ldflags.end()); + } + + // Every directory a package payload may legitimately have been INSTALLED + // into: the global registry, plus the two project-local data roots a custom + // git index installs into. Defined HERE, above its first use, because three + // separate questions now depend on the same answer — where a dependency's + // cache address is anchored, whether its sources came from a store at all, + // and whether a `standard` declaration in its manifest was written by an + // author or by a descriptor generator. One definition, three uses; deriving + // the same fact twice is how two of them start disagreeing. + state.storeRoots = [&]() -> std::vector { + std::vector roots; + if (auto c = state.get_cfg(true)) roots.push_back((*c)->xlingsHome() / "data" / "xpkgs"); + for (auto& d : mcpp::config::project_xlings_data_roots(state.workRoot)) + roots.push_back(d / "xpkgs"); + return roots; + }(); + + // [package] platforms — fixed vocabulary owned by mcpp (it owns the + // target/triple system): the platform name of every row it has + // (`platform_name`, beside `artifact_naming`). Unknown values: warning, or + // error under --strict. + for (auto& pf : state.m->package.platforms) { + if (!mcpp::toolchain::triple::is_platform_name(pf)) { + auto msg = std::format( + "[package] platforms contains unknown platform '{}' " + "(expected: {})", pf, + mcpp::toolchain::triple::platform_names_joined()); + if (state.overrides.strict) return std::unexpected(msg); + mcpp::diag::warning("manifest/platforms", msg); + } + } + + state.tcSpec = state.m->toolchain.for_platform(kCurrentPlatform); + // Where the spec came from decides whether mcpp may later revise it. + // See TcOrigin: mcpp can rewrite a default it chose itself, but must not + // silently overrule one the user wrote down. + state.tcOrigin = state.tcSpec.has_value() ? TcOrigin::ManifestToolchain + : TcOrigin::None; + // `--toolchain` shares `ManifestToolchain`'s precedence and not its + // spelling: the messages that refuse a spec name where it was written, and + // a value from the command line credited to a manifest key sends the + // reader to a file that does not contain it. + state.tcFromCommandLine = false; + state.tcFromConsumer = false; + state.tcSpecSource = [&]() -> std::string { + if (state.tcOrigin == TcOrigin::ManifestToolchain && state.tcFromConsumer) + return std::format("the toolchain chosen for this host tool by {}", + state.overrides.tool_chain); + if (state.tcOrigin == TcOrigin::ManifestToolchain && state.tcFromCommandLine) + return "--toolchain"; + switch (state.tcOrigin) { + case TcOrigin::ManifestToolchain: + return std::format("[toolchain].{}", kCurrentPlatform); + case TcOrigin::TargetSection: + return std::format("[target.{}].toolchain", state.overrides.target_triple); + case TcOrigin::GlobalDefault: + return "the default toolchain (`mcpp toolchain default`)"; + default: + return std::format("the toolchain mcpp chose ({})", + tc_origin_name(state.tcOrigin)); + } + }; + // `--toolchain` (arriving as MCPP_TOOLCHAIN, the same side channel + // `--offline` and `--jobs` use) beats everything, including the manifest. + // + // This is the usable form of "which compiler". On this repository the + // choice is worth 2.48x — gcc@16.1.0 builds mcpp in 79.9s, llvm@22.1.8 in + // 32.2s — but CHANGING THE DEFAULT is an ecosystem decision, not a + // performance one: it invalidates every published package's fingerprint and + // the three platforms do not yet ship the same llvm. Selecting per build + // costs nobody anything and needs no coordination. + // + // It counts as user-explicit, so mcpp will not quietly revise it. + if (const char* tcEnv = std::getenv("MCPP_TOOLCHAIN"); tcEnv && *tcEnv) { + state.tcSpec = std::string(tcEnv); + state.tcOrigin = TcOrigin::ManifestToolchain; + state.tcFromCommandLine = true; + } + if (!state.overrides.toolchain.empty()) { + state.tcSpec = state.overrides.toolchain; + state.tcOrigin = TcOrigin::ManifestToolchain; + state.tcFromConsumer = true; + } + if (!state.tcSpec.has_value()) { + auto cfg = state.get_cfg(true); + if (cfg && !(*cfg)->defaultToolchain.empty()) { + state.tcSpec = (*cfg)->defaultToolchain; + state.tcOrigin = TcOrigin::GlobalDefault; + } + } + + // ─── Windows first run without Visual Studio ──────────────────────── + // The host triple on Windows is MSVC-ABI, so the historical default + // (llvm) resolves to clang targeting MSVC — which uses the MSVC STL and + // the Windows SDK. Neither ships with Windows; both arrive only with + // Visual Studio's "Desktop development with C++" workload. On a bare box + // that default installs fine and then fails at compile time with no + // actionable message. + // + // Seed only the TARGET axis and let the block right below derive the + // rest: the vocabulary table already maps x86_64-windows-gnu to its pin + // (winlibs GCC) and to static linkage, so the toolchain answer stays a + // single derivation instead of being spelled out a second time here. + // "Is MSVC usable here" — either origin. Asking `has_usable_msvc()` (which + // probes the machine) would answer "no" on a box that has a pinned + // msvc@ payload and no Visual Studio, and every decision below + // would then divert a perfectly good toolchain to mingw. + state.msvc_usable_either_origin = [&]() -> bool { + auto c = state.get_cfg(true); + if (!c) return mcpp::toolchain::msvc::has_usable_msvc(); + return mcpp::toolchain::msvc::msvc_available_here( + (*c)->xlingsHome() / "data" / "xpkgs"); + }; + + // THE PLATFORM'S CANONICAL NATIVE DEFAULT — a spec string only; no + // install, no persistence. Two places need "what would a native + // `mcpp build` pick here, with no --target": the first-run installer + // further below (which goes on to install and persist it), and + // `host_tc_for_build_program`'s cross branch (which needs a genuine HOST + // compiler when nothing was ever recorded as one — see its own comment + // for why #622 happened). One derivation, called from both, so they + // cannot drift the way a hand-copied second copy would. + state.native_first_run_spec = [&]() -> std::string { + namespace pins = mcpp::toolchain::triple::pins; + if constexpr (mcpp::platform::is_macos) { + return std::string(pins::kFirstRunMac); + } else if constexpr (mcpp::platform::is_windows) { + // A machine with no usable MSVC gets the GNU pin, not an + // MSVC-ABI clang it cannot use — mirrors the windows-gnu seed + // below, which this function's other caller runs after. + return std::string(state.msvc_usable_either_origin() + ? pins::kFirstRunWinMsvc : pins::kFirstRunWinGnu); + } else if (mcpp::platform::host_arch == std::string_view("x86_64")) { + return std::string(pins::kFirstRunLinuxX86_64); + } else { + return std::string(pins::kFirstRunLinuxOther); + } + }; + + state.windowsGnuFirstRun = false; + if constexpr (mcpp::platform::is_windows) { + if (!state.tcSpec.has_value() && state.overrides.target_triple.empty() + && state.m->buildConfig.target.empty() + && !state.msvc_usable_either_origin()) { + auto cfgW = state.get_cfg(true); + if (!cfgW || (*cfgW)->defaultTarget.empty()) { + state.overrides.target_triple = + std::string(mcpp::toolchain::triple::pins::kFirstRunWinGnuTarget); + state.windowsGnuFirstRun = true; + } + } + } + + // `[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() && !state.tcFromCommandLine && !state.tcFromConsumer) { + state.tcSpec = e.toolchain; + state.tcOrigin = TcOrigin::TargetSection; + } + if (!e.linkage.empty()) state.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()) state.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) > + // [toolchain] default_target (global config) > host. + if (state.overrides.target_triple.empty() && !state.m->buildConfig.target.empty()) + state.overrides.target_triple = state.m->buildConfig.target; + // Remembered, not requested: this one came out of the global config, so + // it must not outrank anything the user wrote down (see the pin below). + bool targetFromGlobalDefault = false; + if (state.overrides.target_triple.empty()) { + if (auto cfg = state.get_cfg(true); cfg && !(*cfg)->defaultTarget.empty()) { + state.overrides.target_triple = (*cfg)->defaultTarget; + targetFromGlobalDefault = true; + } + } + // Normalize the triple (alias spellings → canonical), validate against + // the known-target vocabulary, then apply the manifest [target.] + // override and the vocabulary-table convention (pin + default linkage). + if (!state.overrides.target_triple.empty()) { + namespace triple = mcpp::toolchain::triple; + // THE SPELLING THE PROJECT WROTE, KEPT FOR EVERY DIAGNOSTIC BELOW. + // `state.overrides.target_triple` is canonicalised further down, and until + // this variable existed the refusals quoted the canonical form: + // `--target aarch64-linux` produced "target 'aarch64-linux-gnu' is + // registered but not yet supported", a string the reader never typed + // and cannot find in their own command. + const std::string requestedSpelling = state.overrides.target_triple; + auto parsed = triple::parse(state.overrides.target_triple); + + // THE REQUEST IS COMPLETED FROM THE VOCABULARY BEFORE ANYTHING + // READS IT, AND THE ORDER RELATIVE TO THE `[target.X]` LOOKUP IS PART + // OF THE CONTRACT. + // + // `parse` fills a missing env segment lexically so the identity stays + // total — `x86_64-linux` IS `x86_64-linux-gnu`, and a unit test says so. + // Every gate below then asked about the filled value instead of about + // the request. See `triple::resolve_request` for the two measurements. + // + // The lookup that follows keys on `parsed->str()`, so completing after + // it would match sections against a triple this build is not going to + // use. A project wanting the `planned` row keeps its escape hatch by + // WRITING the segment: `--target aarch64-linux-gnu` skips completion + // entirely, because a written segment is a request rather than a gap. + triple::RequestResolution req; + if (parsed) { + req = triple::resolve_request(*parsed); + parsed = req.triple; + } + + // [target.X] lookup is spelling-independent: a section keyed + // `x86_64-w64-mingw32` matches `--target x86_64-windows-gnu` and + // vice versa. Unparseable keys/inputs compare exactly (escape hatch). + auto it = state.m->targetOverrides.find(state.overrides.target_triple); + if (it == state.m->targetOverrides.end() && parsed) { + for (auto o = state.m->targetOverrides.begin(); + o != state.m->targetOverrides.end(); ++o) { + if (auto k = triple::parse(o->first); + k && k->str() == parsed->str()) { it = o; break; } + } + } + bool hasExplicitSection = it != state.m->targetOverrides.end(); + bool hasToolchainOverride = hasExplicitSection + && !it->second.toolchain.empty(); + + const triple::TargetInfo* known = + parsed ? triple::find_known_target(*parsed) : nullptr; + + // Validation: a typo must never silently fall through to the host + // toolchain (the worst failure mode — you think you cross-compiled). + // An explicit [target.X] section is the escape hatch for custom + // triples outside the vocabulary. + // Several rows serve this (arch, os) and the lexical default names none + // of them, so there is nothing to complete the request WITH. Refusing + // and listing them is the only honest answer; picking one would be an + // invented convention. No group has this shape today — the rule is here + // so the first one that does gets a diagnosis rather than a guess. + if (parsed && req.ambiguous && !hasExplicitSection) { + std::string opts; + for (auto s : req.supported) { + if (!opts.empty()) opts += ", "; + opts += std::string(s); + } + refusal::record(refusal::Code::AmbiguousRequest); + return std::unexpected(std::format( + "target '{}' does not say which C library, and several are " + "supported here.\n" + " candidates: {}\n" + " Name one of them.", + requestedSpelling, opts)); + } + if (!known && !hasExplicitSection) { + // "UNKNOWN" IS A CLAIM ABOUT THE VOCABULARY, AND IT WAS FALSE FOR + // A WHOLE arch+os FAMILY. + // + // Measured on 2026.8.26.1: `--target riscv64-linux` reported + // `unknown target 'riscv64-linux'` while `riscv64-linux-musl` was + // sitting in `kKnownTargets` as `planned`. The lexical fill had + // produced `riscv64-linux-gnu` — a row that genuinely does not + // exist — and the gate reported on the fill. + // + // A non-empty sibling group means the family IS registered, so this + // is the planned refusal wearing the wrong word. It names the row + // that exists, which is also the one the reader would have to write + // to opt in. + if (!req.siblings.empty()) { + std::string rows; + for (auto s : req.siblings) { + if (!rows.empty()) rows += ", "; + rows += std::string(s); + } + refusal::record(refusal::Code::TierPlanned); + return std::unexpected(std::format( + "target '{}' is registered but not yet supported (planned) — " + "no toolchain is published for it yet.\n" + " registered rows for this system: {}\n" + " An explicit [target.] toolchain override can " + "opt in early.", + requestedSpelling, rows)); + } + auto sug = triple::did_you_mean(requestedSpelling); + refusal::record(refusal::Code::UnknownTarget); + return std::unexpected(std::format( + "unknown target '{}'{}\n" + " known targets: `mcpp toolchain list`; a custom triple needs an\n" + " explicit [target.{}] section in mcpp.toml", + requestedSpelling, + sug ? std::format(" — did you mean '{}'?", *sug) : "", + requestedSpelling)); + } + if (known && known->tier == "planned" && !hasToolchainOverride) { + refusal::record(refusal::Code::TierPlanned); + // The subject is what the user wrote. When completion filled a + // segment, both are shown — otherwise the sentence is about a + // string that appears nowhere in their command. + const std::string subject = + requestedSpelling == parsed->str() + ? std::format("'{}'", requestedSpelling) + : std::format("'{}' (which resolves to '{}')", + requestedSpelling, parsed->str()); + return std::unexpected(std::format( + "target {} is registered but not yet supported (planned) — " + "no toolchain is published for it yet.\n" + " An explicit [target.{}] toolchain override can opt in early.", + subject, parsed->str())); + } + // AN APPLE SDK IS LOCATED, SO ITS ABSENCE IS KNOWN NOW. + // + // REFUSED HERE AND NOT WITH THE TOOLCHAIN, which is a decision about + // WHEN rather than about the message. The iOS rows need the machine's + // iPhoneOS or iPhoneSimulator SDK, and that is knowable before any + // payload is resolved -- so a machine without Xcode used to download + // a 700 MB compiler and then be told the thing it was missing was not + // the compiler. + // + // AND UNLIKE `host_can_serve` BELOW, THIS IS NOT DEFERRED. That + // refusal waits for the dependency graph because a package can supply + // a target's C library and platform interface. An Apple SDK is not + // redistributable, so no package supplies it: there is nothing a later + // line could learn that would change this answer. + // + // The escape hatch that opens the tier gate does NOT open this one. + // Declaring a toolchain says which compiler; it says nothing about + // where the headers and stub libraries are, and every compiler needs + // them. + if (parsed && parsed->is_ios()) { + const auto which = parsed->is_ios_simulator() + ? mcpp::platform::macos::sdk_iphonesim + : mcpp::platform::macos::sdk_iphoneos; + state.appleSdkLocated = mcpp::platform::macos::sdk_path(which); + // AN UNSET FLOOR IS THE LOCATED SDK'S VERSION, READ RATHER THAN + // LEFT TO THE DRIVER. `docs/20` promised that an unversioned + // triple meant the SDK's own default; measured on macos-15 with + // Xcode 16.4, clang given `arm64-apple-ios` with no version + // refused thread-local storage for the target, which libc++abi + // uses, so the default it chose was older than any SDK on the + // machine. The version `xcrun` reports for the located SDK is the + // one the SDK was made for, and it enters the manifest here so + // that the fingerprint slot, the effective triple and every + // report read one value. + if (state.appleSdkLocated && state.m->buildConfig.iosDeploymentTarget.empty()) { + if (auto v = mcpp::platform::macos::sdk_version(which)) { + state.m->buildConfig.iosDeploymentTarget = *v; + state.iosFloorFromSdk = true; + } + } + if (!state.appleSdkLocated) { + // A CODE, BECAUSE THE MATRIX COMPARES REASONS AND NOT ONLY + // OUTCOMES. A refusal with no code is recorded as `other`, + // which `check_matrix_reasons.sh` refuses on the ground that + // it freezes an unnamed branch into the expected table. + refusal::record(refusal::Code::AppleSdkAbsent); + return std::unexpected(std::format( + "target {} needs the {} SDK, which this machine does not " + "provide.\n" + " It is not redistributable, so mcpp LOCATES it " + "rather than installing it: `xcrun --sdk {} " + "--show-sdk-path` must answer, which needs Xcode on macOS " + "(not the Command Line Tools alone -- those ship the " + "macOS SDK only).\n" + " Check `xcode-select -p`, and note that the " + "compiler is not what is missing: these rows pin " + "`xim:llvm`, which every other Apple row also uses.", + parsed->str(), which, which)); + } + } + // A `shared` TARGET NAMES A LINK CONTRACT THIS ENGINE DOES NOT RENDER. + // + // `-sSIDE_MODULE` is a different Emscripten link mode from the + // ordinary one (one static image, `artifact_naming`'s `.js`+`.wasm` + // pair) and mcpp emits no flag for it. Falling through to the + // ordinary link would still WRITE a `.so`-shaped file — the fallback + // naming's `sharedLibExt` is empty, so the linker would be asked for + // an empty-named output — so this is caught here, by NAME, rather + // than reached as an obscure link failure. + // + // REFUSED HERE AND NOT AT PLAN TIME, same reasoning as the Apple SDK + // check above: `parsed` and the manifest's own target list are both + // already known, resolving neither an emsdk payload nor any other + // toolchain, so an offline build (no emsdk installed) gets this + // sentence instead of downloading the SDK first. + if (parsed && parsed->object_format() + == triple::ObjectFormat::Wasm) { + for (auto const& t : state.m->targets) { + if (t.kind != mcpp::manifest::Target::SharedLibrary) continue; + return std::unexpected(std::format( + "[targets.{}] kind = \"shared\" is not supported on " + "wasm32-emscripten: a side module needs -sSIDE_MODULE, " + "which mcpp does not render", + t.name)); + } + } + // Known, supported — and IMPOSSIBLE ON THIS HOST. + // + // Without this the target falls through to the host toolchain and the + // build SUCCEEDS, which is the failure the check above calls the worst + // one, arriving through a different door. Measured on Linux: + // + // $ mcpp build --target x86_64-windows-msvc + // Resolved gcc@16.1.0 → x86_64-windows-msvc → …/xim-x-gcc/bin/g++ + // Finished dev [unoptimized + debuginfo] in 0.07s + // $ ls target/ + // x86_64-linux-gnu/ ← an ELF, reported as a Windows build + // + // The vocabulary tier says "mcpp supports this target"; it never said + // "this machine can produce it". `host_can_serve` is the answer to the + // second question and lives beside the payload resolution it has to + // agree with. + // + // The escape hatch stays open on purpose: an explicit `[target.X]` + // toolchain override means the author is supplying the cross toolchain + // themselves, and mcpp's payload matrix has no standing to refuse it. + // DIAGNOSED HERE, REPORTED LATER, AND THE DIFFERENCE IS THE POINT. + // + // Whether a payload on this machine produces this target is knowable + // now. Whether anything ELSE produces it is not: a dependency can + // supply the target's platform interface and C library, and the + // dependency graph does not exist yet at this line. Refusing here + // therefore answered a narrower question than the one it claimed — + // measured, a project that only had to add a dependency was told its + // machine could not build the target at all. + // + // The refusal is kept in full, because it is right whenever nothing + // supplies the target side, which remains the ordinary case. It is + // carried to where the graph is known and released there. Nothing + // between here and there consumes the answer: what follows is toolchain + // and dependency resolution, and a target no payload serves resolves to + // a driver that simply will not be asked to emit anything. + // + // The escape hatch stays open on purpose: an explicit `[target.X]` + // toolchain override means the author is supplying the cross toolchain + // themselves, and mcpp's payload matrix has no standing to refuse it. + if (known && known->tier != "planned" && !hasToolchainOverride + && parsed + && !mcpp::toolchain::host_can_serve(*parsed)) { + std::string servable; + for (auto const& info : triple::known_targets()) { + auto t = triple::parse(info.canonical); + if (!t || info.tier == "planned") continue; + if (!mcpp::toolchain::host_can_serve(*t)) continue; + if (!servable.empty()) servable += ", "; + servable += t->str(); + } + state.unservedTargetDiagnosis = std::format( + "target '{}' cannot be built on this host.\n" + " No toolchain payload here produces it, and nothing in " + "the dependency graph\n" + " supplies its system side.\n" + " this host can build with the payload alone: {}\n" + " To build it anyway, depend on a package that implements " + "the target's system\n" + " (its kernel interface and C library), or supply your own " + "cross toolchain with\n" + " an explicit [target.{}] toolchain = \"…\" section.", + parsed->str(), + servable.empty() ? "(nothing — `mcpp toolchain list`)" : servable, + parsed->str()); + } + // CAPTURED BEFORE CANONICALISATION, BECAUSE CANONICALISATION IS + // EXACTLY WHAT DESTROYS IT. + // + // `str()` renders the filled-in identity, so `x86_64-linux` becomes + // `x86_64-linux-gnu` here and every later `parse` of that string reports + // an env segment the project never wrote. The request has to be taken + // from the ONLY triple that still knows the difference: this one. + if (parsed && parsed->envExplicit) state.requestedCAbi = parsed->env; + // AND THE SPELLING THE PROJECT USED, FOR THE REPORT ONLY. + // + // The canonical form is the identity — the output directory, the cache + // key, the subject of a `cfg()` — and it must stay filled. The REPORT is + // a different thing: it says what was asked for and what resolved, and + // heading it `x86_64-linux-gnu` above a line reading `c-abi musl` states + // a contradiction the build does not actually contain. A project that + // declined to name a C library is shown as having declined. + if (parsed && !parsed->envExplicit && !parsed->env.empty()) { + auto asWritten = *parsed; + asWritten.env.clear(); + state.targetDisplayName = asWritten.str(); + } + + // Canonical from here on: cfg evaluation, spec attachment and the + // target/ output directory all see one spelling. + if (parsed) state.overrides.target_triple = parsed->str(); + + 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 + // mapping, not here) and its default linkage. GCC 16 pin rationale: + // GCC 15 drops module template instantiations at link (remediation + // doc A2; packages shipped 2026-07-08/09, GitHub+GitCode). + // A convention, not an instruction: on the Windows-GNU first-run path + // this is what turns the seeded target into `gcc@16.1.0`. + // + // It must not fire when it would overrule a toolchain the user wrote + // down. The pin is mcpp's own default for a target row — `gcc@16.1.0` + // for Windows-GNU, because the mingw payload is what supplies that + // target's headers and C library — and an explicit `[toolchain]` line + // is not a default. This is the promise the no-Visual-Studio fallback + // is built on: mcpp revises its own defaults, never yours. + // + // HOW THE TARGET WAS NAMED IS NOT PART OF THE QUESTION, and it used to + // be. The guard read `targetFromGlobalDefault && user_explicit`, so a + // target given on the command line disabled it — and then the row's pin + // replaced a toolchain the project had stated. Measured 2026-08-23: + // `--target x86_64-windows-gnu` with an explicit `llvm@22.1.8` resolved + // `x86_64-w64-mingw32-g++`, and gcc cannot compile libc++'s std module. + // + // A project that means to use a different compiler for a pinned target + // is stating something about its own build, and a project whose target + // side comes from its dependency graph is the ordinary reason to do so: + // the payload the row names supplies headers and a C library that such + // a project does not use. The narrower reading of this guard was + // patched with an openkal-specific exception; stating the rule + // correctly removes the need for one. + // RECORDED, NOT APPLIED. The convention answers "which payload + // supplies this target's C library", and whether it is needed depends on + // whether the dependency graph supplies one instead. That is knowable + // only after resolution, so the decision waits for + // `resolve_target_toolchain` and only the candidate is kept here. + if (known && !known->pin.empty() && parsed + && !parsed->pin_is_capability()) { + state.targetRowPin = std::string(known->pin); + state.targetRowName = parsed->str(); + } + if (known && !hasToolchainOverride && !known->pin.empty() + && !tc_origin_is_user_explicit(state.tcOrigin)) { + state.targetPinCandidate = std::string(known->pin); + state.targetPinIsCapability = parsed && parsed->pin_is_capability(); + } + // A USER'S EXPLICIT TOOLCHAIN OVERRIDES A CONVENTION, NOT A + // CAPABILITY — AND UNTIL THIS LINE IT OVERRODE BOTH. + // + // The block above deliberately steps aside for an explicit + // `[toolchain] default`: a hosted row's pin says "this payload supplies + // the target's C library", and an author who names their own compiler + // has said they will supply it instead. A bare-metal row's pin says + // something the author cannot override — the table's own words: "the + // pin is llvm on every host because clang/lld are cross-compilers by + // construction". A host g++ does not emit riscv64 whatever anyone + // declares. + // + // Measured 2026-08-26: + // + // [toolchain] default = "gcc@16.1.0" + // $ mcpp build --target riscv64-none-elf + // g++: error: unrecognized argument in option '-mabi=lp64d' + // g++: note: valid arguments to '-mabi=' are: ms sysv + // + // — a message about an option, for a decision made here. Refusing at + // the decision costs one line; the alternative is a compiler complaining + // about flags the reader never wrote. + if (known && parsed && parsed->pin_is_capability() + && tc_origin_is_user_explicit(state.tcOrigin) && state.tcSpec.has_value()) { + auto declared = mcpp::toolchain::parse_toolchain_spec(*state.tcSpec); + // WHICH DECLARATIONS THE ROW ACCEPTS IS THE ROW'S PIN, NOT A FIXED + // FAMILY. + // + // This asked `family != Llvm`, which was right while every + // capability-pinned row pinned llvm. `wasm32-emscripten` pins + // `emsdk@6.0.9`, and emsdk NORMALISES to the llvm family -- `em++` + // is clang -- so a declared `llvm@22.1.8` passed this gate, was + // never refused, and resolved the generic llvm payload for a target + // it cannot emit. The condition is now the pin's own family, which + // is the question the row was always answering. + const auto pinFamily = [&]() -> std::optional { + if (known->pin.empty()) return mcpp::toolchain::Family::Llvm; + if (auto ps = mcpp::toolchain::parse_toolchain_spec( + std::string(known->pin))) + return ps->family; + return std::nullopt; + }(); + const bool declaredMatchesPin = + declared && pinFamily && declared->family == *pinFamily + // An emsdk row is llvm-family, so the family alone cannot + // separate `emsdk@6.0.9` from `llvm@22.1.8`. The pin's own + // spelling is what does. + && (known->pin.empty() + || state.tcSpec->find(known->pin.substr(0, known->pin.find('@'))) + != std::string::npos); + if (declared && !declaredMatchesPin) { + // THE REASON TRAVELS WITH THE ROW. The rows refuse for the + // same rule and NOT for the same reason, and one sentence + // covering all of them would be wrong about the others: a + // PE+musl target is not bare metal, a wasm target is neither, + // and a reader told the wrong one stops reading. + // + // Measured before the third arm existed: `--target + // wasm32-emscripten` with a declared gcc was refused correctly + // and explained with "No gcc payload emits a PE with a musl C + // library", which is a true sentence about a different row. + // + // IT HAPPENED AGAIN, AND ADDING AN ARM IS ONLY HALF THE FIX. + // Android became a capability row and this chain still had + // three arms, so a declared `llvm@22.1.8` against + // `aarch64-linux-android` was refused correctly and explained + // with the PE+musl sentence -- the identical wrong answer the + // paragraph above records for wasm, reached the same way: by a + // fourth case falling into a final `else` that was written as + // the third case's answer. + // + // So the last arm now NAMES ITS OWN ROW and the fallthrough is + // generic. A capability added later gets a sentence that is + // merely unspecific instead of one that is false, and the + // refusal still names the pin either way. + std::string_view why = parsed->is_freestanding() + ? "A freestanding target has no per-host cross payload: " + "clang and lld are\n" + " cross-compilers by construction and gcc is not." + : parsed->is_wasm() + ? "Nothing but Emscripten emits WebAssembly: `em++` is a " + "clang whose target,\n" + " sysroot and JavaScript glue all come from its own " + "payload." + : parsed->is_android() + ? "An Android target needs bionic, not just an aarch64 or " + "x86_64 back end:\n" + " its headers, its per-API-level stubs and its " + "loader path are inside the\n" + " NDK, and no package adds them to another compiler." + : (parsed->is_pe() && parsed->is_musl()) + ? "No gcc payload emits a PE with a musl C library — the " + "mingw payload emits\n" + " PE with the MinGW CRT, which is the separate " + "`-gnu` row." + : "This row's toolchain is the only one that can emit the " + "target at all."; + refusal::record(refusal::Code::CapabilityPin); + return std::unexpected(std::format( + "target '{}' cannot be emitted by '{}'.\n" + " {}\n" + " The row names `{}` as a capability rather than as a " + "preference, so\n" + " this one line is not a convention you can override.\n" + " remove the `[toolchain]` line for this target, or set " + "it to `{}`.", + parsed->str(), *state.tcSpec, why, + known->pin.empty() ? std::string_view("llvm") : known->pin, + known->pin.empty() ? std::string_view("llvm") : known->pin)); + } + } + if (known && known->defaultStatic && state.m->buildConfig.linkage.empty()) + state.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 `state.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(*state.m, mcpp::toolchain::triple::host_triple())) + apply_target_section(*hostRow); + if (state.overrides.force_static) state.m->buildConfig.linkage = "static"; + + // #254: everything compiled INTO this build is resolved for the TARGET — + // an xpkg descriptor's per-OS sections (sources, flags, deps) and its xpm + // asset/version table all describe code that will run on the target, not + // on the machine building it. Previously a compile-time host constant, + // which is invisible natively (host == target) and picks the wrong leg + // under --target. + // + // Computed HERE, not earlier: `state.overrides.target_triple` is only complete + // above — it is filled from `[build] target` and the config default, then + // canonicalized. Reading it before that point would silently fall back to + // the host for any project that sets its target in the manifest rather + // than on the command line. + // ── The device axis, resolved ONCE ──────────────────────────────────── + // + // `--accel` / `--no-accel` over `[build] accel`. `--no-accel` arrives as the + // sentinel "(none)", which parse_accel reads as nothing, and printing the + // parsed form back normalises the spelling -- so every reader below sees + // one string, and a build program sees the same one in MCPP_ACCEL. Read + // at call time rather than captured: a `[target.'cfg(...)'.build]` section + // may set `accel`, and the merge that applies it runs a few lines down. + // + // "NO ACCELERATOR" IS THE EMPTY STRING HERE, NOT `accel_str`'s "(none)". + // + // `accel_str` is a DISPLAY function: it prints `(none)` for an empty set so + // an ABI tag reads as a sentence. Handing that spelling on as a value made + // two readers wrong at once. A build program saw `MCPP_ACCEL=(none)` while + // the manual promised an empty string, so a rule package asking "is there + // an accelerator" got a yes and a backend named `(none)`; and the + // fingerprint's own guard, `if (!accel.empty())`, was true for every + // project on earth, appending `#accel=(none)` to builds that had asked for + // nothing. Measured 2026-09-05 with a build program that wrote the value to + // a file, which is the only way to see it -- a program's stdout is shown + // only when it fails. + state.resolvedAccel = [&]() -> std::string { + const auto sets = mcpp::pack::parse_accel( + state.overrides.accel.empty() ? state.m->buildConfig.accel : state.overrides.accel); + return sets.empty() ? std::string{} : mcpp::pack::accel_str(sets); + }; + // The cfg context, with the accelerator layer filled from the resolved + // accel's backend names. `cfg(accelerator = "cuda")` is a membership test + // over these (prepare_inputs::Ctx::layer_matches); before this the field + // was declared, documented, and never written, so the key matched nothing. + state.cfgCtx = [&]() { + auto c = cfgpred::context_for(state.overrides.target_triple); + for (auto const& set : mcpp::pack::parse_accel(state.resolvedAccel())) + c.accelerators.push_back(set.backend); + return c; + }; + state.targetPlatform = mcpp::platform::TargetPlatform::for_os(state.cfgCtx().os); + + // ── L1: merge conditional [target.'cfg(...)'] sections ─────────────────── + // Evaluated now (target resolved) against the resolved target — the + // --target triple for a cross build, else the host. + // + // #229: merge_conditional_config MUST run here — before + // `packages[0] = makePackageRoot(*root, *m)` snapshots `m->buildConfig` + // into `packages[0].privateBuild`/`.manifest` — because that snapshot, + // not `*m`, is what the modgraph scan and per-TU compile-flag assembly + // actually read afterward. Every dependency (path/git/version alike) gets + // the SAME treatment, at the mirror-image point in its own load path + // (right before ITS `makePackageRoot`/`propagateLinkFlags`) — see the + // dependency-manifest-acquisition block below. That makes this the root + // package's half of the one funnel, not a special case: every package is + // merged exactly once, immediately before it is captured into `packages[]`. + if (!state.m->conditionalConfigs.empty()) { + merge_conditional_config(*state.m, state.cfgCtx()); + } + // `[target..abi] threads` -- the ROOT's statement, rendered once, + // into channels that already reach the whole artefact: the graph-global + // dialect flag set (every C++ translation unit, the std module's own + // commands, the scan, every dependency's cache key), the C flags of every + // package (the root here, each dependency where it is loaded), and the link. + // + // For hosted targets that are not PE. On PE the MSVC runtime is always + // multithreaded and mingw-w64's threading model belongs to its payload; a + // freestanding target has no thread library to select. + state.abiThreadsRendered = [&] { + if (!state.m->buildConfig.abiThreads) return false; + const auto abiTriple = mcpp::toolchain::triple::parse( + state.overrides.target_triple.empty() + ? mcpp::toolchain::triple::host_triple().str() + : state.overrides.target_triple); + return abiTriple && !abiTriple->is_pe() && !abiTriple->is_freestanding(); + }(); + state.add_once = [](std::vector& v, std::string_view flag) { + if (std::ranges::find(v, flag) == v.end()) v.emplace_back(flag); + }; + if (state.abiThreadsRendered) { + state.add_once(state.m->buildConfig.dialectCxxflags, "-pthread"); + state.add_once(state.m->buildConfig.cflags, "-pthread"); + state.add_once(state.m->buildConfig.ldflags, "-pthread"); + } + // `[target..abi] exceptions` -- design 2026-09-12 (the UI + // framework record), section 2.1, A1: the second `abi` member, the + // ROOT's statement, rendered once. Reaches the dialect flag set (every + // C++ translation unit, the std module's own commands, the scan, every + // dependency's cache key) and the link -- NOT the C flags, unlike + // `threads`: `-fexceptions` has no C-language meaning worth carrying to + // a `.c` translation unit. + // + // Rendered only where the target's default is OFF: Emscripten's native + // toolchain builds without exceptions unless asked. gcc, clang and MSVC + // already link with exceptions on, so a host build with the member + // declared is byte-identical to one without -- the same property + // `threads` has on PE. + const bool abiExceptionsRendered = state.m->buildConfig.abiExceptions + && state.cfgCtx().os == "emscripten"; + if (abiExceptionsRendered) { + state.add_once(state.m->buildConfig.dialectCxxflags, "-fexceptions"); + state.add_once(state.m->buildConfig.ldflags, "-fexceptions"); + } + // `[build].defines` must reach the scanner (P1689) and the compile edge, + // and must participate in the fingerprint. Fold before dependency + // resolution / fingerprinting. + report_flag_words_changes(*state.m); + fold_build_defines_into_flags(state.m->buildConfig); + + // ORIGIN, RESOLVED ONCE. + // + // The spec used to be parsed TWICE from the same string a dozen lines + // apart — once to ask "is this msvc@system", once to get the package — + // and each call site drew its own conclusions from the result. Two parses + // of one string is two places for the answer to differ, which is the shape + // §1 of the three-axes design is about: a platform special case whose cost + // is paid at every site that has to know about it. + // + // `Origin::SystemMsvc` is located on the machine and never resolved + // through an xim package — mcpp does not install the machine's Visual + // Studio. `Origin::Managed` is everything else, including a VERSIONED + // msvc spec, and that is the point: what the manifest says is what gets + // used, on every machine, instead of whatever this one happens to have. + // RESOLVED HERE, RUN AFTER THE DEPENDENCY GRAPH — AND THE SPLIT IS THE + // WHOLE POINT. + // + // A target row's convention does not name a preferred compiler. It names + // the payload that supplies THAT TARGET'S C library. Whether the user's own + // toolchain can serve the target instead depends on whether something ELSE + // supplies the target side — and that is knowable only once the graph is + // resolved, which is after this point in the function. + // + // Deciding early was measured to be wrong in both directions. Applying the + // convention unconditionally replaced a toolchain the user had set with + // `mcpp toolchain default`, for a payload their project never used. NOT + // applying it turned a working zero-dependency cross build into a failing + // one, because clang alone carries no C runtime for `x86_64-windows-gnu` + // while the payload the row names does. + // + // The body does not MOVE; only its execution does. Everything between + // here and the call site was measured to read `tc` exactly once, and that + // one read wanted the target triple rather than the compiler. + // `std::function` AND NOT `auto`, BECAUSE THE FIRST-RUN BRANCH INSIDE + // CALLS BACK INTO IT. That branch installs a host default and then has to + // resolve THAT default for the requested target — which is what the top of + // this same function does. Recursing reuses it; writing it a second time + // there would be a second answer to one question. Depth is one: the second + // pass takes the `tcSpec.has_value()` branch that the first-run path just + // made true. + state.firstRunNeedsTargetPass = false; + // Guards the one recursive call below. Set before the call so the second + // pass cannot reach it, whatever else changed in between. + state.targetPassDone = false; + + return {}; +} + +std::expected phase2_define_toolchain_resolver(PrepareState& state) { + state.resolve_target_toolchain = [&]() -> std::expected { + std::optional parsedSpec; + auto tcOriginAxis = mcpp::toolchain::Origin::Managed; + if (state.tcSpec.has_value() && *state.tcSpec != "system") { + // A parse FAILURE is not the same as an unparseable spec being + // absent: `gcc@system` now fails here by name (see + // parse_toolchain_spec), and swallowing that would put the error back + // where it used to happen — somewhere else, saying something else. + auto s = mcpp::toolchain::parse_toolchain_spec(*state.tcSpec); + if (!s) return std::unexpected(std::format( + "{} = '{}': {}", state.tcSpecSource(), *state.tcSpec, s.error())); + parsedSpec = std::move(*s); + tcOriginAxis = mcpp::toolchain::origin_of(*parsedSpec); + } + // ASSIGNED, NOT DECLARED. `host_tc_for_build_program` reads it and is + // defined outside this lambda, so the declaration lives in the enclosing + // scope; the value is still decided here, where the spec is parsed. + state.tcSpecIsMsvc = + parsedSpec && tcOriginAxis == mcpp::toolchain::Origin::SystemMsvc; + + // A PINNED TOOLSET THIS MACHINE ALREADY HAS IS USED WHERE IT IS. + // + // `msvc@14.44.35207` names one Microsoft build, and the ecosystem package + // of that version unpacks the same installer payloads Visual Studio does, + // so an installed copy is the same toolset without a download. `xim:` + // opts out: it asks for the package, whose SDK is pinned with it. + std::optional installedPin; + std::vector installedPinNotes; + if constexpr (mcpp::platform::is_windows) { + if (parsedSpec && !state.tcSpecIsMsvc + && parsedSpec->family == mcpp::toolchain::Family::Msvc + && !parsedSpec->ecosystemOnly && !parsedSpec->version.empty()) + installedPin = mcpp::toolchain::msvc::system_installation_matching( + parsedSpec->version, mcpp::toolchain::msvc::ToolsetNeeds{}, + &installedPinNotes); + } + + if (installedPin) { + for (auto const& n : installedPinNotes) mcpp::ui::info("note", n); + state.explicit_compiler = installedPin->clPath; + mcpp::ui::info("Resolved", std::format( + "{} → msvc {} (installed: {})", parsedSpec->display(), + installedPin->display_version(), installedPin->clPath.string())); + } else if (state.tcSpecIsMsvc) { + if (!mcpp::platform::is_windows) { + return std::unexpected(std::format( + "toolchain '{}' is only available on Windows hosts", *state.tcSpec)); + } + auto inst = mcpp::toolchain::msvc::detect_installation(); + if (!inst) { + return std::unexpected(mcpp::toolchain::msvc::install_guidance()); + } + state.explicit_compiler = inst->clPath; + mcpp::ui::info("Resolved", std::format( + "msvc@system → msvc {} ({})", + inst->display_version(), inst->clPath.string())); + } else if (parsedSpec) { + auto spec = parsedSpec; + if (spec->version.empty()) { + return std::unexpected(std::format( + "{} = '{}' is invalid; expected '@'", + state.tcSpecSource(), *state.tcSpec)); + } + // A `--target ` build carries the (already canonical) triple + // into the spec's target axis: the payload mapping then resolves the + // right package/frontend (e.g. aarch64-linux-musl-g++ for a cross + // musl build, never the host g++). Escape-hatch triples outside the + // language don't parse and leave the spec on the host target. + if (!state.overrides.target_triple.empty()) { + if (auto t = mcpp::toolchain::triple::parse(state.overrides.target_triple)) + spec->target = *t; + } + auto pkg = mcpp::toolchain::to_xim_package(*spec); + + // AND NOT INSTALLED WHEN NO PAYLOAD HERE COULD SERVE THE TARGET. + // + // `unservedTargetDiagnosis` is decided a thousand lines above and + // released a thousand lines below — deliberately, because whether the + // dependency GRAPH supplies the target's system is not knowable until + // it is resolved. This install sits between the two, and it does not + // need to wait: if no payload here serves the target, then either the + // graph supplies the system (and this payload is not wanted) or the + // build refuses later (and it is not wanted then either). + // + // Measured on ubuntu-24.04-arm, `--target x86_64-linux-musl`: + // + // error: toolchain 'gcc@16.1.0': xlings install of + // 'xim:x86_64-linux-musl-gcc@16.1.0' failed … + // + // — the cross-musl packages are published per host arch and that one is + // x86_64-only. The refusal that names this correctly never ran, because + // the install failed first and failed hard. + // + // Skipping leaves BOTH later paths intact; attempting cannot help + // either of them. + const bool targetPayloadUnservable = + !state.unservedTargetDiagnosis.empty() && !spec->target.empty(); + + auto cfg = state.get_cfg(true); + if (!cfg) return std::unexpected(cfg.error()); + mcpp::fetcher::Fetcher fetcher(**cfg); + + mcpp::ui::info("Resolving", "toolchain"); + mcpp::fetcher::InstallProgressHandler progress; + auto payload = fetcher.resolve_xpkg_path( + pkg.target(), /*autoInstall=*/!targetPayloadUnservable, &progress); + if (!payload && targetPayloadUnservable) { + // The held diagnosis is already the right words for this; releasing + // it here rather than at its usual site keeps one sentence per cause. + refusal::record(refusal::Code::HostCannotServe); + return std::unexpected(state.unservedTargetDiagnosis); + } + if (!payload) { + // `windows = "msvc@19.44"` in a manifest is the retired + // cl-version spelling; saying "no such xim package" would send + // the reader looking for a toolset that cannot exist. + if (spec->family == mcpp::toolchain::Family::Msvc) { + if (auto hint = mcpp::toolchain::msvc::cl_version_spelling_hint( + spec->version)) + return std::unexpected(*hint); + } + return std::unexpected(std::format( + "toolchain '{}': {}", *state.tcSpec, payload.error().message)); + } + + // A pinned MSVC toolset: the payload root IS a VS-shaped root and the + // package version IS the toolset directory name, so cl.exe is + // derived, not searched for. Nothing here can silently pick a + // different toolset — which is the defect this path exists to close. + // + // It also skips the two steps below: the bin/-shaped frontend lookup + // (cl.exe is four levels deeper) and the ELF post-install fixup + // (there is nothing to patchelf on a PE toolchain). + if (spec->family == mcpp::toolchain::Family::Msvc) { + // One rule, one place: where a managed toolset lives and why the + // fetcher's `root` must not be used for it (mcpp.toolchain. + // registry). Install and build asked the same question and each + // answered it in its own words. + auto inst = mcpp::toolchain::resolve_managed_msvc( + mcpp::config::make_xlings_env(**cfg), pkg); + if (!inst) return std::unexpected(inst.error()); + state.explicit_compiler = inst->clPath; + mcpp::ui::info("Resolved", std::format( + "{} → msvc {} ({})", spec->display(), + inst->display_version(), inst->clPath.string())); + } else { + auto frontendR = mcpp::toolchain::payload_frontend(payload->root, pkg); + // A payload that describes itself and describes itself wrongly is + // refused by name -- not reported as a missing frontend, which is + // a different repair. + if (!frontendR) return std::unexpected(frontendR.error()); + state.explicit_compiler = *frontendR; + if (!std::filesystem::exists(state.explicit_compiler)) { + return std::unexpected(std::format( + "toolchain payload '{}' has no known C++ frontend in {}", + pkg.target(), + mcpp::toolchain::payload_frontend_dir(payload->root, pkg).string())); + } + // Same post-install fixup as `mcpp toolchain install` — this + // manifest [toolchain] path previously ran none, so a freshly + // auto-installed payload kept its stale install-time cfg / + // unpatched runtime libs. + state.provide_runtime_payload(pkg); + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfg, payload->root, pkg, + state.runtimeBindingSnapshot.runtimeId, state.runtimeLibDir); !fixed) + return std::unexpected(std::format( + "toolchain post-install fixup: {}", fixed.error())); + else state.report_fixup(*fixed, payload->root); + // Canonical rendering, whatever spelling the manifest/config used: + // "Resolved gcc@16.1.0 → x86_64-linux-musl → ". + // + // AND IT SAYS SO WHEN MCPP CHOSE. A toolchain the user wrote down + // needs no explanation — they can read their own manifest. One this + // engine selected from a target row is a decision the user did not + // make, and a status line that reports the outcome without the + // reason leaves them to discover the rule by experiment. + std::string chosenBy; + // A COMPILER THE GRAPH ASKED FOR IS ANNOUNCED WITH THE PACKAGE + // THAT ASKED. Without the name this reads as mcpp ignoring the + // user's default; with it, it reads as the dependency it is. + // The second line appears only when something was displaced — + // "replacing nothing" is not worth a line. + if (!state.graphCompilerRequiredBy.empty()) + chosenBy = std::format( + "\n required by {} (`requires = " + "[\"mcpp:compiler={}\"]`){}", + state.graphCompilerRequiredBy, state.graphCompilerFamily, + state.graphCompilerReplaced.empty() + ? std::string{} + : std::format(", not your {} — this project only", + state.graphCompilerReplaced)); + else if (!state.pinReplacedDefault.empty()) + chosenBy = std::format( + "\n target default for {}, replacing your " + "{} — override with `[target.{}] toolchain`", + state.overrides.target_triple, state.pinReplacedDefault, + state.overrides.target_triple); + else if (state.tcOrigin == TcOrigin::TargetPin + || state.tcOrigin == TcOrigin::FirstRun) + chosenBy = std::format(" ({})", tc_origin_name(state.tcOrigin)); + mcpp::ui::info("Resolved", + std::format("{} → {}{}", spec->display(), + mcpp::ui::shorten_path(state.explicit_compiler, + mcpp::fetcher::make_path_ctx(&**state.get_cfg(true), *state.root)), + chosenBy)); + } + } else if (state.tcSpec.has_value() && *state.tcSpec == "system") { + // REFUSED. THE COMPILER IS THE ONE AXIS THAT IS NOT THE PROJECT'S TO + // TAKE FROM THE HOST. + // + // mcpp's host-dependence policy is not uniform across axes, and the + // split is the point rather than an inconsistency: + // + // LIBRARIES are the program's business. A project may link a host + // library or its own `.so`; mcpp says what that costs and what the + // supported route is, and does not refuse as long as the result + // builds and runs. The developer owns the artifact and guarantees it. + // + // THE TOOLCHAIN is mcpp's own contract. Everything mcpp promises — + // that `import std` is available, that the runtime closure is + // computable, that two machines and CI produce the same build — is a + // statement about a compiler mcpp resolved and can identify. A + // compiler picked off `PATH` makes every one of those promises + // unverifiable, and a build tool that cannot state what it built with + // is answering in the wrong version (see + // `.agents/docs/…a-build-must-be-able-to-state-its-own-version`). + // + // So this is refused rather than warned about, and it is refused HERE, + // before any resolution work, so the message is the first thing the + // user sees rather than a consequence three layers down. + // + // `msvc@system` is a different spelling and stays supported: it names a + // FAMILY whose installation mcpp locates and identifies, on the one + // platform where the compiler cannot be redistributed. + return std::unexpected(std::format( + "[toolchain] {} = \"system\" is not supported: mcpp builds only " + "with toolchains it manages.\n" + " A compiler taken from PATH cannot be identified or " + "reproduced, so `import std` availability, the runtime closure and " + "\"the same build on another machine\" all stop being things mcpp " + "can promise.\n" + " Name one instead — mcpp installs it on first use:\n" + "\n" + " [toolchain]\n" + " {} = \"gcc@16.1.0\"\n" + "\n" + " or set a machine default with `mcpp toolchain default " + "gcc@16.1.0`, and see `mcpp toolchain list` for what is available.\n" + " (On Windows, `msvc@system` is different and remains " + "supported: it names a family whose installation mcpp locates.)\n" + " Host LIBRARIES are a separate question and are not refused " + "— a project may link them and owns the result.", + kCurrentPlatform, kCurrentPlatform)); + } else if (mcpp::platform::env::offline_mode() + || mcpp::platform::env::no_auto_install()) { + // CI / offline / test opt-out: hard-error instead of silently + // pulling ~800 MB of toolchain. Preserves the original M5.5 + // contract for environments that need it. + // + // `--offline` / MCPP_OFFLINE subsumes MCPP_NO_AUTO_INSTALL: the older + // name only ever covered this one gate, which made "don't use the + // network" three separate concepts with three spellings. The old var is + // kept working (it predates offline mode and CI still exports it). + namespace pins = mcpp::toolchain::triple::pins; + // Name the knob that actually fired, not a fixed one: telling a user + // who passed `--offline` to unset MCPP_NO_AUTO_INSTALL sends them + // looking for a variable they never set. + std::string_view release = mcpp::platform::env::offline_mode() + ? "or drop --offline / unset MCPP_OFFLINE to let mcpp auto-install." + : "or unset MCPP_NO_AUTO_INSTALL to let mcpp auto-install."; + // Windows without a usable MSVC must not be told to install llvm: + // that default resolves to clang targeting the MSVC ABI, which is + // exactly what this machine cannot build. Name the toolchain that + // will actually work there instead. + if (mcpp::platform::is_windows + && !state.msvc_usable_either_origin()) { + refusal::record(refusal::Code::OfflineDownloadRequired); + return std::unexpected(std::format( + "no toolchain configured (and no Visual Studio found).\n" + " run one of:\n" + " mcpp toolchain install {} --target {}\n" + " mcpp toolchain default {} --target {}\n" + " {}", + pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget, + pins::kFirstRunWinGnu, pins::kFirstRunWinGnuTarget, release)); + } + if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { + refusal::record(refusal::Code::OfflineDownloadRequired); + return std::unexpected(std::format( + "no toolchain configured.\n" + " run one of:\n" + " mcpp toolchain install {}\n" + " mcpp toolchain default {}\n" + " {}", + pins::kSuggestLlvm, pins::kFirstRunMac, release)); + } else { + refusal::record(refusal::Code::OfflineDownloadRequired); + return std::unexpected(std::format( + "no toolchain configured.\n" + " run one of:\n" + " mcpp toolchain install {}\n" + " mcpp toolchain default {}\n" + " {}", + pins::kSuggestGccMusl, pins::kFirstRunLinuxOther, release)); + } + } else { + // First-run UX: no project-level [toolchain], no global default, + // and the user just ran `mcpp build` (or similar). Auto-install + // the platform's canonical default so the user gets a working + // binary out of the box without any config. We pin it as the + // global default so the next invocation is silent. + // Users can switch any time via `mcpp toolchain default `. + // + // macOS: LLVM/Clang — Apple doesn't ship GCC; upstream LLVM with + // bundled libc++ is the self-contained choice. + // Linux: glibc gcc — the platform-native ABI. A musl-static default + // cannot link the glibc world (X11/GL/system libs), so it + // breaks GUI/native packages out of the box. musl-static stays + // opt-in via `mcpp build --target x86_64-linux-musl` for users + // who explicitly want portable static binaries. + // Linux default is arch-aware: + // x86_64 → glibc gcc (native ABI; the glibc toolchain is published + // for x86_64). musl-static stays opt-in via --target. + // other arches (aarch64, ...) → musl-static gcc: it's what's + // published for them, is self-contained, and yields portable + // static binaries (ideal for aarch64 / Termux, no bionic dep). + // glibc-world linking (X11/GL) needs an explicit glibc + // toolchain, addable later for native-ABI aarch64 builds. + // `native_first_run_spec()` (declared above) is this exact selection + // — on Windows it re-checks `msvc_usable_either_origin()`, which here + // is redundant (the seed above already diverted the unusable case + // onto the windows-gnu target before this block runs) but harmless. + std::string defaultSpec = state.native_first_run_spec(); + auto defaultParsed = mcpp::toolchain::parse_toolchain_spec(defaultSpec); + // The legacy "-musl" spelling normalizes to (gcc, -linux-musl), + // so the resolver finds the `-linux-musl-g++` frontend + // without any manual triple seeding. + bool muslDefault = defaultParsed->target.is_musl(); + auto defaultPkg = mcpp::toolchain::to_xim_package(*defaultParsed); + + if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { + mcpp::ui::info("First run", + std::format("no toolchain configured — installing {} (LLVM/Clang) as default", + defaultSpec)); + } else { + mcpp::ui::info("First run", + std::format("no toolchain configured — installing {} ({}) as default", + defaultSpec, muslDefault ? "musl, static" : "glibc, native ABI")); + } + + auto cfg = state.get_cfg(true); + if (!cfg) return std::unexpected(cfg.error()); + mcpp::fetcher::Fetcher fetcher(**cfg); + + mcpp::fetcher::InstallProgressHandler progress; + auto payload = fetcher.resolve_xpkg_path(defaultPkg.target(), + /*autoInstall=*/true, &progress); + if (!payload) { + return std::unexpected(std::format( + "auto-installing default toolchain {} failed: {}\n" + " you can install it manually with:\n" + " mcpp toolchain install {}", + defaultSpec, payload.error().message, defaultSpec)); + } + auto defaultFrontendR = + mcpp::toolchain::payload_frontend(payload->root, defaultPkg); + if (!defaultFrontendR) return std::unexpected(defaultFrontendR.error()); + state.explicit_compiler = *defaultFrontendR; + if (!std::filesystem::exists(state.explicit_compiler)) { + return std::unexpected(std::format( + "default toolchain payload {} has no known C++ frontend in {}", + defaultPkg.target(), + mcpp::toolchain::payload_frontend_dir(payload->root, defaultPkg).string())); + } + + // The freshly-installed toolchain needs the SAME post-install fixup + // (patchelf / specs / cfg wiring against the sandbox glibc) that + // `mcpp toolchain install` performs — without it a fresh sandbox + // gcc cannot find the C library (stdlib.h: No such file or + // directory) and a fresh llvm keeps its stale install-time cfg. + state.provide_runtime_payload(defaultPkg); + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfg, payload->root, defaultPkg, + state.runtimeBindingSnapshot.runtimeId, state.runtimeLibDir); !fixed) + return std::unexpected(std::format( + "default toolchain post-install fixup: {}", fixed.error())); + else state.report_fixup(*fixed, payload->root); + + // Persist the default so we don't ask again next time. + if (auto wr = mcpp::config::write_default_toolchain(**cfg, defaultSpec); wr) { + (*cfg)->defaultToolchain = defaultSpec; + mcpp::ui::status("Default", std::format("set to {}", defaultSpec)); + } // best-effort: a failed config write only loses the persistence, + // not the running build. + state.tcSpec = defaultSpec; + state.tcOrigin = TcOrigin::FirstRun; + + // AND IF A TARGET WAS ASKED FOR, RESOLVE FOR IT — THIS BRANCH JUST + // INSTALLED A HOST COMPILER AND WAS ABOUT TO BUILD WITH IT. + // + // Everything above answers "this machine has no toolchain, give it + // one", and the answer is a HOST payload. `--target` was never read + // here, so on a machine that had never built anything, + // `mcpp build --target x86_64-windows-gnu` installed a native gcc and + // compiled Windows sources with it. Measured in CI 2026-08-25: + // + // First run no toolchain configured — installing gcc@16.1.0 … + // Resolved gcc@16.1.0 → …/xim-x-gcc/16.1.0/bin/g++ + // ↑ no target in the path + // + // against the same command on a machine that already had one: + // + // Resolved gcc@16.1.0 → x86_64-windows-gnu → …/mingw-cross-gcc/… + // + // REUSES THE PATH THAT ALREADY KNOWS HOW, rather than repeating what + // it does. `resolve_target_toolchain` maps a spec plus a target onto a + // payload and installs it; the default just chosen is the spec. A + // second implementation here would be a second answer to one question, + // which is the shape this release exists to remove. + // RECORDED HERE, ACTED ON BELOW — the Windows first-run block that + // follows SETS `state.overrides.target_triple` itself, and returning from + // here would skip it. Its own comment says why that matters: it + // persists BOTH axes, and persisting only the target leaves + // `mcpp toolchain list` disagreeing with what the build used. + state.firstRunNeedsTargetPass = !state.overrides.target_triple.empty(); + } + + // Windows first run that got diverted to winlibs GCC: announce it and + // persist BOTH axes, so the next invocation is silent and + // `mcpp toolchain list` shows the same pair the build actually used. + // Persisting only the target would leave the toolchain axis implicit + // (derived from the vocabulary pin) and the two views would disagree. + // + // NOT WHEN THE DEPENDENCY GRAPH SUPPLIED THE ANSWER. This branch's + // condition is `tcSpec.has_value()`, and since 2026.8.26.2 a package's + // `requires = ["mcpp:compiler=…"]` can be what made it true — so a bare + // Windows box building ONE project with an llvm-requiring dependency + // would have persisted llvm as the MACHINE's default, and the next + // project, which asked for nothing, would inherit it. + // + // A requirement is a property of the package that states it. It decides + // this build and nothing else; the first-run answer for the machine is + // still the one this branch was written for. + if (state.windowsGnuFirstRun && state.tcSpec.has_value() + && tc_origin_may_persist(state.tcOrigin)) { + mcpp::ui::info("First run", + std::format("no toolchain configured and no Visual Studio found — " + "using {} for {} (MinGW-w64, self-contained)", + *state.tcSpec, state.overrides.target_triple)); + if (auto cfgW = state.get_cfg(true); cfgW) { + if (mcpp::config::write_default_toolchain(**cfgW, *state.tcSpec)) + (*cfgW)->defaultToolchain = *state.tcSpec; + if (mcpp::config::write_default_target(**cfgW, state.overrides.target_triple)) + (*cfgW)->defaultTarget = state.overrides.target_triple; + mcpp::ui::status("Default", + std::format("set to {} → {}", *state.tcSpec, state.overrides.target_triple)); + } + state.tcOrigin = TcOrigin::FirstRun; + } + + // AND NOW RESOLVE FOR THE TARGET, IF ONE WAS ASKED FOR. + // + // The first-run branch above answers "this machine has no toolchain, give + // it one", and the answer is a HOST payload; `--target` was never read + // there. On a machine that had never built anything, + // `mcpp build --target x86_64-windows-gnu` therefore installed a native + // gcc and compiled Windows sources with it — measured in CI 2026-08-25: + // + // First run no toolchain configured — installing gcc@16.1.0 … + // Resolved gcc@16.1.0 → …/xim-x-gcc/16.1.0/bin/g++ + // ↑ no target in the path + // + // against the same command where one already existed: + // + // Resolved gcc@16.1.0 → x86_64-windows-gnu → …/mingw-cross-gcc/… + // + // REUSES THE PATH THAT ALREADY KNOWS HOW rather than repeating it. The + // default just chosen is the spec; mapping a spec plus a target onto a + // payload (installing it if absent — `autoInstall` was always true there) + // is what the top of this function does. Depth is one: the second pass + // takes the `tcSpec.has_value()` branch the first run just made true. + // ONE-SHOT, AND THE FLAG IS SET BEFORE THE CALL, NOT AFTER. + // + // This line sits OUTSIDE the first-run branch — it has to, because the + // Windows block just above sets the target itself — so it is evaluated on + // every pass. The first version relied on `firstRunNeedsTargetPass` being + // false on the second pass; it is a captured variable that nothing + // resets, so every pass recursed again. Measured in a consumer's CI as + // the same `Resolved` line four times and then + // + // ##[error]Process completed with exit code 139 + // + // — SIGSEGV, a stack that ran out. A recursion whose termination depends + // on state the recursive call does not change is not a depth-one + // recursion, however its comment reads. + if (!state.targetPassDone + && (state.firstRunNeedsTargetPass + || (state.windowsGnuFirstRun && state.tcSpec.has_value()))) { + state.targetPassDone = true; + return state.resolve_target_toolchain(); + } + + auto detected = mcpp::toolchain::detect( + state.explicit_compiler, state.runtimePayload, state.runtimeBindingSnapshot.contractHash); + if (!detected) return std::unexpected(detected.error().message); + state.tc = std::move(*detected); + + // Something about the resolution the user has to be told, but which is + // not a failure. Today's only producer is the Windows SDK axis: a managed + // toolset binds the SDK it was installed with, so a `WindowsSdkDir` in + // the environment does not apply — and an override that is ignored + // SILENTLY is indistinguishable from one that was never set. + if (!state.tc->resolutionNote.empty()) + mcpp::ui::info("note", state.tc->resolutionNote); + + // ── A retargetable driver has to be TOLD what it is targeting ──────── + // + // `tc.targetTriple` comes from `-dumpmachine`, and for every cross target + // that worked before this it was right for a reason that does not + // generalise: those targets use a DISTINCT compiler binary + // (`x86_64-w64-mingw32-g++`, `aarch64-linux-musl-g++`), whose own + // -dumpmachine reports the cross triple. Clang is ONE binary that emits + // every target it was built with, so -dumpmachine always answers with the + // host — and nothing downstream ever learns otherwise. + // + // Measured before this line existed: + // + // $ mcpp build --target riscv64-none-elf + // Resolved llvm@22.1.8 → riscv64-none-elf → …/bin/clang++ + // Finished dev [unoptimized + debuginfo] in 0.47s + // $ ls target/ + // x86_64-linux-gnu/ ← an ELF for the host, reported as riscv64 + // + // That is E1: success reported, host artifact produced. The output + // directory, the fingerprint, the cache key and the flag layer all read + // `tc.targetTriple`, so correcting it here corrects all of them at once — + // which is the point of there being one field rather than five answers. + // + // THIS USED TO BE SCOPED TO FREESTANDING, WITH THIS REASON: + // + // The hosted cross targets already resolve a per-target binary, and + // overwriting their probed triple would replace a measured fact with + // an assumed one for no gain. + // + // That was true while every hosted cross was served by a payload. It + // stops being true when the TARGET SIDE comes from the dependency graph: + // the C library, the C++ runtime and the platform's own implementation are + // then packages built from source, and the compiler is an ordinary clang — + // whose `-dumpmachine` answers the host, exactly as the paragraph above + // describes for freestanding. + // + // Measured 2026-08-23, with an explicit `[target.aarch64-macos] + // toolchain = "llvm@…"`. The manifest's cfg evaluation used the REQUESTED + // target, so the C library's aarch64 headers were on the command line; the + // toolchain's own triple was still the host's, so code generation was + // x86_64. Two answers to one question, in one command: + // + // okm_float_assert.c: the C library and the compiler disagree about + // LDBL_DIG ('33 == 18') 33 = aarch64 binary128, 18 = x87 + // + // ⇒ The condition is now the property the first paragraph of this comment + // already names: a RETARGETABLE driver has to be told. gcc is not one — a + // gcc payload IS its target — so the mingw and musl-gcc crosses keep + // answering from `-dumpmachine`, which for them remains a measured fact. + if (!state.overrides.target_triple.empty()) { + if (auto want = mcpp::toolchain::triple::parse(state.overrides.target_triple); + want && (want->is_freestanding() + || state.tc->compiler == mcpp::toolchain::CompilerId::Clang)) + { + state.tc->targetTriple = want->str(); + + // AND THE GATE THAT ALREADY EXISTS FOR THIS, APPLIED WHERE THE + // ANSWER IS KNOWN. + // + // `discover_link_runtime_dirs` refuses to report these + // directories for a target that carries its own sysroot, and the + // refusal never fired: that function runs during DETECTION, + // before this line, when `targetTriple` is still the HOST's. The + // gate read a host triple and answered correctly about it. + // + // The artefact is what showed it. An Android link line carried + // + // -L /toolchains/llvm/prebuilt/linux-x86_64/lib/ + // x86_64-unknown-linux-gnu + // + // whose last component is this machine's triple, produced by + // `root / "lib" / targetTriple` -- so the string names the + // question that was asked. Those are the compiler's own host + // runtime directories; an Android artefact must resolve libc++, + // the crt objects and the loader from the NDK's sysroot, and the + // hermetic check reported exactly that failure with six host + // objects. + // + // Cleared rather than re-derived. Re-running the discovery with + // the final triple would also change what every OTHER clang cross + // target gets, and those are measured as they stand; the claim + // being made here is only the one the gate already states. + if (want->has_own_sysroot()) state.tc->linkRuntimeDirs.clear(); + + // And the flag that says it to the driver — for a HOSTED target + // only. Freestanding already emits its own `--target`, together + // with the ISA flags that must accompany it + // (freestanding/target.cppm); a second one here would be the same + // decision in two places. + if (!want->is_freestanding() + && state.tc->compiler == mcpp::toolchain::CompilerId::Clang) { + state.tc->crossTargetFlag = + "--target=" + want->llvm_triple( + min_platform_version(*state.m, *want, state.tc->binaryPath)); + + // AND THE SAME FLAG ON THE std MODULE'S OWN COMMANDS, FOR A + // PAYLOAD THAT SERVES MORE THAN ONE TARGET. + // + // The std module is built by its own command assembly + // (clang.cppm), not by the compile flags, so a decision made + // only here reaches every translation unit and not that. For + // most toolchains the omission cannot be seen: a payload + // whose compiler IS its target finds its own headers, and a + // package-provided module carries the target inside + // `stdModuleFlags`. + // + // ONE NDK SERVES BOTH ANDROID ARCHES, which is the property + // that makes this necessary and is stated in the row's own + // pin: `android-ndk@` names no arch, so `--target` is the + // only thing that says which. Without it the precompile + // resolved libc++'s `#include <__config>` against the + // building machine and stopped there. + // + // NOT `has_own_sysroot()`, though both rows that answer true + // to it are SDKs with their own sysroot. Emscripten's `em++` + // serves exactly one target and needs no flag -- the verified + // wasm loop is measured without it -- so widening the gate to + // the predicate would add a flag to a command that does not + // want one. The property here is "one payload, several + // targets", and Android is the only row that has it; a future + // row brings its own measurement. + if (want->is_android()) { + state.tc->stdModuleTargetFlags = " " + state.tc->crossTargetFlag; + // BIONIC'S ctype HEADER AND A MODULE'S EXPORT RULES. + // + // bionic declares `isalnum` and its neighbours + // `static inline`, and libc++'s module surface exports + // them with `using std::isalnum`. A using-declaration + // cannot export a name with internal linkage, so the + // precompile fails on 14 names at once. Defining the + // macro empty makes those declarations extern, which is + // what every other C library this engine compiles + // against already does. + // + // Scoped to the std module and not to every unit: the + // rule being satisfied is about exporting from a module, + // and a translation unit that includes + // directly is entitled to bionic's inline definitions. + // `xim:android-ndk`'s own install-time self-test reaches + // the identical conclusion from the other direction. + // + // AND THE PAYLOAD MAY SAY SO ITSELF. The recipe applies + // this same define in that self-test, so it is a fact + // the payload already holds; `std_module_defines` in + // `.mcpp-toolchain.json` is the channel for it, and the + // define below is what a payload that ships no + // descriptor still gets. The two are not added + // together: a descriptor that names defines is the + // payload's complete answer for this channel, and + // appending to it would mean a payload could not + // withdraw a define this engine once needed. + auto stdDefines = [&]() -> std::vector { + auto desc = + mcpp::toolchain::payload_descriptor_for_compiler( + state.tc->binaryPath); + if (desc && *desc && !(*desc)->stdModuleDefines.empty()) + return (*desc)->stdModuleDefines; + return { "__BIONIC_CTYPE_INLINE=" }; + }(); + for (auto const& def : stdDefines) + state.tc->stdModuleTargetFlags += " -D" + def; + } + + // ── iOS: THE COMPILER IS OURS, THE SDK IS THE MACHINE'S ── + // + // The three iOS rows pin `llvm@22.1.8` -- any sufficiently + // new clang emits arm64 Mach-O for an iOS deployment target + // -- and take their headers and stub libraries from the + // machine's Xcode, which is where the whole item shrinks to + // a located sysroot. `aarch64-macos` is verified on exactly + // this split and is the precedent. + // + // LOCATED HERE, ONCE. Three later sites need the answer (the + // compile flags, the link line, and the std module's own + // command), and a function that probes the machine is the + // wrong thing to call three times: `xcrun` shells out, and + // three answers can differ if the developer directory + // changes mid-build. + // + // AND ITS ABSENCE IS A REFUSAL THAT NAMES THE SDK. The + // recorded host-surface rule is that a host dependency must + // be minimal, named, and never a fallthrough; the iOS SDK + // and `simctl` are the two this platform adds, both in the + // "proprietary runtime that exists only on its own OS" + // category. A build that continued without the SDK would + // fail in the driver's header search, naming a file rather + // than the thing that is missing. + if (want->is_ios()) { + // READ, NOT RE-DERIVED. The refusal above located it + // before any payload was resolved, and that is the one + // `xcrun` call this build makes. + // + // An empty answer here cannot happen through the + // `--target` path, and a line that prints when it does + // is cheaper than a branch that pretends it cannot: the + // row could be reached one day by a route that skipped + // the gate, and an iOS build with no `-isysroot` is a + // macOS artefact with an iOS triple on it. + if (!state.appleSdkLocated) { + return std::unexpected(std::format( + "internal: target {} reached toolchain " + "resolution without its SDK being located; the " + "gate that locates it did not run for this " + "request", want->str())); + } + state.tc->appleSdkRoot = *state.appleSdkLocated; + auto sdk = state.appleSdkLocated; + // AND THE std MODULE'S OWN COMMAND, WHICH IS A SEPARATE + // CHANNEL. Same reason the Android rows set it: the + // module is precompiled by `clang.cppm`'s own assembly + // rather than by the compile flags, so a decision made + // only in the flag builder reaches every translation + // unit and not the module they all import. Without the + // SDK here the precompile resolves libc++'s + // `#include <__config>` against the macOS SDK and the + // module is built for the wrong platform. + // + // QUOTED, as every path this string carries is (see the + // package-provided producer, which uses `shq` for each + // `-isystem`). The string is spliced into a shell + // command, and an Xcode installed as `Xcode 16.app` is + // a path with a space in it. + state.tc->stdModuleTargetFlags = + " " + state.tc->crossTargetFlag + + " -isysroot " + mcpp::xlings::shq(sdk->string()); + } + } + } + if (auto want = mcpp::toolchain::triple::parse(state.overrides.target_triple); + want && want->is_freestanding()) + { + // `import std` is structurally hosted, and turning it off is the + // SAME fact as the line above, not a second policy: libc++'s + // std.cppm is one module over the whole library, including the + // parts that are threads, filesystem and iostreams. There is no + // subset of it to precompile. + // + // Left on, the failure is neither early nor legible — measured: + // + // error: std module precompile failed (rc=1): + // .../include/c++/v1/__config:13:10: fatal error: + // '__config_site' file not found + // + // which reads as a broken toolchain payload and says nothing about + // the target. The freestanding std subset a user actually wants is + // an ordinary package (`mcpplibs.std.freestanding`), so mcpp's job + // here is to stop pretending the hosted one exists and to say + // where the other one is. + state.tc->hasImportStd = false; + state.tc->stdModuleSource.clear(); + state.tc->stdCompatSource.clear(); + + // ── The target's C library, resolved like its compiler ───────── + // + // The row in kKnownTargets names it, exactly as it names the + // toolchain pin, and it is installed through the same channel a + // project's `[xlings] deps` use (see the materialization above). + // Resolved HERE because the config is already open; the flag + // builder only reads the result. + // + // Absent is not an error at this point: the install happens + // earlier in this function and may legitimately not have run yet + // on a first pass. What follows would then simply not add the + // paths, and the link fails naming the missing libc — which is the + // truthful message either way. + if (const std::string want_sysroot = + mcpp::toolchain::triple::effective_sysroot( + *want, sysroot_override(*state.m, *want)); + !want_sysroot.empty()) { + if (auto cfg3 = state.get_cfg(true); cfg3) { + auto ref = mcpp::xlings::paths::parse_xpkg_ref(want_sysroot); + auto xl = mcpp::config::make_xlings_env(**cfg3); + // INSTALLED, NOT MERELY LOOKED UP — THE SAME CHANNEL + // THE ROW'S TOOLCHAIN PIN GOES THROUGH. + // + // The row names two things and only one of them used to + // be made to exist: `pin` went through + // `resolve_xpkg_path(…, autoInstall=true, …)` while + // `sysroot` was a pure lookup that returned nullopt and + // let the whole block below be skipped without a word. + // + // Measured 2026-08-26 in a clean environment (an empty + // home, so mcpp's registry starts fresh): + // + // Target riscv64-none-elf + // c-abi picolibc-riscv (…, prebuilt) + // error: 'stdio.h' file not found + // + // The report named the C library and the build could not + // find its headers. mcpp's own bare-metal CI installs it + // by hand, which is why no test ever saw this — every + // bare-metal e2e runs on a machine where the gap has + // already been papered over. + // + // OFFLINE AND `MCPP_NO_AUTO_INSTALL` ARE THE FETCHER'S + // DECISION, not re-derived here. One question, one place + // that answers it — asking it twice is the shape this + // whole release exists to remove. + mcpp::fetcher::Fetcher srFetcher(**cfg3); + mcpp::fetcher::InstallProgressHandler srProgress; + std::optional dir; + if (auto p = srFetcher.resolve_xpkg_path( + want_sysroot, /*autoInstall=*/true, &srProgress)) + dir = p->root; + else + dir = mcpp::xlings::paths::xpkg_payload(xl, ref); + if (dir) { + if (auto spec = mcpp::freestanding::resolve(*want)) { + const auto inc = + *dir / "include" / std::string(spec->libdir); + const auto lib = + *dir / "lib" / std::string(spec->libdir); + std::error_code ec2; + state.tc->targetSysrootRoot = *dir; + state.tc->targetSysrootPkg = ref.name; + if (std::filesystem::is_directory(inc, ec2)) + state.tc->targetSysrootInclude = inc; + if (std::filesystem::is_directory(lib, ec2)) + state.tc->targetSysrootLib = lib; + } + } + } + } + } + } + + // THE MSVC TOOLSET OF THE CLANG ROW, chosen once and recorded before the + // runtime identity below reads its SDK version. See bind_msvc_sysroot. + if (state.tc->compiler == mcpp::toolchain::CompilerId::Clang + && mcpp::toolchain::is_msvc_target(*state.tc)) { + auto bound = bind_msvc_sysroot(*state.tc, *state.m, [&] { return state.get_cfg(true); }); + if (!bound) return std::unexpected(bound.error()); + } else if (state.tc->compiler == mcpp::toolchain::CompilerId::MSVC) { + if (auto ok = check_cl_row_sysroot(*state.tc, *state.m); !ok) + return std::unexpected(ok.error()); + } + + // The Windows runtime identity, flowing BACK into the contract. + // + // Everything else about the runtime is known before a toolchain is + // resolved, and deliberately so (see the RuntimeBinding block above). The + // Windows SDK is the exception: it is a property of the toolchain, and + // until it reached the contract hash the version axis simply did not + // exist one layer below the compiler — two SDKs produced one cache key. + // + // `ucrt@` is a COMPATIBILITY FLOOR, not a payload binding like + // `glibc@`: ucrtbase.dll is an OS component and mcpp ships no + // redistributable for it. See mcpp.runtime.binding. + if (!state.tc->windowsSdkVersion.empty()) { + mcpp::platform::runtime::bind_windows_ucrt( + state.runtimeBindingSnapshot, state.tc->windowsSdkVersion); + state.tc->runtimeContractHash = state.runtimeBindingSnapshot.contractHash; + } + + // ── Targeting the MSVC ABI without a usable MSVC ───────────────────── + // + // One judgement, one place. This used to be two separate concerns and + // only one of them was implemented: `msvc@system` with no Windows SDK + // was caught here, while clang-targeting-MSVC on a machine with no + // Visual Studio at all — the default on every bare Windows box — fell + // straight through to clang's own "'vector' file not found", from which + // no user could infer that a working alternative was one flag away. + // Deriving the same judgement in two places is how the second case went + // unnoticed, so they are now one condition with two outcomes. + const bool targetsMsvcAbi = + state.tc->compiler == mcpp::toolchain::CompilerId::MSVC + || mcpp::toolchain::is_msvc_target(*state.tc); + if (targetsMsvcAbi && !state.msvc_usable_either_origin()) { + // Native cl.exe is ALWAYS a deliberate choice: mcpp never selects + // msvc@system on its own — it cannot install one — so the only way it + // reaches config.toml is a user typing `mcpp toolchain default msvc`. + // Without this, that user (who evidently wants MSVC and is probably + // just missing the SDK component) would be silently moved to MinGW + // instead of being told which component to install. + // + // The residual imprecision is deliberate and bounded: a *global* + // default of llvm@20.1.7 is indistinguishable from the one mcpp used + // to write itself, so an explicitly-typed one gets repaired too. The + // value is identical either way and the machine cannot build with it; + // a user who wants that failure can pin it in mcpp.toml, which is + // honoured exactly. + const bool userChoseMsvcItself = + state.tc->compiler == mcpp::toolchain::CompilerId::MSVC; + // AND NOT A COMPILER THE GRAPH REQUIRED. The repair below rewrites + // the machine's default to winlibs GCC, which is right when mcpp's + // own default cannot work here. A family a package REQUIRED is not + // mcpp's default to revise: switching to gcc would satisfy nothing — + // `check_requirements` refuses the build three thousand lines later — + // while having changed the user's configuration on the way there. + // Refusing at the decision is what the rest of this release is about. + const bool mayRepair = + !tc_origin_is_user_explicit(state.tcOrigin) + && tc_origin_may_persist(state.tcOrigin) + && !userChoseMsvcItself + && !mcpp::platform::env::offline_mode() + && !mcpp::platform::env::no_auto_install() + && mcpp::platform::is_windows; + if (!mayRepair) { + return std::unexpected(msvc_unavailable_guidance(*state.tc)); + } + // mcpp chose this default itself and it cannot work on this machine. + // Revise it — including for users who already have `llvm@20.1.7` + // persisted by an older mcpp: the first-run branch never fires again + // for them, so this gate (which runs on EVERY build) is what repairs + // them without a single manual command. + namespace pins = mcpp::toolchain::triple::pins; + mcpp::ui::info("Toolchain", + std::format("{} targets the MSVC ABI but no Visual Studio " + "(MSVC STL + Windows SDK) was found — switching to {} → {}", + state.tcSpec.value_or("the configured default"), + pins::kFirstRunWinGnu, pins::kFirstRunWinGnuTarget)); + + state.overrides.target_triple = std::string(pins::kFirstRunWinGnuTarget); + // The x86_64-windows-gnu row is defaultStatic; the target block that + // normally applies that already ran, so mirror just this one field. + if (state.m->buildConfig.linkage.empty()) state.m->buildConfig.linkage = "static"; + + auto gnuSpec = mcpp::toolchain::parse_toolchain_spec( + std::string(pins::kFirstRunWinGnu)); + if (!gnuSpec) return std::unexpected(gnuSpec.error()); + if (auto t = mcpp::toolchain::triple::parse(state.overrides.target_triple)) + gnuSpec->target = *t; + auto gnuPkg = mcpp::toolchain::to_xim_package(*gnuSpec); + + auto cfgR = state.get_cfg(true); + if (!cfgR) return std::unexpected(cfgR.error()); + mcpp::fetcher::Fetcher fetcherR(**cfgR); + mcpp::fetcher::InstallProgressHandler progressR; + auto payloadR = fetcherR.resolve_xpkg_path(gnuPkg.target(), + /*autoInstall=*/true, &progressR); + if (!payloadR) { + return std::unexpected(std::format( + "switching to the MinGW-w64 toolchain ({}) failed: {}\n" + " install it manually with:\n" + " mcpp toolchain install {} --target {}", + pins::kFirstRunWinGnu, payloadR.error().message, + pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget)); + } + auto gnuFrontendR = + mcpp::toolchain::payload_frontend(payloadR->root, gnuPkg); + if (!gnuFrontendR) return std::unexpected(gnuFrontendR.error()); + state.explicit_compiler = *gnuFrontendR; + if (!std::filesystem::exists(state.explicit_compiler)) { + return std::unexpected(std::format( + "MinGW-w64 payload {} has no known C++ frontend in {}", + gnuPkg.target(), + mcpp::toolchain::payload_frontend_dir(payloadR->root, gnuPkg).string())); + } + state.provide_runtime_payload(gnuPkg); + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfgR, payloadR->root, gnuPkg, + state.runtimeBindingSnapshot.runtimeId, state.runtimeLibDir); !fixed) + return std::unexpected(std::format( + "MinGW toolchain post-install fixup: {}", fixed.error())); + else state.report_fixup(*fixed, payloadR->root); + + // Persist both axes so the repair happens once, not on every build. + if (mcpp::config::write_default_toolchain(**cfgR, pins::kFirstRunWinGnu)) + (*cfgR)->defaultToolchain = std::string(pins::kFirstRunWinGnu); + if (mcpp::config::write_default_target(**cfgR, state.overrides.target_triple)) + (*cfgR)->defaultTarget = state.overrides.target_triple; + + state.tcSpec = std::string(pins::kFirstRunWinGnu); + state.tcOrigin = TcOrigin::FirstRun; + auto redetected = mcpp::toolchain::detect( + state.explicit_compiler, state.runtimePayload, + state.runtimeBindingSnapshot.contractHash); + if (!redetected) return std::unexpected(redetected.error().message); + state.tc = std::move(*redetected); + } + + // For musl-gcc the toolchain is fully self-contained + // (`/x86_64-linux-musl/{include,lib}` is its own sysroot). + // musl-gcc's `-dumpmachine` reports `x86_64-linux-musl`. + bool isMuslTc = mcpp::toolchain::is_musl_target(*state.tc); + + // A musl toolchain only really makes sense with static linkage — + // dynamic-musl binaries depend on a system /lib/ld-musl-x86_64.so.1 + // that most distros don't ship. Default linkage to "static" when + // the resolved toolchain is musl, unless the user has already opted + // out via `--static` or [target.].linkage. (There is no + // [build].linkage — the parser only reads it under a target section.) + if (isMuslTc && state.m->buildConfig.linkage.empty()) { + state.m->buildConfig.linkage = "static"; + } + return {}; + }; + + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/toolchain_decision.cpp b/src/build/prepare/toolchain_decision.cpp new file mode 100644 index 000000000..0255c7250 --- /dev/null +++ b/src/build/prepare/toolchain_decision.cpp @@ -0,0 +1,430 @@ +// toolchain_decision.cpp -- P5: the toolchain decided once the graph exists, +// through the resolver P2 defined. + +module mcpp.build.prepare; +import :state; + + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.targetside; +import mcpp.build.refusal; +import mcpp.build.version_floor; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.toolchain.post_install; +import mcpp.platform; + +namespace mcpp::build { + +std::expected phase5_toolchain_after_graph(PrepareState& state) { + + // ─── The toolchain, resolved now that the graph exists ────────────────── + // + // THE TARGET AND THE COMPILER ARE NOT BOUND TOGETHER, AND THE ROW'S + // CONVENTION IS A FALLBACK RATHER THAN A RULE. + // + // `x86_64-linux-musl → gcc@16.1.0` does not say "prefer gcc". It says "the + // musl-gcc payload is what supplies this target's C library". A project + // whose C library comes from its dependency graph does not use that payload, + // and for it the convention is not a default but a substitution — measured, + // it replaced a toolchain the user had set with `mcpp toolchain default` and + // said nothing. + // + // The discriminator is whether anything in the graph supplies the system, + // which is what these few lines ask. It is the same question + // `mcpp.targetside` answers in full further down; asked here it needs only + // the answer's shape, so it reads the manifests rather than resolving them. + { + bool graphSuppliesSystem = false; + // `requires` IS READ HERE TOO, AND UNTIL THIS LOOP IT WAS ONLY EVER + // CHECKED — A THOUSAND LINES LATER, AGAINST A DECISION THIS BLOCK HAD + // ALREADY MADE WITHOUT IT. + // + // `provides` and `requires` are the two halves of one vocabulary and + // they were read at opposite ends of the function: this block consulted + // the first to decide the compiler, and `check_requirements` used the + // second only to reject the outcome. Measured on 2026.8.26.1, one + // three-line manifest, `llvm@22.1.8` already installed: + // + // [dependencies] + // openkal-llvm-runtime = "0.1.3" # requires mcpp:compiler=llvm + // + // $ mcpp build # global default gcc@16.1.0 + // error: `openkal-llvm-runtime@0.1.3` requires the compiler to be `llvm`. + // Select that compiler … mcpp toolchain default llvm + // $ MCPP_TOOLCHAIN=llvm@22.1.8 mcpp build + // Finished dev [unoptimized + debuginfo] in 1.02s + // + // Nothing was missing. The engine knew which compiler was wanted, the + // payload was on the machine, and the remedy it printed was to change + // the default for EVERY project on the box because ONE project's + // dependency asked. + // + // AND THIS IS THE PLACE, NOT MERELY *A* PLACE. `resolve_target_toolchain` + // has exactly two call sites — its own one-shot recursion, and the one + // at the bottom of this block — so every branch inside it, INCLUDING the + // first-run install-and-persist path and all three + // `write_default_toolchain` calls, is downstream of this line. Setting + // `tcSpec` here therefore selects the compiler without writing anything: + // on a machine with no toolchain at all the first-run branch is not even + // reached, because its condition is `!tcSpec.has_value()`. + // + // That is the whole design. "Do not touch the user's configuration" is + // not a rule anyone has to remember here — the writes live on a branch + // this no longer enters. + std::string reqCompiler, reqCompilerBy; + + // A FAMILY NAME BECOMES A CONCRETE SPEC THE SAME WAY IT DOES FOR + // `mcpp toolchain default `, AND FOR THE SAME REASON. + // + // `requires = ["mcpp:compiler=llvm"]` names a family; the build path + // needs `@` and refuses anything else + // (`expected '@'`). There are two honest sources for the + // missing half and they are tried in this order: + // + // 1. what is already installed — highest version wins, nothing is + // downloaded, and it is literally the same two functions + // `toolchain_set_default` calls; + // 2. the vocabulary's own pins — the version this ecosystem ships for + // that family's payload, already written down once per row + // (`pinned_versions_for`). Deriving it from + // there rather than from a fresh constant means the answer moves + // when the ecosystem moves, with nobody having to remember a + // second place. + // + // NOT `pins::kFirstRun*`. Those are per-HOST first-run defaults — + // `llvm@20.1.7` on macOS, `gcc@16.1.0` on Linux x86_64 — so reading them + // would make the version a package requires depend on which machine + // built it. A requirement is a property of the package. + auto resolve_required_family = + [&](const std::string& family) + -> std::expected { + auto spec = mcpp::toolchain::parse_toolchain_spec(family); + if (!spec) { + refusal::record(refusal::Code::CompilerRequirementConflict); + return std::unexpected(std::format( + "`{}` requires the compiler to be `{}`, and mcpp has no " + "compiler family by that name.\n" + " known families: gcc, llvm, msvc, emsdk, android-ndk.", + reqCompilerBy, family)); + } + + if (auto cfg = state.get_cfg(true); cfg) { + auto pkg = mcpp::toolchain::to_xim_package(*spec); + if (auto picked = mcpp::toolchain::resolve_version_match( + "", mcpp::toolchain::list_installed_versions( + (*cfg)->xlingsHome() / "data" / "xpkgs", + pkg.ximName))) + return std::format("{}@{}", family, *picked); + } + + // Matched by the payload a pin names, not by its family: the NDK + // and emsdk pins are llvm-family pins of other payloads (#641). + if (auto picked = mcpp::toolchain::resolve_version_match( + "", mcpp::toolchain::pinned_versions_for(*spec))) + return std::format("{}@{}", family, *picked); + + // Neither source has one. Saying which family and which two places + // were consulted is the difference between an actionable message + // and "something went wrong". + // RECORDED, like every other refusal in this function. An + // unnamed branch reports `other`, and this release exists partly + // because one of those had a perfectly good name. + refusal::record(refusal::Code::CompilerRequirementConflict); + return std::unexpected(std::format( + "`{}` requires the compiler to be `{}`, and mcpp has no version " + "of it to use.\n" + " none is installed, and no target row pins one.\n" + " install one — `mcpp toolchain install {} ` " + "(`mcpp toolchain list --available {}`).", + reqCompilerBy, family, family, family)); + }; + + for (auto const& pkg : state.packages) { + for (auto const& entry : pkg.manifest.provides) { + auto cap = mcpp::targetside::parse_capability(entry); + if (!cap || !*cap) continue; + if ((*cap)->layer == mcpp::targetside::CapLayer::KernelAbi + || (*cap)->layer == mcpp::targetside::CapLayer::CAbi) { + graphSuppliesSystem = true; + } + } + for (auto const& entry : pkg.manifest.requires_) { + auto cap = mcpp::targetside::parse_capability(entry); + if (!cap || !*cap) continue; + if ((*cap)->layer != mcpp::targetside::CapLayer::Compiler) continue; + // A bare `mcpp:compiler` asks only that one exist, which it + // always does. Only a named family selects anything. + if ((*cap)->interfaceName.empty()) continue; + const auto pkgId = pkg.manifest.package.version.empty() + ? pkg.manifest.package.name + : std::format("{}@{}", pkg.manifest.package.name, + pkg.manifest.package.version); + // TWO DIFFERENT FAMILIES IS AN ERROR RATHER THAN A PICK, the + // same rule `provides` already follows one screen down. Choosing + // by graph-traversal order would make the answer depend on an + // order the author neither writes nor can predict — and unlike a + // conflicting `provides`, this one would silently satisfy one + // package's requirement and fail the other's inside a header. + if (!reqCompiler.empty() && reqCompiler != (*cap)->interfaceName) { + refusal::record(refusal::Code::CompilerRequirementConflict); + return std::unexpected(std::format( + "two packages require different compilers, and a build " + "has only one.\n" + " {:<28} requires `{}`\n" + " {:<28} requires `{}`\n" + " Both cannot hold. Drop one of them, or take a " + "version of one that is\n" + " configured for the other's compiler.", + reqCompilerBy, reqCompiler, pkgId, + (*cap)->interfaceName)); + } + if (reqCompiler.empty()) { + reqCompiler = (*cap)->interfaceName; + reqCompilerBy = pkgId; + } + } + } + // AND A FREESTANDING PIN SURVIVES IT. `graphSuppliesSystem` spans + // kernel-abi and c-abi, and it correctly cancels a HOSTED row's + // convention — that row names the payload the graph is replacing. + // A bare-metal row names the only compiler that emits the target. + // + // Measured 2026-08-25, on a three-line manifest: + // + // provides = ["mcpp:kernel-abi=openkal"] + // $ mcpp build --target riscv64-none-elf + // Resolved gcc@16.1.0 → riscv64-none-elf → …/bin/g++ + // g++: error: unrecognized argument in option '-mabi=lp64d' + // g++: error: unrecognized command-line option + // '--target=riscv64-none-elf' + // + // A package saying which layer it supplies made the host compiler be + // chosen for a target it cannot produce. Same shape as the four + // defects 2026.8.25.1 fixed: a predicate spanning two layers deciding + // something that does not depend on either of them. + if (!state.targetPinCandidate.empty() + && (!graphSuppliesSystem || state.targetPinIsCapability)) { + if (state.tcOrigin == TcOrigin::GlobalDefault && state.tcSpec.has_value() + && *state.tcSpec != state.targetPinCandidate) + state.pinReplacedDefault = *state.tcSpec; + // Kept for the build program's host resolution; see the + // declaration. Taken from every origin, not only the global + // default, because a `[toolchain]` the manifest named is just as + // much the host's compiler as a remembered default is. + if (state.tcSpec.has_value() && *state.tcSpec != state.targetPinCandidate) + state.hostSpecBeforeRowPin = *state.tcSpec; + state.tcSpec = state.targetPinCandidate; + state.tcOrigin = TcOrigin::TargetPin; + } + + // THE GRAPH'S REQUIREMENT, TAKEN AS AN INSTRUCTION RATHER THAN AS A + // TEST TO FAIL LATER. + // + // Everything above this line decides the compiler from what mcpp knows + // about the TARGET. A package saying `requires = ["mcpp:compiler=llvm"]` + // is saying something about ITSELF — its C++ runtime was configured for + // one family and its headers record that configuration — and it is the + // most specific statement in the build. Below the user's own word, above + // every default mcpp keeps. + // + // THE RANK IS NOT NEW. `TcOrigin` already sorts these, and + // `tc_origin_is_user_explicit` already answers "may mcpp revise this". + // The defect was never that the answer was wrong; it was that nobody + // asked. `GlobalDefault` is deliberately not user-explicit — see the + // note on that function — so a remembered default is exactly the kind of + // value this may replace. + // `system` IS LEFT ALONE, AND IT IS THE ONE VALUE HERE THAT IS AN + // ESCAPE HATCH RATHER THAN AN ANSWER. + // + // It means "the PATH compiler, whatever it is" — a deliberate opt-out + // of the payload model. Substituting a payload for it would defeat + // exactly what the user asked for, and mcpp cannot even tell whether + // the requirement is already satisfied: the family of a PATH compiler + // is not knowable from the spec. `check_requirements` reports the + // mismatch further down against what the driver actually turned out to + // be, which is the only place that answer exists. + const bool tcIsSystemEscapeHatch = + state.tcSpec.has_value() && *state.tcSpec == "system"; + if (!reqCompiler.empty() && !tcIsSystemEscapeHatch) { + std::string haveFamily; + if (state.tcSpec.has_value()) + if (auto s = mcpp::toolchain::parse_toolchain_spec(*state.tcSpec); s) + haveFamily = + std::string(mcpp::toolchain::family_name(s->family)); + + if (haveFamily != reqCompiler) { + // THE PROJECT'S OWN WORD IS NOT REVISED, AND THIS IS THE ONLY + // CASE THAT STILL REFUSES. `[toolchain]`, `[target.X].toolchain` + // and `MCPP_TOOLCHAIN` are statements about THIS build; the + // graph disagreeing with one of them is a real contradiction and + // `check_requirements` reports it further down with both names. + // Nothing to do here but leave the value alone. + if (tc_origin_is_user_explicit(state.tcOrigin)) { + // fall through to check_requirements + } + // A ROW'S PIN THAT SURVIVED TO HERE CANNOT BE OVERRIDDEN BY + // A REQUIREMENT, AND THE REASON IS THE SAME ONE THE PIN EXISTS + // FOR. + // + // The block above applied it only when the graph does NOT supply + // the system, or when the row names a capability. In the first + // case the row's payload is what carries this target's headers + // and C library, and a different compiler brings none — measured + // as `crtbeginT.o (bare name)` and as a host `crtbegin.o`, both + // accurate about the symptom and silent about the decision. In + // the second the row names the only compiler that emits the + // target at all. + // + // Either way the requirement cannot be honoured, and saying so + // here — where both halves are known — beats a compiler + // complaining about a file the reader never named. + else if (state.tcOrigin == TcOrigin::TargetPin) { + // THE TWO ROWS REFUSE UNDER ONE RULE AND FOR TWO + // REASONS, AND ONE REMEDY DOES NOT SERVE BOTH. + // + // A CONVENTION pin is cancelled by a graph that supplies the + // target's system — that is `graphSuppliesSystem`, one + // screen up — so "depend on a package that supplies it" is + // exactly the way out. + // + // A CAPABILITY pin is not: `targetPinIsCapability` keeps it + // applied no matter what the graph supplies, because no + // other family emits the target at all. Offering the same + // remedy there prints an instruction that the sentence + // directly above it has already ruled out — the failure + // this release removes from `check_requirements`, reproduced + // three screens away. + std::string_view why = state.targetPinIsCapability + ? "The row names its compiler as a capability: no other " + "family emits this target." + : "The row's payload is what supplies this target's " + "headers and C library,\n and nothing in the " + "dependency graph supplies them instead."; + std::string remedy = state.targetPinIsCapability + ? std::format( + " Drop the package that requires `{}`, or " + "take a version of it built\n" + " for `{}`.", + reqCompiler, state.targetPinCandidate) + : std::format( + " Depend on a package that supplies this " + "target's system (its kernel\n" + " interface and C library) so the row's " + "payload is not needed, or drop\n" + " the package that requires `{}`.", + reqCompiler); + refusal::record(refusal::Code::CompilerRequirementConflict); + return std::unexpected(std::format( + "`{}` requires the compiler to be `{}`, and target '{}' " + "cannot be built with it here.\n" + " target row {:<14} ({})\n" + " required {:<14} (required by {})\n" + " {}\n{}", + reqCompilerBy, reqCompiler, + state.targetRowName.empty() ? state.overrides.target_triple + : state.targetRowName, + state.targetPinCandidate, + state.targetPinIsCapability ? "capability" : "convention", + reqCompiler, reqCompilerBy, + why, remedy)); + } + // Free to take it. `tcSpec` is either absent (nothing configured + // anywhere) or one of mcpp's own remembered answers. + else { + auto pickedSpec = resolve_required_family(reqCompiler); + if (!pickedSpec) + return std::unexpected(pickedSpec.error()); + state.graphCompilerReplaced = state.tcSpec.value_or(""); + state.graphCompilerRequiredBy = reqCompilerBy; + state.graphCompilerFamily = reqCompiler; + state.tcSpec = *pickedSpec; + state.tcOrigin = TcOrigin::GraphRequirement; + } + } + } + // OVERRIDING THE CONVENTION IS ALLOWED; OVERRIDING IT AND SUPPLYING + // NOTHING IN ITS PLACE IS NOT, AND UNTIL THIS BLOCK IT LOOKED THE SAME. + // + // A hosted row's pin names the payload that supplies the target's C + // library. A project may name a different compiler — that is the escape + // hatch the whole convention/capability distinction exists to protect — + // and the ordinary reason to do so is that its dependency graph supplies + // the C library instead. `examples/06-openkal-cross` is exactly that: + // `llvm@22.1.8` plus `openkal-llvm-runtime`, and `graphSuppliesSystem` + // is true there. + // + // WITH NEITHER, THE BUILD USED TO RUN ANYWAY AND FAIL SOMEWHERE ELSE. + // Measured 2026-08-26 on Linux, `[toolchain] default = "llvm@22.1.8"` + // and no dependencies: + // + // --target x86_64-linux-musl + // hermetic link check failed … crtbeginT.o (bare name) + // --target x86_64-windows-gnu + // hermetic link check failed … + // /usr/lib/gcc/x86_64-w64-mingw32/13-win32/crtbegin.o (outside) + // + // Both are accurate about the symptom and silent about the decision: + // clang is retargetable and brings no C library, so it reached for a + // gcc installation — one that does not exist under the payload prefix + // in the first case, and that belongs to the HOST in the second. There + // is no llvm payload supplying either target's C library today. + // + // THE REFUSAL IS DECIDED HERE BECAUSE ONLY HERE ARE BOTH HALVES + // KNOWN. The row is read a thousand lines earlier and the graph does + // not exist then; `host_can_serve` is family-agnostic and would answer + // "yes, some payload here produces it" — the same shape as the family + // this release is about, a predicate answering a question narrower than + // the one it is asked. + if (!state.targetRowPin.empty() && !graphSuppliesSystem + && tc_origin_is_user_explicit(state.tcOrigin) && state.tcSpec.has_value()) { + auto declared = mcpp::toolchain::parse_toolchain_spec(*state.tcSpec); + auto rowTc = mcpp::toolchain::parse_toolchain_spec(state.targetRowPin); + if (declared && rowTc && declared->family != rowTc->family) { + refusal::record(refusal::Code::ConventionUnreplaced); + return std::unexpected(std::format( + "target '{}' takes its C library from the '{}' payload, and " + "'{}' has none here.\n" + " The row's toolchain is a convention, so naming your " + "own compiler overrides it —\n" + " but the convention is what supplies this target's " + "headers and C library, and\n" + " nothing in the dependency graph supplies them " + "instead.\n" + " depend on a package that implements the target's C " + "library (openkal-musl and\n" + " openkal-llvm-runtime are the ones in the index), or " + "remove the `[toolchain]`\n" + " line so `{}` is used for this target.", + state.targetRowName, state.targetRowPin, *state.tcSpec, state.targetRowPin)); + } + } + if (auto r = state.resolve_target_toolchain(); !r) + return std::unexpected(r.error()); + } + + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/toolchain_env.cpp b/src/build/prepare/toolchain_env.cpp new file mode 100644 index 000000000..c4b16d42d --- /dev/null +++ b/src/build/prepare/toolchain_env.cpp @@ -0,0 +1,449 @@ +// toolchain_env.cpp -- target rows, sysroot overrides, the MSVC toolset +// binding and the environment a build program is given, plus the toolchain a +// host tool chose for itself (#710). Declared in `:state`, or exported from +// prepare.cppm. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.build.version_floor; +import mcpp.platform.axis; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.msvc; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +import mcpp.toolchain.gcc; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.freestanding.target; // the target sysroot layout (libdir) +import mcpp.freestanding.linkline; // the ISA profile, for the std module command +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.config; +import mcpp.xlings; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.platform.macos; +import mcpp.fetcher; +import mcpp.fetcher.progress; +import mcpp.ui; +import mcpp.project; + +namespace mcpp::build { + +// `prepare_build` builds the BuildContext for any verb that compiles. +// includeDevDeps: when true, dev-dependencies are also fetched + scanned +// into the modgraph. mcpp test passes true; build/run pass false. +// extraTargets: additional Target entries (e.g. synthetic test targets) +// appended to the manifest before the modgraph runs. +// overrides: --target / --static. +// A dependency that "cannot be found" while an index is unreadable is almost +// never missing — it is unreachable, and the two need different actions from +// the user (publish it vs upgrade mcpp). The floor error is printed when the +// index is first opened, which can be hundreds of lines earlier; the message +// that STOPS the build has to carry the cause, because that is the one a user +// reads. See mcpp::pm::unusable_index_hint. +// Spelling-independent `[target.]` lookup. +// +// A section keyed `x86_64-w64-mingw32` matches a resolved `x86_64-windows-gnu`, +// and unparseable keys compare exactly (the escape hatch for custom triples). +// Factored out of the toolchain-override path because the sysroot override must +// use the SAME matching: two lookups that disagreed about spelling would give a +// section that applies to `toolchain` and not to `sysroot`, which is a defect +// nobody would think to look for. +const mcpp::manifest::TargetEntry* +find_target_entry(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::triple::Triple& t) { + if (auto it = m.targetOverrides.find(t.str()); it != m.targetOverrides.end()) + return &it->second; + for (auto const& [key, entry] : m.targetOverrides) { + if (auto k = mcpp::toolchain::triple::parse(key); k && k->str() == t.str()) + return &entry; + } + return nullptr; +} + +// The project's `[target.].sysroot`, or nullptr when it declared none. +const std::string* +sysroot_override(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::triple::Triple& t) { + auto* e = find_target_entry(m, t); + return (e && e->sysrootDeclared) ? &e->sysroot : nullptr; +} + +// 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 +// STL and CRT, and the Windows SDK that follows it -- is the sysroot, named by +// `[target.].sysroot` (default `msvc@system`). Until this existed the +// clang driver searched the machine for headers and libraries while mcpp +// searched it again for `std.ixx`, by a different order, so a machine with two +// installations could compile one toolset's `std.ixx` against another's +// headers, and the choice reached neither the cache key nor any report. The +// choice is made here once, recorded on the toolchain, and handed to the +// driver by the link model. +// +// `msvc@system` that finds nothing returns without binding, so the build +// reaches the "targeting the MSVC ABI without a usable MSVC" diagnosis that +// already names the alternatives. +std::expected +bind_msvc_sysroot(mcpp::toolchain::Toolchain& tc, + const mcpp::manifest::Manifest& m, + const std::function()>& cfgOf) { + namespace msvc = mcpp::toolchain::msvc; + auto tt = mcpp::toolchain::triple::parse(tc.targetTriple); + if (!tt) return {}; + const std::string* declared = sysroot_override(m, *tt); + const std::string text = declared ? *declared : std::string("msvc@system"); + auto spec = mcpp::toolchain::parse_toolchain_spec(text); + if (!spec) + return std::unexpected(std::format( + "[target.{}].sysroot = '{}': {}", tt->str(), text, spec.error())); + if (spec->family != mcpp::toolchain::Family::Msvc) + return std::unexpected(std::format( + "[target.{}].sysroot = '{}': on an MSVC-ABI row the sysroot is an " + "MSVC toolset (msvc@system, msvc@ or xim:msvc@)", + tt->str(), text)); + + msvc::ToolsetNeeds needs; + needs.cl = false; // clang compiles against the toolset; it does not run cl.exe + const bool systemSel = spec->version.empty() || spec->version == "system"; + std::vector instances; + std::optional choice; + if (!spec->ecosystemOnly) { + instances = msvc::enumerate_vs_instances(); + choice = msvc::select_system_toolset( + instances, msvc::msvc_env_snapshot(), + systemSel ? std::string_view("system") : std::string_view(spec->version), + needs); + if (!choice && systemSel) return {}; + } + + std::string origin = "system"; + if (!choice) { + // THE PACKAGE: `xim:` asked for it, or no installed toolset matched. + auto cfg = cfgOf(); + if (!cfg) return std::unexpected(cfg.error()); + mcpp::toolchain::ToolchainSpec pkgSpec = *spec; + pkgSpec.target = {}; + auto pkg = mcpp::toolchain::to_xim_package(pkgSpec); + mcpp::fetcher::Fetcher fetcher(**cfg); + mcpp::fetcher::InstallProgressHandler progress; + auto payload = fetcher.resolve_xpkg_path(pkg.target(), /*autoInstall=*/true, + &progress); + if (!payload) { + // `xim:` never looked at the machine, so the refusal does not + // report on it. + if (spec->ecosystemOnly) + return std::unexpected(std::format( + "[target.{}].sysroot = '{}': the package could not be " + "provided: {}\n" + " packages: `mcpp toolchain list --available msvc`", + tt->str(), text, payload.error().message)); + std::string onMachine; + for (auto const& line : msvc::describe_system_toolsets(instances, needs)) + onMachine += "\n " + line; + return std::unexpected(std::format( + "[target.{}].sysroot = '{}' matches no toolset on this machine, " + "and the package could not be provided: {}\n" + " installed on this machine:{}\n" + " packages: `mcpp toolchain list --available msvc`", + tt->str(), text, payload.error().message, + onMachine.empty() ? std::string(" none") : onMachine)); + } + auto inst = mcpp::toolchain::resolve_managed_msvc( + mcpp::config::make_xlings_env(**cfg), pkg, /*identifyVersion=*/false); + if (!inst) return std::unexpected(inst.error()); + choice.emplace(); + choice->vsRoot = inst->vsRoot; + choice->version = inst->toolsVersion; + choice->toolsDir = inst->vsRoot / "VC" / "Tools" / "MSVC" / inst->toolsVersion; + choice->product = "xim:msvc@" + inst->toolsVersion; + choice->via = "package"; + origin = "managed"; + } + for (auto const& n : choice->notes) mcpp::ui::info("note", n); + + // THE SDK FOLLOWS THE TOOLSET'S ORIGIN: a package binds the windows-sdk + // installed with it, a machine's toolset takes the machine's SDK by the + // search `msvc@system` has always used. The same function the cl.exe row + // uses, asked about the toolset directory rather than a cl.exe. + auto sdk = msvc::resolve_sdk_for(choice->toolsDir / "bin"); + if (!sdk.note.empty()) mcpp::ui::info("note", sdk.note); + + tc.msvcToolsDir = choice->toolsDir; + tc.msvcToolsVersion = choice->version; + tc.msvcOrigin = origin; + tc.msvcProduct = choice->product; + if (sdk.sdk) { + tc.windowsSdkRoot = sdk.sdk->root; + tc.windowsSdkVersion = sdk.sdk->version; + } + // The STL is this toolset's, so its version is the standard library's. + tc.stdlibVersion = choice->version; + + // THE STD MODULE OF THIS TOOLSET, replacing the `std.ixx` detection found + // by its own search. A toolset without one leaves `import std` unavailable + // rather than borrowing another toolset's. + // + // Only `std` is rebound. Detection never gave this row a `std.compat` + // source, and the clang builder for it passes the file without + // `-x c++-module`: given `std.compat.ixx`, clang takes it for linker + // input, `--precompile` writes nothing and exits 0, and the next command + // fails on the missing BMI (measured on the Windows runners). + std::error_code ec; + const auto ixx = choice->toolsDir / "modules" / "std.ixx"; + const bool msvcStl = tc.stdModuleSource.empty() + || tc.stdModuleSource.filename() == "std.ixx"; + if (msvcStl && std::filesystem::exists(ixx, ec)) { + tc.stdModuleSource = ixx; + tc.hasImportStd = true; + tc.importStdMinLevel = msvc::std_module_min_level_for_stl(ixx); + } else if (msvcStl && !tc.stdModuleSource.empty()) { + tc.stdModuleSource.clear(); + tc.hasImportStd = false; + } + + mcpp::ui::info("Resolved", std::format( + "sysroot {} → MSVC {} ({}: {}){}", spec->spec_str(), choice->version, + origin, choice->product, + tc.windowsSdkVersion.empty() + ? std::string{} + : std::format(" · Windows SDK {}", tc.windowsSdkVersion))); + return {}; +} + +// ON THE CL.EXE ROW THE COMPILER IS ITS OWN SYSROOT: cl.exe cannot compile +// against another toolset's STL. A declared sysroot is therefore a second +// statement of the compiler's toolset, and one that names a different +// toolset is refused rather than silently ignored. +std::expected +check_cl_row_sysroot(const mcpp::toolchain::Toolchain& tc, + const mcpp::manifest::Manifest& m) { + auto tt = mcpp::toolchain::triple::parse(tc.targetTriple); + if (!tt) return {}; + const std::string* declared = sysroot_override(m, *tt); + if (!declared) return {}; + auto spec = mcpp::toolchain::parse_toolchain_spec(*declared); + if (!spec || spec->version.empty() || spec->version == "system") return {}; + // /bin/Host//cl.exe → is named by the toolset. + const auto toolset = tc.binaryPath.parent_path().parent_path() + .parent_path().parent_path().filename().string(); + if (mcpp::toolchain::msvc::toolset_version_matches(spec->version, toolset)) + return {}; + return std::unexpected(std::format( + "[target.{}].sysroot = '{}' names a different toolset than the " + "compiler ({}, toolset {}). With cl.exe the compiler is its own " + "sysroot: pin the toolset in the toolchain (`msvc@`) and " + "drop `sysroot`, or build with clang to compile against another " + "toolset.", + tt->str(), *declared, tc.binaryPath.string(), toolset)); +} + +// `[package]`, for the build program of the package that declares it. +// +// ONE CALL RATHER THAN A FIELD PER SITE. Two places build a +// `BuildProgramEnv` -- the dependency loop and the root -- and the values a +// build program is told about its own package are the same question in both. +// Setting them field by field at each site is how the two answers drift: the +// root gained `packageName` and the dependency loop gained it separately, and +// a value added to only one of them is a rule package that works for a root +// project and not for a dependency, with nothing failing to say so. +void fill_package_build_env(mcpp::build::BuildProgramEnv& e, + const mcpp::manifest::Manifest& m) { + e.packageName = m.package.name; + e.packageNamespace = m.package.namespace_; + e.packageVersion = m.package.version; + e.packageDescription = m.package.description; + e.packageLicense = m.package.license; + e.packageRepo = m.package.repo; + // ';' rather than ',': an author entry is conventionally `Name ` + // and a name may carry a comma, so a comma-joined list cannot be split back + // into the entries it was made from. + e.packageAuthors.clear(); + for (auto const& a : m.package.authors) { + if (!e.packageAuthors.empty()) e.packageAuthors += ';'; + e.packageAuthors += a; + } +} + +void fill_target_build_env(mcpp::build::BuildProgramEnv& e, + const mcpp::manifest::Manifest& m, + const mcpp::toolchain::Toolchain* tc, + const mcpp::config::GlobalConfig* cfg) { + // The registry SubOS is where payloads are installed, whichever + // toolchain or link mode resolved, so this is set before the toolchain + // gate below. + if (cfg) { + const auto view = mcpp::xlings::paths::sysroot(mcpp::config::make_xlings_env(*cfg)); + e.pkgConfigLibdir = (view / "usr" / "lib" / "pkgconfig").generic_string() + + mcpp::platform::env::path_list_separator() + + (view / "usr" / "share" / "pkgconfig").generic_string(); + } + e.toolchainDir = (tc && !tc->binaryPath.empty()) + ? tc->binaryPath.parent_path().parent_path().string() : std::string{}; + e.targetSysroot = tc ? tc->targetSysrootRoot.string() : std::string{}; + e.compilerId = !tc ? std::string{} + : tc->compiler == mcpp::toolchain::CompilerId::GCC ? "gcc" + : tc->compiler == mcpp::toolchain::CompilerId::Clang ? "clang" + : tc->compiler == mcpp::toolchain::CompilerId::MSVC ? "msvc" + : std::string{}; + e.targetLibc = tc ? tc->targetSysrootPkg : std::string{}; + // Read from the toolchain the engine resolved, the same field the cache + // key, the ABI tag and the toolchain fingerprint read. Not re-derived from + // `compilerId`: clang answers "libc++" or "libstdc++" depending on how the + // payload was configured, and deriving it here would restate an assumption + // the resolver already measured. + e.cxxStdlib = tc ? tc->stdlibId : std::string{}; + if (!tc) return; + + // The two flags mcpp passes to ITS OWN compiler, so a rule package driving + // a second compiler passes the same two. Both read from the single + // producer that already decides them for the engine's own command lines — + // `resolve_link_model` for the sysroot, `gcc::binutils_prefix_dir` for the + // `-B` — rather than a fifth re-derivation of either. + if (auto lm = mcpp::toolchain::resolve_link_model(*tc); + lm.mode == mcpp::toolchain::CLibMode::Sysroot) + e.toolchainSysroot = lm.sysroot.string(); + e.toolchainBinutilsDir = mcpp::toolchain::gcc::binutils_prefix_dir(*tc).string(); + + // The C LIBRARY's sub-directory for this ISA profile, from the freestanding + // table — the same single read point the compile flags use. + // + // Gated on there being a C library at all, and the gate is the point: the + // value is a multilib convention, so on the zero-libc tier there is nothing + // for it to be a convention OF. Emitting `rv64gc/lp64d` there would hand a + // kernel a path into a directory that does not exist, and the name of the + // accessor would be a lie. All three libc-facing answers are empty together. + if (!e.targetSysroot.empty()) + if (auto spec = mcpp::freestanding::resolve(tc->targetTriple)) + e.targetLibcProfile = std::string(spec->libdir); + + // Which builtins library the RESOLVED toolchain ships. Freestanding only: + // on a hosted target the driver links them without being asked, and + // handing a package a name it must not use would invite it to. + if (auto t = mcpp::toolchain::triple::parse(tc->targetTriple); + t && t->is_freestanding()) { + e.targetBuiltinsLib = mcpp::toolchain::is_clang(*tc) + ? "clang_rt.builtins-" + t->arch + : std::string("gcc"); + } + + // #622 A11: MCPP_TARGET_MIN_PLATFORM_VERSION. One call, so a new consumer + // (`dist-apple`, `dist-apk`) reads the same answer the compiler flag and + // the fingerprint slot already resolved, rather than restating it. + if (auto tt = mcpp::toolchain::triple::parse(tc->targetTriple)) + e.minPlatformVersion = min_platform_version(m, *tt, tc->binaryPath); +} + +// THE PROJECT'S MINIMUM PLATFORM VERSION FOR THIS TARGET, in one place. +// +// Two platforms fuse it into the effective triple and each names it in its own +// words: macOS's deployment target lives in `[build]` because it applies to +// every Apple artefact a project produces, and Android's API level lives in +// `[target.]` because it applies to one row. `llvm_triple` takes one +// parameter for both, so the choice between them is made here rather than at +// each of its call sites -- there are two, and a decision made twice is the +// shape this codebase records most often. +std::string min_platform_version(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::triple::Triple& t, + const std::filesystem::path& compilerPath) { + if (t.is_android()) { + if (auto it = m.targetOverrides.find(t.str()); it != m.targetOverrides.end()) + if (it->second.minApiLevel > 0) + return std::to_string(it->second.minApiLevel); + // AND THERE IS NO SUCH THING AS LEAVING IT OUT. This returned an empty + // string with the comment "the NDK's own default, which clang + // supplies", which was never verified and is false. Measured: + // + // --target=aarch64-unknown-linux-android (no level) + // sys/cdefs.h:365:2: error: Unversioned target triples are not + // supported! + // + // bionic refuses it, so the level is mandatory and a project that + // never heard of API levels still needs one. The NDK declares the + // floor it supports in `meta/platforms.json` and that is the honest + // default -- the payload's own answer, which moves when the payload + // does. macOS is the same shape and already works this way: its + // default comes from the platform module, not from the manifest. + // THE PAYLOAD'S OWN ANSWER FIRST, AND THE ENGINE'S DERIVATION AS + // THE FALLBACK. `platform_floor` in `.mcpp-toolchain.json` is the + // same number by a channel that does not require this engine to know + // that an NDK keeps it in `meta/platforms.json`, nor that file's + // schema. A payload shipping no descriptor still resolves, which is + // what makes the descriptor additive. + // + // A MALFORMED descriptor is read as absence HERE ONLY, because this + // function has no error channel and does not need one: a + // payload-provided compiler reaches this point through + // `payload_frontend`, which refuses a malformed descriptor by name + // before any of these decisions are made. + if (auto desc = + mcpp::toolchain::payload_descriptor_for_compiler(compilerPath); + desc && *desc && !(*desc)->platformFloor.empty()) + return (*desc)->platformFloor; + if (auto level = mcpp::toolchain::ndk_min_api_level(compilerPath); + level > 0) + return std::to_string(level); + return {}; // the caller refuses; see android_api_level_refusal + } + // APPLE'S TWO PLATFORMS ANSWER FROM TWO KEYS, ONE SLOT. + // + // "14.0" is a macOS version and means nothing to an iOS SDK, so the + // project states them separately -- and only one of them can apply to any + // one target, which is why they still share this function's single return + // and the single fingerprint slot behind it. + // + // Empty is a legal answer here and not a refusal, unlike Android's, and + // for the iOS rows prepare fills it with the located SDK's version before + // this is read: an unversioned `arm64-apple-ios` made clang refuse + // thread-local storage (measured, Xcode 16.4), so the driver's own + // default is not the SDK's. Bionic rejects the unversioned triple + // outright, which is the other half of the asymmetry. + if (t.is_ios()) return m.buildConfig.iosDeploymentTarget; + // AND ONLY FOR A macOS TARGET. `deployment_target` itself no longer + // consults the host (#685); the discriminator is `t.os`, which is this + // function's own target parameter and is available regardless of what + // machine mcpp runs on. A non-Apple target (Linux, Windows, wasm, + // freestanding) answers empty here, same as it always has. + if (t.os == "macos") + return mcpp::platform::macos::deployment_target( + /*targetIsMacos=*/true, m.buildConfig.macosDeploymentTarget); + return {}; +} + +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); +} + +} // namespace mcpp::build diff --git a/src/build/prepare/xlings.cpp b/src/build/prepare/xlings.cpp new file mode 100644 index 000000000..5b5adcd1c --- /dev/null +++ b/src/build/prepare/xlings.cpp @@ -0,0 +1,574 @@ +// xlings.cpp -- P3: the xlings payloads the root declares, provisioned +// before the dependency graph is built. + +module mcpp.build.prepare; +import :state; + +import mcpp.build.prepare_inputs; + +import std; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.xlings.address_set; +import mcpp.build.version_floor; +import mcpp.log; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +// For `resolve_version_match` / `list_installed_versions`: a bare compiler +// family named by the dependency graph resolves to a concrete version through +// exactly the path `mcpp toolchain default ` uses. +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.plan; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.build_program; +import mcpp.build.backend; // BuildOptions for the tool sub-build +import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.config; +import mcpp.xlings; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.fetcher; +import mcpp.fetcher.progress; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.ui; +import mcpp.log; + +namespace mcpp::build { + +std::expected phase3_xlings_before_graph(PrepareState& state) { + + // Sysroot comes from the toolchain payload itself (GCC -print-sysroot, + // Clang clang++.cfg). mcpp does not override it — the payload is + // self-describing. See docs: 2026-05-21-linux-sysroot-missing-kernel-headers.md + + // ── L3: project-local `build.mcpp` imperative build program ───────────── + // The ROOT program is compiled with the HOST toolchain and run AFTER + // dependency resolution + feature activation (so it receives + // MCPP_DEP__DIR like a dependency's does — design §3.1 item 4) and + // BEFORE the modgraph scan (so its `generated=`/`source=` sources are + // picked up) — see the call site further below, after the dep build.mcpp + // loop. Its stdout directives augment buildConfig; a declared-input cache + // re-runs it only when its source/inputs/env/contract change. It cannot + // gate the top-level dependency graph (leaf-only rule). Under a cross + // --target it runs with a host-resolved toolchain and sees MCPP_TARGET = + // the cross triple (G3). + // See .agents/docs/2026-06-30-l3-build-mcpp-implementation-design.md, + // 2026-07-17-asm-sources-and-general-build-capabilities-design.md §2.4 and + // 2026-07-19-large-source-pkg-platform-fixes-and-buildmcpp-generation-design.md. + // Root [generated_files]: materialize before build.mcpp and the modgraph + // scan so synthesized sources are globbed like any on-disk file — and + // BEFORE dependency resolution, since generated_files may produce + // build.mcpp itself. (The per-dependency call sits in the dep resolution + // loop below; the root manifest needs its own.) + if (!state.m->buildConfig.generatedFiles.empty()) { + std::vector staleGenerated; + if (auto r = materialize_generated_files( + *state.root, *state.m, state.overrides.plan_only ? &staleGenerated : nullptr); !r) { + return std::unexpected(r.error()); + } + for (auto const& path : staleGenerated) + state.planNotes.push_back({"MCPP_GENERATED_FILE_NOT_MATERIALIZED", + std::format("'{}' is declared in [build] generated_files and its " + "content on disk differs from the declaration; this " + "command does not write the project, and `mcpp build` " + "writes it", path.string())}); + } + + // Canonical rendering of the resolved target (for the env contract). + if (!state.overrides.target_triple.empty()) { + auto tt = mcpp::toolchain::triple::parse(state.overrides.target_triple); + state.resolvedTargetCanonical = tt ? tt->str() : state.overrides.target_triple; + } + + // Host toolchain for build.mcpp (G3): under a cross --target the resolved + // `tc` is the cross toolchain, whose products cannot run here — resolve a + // host-target toolchain from the same spec vocabulary (the spec WITHOUT + // the --target axis), lazily and only when a build.mcpp actually exists + // (root or dependency). + // 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. + state.host_spec_for_build_program = [&]() -> std::string { + if (!state.tcSpec) return {}; + if (state.overrides.target_triple.empty()) return *state.tcSpec; + return (state.tcOrigin == TcOrigin::TargetPin && state.hostSpecBeforeRowPin.has_value() + && !state.hostSpecBeforeRowPin->empty() && *state.hostSpecBeforeRowPin != "system") + ? *state.hostSpecBeforeRowPin + : (state.tcOrigin == TcOrigin::TargetPin ? state.native_first_run_spec() : *state.tcSpec); + }; + state.host_tc_for_build_program = [&]() -> std::expected< + std::pair, std::string> { + // A HOST TOOLCHAIN'S C LIBRARY IS THE PAYLOAD'S, WHATEVER THE + // PROJECT'S TARGET SIDE IS. + // + // `build.mcpp` is compiled AND RUN on the machine doing the build. Its + // C library therefore comes from the compiler payload — even for a + // project whose TARGET takes its C library from the dependency graph. + // The two are different machines and this function's whole job is to + // keep them apart. + // + // AND THE NATIVE BRANCH BELOW RETURNS THE MAIN `tc`, WHICH CARRIES + // THE OTHER ANSWER. `build_program.cppm`'s own header states the + // invariant — "`tc` is always a HOST-targeting toolchain" — and for + // every field but this one the native branch satisfied it, because on a + // native build the compiler IS the host compiler. `cAbiPrebuilt` is the + // first field where "same compiler" and "same target side" come apart. + // + // AN INVARIANT, NOT A BUG FIX FOR ANY MEASURED FAILURE. It was + // written while chasing a `features.h: No such file` on openkal-musl's + // CI and it is NOT that failure's cause: measured on `origin/main` and + // on this branch, the gcc std module carries zero `-isystem`/ + // `-idirafter` rows either way — that toolchain reaches its C library + // through the specs the post-install fixup rewrites, and the real + // defect was in resolving WHICH glibc payload those specs name. + // + // Kept because the invariant is worth being true: a helper compiled and + // run on the build machine must not inherit the target's C-library + // origin, and the next field that comes apart would find no rule here. + // + // ⇒ Stated once, so every consumer (the std module build, + // `host_base_flags`) gets it without asking. + auto as_host = [](mcpp::toolchain::Toolchain t) { + t.cAbiPrebuilt = true; + return t; + }; + // `explicit_compiler` IS EMPTY FOR ONE RESOLUTION PATH, AND THIS IS + // THE ONLY CALLER THAT NOTICED BY CRASHING (#527). + // + // Every branch that resolves a toolchain from the index assigns + // `explicit_compiler`; the `[toolchain] system` branch does not, because + // it has nothing to assign yet — `detect` finds the PATH compiler a few + // hundred lines below and stores the resolved ABSOLUTE path in + // `tc->binaryPath`. The main build reads the compiler from `tc` and is + // fine; this closure returned the local variable and handed "" to + // `posix_spawnp`, which is `exit 127: posix_spawnp('') failed`. + // + // AND THE FIX IS NOT "SUPPORT THE HOST". `tc->binaryPath` is the + // compiler this build is ALREADY using for every other translation + // unit; build.mcpp is compiled with the project's toolchain by + // definition (see this lambda's header). Reading it from the place it + // was resolved makes the two paths agree — it grants no capability the + // project did not already have, and the host-dependence warning at the + // `system` branch is what states the cost. + // + // The CROSS branch below is a different question and deliberately + // unchanged: there `explicit_compiler` is empty because NO host + // toolchain was resolved at all, and its classified refusal is correct. + if (state.overrides.target_triple.empty()) + return std::pair{ + state.explicit_compiler.empty() ? state.tc->binaryPath : state.explicit_compiler, + as_host(*state.tc)}; + if (state.hostTcCache) + return std::pair{state.hostTcCache->first, as_host(state.hostTcCache->second)}; + if (!state.tcSpec || *state.tcSpec == "system" || state.tcSpecIsMsvc) { + // A READABLE REFUSAL THAT HAD NO CODE, so the target matrix + // recorded four identical `other` cells for it. The sentence was + // right; the classification was missing. Measured on windows-2022 + // with `msvc@system` declared and any cross target. + refusal::record(refusal::Code::HostToolToolchain); + return std::unexpected(std::string( + "build.mcpp under a cross --target needs a resolvable host " + "toolchain — set one via [toolchain] or `mcpp toolchain default`")); + } + // THE ROW'S CONVENTION IS NOT THE HOST'S COMPILER. When the target + // row's pin replaced a spec the user or the machine had chosen, the + // build program resolves the replaced one: it is what a native build + // on this machine would use, and it is what the user wrote. + // + // A PIN THAT REPLACED NOTHING IS NOT "RESOLVED AS BEFORE" ANY MORE + // (#622). "Before" meant falling through to `*tcSpec`, which at this + // point (`tcOrigin == TargetPin`) IS the row's own pin — a TARGET + // answer. For a row whose payload can only ever emit its target + // (`emscripten@…` → em++, WebAssembly under every invocation) that + // resolved a cross compiler as the HOST toolchain for build.mcpp, + // which is compiled AND RUN on this machine: the compile itself + // "succeeds" (clang accepts the syntax) and the failure surfaces one + // step later, inside the payload's own driver, trying to produce a + // program this machine can execute (measured: emcc.py's + // `phase_compile_inputs` hits `assert os.path.exists(output_file)` + // and raises, on the very first `mcpp build --target + // wasm32-emscripten` in a fresh $HOME, before any [toolchain] default + // has ever been resolved or persisted). A row whose payload happens + // to double as a host compiler (an NDK clang) hid the same defect by + // accident. + // + // "Nothing to fall back on" must mean "resolve the platform's native + // default now", exactly as a plain `mcpp build` would on a virgin + // machine — not "reuse the target's answer". `native_first_run_spec()` + // 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 = state.host_spec_for_build_program(); + auto spec = mcpp::toolchain::parse_toolchain_spec(hostSpecText); + if (!spec || spec->version.empty()) { + return std::unexpected(std::format( + "toolchain spec '{}' is invalid for the build.mcpp host resolve", hostSpecText)); + } + // Deliberately NO target injection: the spec resolves for the host. + auto pkg = mcpp::toolchain::to_xim_package(*spec); + auto cfgH = state.get_cfg(true); + if (!cfgH) return std::unexpected(cfgH.error()); + mcpp::fetcher::Fetcher fetcher(**cfgH); + mcpp::fetcher::InstallProgressHandler progress; + auto payload = fetcher.resolve_xpkg_path(pkg.target(), /*autoInstall=*/true, &progress); + if (!payload) { + return std::unexpected(std::format( + "host toolchain for build.mcpp ('{}'): {}", hostSpecText, + payload.error().message)); + } + auto frontendR = mcpp::toolchain::payload_frontend(payload->root, pkg); + if (!frontendR) return std::unexpected(frontendR.error()); + auto frontend = *frontendR; + if (!std::filesystem::exists(frontend)) { + return std::unexpected(std::format( + "host toolchain payload '{}' has no known C++ frontend in {}", + pkg.target(), + mcpp::toolchain::payload_frontend_dir(payload->root, pkg).string())); + } + state.provide_runtime_payload(pkg); + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfgH, payload->root, pkg, + state.runtimeBindingSnapshot.runtimeId, state.runtimeLibDir); !fixed) + return std::unexpected(std::format( + "host toolchain post-install fixup: {}", fixed.error())); + else state.report_fixup(*fixed, payload->root); + // SAME THREE ARGUMENTS THE NATIVE CALL USES (line ~3550), not the + // one-argument form. `detect()` probes `payloadPaths` — the + // fine-grained glibc/linux-headers xpkg directories `resolve_link_model` + // attaches as explicit `-isystem` rows — from the SECOND argument, and + // does so only when it is given; passing only `frontend` leaves + // `tc.payloadPaths` unset, so `host_base_flags`/`host_compile_tokens` + // fell back to `tc.sysroot` alone (from the payload's own + // `*sysroot_spec: --sysroot=%R`, `%R` being wherever the fixup pointed + // it — nothing, on a sandbox with no leaked subos sysroot to fill it + // in by accident). + // + // Measured in the xlings sandbox against the released 2026.9.12.3, on + // a fresh registry (a real, non-symlinked gcc@16.1.0 payload, no + // ambient /usr/include, no subos state to leak): "Resolved host + // toolchain for build.mcpp: gcc 16.1.0 (x86_64-linux-gnu)" — the right + // FAMILY, since #622's first fix already keeps the pre-row spec — and + // then the `mcpp` module compile failed with `features.h: No such + // file or directory`, because that gcc's specs alone name no C + // library. `echo | g++ -x c++ -E -v -` there lists only the payload's + // own `c++/16.1.0`, `include`, `include-fixed` — no glibc directory. + // On a development machine the same probe happens to pass, but for a + // reason that has nothing to do with this code path: the shared-store + // gcc's search list there ends with a SUBOS's `usr/include`, leaked + // into `%R` by machine state the payload never declared (the same + // shape as "host /usr/include silently completes a payload + // toolchain") — which is exactly the kind of thing a fresh sandbox + // does not have lying around to hide the gap. + // + // `runtimePayload` and `runtimeBindingSnapshot` (declared once, near + // the top of this function) are the HOST's C-library identity — never + // re-derived from `--target`, see their own declarations — so passing + // them here is not a parallel derivation; it is the one this function + // already had in scope and the native call already trusts. + auto htc = mcpp::toolchain::detect( + frontend, state.runtimePayload, state.runtimeBindingSnapshot.contractHash); + if (!htc) return std::unexpected(htc.error().message); + mcpp::ui::info("Resolved", std::format( + "host toolchain for build.mcpp: {}", htc->label())); + state.hostTcCache = std::pair{frontend, *htc}; + return std::pair{state.hostTcCache->first, as_host(state.hostTcCache->second)}; + }; + + // Resolve dependencies: walk the **transitive** graph from the main + // manifest, BFS-style. Each unique `(namespace, shortName)` is fetched + // once, its `[build].include_dirs` are propagated to the main + // manifest, and its own `[dependencies]` are queued for processing + // (its `[dev-dependencies]` are NOT — those are private to the dep's + // own test runs). + // + // Conflict policy: C++ modules require globally-unique module names + // and ODR-respecting symbols, so the same `(ns, name)` resolved to + // two different exact versions is an error — mcpp prints both + // requesting parents and asks the user to align them. + + // Refresh the builtin package index only when a dependency cannot be + // resolved from the local copy (#315). + // + // This used to fire whenever the refresh marker was older than an hour, + // whether or not anything was actually missing — so every build with a + // registry dependency paid a multi-repo network sync once an hour, which is + // minutes on a slow or blocked network for data it already had. The policy + // now lives in mcpp.pm.index_refresh and is shared with `mcpp add` and the + // xim install gate, which had each derived their own (and disagreed). + // + // Nothing here decides anything itself — in particular the "a miss proves + // nothing for this namespace" rule must not be re-derived; see that module. + if (!state.m->dependencies.empty()) { + if (auto cfg2 = state.get_cfg(true)) { + auto xlEnv = mcpp::config::make_xlings_env(**cfg2); + auto policy = mcpp::pm::policy_for(**cfg2); + // Same routing the dependency walk below uses (the `index_route` + // lambda is declared further down; this is the identical value). + mcpp::pm::IndexRoute route{ &state.m->indices, *state.root, *cfg2 }; + for (auto& [depName, spec] : state.m->dependencies) { + auto decision = mcpp::pm::decide_for_dependency( + route, depName, spec, xlEnv, *state.targetPlatform, policy); + if (!decision.shouldRefresh) { + mcpp::log::verbose("index", std::format( + "{}: {}", decision.subject, + mcpp::pm::reason_text(decision.reason))); + continue; + } + // A failed refresh is not a failed build: the dependency walk + // below may still resolve everything from what is on disk, and + // if it cannot, it reports the actual missing package with the + // index's age attached. Failing here instead would turn a + // transient network blip into a hard stop for a build that + // needed no network at all. + if (auto r = mcpp::pm::apply(decision, xlEnv); !r) + mcpp::ui::warning(r.error()); + break; // one sync covers every dependency + } + } + } + + // Set up project-level .mcpp/ directory for custom indices and/or the + // [xlings] build environment (L-1). This creates .mcpp/.xlings.json with + // custom non-builtin index entries (so xlings can clone them) plus the + // [xlings] deps/workspace/subos/envs materialized verbatim. + // A pointer, not a reference: state.wsManifest and state.m outlive every + // phase, so this stays valid wherever it is read from, but a PrepareState + // member cannot itself be a reference (see the PrepareState comment). + state.runtimeOwnerManifest = state.wsManifest ? &*state.wsManifest : &*state.m; + // The TARGET's C library, if this target has one. Resolved here and not by + // any package, for the same reason the compiler pin is: it is a property + // of the target. + // + // It rides the SAME channel as `[xlings] deps` rather than getting an + // install path of its own — one materialization, one place that can be + // wrong. What it must NOT do is depend on the project having an `[xlings]` + // section: a bare-metal project written to the template has none, and the + // whole point is that it never mentions a libc. + // FROM THE REQUESTED TRIPLE, NOT FROM THE TOOLCHAIN — AND THE TWO WERE + // THE SAME VALUE ALL ALONG. + // + // This read of `tc->targetTriple` was the ONLY thing tying the compiler's + // resolution to a point before dependency resolution, and it never wanted + // the compiler: `tc->targetTriple` is corrected to the requested triple a + // few lines after the toolchain is detected, so the value here is the one + // `--target` named. Taking it from the request instead lets the toolchain + // be resolved where the information it needs actually exists. + std::string targetSysroot; + { + auto tt = state.overrides.target_triple.empty() + ? std::optional{mcpp::toolchain::triple::host_triple()} + : mcpp::toolchain::triple::parse(state.overrides.target_triple); + // Not on an MSVC-ABI row: there the key names an MSVC toolset, which + // `bind_msvc_sysroot` locates or installs itself -- an installed + // toolset of the pinned version must win over a download, and + // `msvc@system` is not a package at all. + if (tt && !tt->is_msvc_env()) + targetSysroot = mcpp::toolchain::triple::effective_sysroot( + *tt, sysroot_override(*state.m, *tt)); + } + const bool materializeRootRuntime = + !state.overrides.inherited_runtime_binding + && (!state.runtimeOwnerManifest->xlings.empty() || !targetSysroot.empty()); + if (!state.m->indices.empty() || materializeRootRuntime) { + auto cfg2 = state.get_cfg(true); + if (cfg2) { + mcpp::xlings::ProjectEnv penv; + if (materializeRootRuntime) { + penv.deps = state.runtimeOwnerManifest->xlings.deps; + // Appended, never substituted: a project may legitimately + // declare other xim packages, and a target sysroot is one more + // entry rather than a replacement for the list. Deduplicated + // because a manifest written before this axis existed still + // names it, and declaring it twice is not an error the author + // should have to hear about. + if (!targetSysroot.empty() + && std::ranges::find(penv.deps, targetSysroot) == penv.deps.end()) + penv.deps.push_back(targetSysroot); + penv.subos = state.runtimeOwnerManifest->xlings.subos; + for (auto const& [k, v] : state.runtimeOwnerManifest->xlings.workspace) + penv.workspace.emplace_back(k, v); + // `[feature-xlings.]` becomes part of the project's + // environment only while `` is active. It is written into + // the same two fields, because from xlings' side there is no + // such thing as a feature: the file states what this project + // uses, and the feature decided that. + for (auto const& f : + feature_closure(*state.runtimeOwnerManifest, + parse_feature_request(state.overrides.features))) + if (auto it = state.runtimeOwnerManifest->xlings.featureDeps.find(f); + it != state.runtimeOwnerManifest->xlings.featureDeps.end()) + for (auto const& address : it->second) { + if (std::ranges::find(penv.deps, address) == penv.deps.end()) + penv.deps.push_back(address); + const auto entry = + mcpp::manifest::parse_address(address); + if (std::ranges::none_of(penv.workspace, + [&](auto const& kv) { return kv.first == entry.target; })) + penv.workspace.emplace_back(entry.target, entry.pin()); + } + } + if (state.runtimeSelection.ownerRoot == state.workRoot) { + mcpp::config::ensure_project_index_dir( + **cfg2, state.workRoot, state.m->indices, penv); + } else { + if (!state.m->indices.empty()) + mcpp::config::ensure_project_index_dir( + **cfg2, state.workRoot, state.m->indices, {}); + if (materializeRootRuntime) + mcpp::config::ensure_project_index_dir( + **cfg2, state.runtimeSelection.ownerRoot, {}, penv); + } + + // `[xlings] deps` are DECLARED above and, until now, nothing + // installed them (mcpp-index #281 §9). + // + // `ensure_project_index_dir` writes them into `.mcpp/.xlings.json` + // verbatim and stops there, so a manifest saying + // `deps = ["xim:mesa"]` produced a file naming mesa, no project + // SubOS, and `fatal error: gbm.h: No such file or directory`. The + // declaration looked accepted and did nothing — which is the worst + // shape a config key can have. + // + // This is the same "declare it and mcpp provisions it on first use" + // contract `[toolchain]` has had all along; that path is a few + // hundred lines up ("First run — no toolchain configured … + // installing … as default"). A build environment should not have + // two grades of declaration. + // + // ORDER IS LOAD-BEARING: this must run BEFORE the runtime binding + // resolves, because a named `[xlings] subos` that does not exist + // yet is a hard error ("selected SubOS '…' does not exist; + // create/bootstrap that environment"), and provisioning is what + // creates it. Placed here, next to the index sync below, both + // first-use provisioning steps sit in one place. + // + // `install_packages` rather than `fetcher.install`: the install + // DESTINATION is chosen by package scope (project vs global), and + // the project scope is what materializes the project SubOS. It also + // carries the live progress UI and captured child errors, matching + // the toolchain and custom-index paths. + // Only what the MANIFEST declared, deliberately not `penv.deps`. + // + // A cross-compilation target sysroot is APPENDED to that list a few + // lines up, and provisioning it here would change behaviour for + // projects that never asked for it: a name that does not resolve + // would turn a build that used to proceed into a hard failure. The + // contract being added is "what you declared gets installed", and + // the sysroot entry is mcpp's own inference rather than the + // author's declaration. + // Only the tiers this verb needs, and only what the ROOT + // declared. The graph's own declarations are provisioned after + // resolution, which is the first moment they are known — see the + // second pass near `xlingsDepBinDirs`. + // ONE PACKAGE, ONE VERSION, INSIDE ONE MANIFEST TOO. The + // conditional merge already unified the two tool AXES by package; + // what it cannot see is `[xlings.workspace]` and + // `[feature-xlings.]` naming one package at two versions, which + // reaches here as two addresses and used to install both. + std::vector rootClaims; + for (auto const& spec : applicable_xlings_addresses( + *state.runtimeOwnerManifest, + feature_closure(*state.runtimeOwnerManifest, + parse_feature_request(state.overrides.features)), + state.toolPurpose, /*isRoot=*/true)) + rootClaims.push_back({spec, "this project", 0}); + auto rootUnified = mcpp::xlings::addrset::unify(rootClaims); + if (!rootUnified) { + refusal::record(refusal::Code::ToolVersionConflict); + return std::unexpected(rootUnified.error()); + } + for (auto const& note : rootUnified->overrides) + mcpp::diag::warning("xlings/version-override", note); + std::vector declaredDeps; + for (auto const& w : rootUnified->winners) + declaredDeps.push_back(w.address); + if (materializeRootRuntime && !declaredDeps.empty()) { + if (auto pv = provision_xlings_addresses( + **cfg2, declaredDeps, state.runtimeSelection.ownerRoot, + "[xlings.workspace] entries"); + !pv) return std::unexpected(pv.error()); + } + + // On first build, the project index data root may be empty because + // ensure_project_index_dir only writes .xlings.json but does not + // trigger clone/link creation. Local path indices are read directly; + // remote custom indices are synced quietly before dependency resolution. + bool hasCustomIndices = false; + for (auto& [idxName, spec] : state.m->indices) { + if (!spec.is_builtin()) { + hasCustomIndices = true; + break; + } + } + if (hasCustomIndices) { + bool needsClone = !mcpp::config::project_index_data_initialized(*state.root); + if (needsClone) { + bool needsRemoteUpdate = false; + for (auto& [idxName, spec] : state.m->indices) { + if (spec.is_builtin() || spec.is_local()) continue; + needsRemoteUpdate = true; + break; + } + // A first sync is a refresh of an index that has no local + // copy yet, and `[index] auto_refresh = false` means that no + // refresh happens implicitly (docs/05). The opt-outs are the + // policy's (#648 A5); offline, the sync is a no-op as before + // and resolution reports what is missing. + // + // WHY THIS ONE DOES NOT GO THROUGH `decide_for_miss`/`apply`. + // Those answer "may this run refresh the index that would + // resolve a dependency", and their debounce and one-sync-per- + // process guard are about that one index. This sync creates a + // local copy that does not exist yet, of a DIFFERENT set of + // repositories, and nothing else will create it: taking the + // guard would let a refresh of the builtin index earlier in + // the same run suppress a clone the build cannot proceed + // without. Only the opt-outs are shared, and they are read + // from the same `policy_for`. + const auto refreshPolicy = mcpp::pm::policy_for(**cfg2); + if (needsRemoteUpdate && !refreshPolicy.offline && !refreshPolicy.autoRefresh) { + return std::unexpected(std::string( + "the project's custom index repositories have never been synced, " + "and [index] auto_refresh = false forbids syncing them implicitly\n" + " run `mcpp index update` once, then build again")); + } + if (needsRemoteUpdate && !refreshPolicy.offline) { + mcpp::ui::status("Fetching", "custom index repos (first use)"); + auto projEnv = mcpp::config::make_project_xlings_env(**cfg2, *state.root); + int rc = mcpp::xlings::update_index(projEnv, /*quiet=*/true); + if (rc != 0) { + return std::unexpected( + "project custom index update failed; run `mcpp index update` for details"); + } + } + } + } + } + } + + return {}; +} + +} // namespace mcpp::build diff --git a/src/cli.cppm b/src/cli.cppm index 8bf13376b..7df677d55 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1013,39 +1013,97 @@ 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()) { + // The directory the command inherits is a plain absolute path, not + // `extended_length`'s `\\?\` form: that form is a spelling for + // opening files. Windows accepts it as the current directory, but + // a child started there does not recognise it -- an MSYS shell ran + // in C:\Windows instead (e2e 799) -- and a working directory is + // limited to MAX_PATH in either spelling. + std::error_code cec; + const auto dir = std::filesystem::absolute(std::filesystem::path{cwd}, cec) + .lexically_normal(); + if (!cec) std::filesystem::current_path(dir, 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/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/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/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/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/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 a80079a7c..a870069ea 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 { @@ -110,7 +111,7 @@ namespace pinned { // no output (mcpp#693), and under an MCPP_HOME outside it the xlings mcpp // vendors could not initialise its sandbox. It now declares the UTF-8 code // page, as mcpp.exe does. - inline constexpr std::string_view kXlingsVersion = "2026.9.26.2"; + inline constexpr std::string_view kXlingsVersion = "2026.9.27.1"; inline constexpr std::string_view kNasmVersion = "3.02"; } @@ -157,29 +158,64 @@ 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); + + // 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. @@ -923,13 +959,21 @@ 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. +// +// 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; return xpkg_payload_at(xpkgs_base(env), ref); } @@ -938,55 +982,114 @@ 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()) { - // 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()); + auto pick = mcpp::xpkg_version::select_installed(installed, ref.version); + 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 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); } - return best; } +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; + 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/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/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..4b3fc1e41 --- /dev/null +++ b/tests/e2e/802_a_host_build_applies_its_host_row.sh @@ -0,0 +1,89 @@ +#!/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/e2e/804_a_path_host_tool_builds_with_its_chosen_toolchain.sh b/tests/e2e/804_a_path_host_tool_builds_with_its_chosen_toolchain.sh new file mode 100755 index 000000000..686c2956e --- /dev/null +++ b/tests/e2e/804_a_path_host_tool_builds_with_its_chosen_toolchain.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# requires: gcc elf +# 804_a_path_host_tool_builds_with_its_chosen_toolchain.sh — mcpp#710. +# +# A host tool is built by one compiler, chosen once and recorded in the tool +# store's key: `MCPP_TOOLCHAIN` when set, else the tool package's own +# `[toolchain]` (its workspace's, for a member), else the compiler the +# consumer compiles its build programs with. The member case has a unit test +# (HostToolToolchain.AMemberToolReadsItsWorkspaceToolchain). This test covers +# a tool reached through a plain path dependency, which belongs to no +# workspace: +# 1. a tool package that names no toolchain is built by the consumer's +# build-program compiler (llvm 22.1.8 here), not by the global default +# (gcc on Linux), which is what the sub-build used to resolve for itself; +# 2. a tool package that names its own toolchain is built by it +# (gcc 16.1.0) while the consumer keeps llvm. +# The compiler that produced the tool is read from its `.comment` section. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# An isolated home, so neither the user's default toolchain nor a stale tool +# store entry decides the outcome. The registry is shared: toolchains are +# expensive to install. +export MCPP_HOME="$TMP/mcpphome" +mkdir -p "$MCPP_HOME" +if [ -d "$HOME/.mcpp/registry" ]; then + ln -s "$HOME/.mcpp/registry" "$MCPP_HOME/registry" +fi +unset MCPP_TOOLCHAIN + +mkdir -p toolpkg/src app/src +write_tool() { # $1 = the body of the tool's [toolchain] table, empty for none + cat > toolpkg/mcpp.toml <<'TOML' +[package] +name = "toolpkg" +version = "0.1.0" + +[targets.stamp] +kind = "bin" +main = "src/stamp.cpp" +TOML + if [[ -n "$1" ]]; then + printf '\n[toolchain]\n%s\n' "$1" >> toolpkg/mcpp.toml + fi +} +cat > toolpkg/src/stamp.cpp <<'CPP' +int main() { return 0; } +CPP + +cat > app/mcpp.toml <<'TOML' +[package] +name = "app" +version = "0.1.0" + +[toolchain] +default = "llvm@22.1.8" + +[dependencies] +toolpkg = { path = "../toolpkg", tools = ["stamp"] } +TOML +cat > app/src/main.cpp <<'CPP' +int main() {} +CPP +cat > app/build.mcpp <<'CPP' +#include +import mcpp; +int main() { + const char* tool = mcpp::dep_bin("toolpkg", "stamp"); + if (!tool || !*tool) return 1; + mcpp::warning((std::string("TOOL=") + tool).c_str()); +} +CPP + +tool_path() { sed -n 's/.*TOOL=//p' "$1" | tail -1; } + +# ── 1 ── no toolchain of its own: the consumer's build-program compiler +write_tool "" +(cd app && "$MCPP" build > ../b1.log 2>&1) || { cat b1.log; echo "FAIL: 1: build failed"; exit 1; } +t="$(tool_path b1.log)" +[[ -x "$t" ]] || { cat b1.log; echo "FAIL: 1: no tool binary at '$t'"; exit 1; } +readelf -p .comment "$t" > c1.txt +grep -q 'clang version 22\.1\.8' c1.txt || { + cat c1.txt; echo "FAIL: 1: the tool was not built by the consumer's llvm 22.1.8"; exit 1; } +echo "ok: 1" + +# ── 2 ── its own toolchain: that one, whatever the consumer uses +write_tool 'default = "gcc@16.1.0"' +rm -rf app/target +(cd app && "$MCPP" build > ../b2.log 2>&1) || { cat b2.log; echo "FAIL: 2: build failed"; exit 1; } +t="$(tool_path b2.log)" +[[ -x "$t" ]] || { cat b2.log; echo "FAIL: 2: no tool binary at '$t'"; exit 1; } +readelf -p .comment "$t" > c2.txt +grep -qE 'GCC: \(.*\) 16\.1\.0' c2.txt || { + cat c2.txt; echo "FAIL: 2: the tool's own [toolchain] was not used"; exit 1; } +if grep -q 'clang version' c2.txt; then + cat c2.txt; echo "FAIL: 2: the tool was built by the consumer's clang"; exit 1 +fi +echo "ok: 2" + +echo "PASS: 804_a_path_host_tool_builds_with_its_chosen_toolchain" 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..df2f898c7 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,65 @@ 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); +} + +// 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 21021d267..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. @@ -3782,7 +3813,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 +3832,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 +5811,70 @@ 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 +// 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_prepare_helpers.cpp b/tests/unit/test_prepare_helpers.cpp new file mode 100644 index 000000000..a8d52ed2e --- /dev/null +++ b/tests/unit/test_prepare_helpers.cpp @@ -0,0 +1,65 @@ +// Unit tests for the pure helpers the phases of prepare_build share +// (src/build/prepare/config.cpp and fetch.cpp): the --features request +// grammar, the macro name a define entry names, the previous release's +// reading of a flag element, and whether a git remote is local. Before the +// decomposition of prepare.cppm these were internal to one 16,000-line unit +// and reached only through whole builds; they are exported for this file. + +#include + +import std; +import mcpp.build.prepare; + +using Words = std::vector; + +TEST(FeatureRequest, TokensSplitOnCommasAndSpaces) { + EXPECT_EQ(mcpp::build::feature_request_tokens("a,b c,, d"), (Words{"a", "b", "c", "d"})); + EXPECT_TRUE(mcpp::build::feature_request_tokens("").empty()); + EXPECT_TRUE(mcpp::build::feature_request_tokens(" , ").empty()); +} + +// #649 E8: a token with `/` opens a dependency's feature, so it is a forward +// of the root and never one of the root's own features. A token with an +// empty half stays whole, for the caller to name. +TEST(FeatureRequest, ASlashTokenIsAForwardAndNotARootFeature) { + constexpr std::string_view request = "simd, dep/fast, gpu,/x,y/"; + EXPECT_EQ(mcpp::build::parse_feature_request(request), (Words{"simd", "gpu"})); + EXPECT_EQ(mcpp::build::feature_forward_request_tokens(request), + (Words{"dep/fast", "/x", "y/"})); +} + +TEST(DefineName, IsTheTextBeforeTheFirstEquals) { + EXPECT_EQ(mcpp::build::define_name("NDEBUG"), "NDEBUG"); + EXPECT_EQ(mcpp::build::define_name("VERSION=1"), "VERSION"); + EXPECT_EQ(mcpp::build::define_name("EXPR=a=b"), "EXPR"); + EXPECT_EQ(mcpp::build::define_name("=x"), ""); +} + +// The words an element reached the compiler as before flag_words: `$$` was a +// literal dollar, and `${name}` or `$name` a ninja variable, empty on a +// compile edge. A `defines` entry was read as `-D`. +TEST(PreviousReleaseWords, ReadNinjaEscapesAndVariablesAsTheOldEdgeDid) { + EXPECT_EQ(mcpp::build::previous_release_words("-DA=x$$y", false), (Words{"-DA=x$y"})); + EXPECT_EQ(mcpp::build::previous_release_words("-DA=${v}z", false), (Words{"-DA=z"})); + EXPECT_EQ(mcpp::build::previous_release_words("-DA=$v", false), (Words{"-DA="})); + EXPECT_EQ(mcpp::build::previous_release_words("B=1", true), (Words{"-DB=1"})); +} + +// A remote served by plain filesystem reads is local, so `--offline` keeps +// building it; a scheme or scp-like syntax is remote. A Windows drive letter +// has a colon but no `@`, so an existing `C:\repo` stays local. +TEST(GitRemote, LocalMeansAFileUrlOrAnExistingPath) { + EXPECT_TRUE(mcpp::build::is_local_git_remote("file:///srv/repo.git")); + EXPECT_FALSE(mcpp::build::is_local_git_remote("https://github.com/x/y.git")); + EXPECT_FALSE(mcpp::build::is_local_git_remote("ssh://git@host/x.git")); + EXPECT_FALSE(mcpp::build::is_local_git_remote("git@github.com:x/y.git")); + + namespace fs = std::filesystem; + const auto dir = fs::temp_directory_path() + / std::format("mcpp-prepare-helpers-{:x}", std::random_device{}()); + fs::create_directories(dir); + EXPECT_TRUE(mcpp::build::is_local_git_remote(dir.string())); + EXPECT_FALSE(mcpp::build::is_local_git_remote((dir / "missing").string())); + std::error_code ec; + fs::remove_all(dir, ec); +} 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 diff --git a/tests/unit/test_workspace_inheritance.cpp b/tests/unit/test_workspace_inheritance.cpp index 8c1e2b493..62b7ec9cf 100644 --- a/tests/unit/test_workspace_inheritance.cpp +++ b/tests/unit/test_workspace_inheritance.cpp @@ -165,3 +165,94 @@ 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()); + // Compared through the value: with clang and the MSVC STL, constructing + // std::optional from a literal fails to instantiate in this + // translation unit, which includes gtest's headers and imports std. + auto tc15 = mcpp::build::host_tool_declared_toolchain(*tool, root / "tool", "linux"); + ASSERT_TRUE(tc15.has_value()); + EXPECT_EQ(*tc15, "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()); + auto tc16 = mcpp::build::host_tool_declared_toolchain(*tool, root / "tool", "linux"); + ASSERT_TRUE(tc16.has_value()); + EXPECT_EQ(*tc16, "gcc@16.1.0"); + + std::error_code ec; + fs::remove_all(root, ec); +}