From b5ccee50e7179a9dbe0185885d8a5a25feaaff27 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:38:22 +0800 Subject: [PATCH 1/2] A selected workspace member declares as the root; the clang MSVC row states its compiler version The workspace plan's virtual root declares only its members, and the rules that grant the root's own declarations a privilege read its edges alone, so a rooted workspace's own package and a member selected with -p lost the position each held when planned as its own root (2026.9.28.3). The release canary on mcpp-language-server, whose root package overrides openkal-linux by path against a transitive version request, was refused with "Pick one". PrepareState::declaredByRoot now answers for the root and each selected member, and the kind and reference clashes, the git lock, the identity write-back, edge linkage, direct target-side candidates and the index refresh ask it. Two selected members that disagree about one dependency's checkout or link form are refused, naming both (e2e 850). mcpp#746: the version of the toolset's cl.exe, read from its VS_FIXEDFILEINFO as clang reads it, is passed as -fms-compatibility-version with the toolset words, so it is on every command and in every key (e2e 760, unit tests). e2e 851 states that a member two configurations use is compiled once in each. --- ...-wall-time-progress-count-and-hang-plan.md | 36 ++++ .../docs/2026-09-30-build-wall-time-verify.sh | 18 ++ CHANGELOG.md | 21 ++ docs/05-dependencies.md | 12 ++ docs/07-workspace.md | 13 +- docs/zh/05-dependencies.md | 8 + docs/zh/07-workspace.md | 7 +- modules/toolchain-model/src/linkmodel.cppm | 13 ++ modules/toolchain-model/src/model.cppm | 4 + src/build/prepare/graph.cpp | 43 ++-- src/build/prepare/graph_load.cpp | 12 +- src/build/prepare/manifest.cpp | 1 + src/build/prepare/plan.cpp | 5 +- src/build/prepare/state.cppm | 54 +++++ src/build/prepare/target_side.cpp | 72 +++++-- src/build/prepare/toolchain_env.cpp | 4 + src/build/prepare/xlings.cpp | 61 ++++-- src/toolchain/msvc.cppm | 58 ++++++ tests/e2e/760_msvc_toolset_is_chosen_once.sh | 11 + ..._a_selected_member_declares_as_the_root.sh | 189 ++++++++++++++++++ ...mber_is_compiled_once_per_configuration.sh | 143 +++++++++++++ tests/unit/test_linkmodel.cpp | 15 ++ tests/unit/test_toolchain_msvc.cpp | 63 ++++++ 23 files changed, 809 insertions(+), 54 deletions(-) create mode 100755 tests/e2e/850_a_selected_member_declares_as_the_root.sh create mode 100755 tests/e2e/851_a_shared_member_is_compiled_once_per_configuration.sh diff --git a/.agents/docs/2026-09-30-build-wall-time-progress-count-and-hang-plan.md b/.agents/docs/2026-09-30-build-wall-time-progress-count-and-hang-plan.md index eb190c1c..1ed92430 100644 --- a/.agents/docs/2026-09-30-build-wall-time-progress-count-and-hang-plan.md +++ b/.agents/docs/2026-09-30-build-wall-time-progress-count-and-hang-plan.md @@ -1018,6 +1018,42 @@ resolved as stated. held only `/usr/bin:/bin`. The test's `PATH` now holds System32, as every Windows `PATH` does, and as the other Windows tests with a restricted `PATH` do. +- **The release canary on mcpp-language-server.** Its + commit of 07:44 overrides `openkal-linux` by `path` in its root package, + against the `version` request of `openkal-musl`, which the rule "the root's + declaration wins" settles. 2026.9.28.3 builds it; 2026.9.29.1 to 2026.9.30.2 + refuse it ("Pick one"): the virtual root of the workspace plan declares + only its members, and the rule read the virtual root's edges alone. The + same reading was inventoried across the planner; a selected member now + declares as the root for the kind and reference clashes, the git lock, the + identity write-back, `linkage` on its dependency edges, the target-side + candidates it names directly, and the index refresh. The refusal to mangle + the root's sources stays with the root: a member reaches it only when two + members pin two versions of one package, which one plan builds by mangling + and which each member built on its own before. Two selected members that + disagree about one dependency's checkout (its kind or its reference) or its + link form are refused, naming both. An independent review of the change + found three defects before it was pushed, each fixed: the arguments of the + link-form refusal were out of order, two selected members pointing one + dependency at two directories were only warned about, and link-form + conflicts were compared by the short key, which two packages can share. + e2e 850 states each; every criterion fails on 2026.9.30.2 as released + before the fix. +- **mcpp#746, e2e 760 on Windows.** A job on an older runner image restored + the sandbox cache that a job on a newer image had saved; clang refused the + std module compiled for `msvc19.51.36260` against a translation unit + compiled for `msvc19.51.36257`. The key of the clang MSVC row held the + toolset directory, and a directory name does not determine `cl.exe`'s + build. The version of the toolset's `cl.exe`, read from its + `VS_FIXEDFILEINFO` as clang reads it, is now passed as + `-fms-compatibility-version` with the toolset words, so it is on every + command and in every key. +- **A shared member across two configurations (e2e 851).** A member that two + members of different configurations use is compiled once in each build + directory; in one configuration it is compiled once with the union of the + features its consumers ask for, and a consumer's own flags stay out of it. + The behaviour was correct; the criterion was absent (e2e 833 builds a + member of another standard that uses no shared member). ## 8. Appendix: readings diff --git a/.agents/docs/2026-09-30-build-wall-time-verify.sh b/.agents/docs/2026-09-30-build-wall-time-verify.sh index 7a8b709d..a8b7f4f7 100644 --- a/.agents/docs/2026-09-30-build-wall-time-verify.sh +++ b/.agents/docs/2026-09-30-build-wall-time-verify.sh @@ -158,6 +158,24 @@ if git clone -q --depth 1 https://github.com/openxlings/xlings.git xlings > s8-c else fail "8 xlings from its main branch" ../s8.log; fi else skip "8 xlings could not be cloned"; fi +# ── 9. CHANGE: a rooted workspace's own path override wins ──────────────── +# The shape of the release canary on mcpp-language-server: the workspace's +# own package declares `framework` by `path`, a library it uses asks for it by +# `git`. 2026.9.30.1 refuses it ("Pick one"). +rm -rf "$W/s9"; mkdir -p "$W/s9/fw/src" "$W/s9/libg/src" "$W/s9/ws/src"; cd "$W/s9" +git init -q fw && git -C fw config user.email t@l && git -C fw config user.name t +printf '[package]\nname = "framework"\nversion = "0.1.0"\n\n[build]\nsources = ["src/*.c"]\n\n[targets.framework]\nkind = "lib"\n' > fw/mcpp.toml +printf 'int framework_marker(void) { return 101; }\n' > fw/src/framework.c +git -C fw add -A && git -C fw commit -qm A && rev=$(git -C fw rev-parse HEAD) +printf 'int framework_marker(void) { return 199; }\n' > fw/src/framework.c +printf '[package]\nname = "libg"\nversion = "0.1.0"\n\n[build]\nsources = ["src/*.c"]\n\n[targets.libg]\nkind = "lib"\n\n[dependencies.framework]\ngit = "%s"\nrev = "%s"\n' "$W/s9/fw" "$rev" > libg/mcpp.toml +printf 'extern int framework_marker(void);\nint libg_marker(void) { return framework_marker(); }\n' > libg/src/libg.c +printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nframework = { path = "%s" }\nlibg = { path = "%s" }\n\n[targets.app]\nkind = "bin"\nmain = "src/main.cpp"\n\n[workspace]\nmembers = ["."]\n' "$W/s9/fw" "$W/s9/libg" > ws/mcpp.toml +printf '#include \nextern "C" int libg_marker(void);\nint main() { std::printf("%%d\\n", libg_marker()); }\n' > ws/src/main.cpp +if (cd ws && "$MCPP" build > ../s9.log 2>&1) && [ "$("$(bin_of ws/target app)")" = 199 ]; then + pass "9 CHANGE: a rooted workspace's own path override wins over a library's git declaration" +else fail "9 CHANGE: the rooted workspace's override" s9.log; fi + echo echo "RESULT: $passes passed, $fails failed, $skips skipped (mcpp $VER)" [ "$fails" -eq 0 ] diff --git a/CHANGELOG.md b/CHANGELOG.md index dae8cb7d..8b554358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,27 @@ of 10.5 s; a build with nothing to do is unchanged at 0.05 s. BMI after that compile, so a fresh build could compile the consumer first and fail with `failed to read compiled module`. The stage edge of such a BMI now waits for those BMIs (e2e 849). +- **A selected workspace member declares as the root.** Since 2026.9.29.1 a + workspace is planned from a virtual root that declares only its members, and + the rules that grant the root's own declarations a privilege read that + root's edges alone. A rooted workspace's own package, and a member selected + with `-p`, again hold the position each held when planned as its own root: + its `path` or `git` override of a dependency that another package requests + by another kind wins (the release canary on mcpp-language-server, whose root + package overrides `openkal-linux` by `path`, was refused with "Pick one"); + `linkage` on its dependency edges is honoured; the identity its declarations + adopt is written back for the lock names; and its registry dependencies are + considered for the index refresh. Two selected members that disagree about + one dependency's checkout (its kind or its reference) or its link form are + refused, naming both (e2e 850). +- **The clang MSVC row states the compiler version (mcpp#746).** The version + of the toolset's `cl.exe`, read from its file version as clang reads it, is + passed as `-fms-compatibility-version` on every command and enters every + key. Without it the driver chose the version itself and wrote it into each + BMI, and a std module compiled under one runner image's `cl.exe` was served + to another image whose `cl.exe` differed under the same toolset directory + name (`std.pcm was compiled for ... msvc19.51.36260 ... msvc19.51.36257`). + A clang build for `*-windows-msvc` rebuilds once after the upgrade. ### Changed diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 27d04486..202db560 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -174,6 +174,18 @@ disagrees with another dependency, with neither being the root, is never settled by guessing which one was declared first — that is exactly the "accident of queue order" this section replaces. +**In a workspace (mcpp 2026.9.30.2+).** The root is the package a command +builds: a rooted workspace's own package, and each member the command selects +(every member under `--workspace`, `X` under `-p X`). Each holds the root's +position for its own declarations, as it did when every member was planned as +its own root, so a member's `path` override of a dependency that another +package requests by `version` wins, with the warning above. Two selected +members that declare one identity by two kinds, or by two references of one +kind, are refused, naming both: one configuration builds one checkout of a +package. The same holds for `linkage` +on a selected member's dependency edges, which is honoured, and refused when +two selected members ask for two forms of one package. + ### The identity of a `path` or `git` dependency (mcpp 2026.9.14.2+) A `path` or `git` dependency is the package its manifest declares, whatever key diff --git a/docs/07-workspace.md b/docs/07-workspace.md index 16e564f0..99f6854a 100644 --- a/docs/07-workspace.md +++ b/docs/07-workspace.md @@ -435,7 +435,18 @@ member that several members use is compiled once. that member's commands. Editing them recompiles that member and what imports it; the build directory stays the same. - **Features.** `--features f` activates `f` in each selected member that - declares it, and is refused when no selected member declares it. + declares it, and is refused when no selected member declares it. A package + that several members of one configuration use is compiled once, with the + union of the features they ask for; a package that members of two + configurations use is compiled once in each. +- **The root's declarations.** Each selected member declares as the root did + when it was planned alone: its `path` or `git` override of a dependency wins + over another package's declaration, `linkage` on its dependency edges is + honoured, and its registry dependencies are considered for the index + refresh (2026.9.30.2+). Two selected members that disagree about one + dependency's checkout (its kind or its reference) or its link form are + refused, naming both. See + [05 — When two declarations of one dependency disagree](05-dependencies.md#when-two-declarations-of-one-dependency-disagree). - **Hooks.** The `[hooks]` of every selected member run around the build, in member order. - **Resources.** A member's `[resources]` and `windows_code_page` are diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index afd61014..73510a02 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -160,6 +160,14 @@ sdk = { version = "1.0", visibility = "private" } 冲突、且都不是根的情形,绝不会靠猜哪个先被声明来解决 —— 那正是本节要替换掉 的"队列顺序的意外"。 +**工作区中(mcpp 2026.9.30.2+)。** 根是命令所构建的包:带根包工作区自己的包, +以及命令选中的每个成员(`--workspace` 下是全部成员,`-p X` 下是 `X`)。每个都 +以根的位置对待自己的声明,与每个成员各自作为根规划时一致;因此成员用 `path` +覆盖另一个包以 `version` 请求的依赖时,成员的声明胜出,并给出上文的警告。两个 +被选中的成员以不同种类、或以同一种类的不同引用声明同一身份时被拒绝,并点名双方: +一个配置只构建一个包的一份 checkout。被选中成员依赖边上的 `linkage` 同理:它会被采纳;两个被选中成员 +为同一个包要求两种形态时被拒绝。 + ### `path` 或 `git` 依赖的身份(mcpp 2026.9.14.2+) `path` 或 `git` 依赖就是它的 manifest 所声明的那个包,与指向它的键无关。来源 diff --git a/docs/zh/07-workspace.md b/docs/zh/07-workspace.md index 6ed6777d..6470fec5 100644 --- a/docs/zh/07-workspace.md +++ b/docs/zh/07-workspace.md @@ -403,7 +403,12 @@ mcpp test --workspace --workspace-timeout 1800 # whole fan-out (default 0 = no - **编译参数。** 成员的 `cflags`、`cxxflags`、`ldflags` 与 defines 作用于该成员自己的命令。 修改它们会重新编译该成员以及导入它的单元,构建目录保持不变。 - **Feature。** `--features f` 在每个声明了 `f` 的被选成员中启用它;没有被选成员声明它时, - 命令被拒绝。 + 命令被拒绝。同一配置中被多个成员使用的包只编译一次,feature 取它们请求的并集;被两个 + 配置的成员使用的包在每个配置中各编译一次。 +- **根的声明。** 每个被选成员都像单独规划时的根那样声明依赖:它对某个依赖的 `path` 或 + `git` 覆盖胜过其他包的声明,它依赖边上的 `linkage` 被采纳,它的注册表依赖参与索引刷新 + 的判断(2026.9.30.2+)。两个被选成员对同一依赖的 checkout(种类或引用)或链接形态意见不一 + 时被拒绝,并点名双方。见 [05 —— 同一依赖两条声明冲突的处理](05-dependencies.md#同一依赖两条声明冲突的处理)。 - **Hooks。** 每个被选成员的 `[hooks]` 按成员顺序在构建前后运行。 - **资源。** 成员的 `[resources]` 与 `windows_code_page` 按该成员的目录与 include 目录编译, 只嵌入该成员自己的程序与共享库(2026.9.29.2+)。 diff --git a/modules/toolchain-model/src/linkmodel.cppm b/modules/toolchain-model/src/linkmodel.cppm index 85b7ff4f..fc07195e 100644 --- a/modules/toolchain-model/src/linkmodel.cppm +++ b/modules/toolchain-model/src/linkmodel.cppm @@ -80,6 +80,7 @@ struct ToolchainLinkModel { std::filesystem::path msvcToolsDir; std::filesystem::path winSdkRoot; std::string winSdkVersion; + std::string msvcCompilerVersion; bool clangDriver = false; // clang: -isystem headers; gcc: -idirafter // gcc: -idirafter (…#include_next), -B/-L only @@ -117,6 +118,15 @@ struct ToolchainLinkModel { // and the SDK always travel together: given only the toolset, the driver // stops reading %INCLUDE% and takes the registry's newest SDK instead. // Read by the compile line, the link line and the cache key alike. + // + // THE COMPILER VERSION TRAVELS WITH THEM (mcpp#746). Without + // `-fms-compatibility-version` the driver reads it from a cl.exe it + // locates itself and writes it into every BMI's target + // (`x86_64-pc-windows-msvc19.51.36260`), and a BMI whose version differs + // from the importer's is refused. Two runner images held different cl.exe + // builds under one toolset directory name, so the directory, which was + // the key's only view of the toolset, served one image's std module to + // the other. Said here, the version is on every command and in every key. std::vector msvc_driver_tokens(const PathEscape& esc) const { std::vector out; if (msvcToolsDir.empty()) return out; @@ -128,6 +138,8 @@ struct ToolchainLinkModel { out.push_back("-Xmicrosoft-windows-sdk-version"); out.push_back(winSdkVersion); } + if (!msvcCompilerVersion.empty()) + out.push_back("-fms-compatibility-version=" + msvcCompilerVersion); return out; } @@ -405,6 +417,7 @@ ToolchainLinkModel resolve_link_model(const Toolchain& tc) { lm.msvcToolsDir = tc.msvcToolsDir; lm.winSdkRoot = tc.windowsSdkRoot; lm.winSdkVersion = tc.windowsSdkVersion; + lm.msvcCompilerVersion = tc.msvcCompilerVersion; } return lm; } diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index 2612170c..286ca401 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -279,6 +279,10 @@ struct Toolchain { // the cl.exe row carries the same answer in its own path and envOverrides. std::filesystem::path msvcToolsDir; // /VC/Tools/MSVC/ std::string msvcToolsVersion; // "14.44.35207" + // The version of that toolset's cl.exe ("19.44.35211"), from its file + // version: what clang would otherwise read from the same file itself, and + // what a toolset directory name does not determine (mcpp#746). + std::string msvcCompilerVersion; std::string msvcOrigin; // "system" | "managed" std::string msvcProduct; // for the one printed line std::filesystem::path windowsSdkRoot; // /10 or the payload diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index dd42ef91..61d1e70b 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -768,8 +768,10 @@ step4b_resolve_identity(PrepareState& state, WorklistItemCtx& ctx) { 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()) { + // A root's declaration (a selected member's included) states the + // identity it selected, for its readers: the lock names, the record. + for (auto* mf : state.rootDeclarationManifests(item.consumerDepIndex)) { + if (auto it = mf->dependencies.find(name); it != mf->dependencies.end()) { it->second.namespace_ = spec.namespace_; it->second.shortName = spec.shortName; it->second.candidates = spec.candidates; @@ -901,6 +903,9 @@ step4b_identity_version_merge(PrepareState& state, WorklistItemCtx& ctx, // the main package — main-package mangling // would mean rewriting user-authored sources, // which is too surprising for a fallback path. + // Only `kMainConsumer`: a selected member reaches here + // only when two members pin two versions, which one + // plan builds by mangling (not `declaredByRoot`). // * The secondary version must be a leaf (no own // transitive deps) — recursive mangling is // deferred to a follow-up. @@ -1186,7 +1191,7 @@ step4b_handle_already_resolved(PrepareState& state, WorklistItemCtx& ctx, // edges (dep_spec.cppm). if (it->second.source != sourceKind) { const bool existingIsRoot = it->second.fromRoot; - const bool incomingIsRoot = item.consumerDepIndex == kMainConsumer; + const bool incomingIsRoot = state.declaredByRoot(item.consumerDepIndex); if (!existingIsRoot && !incomingIsRoot) { return std::unexpected(std::format( @@ -1198,6 +1203,12 @@ step4b_handle_already_resolved(PrepareState& state, WorklistItemCtx& ctx, sourceKind, item.requestedBy, key.ns, key.ns.empty() ? "" : ".", key.shortName)); } + // Two selected members, each a root: refused, naming both. + if (it->second.fromSelectedMember && incomingIsRoot && item.consumerDepIndex + != kMainConsumer && it->second.requestedBy != item.requestedBy) + return std::unexpected(state.twoMembersRefusal(key, + std::format("a {} dep by '{}'", it->second.source, it->second.requestedBy), + std::format("a {} dep by '{}'", sourceKind, item.requestedBy))); if (incomingIsRoot && !existingIsRoot) { // FIFO SEEDING MAKES THIS UNREACHABLE. Every root-declared // identity is pushed onto `worklist` before this loop @@ -1296,7 +1307,7 @@ step4b_handle_already_resolved(PrepareState& state, WorklistItemCtx& ctx, sourceRefOf(state, sourceKind, spec, item.resolveRoot, item.originalConstraint); if (incomingRef != it->second.sourceRef) { const bool existingIsRoot = it->second.fromRoot; - const bool incomingIsRoot = item.consumerDepIndex == kMainConsumer; + const bool incomingIsRoot = state.declaredByRoot(item.consumerDepIndex); if (incomingIsRoot && !existingIsRoot) { // See the identical comment in the kind-clash branch // above: unreachable under FIFO seeding, and refused @@ -1310,6 +1321,14 @@ step4b_handle_already_resolved(PrepareState& state, WorklistItemCtx& ctx, key.ns, key.ns.empty() ? "" : ".", key.shortName, it->second.requestedBy)); } + // Two selected members, as in the kind clash above. + if (it->second.fromSelectedMember && incomingIsRoot && item.consumerDepIndex + != kMainConsumer && it->second.requestedBy != item.requestedBy) + return std::unexpected(state.twoMembersRefusal(key, + std::format("{} '{}' by '{}'", sourceKind, it->second.sourceRef, + it->second.requestedBy), + std::format("{} '{}' by '{}'", sourceKind, incomingRef, + item.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 @@ -1324,9 +1343,10 @@ step4b_handle_already_resolved(PrepareState& state, WorklistItemCtx& ctx, 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)), + existingIsRoot && !incomingIsRoot + ? std::string("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)); @@ -1519,10 +1539,7 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { } // A selected workspace member's own git dependencies are locked // as a root's are (workspace design 2026-09-29 §15). - const bool consumerIsMember = item.consumerDepIndex != kMainConsumer - && item.consumerDepIndex + 1 < state.packages.size() - && state.packages[item.consumerDepIndex + 1].selectedMember; - if (item.consumerDepIndex == kMainConsumer || consumerIsMember) { + if (state.declaredByRoot(item.consumerDepIndex)) { // 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. @@ -1822,7 +1839,9 @@ step4b_finalize_dependency(PrepareState& state, WorklistItemCtx& ctx) { .source = sourceKind, .sourceRef = sourceRefOf(state, sourceKind, spec, item.resolveRoot, item.originalConstraint), - .fromRoot = item.consumerDepIndex == kMainConsumer, + .fromRoot = state.declaredByRoot(item.consumerDepIndex), + .fromSelectedMember = item.consumerDepIndex != kMainConsumer + && state.declaredByRoot(item.consumerDepIndex), .devOnly = item.devOnly, .depIndex = state.dep_manifests.size() - 1, .linkFlagsAdded = std::move(linkFlagsAdded), diff --git a/src/build/prepare/graph_load.cpp b/src/build/prepare/graph_load.cpp index 2b932452..abd07720 100644 --- a/src/build/prepare/graph_load.cpp +++ b/src/build/prepare/graph_load.cpp @@ -197,12 +197,14 @@ static void step4a_define_split_and_identity_closures(PrepareState& state) { }; // 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. + // (the build banner, the resolution record, the lock names) sees; in a + // workspace plan a selected member's declaration is the root's. 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; + for (auto* mf : state.rootDeclarationManifests(item.consumerDepIndex)) { + if (auto it = mf->dependencies.find(item.name); it != mf->dependencies.end()) { + it->second.namespace_ = declared.ns; + it->second.shortName = declared.shortName; + } } }; // A key that names an identity other than the one its `path` or `git` diff --git a/src/build/prepare/manifest.cpp b/src/build/prepare/manifest.cpp index 4b22bbba..105b0bb9 100644 --- a/src/build/prepare/manifest.cpp +++ b/src/build/prepare/manifest.cpp @@ -188,6 +188,7 @@ select_workspace_members(PrepareState& state, const std::filesystem::path& wsRoo state.memberTargets[canonical] = t->second; auto member = mcpp::project::load_member_manifest(*state.wsManifest, wsRoot, mp); if (!member) return std::unexpected(member.error()); + state.selectedMemberManifests.emplace_back(dir, *member); if (auto r = refuse_unknown_capability(*member, dir / "mcpp.toml"); !r) return r; if (auto r = report_manifest_statements(*member, state.overrides.strict); !r) diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 13bcc360..a0d7084e 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -360,13 +360,16 @@ static std::expected step13_link_forms(PrepareState& state, B // 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) + // A selected workspace member's edges are the root's, and honoured. + for (std::size_t i = 1; i < state.packages.size(); ++i) { + if (state.packages[i].selectedMember) continue; 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; diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 5353a7d6..2846e4fd 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -187,6 +187,11 @@ constexpr std::size_t kMainConsumer = static_cast(-1); // `DependencySpec::linkage` is honoured only on the root's own // edges — see dep_spec.cppm. bool fromRoot = false; + // True when that declaration is a selected workspace member's own: + // `fromRoot` without being the virtual root's member edge. Two + // selected members that disagree about one dependency are refused, + // since neither outranks the other. + bool fromSelectedMember = 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 @@ -296,6 +301,11 @@ struct PrepareState { // `[workspace] members`, in selection order, beside it. std::map selectedMembers; std::vector selectedMemberPaths; + // Each selected member's directory and manifest, as loaded when the + // selection was made: read by what runs before the graph is walked (the + // index refresh), which a root's own declarations reach and a member's + // must as well (`declaredByRoot`). + std::vector> selectedMemberManifests; // `--features /` tokens, by the selected member that // declares the dependency key: the forwards its edges receive. std::map>> @@ -326,6 +336,50 @@ struct PrepareState { if (selectedMembers.contains(key)) return key; return std::nullopt; } + // A declaration the root makes: the root manifest's own + // (`consumerDepIndex == kMainConsumer`), or, in a workspace plan, a + // selected member's. Each selected member stands where the root stood + // before a workspace became one plan (workspace design 2026-09-29 §15): + // the virtual root of 2026.9.29.1 declares nothing but its members, so a + // rule that read only its edges treated a rooted workspace's own + // declarations as a dependency's. Every privilege the root's own edges + // hold asks this one question: the dependency kind clash and the + // reference clash, the git lock, the identity a declaration adopts, a + // declared `linkage`, which target-side candidates are direct, and the + // index refresh. (The refusal to mangle the root's sources stays with the + // root alone; see the multi-version branch in graph.cpp.) + bool declaredByRoot(std::size_t consumerDepIndex) const { + if (consumerDepIndex == kMainConsumer) return true; + const auto i = consumerDepIndex + 1; + return i < packages.size() && packages[i].selectedMember; + } + // The refusal of two selected members that point one dependency at two + // checkouts, `first` and `second` each saying how and by whom: neither + // outranks the other, and one configuration holds one checkout. + std::string twoMembersRefusal(const ResolvedKey& key, std::string_view first, + std::string_view second) const { + const auto name = qualifiedKey(key); + return std::format("dependency '{}' is declared as {} and as {}, two members " + "this build selects.\n A configuration holds one " + "checkout of a package: declare '{}' alike in both.", + name, first, second, name); + } + // The manifests that hold the root declarations of `consumerDepIndex`: + // the root manifest, or a selected member's two copies (its load record + // in `dep_manifests` and its package's snapshot). Empty for any other + // consumer. A write that states what a root declaration resolved to + // writes all of them, so no reader sees the stale one. + std::vector rootDeclarationManifests(std::size_t consumerDepIndex) { + if (consumerDepIndex == kMainConsumer) { + if (!m) return {}; + return {&*m}; + } + if (!declaredByRoot(consumerDepIndex)) return {}; + std::vector out{&packages[consumerDepIndex + 1].manifest}; + if (consumerDepIndex < dep_manifests.size() && dep_manifests[consumerDepIndex]) + out.push_back(dep_manifests[consumerDepIndex].get()); + return out; + } std::filesystem::path runtimeWorkspaceRoot; mcpp::xlings::runtime::RuntimeSelection runtimeSelection; std::filesystem::path workRoot; diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index bc8e2adf..1b255797 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -112,16 +112,24 @@ step9_gather_target_side_candidates(PrepareState& state) { // a second lookup of something already in hand. using Candidate = TargetSideCandidate; - const auto& rootDeps = state.m->dependencies; + // The root's own edges, and in a workspace plan the selected members' + // (`PrepareState::declaredByRoot`), whose dependencies the author + // wrote as directly as a root's. + std::vector*> rootDeps{ + &state.m->dependencies}; + for (std::size_t i = 1; i < state.packages.size(); ++i) + if (state.packages[i].selectedMember) + rootDeps.push_back(&state.packages[i].manifest.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; - } + for (auto const* deps : rootDeps) + for (auto const& [k, _] : *deps) { + 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; }; @@ -1336,14 +1344,50 @@ static std::expected step9_dependency_link_forms(PrepareState 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; + // consumer is a supply-chain property, not a convenience. In a + // workspace plan the root's edges are the selected members' own + // (`PrepareState::declaredByRoot`): the virtual root declares nothing + // but the members, so reading only its edges ignored every request. + // One configuration builds a package in one form, so two members + // that ask for two forms of one package are refused, naming both. + // + // Compared by the identity each declaration resolved to (its + // namespace and name, written back after resolution), not by the key + // spelled: `mcpplibs.foo` and `foo` are one package, and `a.foo` and + // `b.foo` are two. + struct Asked { lf::DepLinkage form; std::string by; }; + std::map> askedFor; + auto requestEdges = [&](const mcpp::manifest::Manifest& mf, std::string_view who) + -> std::expected { + for (auto const& [depName, spec] : mf.dependencies) { + if (spec.linkage.empty()) continue; + auto parsed = lf::parse(spec.linkage); + if (!parsed) continue; auto shortKey = spec.shortName.empty() ? depName : spec.shortName; + const auto identity = spec.namespace_.empty() + ? depName : spec.namespace_ + "." + shortKey; + if (auto it = askedFor.find(identity); + it != askedFor.end() && it->second.form != *parsed) + return std::unexpected(std::format( + "dependency '{}' is to be linked as '{}' by '{}' and as " + "'{}' by '{}'.\n" + " A configuration builds a package in one form: " + "state the same `linkage` in both.", + identity, lf::to_string(it->second.form), it->second.by, + lf::to_string(*parsed), who)); + askedFor.emplace(identity, Asked{*parsed, std::string(who)}); + request.perPackage[depName] = *parsed; request.perPackage.emplace(shortKey, *parsed); } + return {}; + }; + if (auto r = requestEdges(*state.m, state.m->package.name); !r) + return std::unexpected(r.error()); + for (std::size_t i = 1; i < state.packages.size(); ++i) { + if (!state.packages[i].selectedMember) continue; + if (auto r = requestEdges(state.packages[i].manifest, + state.packages[i].manifest.package.name); !r) + return std::unexpected(r.error()); } lf::TargetFacts targetFacts; diff --git a/src/build/prepare/toolchain_env.cpp b/src/build/prepare/toolchain_env.cpp index 554c154c..51574d7c 100644 --- a/src/build/prepare/toolchain_env.cpp +++ b/src/build/prepare/toolchain_env.cpp @@ -210,6 +210,10 @@ bind_msvc_sysroot(mcpp::toolchain::Toolchain& tc, tc.msvcToolsDir = choice->toolsDir; tc.msvcToolsVersion = choice->version; + // The version of this toolset's cl.exe, read from the file (this row runs + // no cl.exe) and said to the driver by the link model, so it is on every + // command and in every key rather than chosen by the driver (mcpp#746). + tc.msvcCompilerVersion = msvc::compiler_version_in_tools_dir(choice->toolsDir, tt->arch); tc.msvcOrigin = origin; tc.msvcProduct = choice->product; // The toolset's own redistributable CRT (#718), reached from the diff --git a/src/build/prepare/xlings.cpp b/src/build/prepare/xlings.cpp index 7edc3506..3229308a 100644 --- a/src/build/prepare/xlings.cpp +++ b/src/build/prepare/xlings.cpp @@ -324,31 +324,52 @@ step3_define_host_tc_closures_and_refresh_index(PrepareState& state) { // // 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()) { + // + // The root's declarations, and in a workspace plan each selected member's + // (`PrepareState::declaredByRoot`): the virtual root declares only its + // members, by `path`, so asking about its edges alone never refreshed for + // a member's registry dependency, which each member, planned as its own + // root before 2026.9.29.1, did. A member's dependency is routed by the + // member's own `[indices]` and directory, as the walk routes it. + const bool anyDeclared = !state.m->dependencies.empty() + || std::ranges::any_of(state.selectedMemberManifests, + [](auto const& mm) { return !mm.second.dependencies.empty(); }); + if (anyDeclared) { if (auto cfg2 = state.get_cfg(true)) { auto xlEnv = mcpp::config::make_xlings_env(**cfg2); auto policy = mcpp::pm::policy_for(**cfg2); + // Returns true once a refresh has been applied: one sync covers + // every dependency. + auto consider = [&](const mcpp::pm::IndexRoute& route, + const mcpp::manifest::Manifest& mf) { + for (auto& [depName, spec] : mf.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()); + return true; + } + return false; + }; // 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 + bool synced = consider( + mcpp::pm::IndexRoute{ &state.m->indices, *state.root, *cfg2 }, *state.m); + for (auto const& [dir, member] : state.selectedMemberManifests) { + if (synced) break; + synced = consider(mcpp::pm::IndexRoute{ &member.indices, dir, *cfg2 }, member); } } } diff --git a/src/toolchain/msvc.cppm b/src/toolchain/msvc.cppm index 0761ef56..3aa2f4a6 100644 --- a/src/toolchain/msvc.cppm +++ b/src/toolchain/msvc.cppm @@ -426,6 +426,19 @@ std::filesystem::path staged_std_bmi_path(const std::filesystem::path& outputDir std::filesystem::path std_compat_bmi_path(const std::filesystem::path& cacheDir); std::filesystem::path staged_std_compat_bmi_path(const std::filesystem::path& outputDir); +// The file version of a PE image, from its `VS_FIXEDFILEINFO`, as +// `major.minor.build`: the reading clang makes of cl.exe to choose +// `-fms-compatibility-version` when none is given. nullopt when the image +// carries no version resource. +std::optional pe_file_version(std::string_view image); + +// The version of the cl.exe of the toolset at `toolsDir` (`.../MSVC/`) +// that clang reads for a target of `archGnu` ("x86_64", "aarch64", "i686"), +// read from the file without running it (mcpp#746). Empty when that cl.exe is +// absent or carries no version, where clang falls back as it always did. +std::string compiler_version_in_tools_dir(const std::filesystem::path& toolsDir, + std::string_view archGnu); + } // namespace mcpp::toolchain::msvc namespace mcpp::toolchain::msvc { @@ -1399,6 +1412,51 @@ std::filesystem::path staged_std_compat_bmi_path(const std::filesystem::path& ou return outputDir / "ifc.cache" / "std.compat.ifc"; } +std::optional pe_file_version(std::string_view image) { + // VS_FIXEDFILEINFO begins with dwSignature 0xFEEF04BD and dwStrucVersion + // 0x00010000, then dwFileVersionMS and dwFileVersionLS, little-endian. + // clang's own reading takes HIWORD(MS).LOWORD(MS).HIWORD(LS), and so does + // this one, so the version said on the command line is the version the + // driver would have chosen from the same file. + constexpr std::string_view signature{"\xBD\x04\xEF\xFE", 4}; + const auto u32 = [&](std::size_t at) { + std::uint32_t v = 0; + for (std::size_t i = 0; i < 4; ++i) + v |= static_cast(static_cast(image[at + i])) << (8 * i); + return v; + }; + for (auto at = image.find(signature); at != std::string_view::npos; + at = image.find(signature, at + 1)) { + if (at + 16 > image.size()) break; + if (u32(at + 4) != 0x00010000u) continue; + const auto ms = u32(at + 8), ls = u32(at + 12); + if ((ms >> 16) == 0) continue; + return std::format("{}.{}.{}", ms >> 16, ms & 0xFFFFu, ls >> 16); + } + return std::nullopt; +} + +std::string compiler_version_in_tools_dir(const std::filesystem::path& toolsDir, + std::string_view archGnu) { + // The one file clang reads (llvm::getSubDirectoryPath, SubDirectoryType:: + // Bin, for a VS2017-or-newer layout): `bin/Hostx64` when the driver runs + // as x86_64 and `bin/Hostx86` otherwise, arm64 included (clang 22 names + // no other host directory), then the target's directory. Another cl.exe of the same toolset could carry + // another build, and the version said must be the one the driver would + // have chosen, or the flag would change what a build compiles for. + const std::string_view host = + mcpp::platform::host_arch == std::string_view("x86_64") ? "Hostx64" : "Hostx86"; + std::string_view target = "x64"; + if (archGnu == "aarch64") target = "arm64"; + else if (archGnu == "i686" || archGnu == "x86") target = "x86"; + const auto cl = toolsDir / "bin" / host / target / "cl.exe"; + std::error_code ec; + if (!std::filesystem::is_regular_file(cl, ec)) return {}; + std::ifstream is(cl, std::ios::binary); + std::string image{std::istreambuf_iterator(is), std::istreambuf_iterator()}; + return pe_file_version(image).value_or(std::string{}); +} + namespace { std::string cl_stage_command(const Toolchain& tc, diff --git a/tests/e2e/760_msvc_toolset_is_chosen_once.sh b/tests/e2e/760_msvc_toolset_is_chosen_once.sh index f48c111f..ead68f77 100755 --- a/tests/e2e/760_msvc_toolset_is_chosen_once.sh +++ b/tests/e2e/760_msvc_toolset_is_chosen_once.sh @@ -71,6 +71,12 @@ grep -q -- "-Xmicrosoft-windows-sdk-version" "$NINJA" \ || { echo "FAIL: build.ninja does not pass the SDK to clang"; exit 1; } grep -q "MSVC\\\\$EXPECTED\|MSVC/$EXPECTED" "$NINJA" \ || { echo "FAIL: build.ninja names a toolset other than $EXPECTED"; exit 1; } +# The compiler version travels with the toolset (mcpp#746): read from that +# toolset's cl.exe and said on every command, so the std module and the +# translation units cannot be compiled for two MSVC versions. +MSVER="$(grep -o -- '-fms-compatibility-version=[0-9.]*' "$NINJA" | sort -u)" +[[ "$(echo "$MSVER" | wc -l)" -eq 1 && "$MSVER" == -fms-compatibility-version=19.* ]] \ + || { echo "FAIL: build.ninja does not say one MSVC compiler version: '$MSVER'"; exit 1; } out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $out"; exit 1; } [[ "$out" == *"Hello"* || "$out" == *"hello"* ]] || { echo "FAIL: run output: $out"; exit 1; } @@ -91,6 +97,11 @@ grep -q "VCToolsInstallDir (14.99.0) is ignored" pinned.log \ || { echo "FAIL: the ignored VCToolsInstallDir was not reported:"; cat pinned.log; exit 1; } grep -q "sysroot msvc@$EXPECTED → MSVC $EXPECTED (system" pinned.log \ || { echo "FAIL: pinned toolset not taken from the machine:"; cat pinned.log; exit 1; } +# The declaration the environment makes is ignored for the version too: the +# pinned build says the same version as the default one. +PINNED_NINJA="$(find target -name build.ninja | xargs ls -t | head -1)" +[[ "$(grep -o -- '-fms-compatibility-version=[0-9.]*' "$PINNED_NINJA" | sort -u)" == "$MSVER" ]] \ + || { echo "FAIL: the pinned build says another MSVC compiler version than $MSVER"; exit 1; } # ── 3. What is not an MSVC toolset is refused where the manifest is read ── sed -i "s|sysroot = \"msvc@$EXPECTED\"|sysroot = \"xim:glibc@2.39\"|" mcpp.toml diff --git a/tests/e2e/850_a_selected_member_declares_as_the_root.sh b/tests/e2e/850_a_selected_member_declares_as_the_root.sh new file mode 100755 index 00000000..0c4e0487 --- /dev/null +++ b/tests/e2e/850_a_selected_member_declares_as_the_root.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# requires: gcc +# 850 -- a selected workspace member's own declarations are the root's. +# +# Since 2026.9.29.1 a workspace is one plan whose root is a virtual manifest +# that declares only its members, by `path`. The rules that grant the root's +# own declarations a privilege read that virtual root's edges alone, so a +# rooted workspace's own package, and any member selected with `-p`, lost the +# position each held when it was planned as its own root (2026.9.28.3): +# +# - a `path` override of a dependency that a transitive package requests by +# another kind was refused ("... Pick one. declare it in the root"), which +# is how the release canary of 2026.9.30.2 failed on mcpp-language-server, +# whose root package overrides `openkal-linux` by `path`; +# - a `linkage` stated on a member's dependency edge was ignored; +# - the index refresh never considered a member's own dependencies. +# +# Criteria: +# A. A rooted workspace's own package declares `framework` by `path`; a +# library it uses declares it by `git`. `mcpp build` builds, states that +# the root's declaration wins, and the program reads the path checkout. +# B. The same for a member selected with `-p`. +# C. Two selected members that declare `framework` by different kinds are +# refused, naming both; so are two that point it at two directories. +# D. A member's `linkage = "shared"` on its dependency edge is honoured: the +# dependency is a shared library beside the member's program. +# E. Two selected members that ask for two forms of one dependency are +# refused, naming both; one of them alone builds. +# F. The index refresh considers a member's own dependencies. +set -e +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +EXE="" +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe" ;; esac +bin_of() { find "$1" -path "*/bin/*" -name "$2$EXE" -type f | head -1; } + +# ── a git origin for "framework": commit A answers 101, the working tree 199 ── +FW="$TMP/framework" +FW_HOST="$(host_path "$FW")" +mkdir -p "$FW/src" +git init --quiet "$FW" +git -C "$FW" config user.email "test@local" +git -C "$FW" config user.name "test" +printf '[package]\nname = "framework"\nversion = "0.1.0"\n\n[build]\nsources = ["src/*.c"]\n\n[targets.framework]\nkind = "lib"\n' > "$FW/mcpp.toml" +printf 'int framework_marker(void) { return 101; }\n' > "$FW/src/framework.c" +git -C "$FW" add -A > /dev/null +git -C "$FW" commit --quiet -m A +REV_A=$(git -C "$FW" rev-parse HEAD) +printf 'int framework_marker(void) { return 199; }\n' > "$FW/src/framework.c" + +# libg: a library outside every workspace that asks for framework at commit A. +mkdir -p "$TMP/libg/src" +cat > "$TMP/libg/mcpp.toml" < "$TMP/libg/src/libg.c" + +main_file() { + printf '#include \nextern "C" int libg_marker(void);\nint main() { std::printf("libg=%%d\\n", libg_marker()); return 0; }\n' > "$1" +} +# package : a program that uses libg and +# declares framework itself. +package() { + mkdir -p "$1/src" + main_file "$1/src/main.cpp" + cat > "$1/mcpp.toml" <> "$W1/mcpp.toml" <<'EOF' + +[workspace] +members = [".", "tool"] +EOF +(cd "$W1" && "$MCPP" build > "$TMP/a.log" 2>&1) || fail "A: the rooted workspace was refused" "$TMP/a.log" +grep -q "the root's declaration wins" "$TMP/a.log" || fail "A: no statement that the root's declaration wins" "$TMP/a.log" +[ "$("$(bin_of "$W1/target" app)" | tr -d '\r')" = "libg=199" ] \ + || fail "A: the program does not read the path checkout" "$TMP/a.log" +echo "ok: A, a rooted workspace's own path declaration wins over a library's git one" + +# ── B ────────────────────────────────────────────────────────────────────── +(cd "$W1" && "$MCPP" build -p tool > "$TMP/b.log" 2>&1) || fail "B: -p tool was refused" "$TMP/b.log" +[ "$("$(bin_of "$W1/target" tool)" | tr -d '\r')" = "libg=199" ] \ + || fail "B: the selected member does not read the path checkout" "$TMP/b.log" +echo "ok: B, a member selected with -p declares as the root" + +# ── C ────────────────────────────────────────────────────────────────────── +W2="$TMP/w2" +mkdir -p "$W2" +printf '[workspace]\nmembers = ["one", "two"]\n' > "$W2/mcpp.toml" +package "$W2/one" one "framework = { path = \"$FW_HOST\" }" +package "$W2/two" two "framework = { git = \"$FW_HOST\", rev = \"$REV_A\" }" +if (cd "$W2" && "$MCPP" build --workspace > "$TMP/c.log" 2>&1); then + fail "C: two members that declare one dependency by two kinds were accepted" "$TMP/c.log" +fi +grep -q "two members this build selects" "$TMP/c.log" \ + && grep -q "'[a-z.]*one@path'" "$TMP/c.log" && grep -q "'[a-z.]*two@path'" "$TMP/c.log" \ + || fail "C: the refusal does not name both members" "$TMP/c.log" +# The same kind, two references: a second checkout of framework at commit A. +git clone --quiet "$FW" "$TMP/framework-copy" +package "$W2/two" two "framework = { path = \"$(host_path "$TMP/framework-copy")\" }" +if (cd "$W2" && "$MCPP" build --workspace > "$TMP/c2.log" 2>&1); then + fail "C: two members that point a dependency at two directories were accepted" "$TMP/c2.log" +fi +grep -q "two members this build selects" "$TMP/c2.log" \ + && grep -q "'[a-z.]*one@path'" "$TMP/c2.log" && grep -q "'[a-z.]*two@path'" "$TMP/c2.log" \ + || fail "C: the refusal of two references does not name both members" "$TMP/c2.log" +echo "ok: C, two members that disagree about a dependency's checkout are refused, naming both" + +# ── D ────────────────────────────────────────────────────────────────────── +W3="$TMP/w3" +mkdir -p "$W3/libs/src" "$W3/tool/src" +printf '[workspace]\nmembers = ["tool"]\n' > "$W3/mcpp.toml" +printf '[package]\nname = "libs"\nversion = "0.1.0"\n\n[targets.libs]\nkind = "lib"\n' > "$W3/libs/mcpp.toml" +printf 'int libs_value() { return 7; }\n' > "$W3/libs/src/libs.cpp" +cat > "$W3/tool/mcpp.toml" <<'EOF' +[package] +name = "tool" +version = "0.1.0" + +[dependencies] +libs = { path = "../libs", linkage = "shared" } + +[targets.tool] +kind = "bin" +main = "src/main.cpp" +EOF +printf '#include \nint libs_value();\nint main() { std::printf("%%d\\n", libs_value()); }\n' > "$W3/tool/src/main.cpp" +(cd "$W3" && "$MCPP" build -v > "$TMP/d.log" 2>&1) || fail "D: the member with a linkage request did not build" "$TMP/d.log" +tooldir=$(dirname "$(bin_of "$W3/target" tool)") +[ -n "$(find "$tooldir" -maxdepth 1 \( -name 'liblibs.so*' -o -name 'liblibs*.dylib' -o -name 'libs.dll' -o -name 'liblibs.dll' \) | head -1)" ] \ + || fail "D: the dependency is not a shared library beside the member's program" "$TMP/d.log" +! grep -q "only the root project decides link forms" "$TMP/d.log" \ + || fail "D: the member's request was reported as ignored" "$TMP/d.log" +[ "$("$(bin_of "$W3/target" tool)" | tr -d '\r')" = 7 ] || fail "D: the program does not run" "$TMP/d.log" +echo "ok: D, a member's linkage request is honoured" + +# ── F ────────────────────────────────────────────────────────────────────── +grep -q "index: libs:" "$TMP/d.log" \ + || fail "F: the index refresh did not consider the member's own dependency" "$TMP/d.log" +echo "ok: F, the index refresh considers a member's own dependencies" + +# ── E ────────────────────────────────────────────────────────────────────── +mkdir -p "$W3/tool2/src" +sed -e 's/"tool"/"tool2"/; s/targets.tool/targets.tool2/; s/linkage = "shared"/linkage = "static"/' \ + "$W3/tool/mcpp.toml" > "$W3/tool2/mcpp.toml" +cp "$W3/tool/src/main.cpp" "$W3/tool2/src/main.cpp" +printf '[workspace]\nmembers = ["tool", "tool2"]\n' > "$W3/mcpp.toml" +if (cd "$W3" && "$MCPP" build --workspace > "$TMP/e.log" 2>&1); then + fail "E: two members asking for two forms of one dependency were accepted" "$TMP/e.log" +fi +grep -Eq "linked as '(shared|static)' by 'tool2?' and as '(shared|static)' by 'tool2?'" "$TMP/e.log" \ + && grep -q "'tool'" "$TMP/e.log" && grep -q "'tool2'" "$TMP/e.log" \ + && grep -q "'shared'" "$TMP/e.log" && grep -q "'static'" "$TMP/e.log" \ + || fail "E: the refusal does not name both members and both forms" "$TMP/e.log" +(cd "$W3" && "$MCPP" build -p tool2 > "$TMP/e2.log" 2>&1) || fail "E: one member alone did not build" "$TMP/e2.log" +echo "ok: E, two members asking for two link forms are refused; one alone builds" + +echo "PASS: 850_a_selected_member_declares_as_the_root" diff --git a/tests/e2e/851_a_shared_member_is_compiled_once_per_configuration.sh b/tests/e2e/851_a_shared_member_is_compiled_once_per_configuration.sh new file mode 100755 index 00000000..076c7173 --- /dev/null +++ b/tests/e2e/851_a_shared_member_is_compiled_once_per_configuration.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 851 -- a member that two members of different configurations use is +# compiled once in each configuration, and never served across them. +# +# A workspace is one graph per configuration (workspace design 2026-09-29 +# §2, §3): a node is a (package, configuration) pair. e2e 833 builds a member +# of another standard beside the others, but that member uses no shared one; +# this states what happens to a member both configurations reach, and what +# does not split a package into two nodes. +# +# Criteria: +# A. `lib` is used by `app23` (C++23, with lib's feature `extra`) and by +# `app26` (`standard = "c++26"`, without it). `--workspace` gives two +# build directories; each compiles lib's object once; each program +# prints its own configuration's `__cplusplus` and feature. +# B. In one configuration, a consumer's own `cxxflags` stay out of lib's +# compile, and lib is compiled once for its two consumers there, with +# the union of the features they ask for (docs/06, "Cargo-style, +# additive"). +# C. `-p app23b` plans app23b's closure alone: lib is compiled without the +# feature that only app23 asks for, in the same build directory. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +cd "$TMP" + +mkdir -p lib/src app23/src app23b/src app26/src +cat > mcpp.toml <<'EOF' +[workspace] +members = ["lib", "app23", "app26"] +EOF +cat > lib/mcpp.toml <<'EOF' +[package] +name = "lib" +version = "0.1.0" + +[features] +extra = [] + +[targets.lib] +kind = "lib" +EOF +cat > lib/src/lib.cppm <<'EOF' +export module shared_lib; +export long std_v() { return __cplusplus; } +export int extra_v() { +#ifdef MCPP_FEATURE_EXTRA + return 1; +#else + return 0; +#endif +} +export int consumer_flag_v() { +#ifdef CONSUMER_ONLY_FLAG + return 1; +#else + return 0; +#endif +} +EOF +for a in app23 app23b app26; do + printf '#include \nimport shared_lib;\nint main() { std::printf("%%ld %%d %%d\\n", std_v(), extra_v(), consumer_flag_v()); }\n' \ + > $a/src/main.cpp +done +cat > app23/mcpp.toml <<'EOF' +[package] +name = "app23" +version = "0.1.0" + +[dependencies] +lib = { path = "../lib", features = ["extra"] } + +[targets.app23] +kind = "bin" +main = "src/main.cpp" +EOF +cat > app26/mcpp.toml <<'EOF' +[package] +name = "app26" +version = "0.1.0" +standard = "c++26" + +[dependencies] +lib = { path = "../lib" } + +[targets.app26] +kind = "bin" +main = "src/main.cpp" +EOF +cat > app23b/mcpp.toml <<'EOF' +[package] +name = "app23b" +version = "0.1.0" + +[dependencies] +lib = { path = "../lib" } + +[build] +cxxflags = ["-DCONSUMER_ONLY_FLAG=1"] + +[targets.app23b] +kind = "bin" +main = "src/main.cpp" +EOF + +run() { "$(find target -path "*/bin/$1/$1" -type f | head -1)"; } +lib_objects() { awk -F'\t' '$4 ~ /(^|\/)lib\/src\/lib\.m\.o$/' "$1/.ninja_log" | wc -l | tr -d ' '; } + +# ── A ────────────────────────────────────────────────────────────────────── +"$MCPP" build --workspace > a.log 2>&1 || fail "A: the workspace did not build" a.log +dirs=$(find target -name build.ninja -exec dirname {} \; | sort) +[ "$(echo "$dirs" | wc -l | tr -d ' ')" = 2 ] || fail "A: expected two build directories, got: $dirs" a.log +for d in $dirs; do + [ "$(lib_objects "$d")" = 1 ] || fail "A: lib's object was compiled $(lib_objects "$d") times in $d" "$d/.ninja_log" +done +read -r v23 x23 _ <<< "$(run app23)" +read -r v26 x26 _ <<< "$(run app26)" +[ "$x23" = 1 ] && [ "$x26" = 0 ] || fail "A: the features crossed configurations (app23 $x23, app26 $x26)" a.log +[ -n "$v23" ] && [ -n "$v26" ] && [ "$v26" -gt "$v23" ] \ + || fail "A: app26 does not link the lib compiled for its own standard ($v23 against $v26)" a.log +echo "ok: A, lib is compiled once per configuration and each program links its own ($v23, $v26)" + +# ── B ────────────────────────────────────────────────────────────────────── +printf '[workspace]\nmembers = ["lib", "app23", "app23b", "app26"]\n' > mcpp.toml +"$MCPP" build --workspace > b.log 2>&1 || fail "B: the workspace did not build" b.log +d23=$(dirname "$(find target -path '*/bin/app23/app23' -type f | head -1)"); d23=${d23%/bin/app23} +[ "$(lib_objects "$d23")" = 1 ] || fail "B: lib's object was compiled more than once for its two consumers" "$d23/.ninja_log" +read -r _ xb fb <<< "$(run app23b)" +[ "$fb" = 0 ] || fail "B: a consumer's own cxxflags reached lib's compile" b.log +[ "$xb" = 1 ] || fail "B: lib was not compiled with the union of its consumers' features" b.log +echo "ok: B, one configuration compiles lib once, with its own flags and the union of the features" + +# ── C ────────────────────────────────────────────────────────────────────── +"$MCPP" build -p app23b > c.log 2>&1 || fail "C: -p app23b did not build" c.log +read -r _ xc _ <<< "$(run app23b)" +[ "$xc" = 0 ] || fail "C: lib kept a feature app23b's closure does not ask for" c.log +[ "$(find target -name build.ninja | wc -l | tr -d ' ')" = 2 ] || fail "C: -p made a third build directory" c.log +echo "ok: C, -p plans its own closure's features in the same build directory" + +echo "PASS: 851_a_shared_member_is_compiled_once_per_configuration" diff --git a/tests/unit/test_linkmodel.cpp b/tests/unit/test_linkmodel.cpp index 02fcf75e..1af16a87 100644 --- a/tests/unit/test_linkmodel.cpp +++ b/tests/unit/test_linkmodel.cpp @@ -295,6 +295,21 @@ TEST(LinkModel, ClangOnTheMsvcAbiNamesItsToolsetAndSdk) { EXPECT_TRUE(lm.compile_tokens(ident).empty()); } +// mcpp#746. The compiler version travels with the toolset: two runner images +// held different cl.exe builds under one toolset directory name, and a key +// that saw only the directory served one image's std module to the other. +TEST(LinkModel, TheCompilerVersionIsSaidWithTheToolset) { + auto t = clang_msvc(); + t.msvcCompilerVersion = "19.44.35211"; + auto words = tc::resolve_link_model(t).msvc_driver_tokens(ident); + ASSERT_FALSE(words.empty()); + EXPECT_EQ(words.back(), "-fms-compatibility-version=19.44.35211"); + // A second build of cl.exe under the same directory is a different word. + auto other = t; + other.msvcCompilerVersion = "19.44.35217"; + EXPECT_NE(tc::resolve_link_model(other).msvc_driver_tokens(ident), words); +} + TEST(LinkModel, TheToolsetWithoutAnSdkOmitsTheSdkWords) { auto t = clang_msvc(); t.windowsSdkRoot.clear(); diff --git a/tests/unit/test_toolchain_msvc.cpp b/tests/unit/test_toolchain_msvc.cpp index b454428d..30b91f7f 100644 --- a/tests/unit/test_toolchain_msvc.cpp +++ b/tests/unit/test_toolchain_msvc.cpp @@ -57,6 +57,69 @@ TEST(MsvcBanner, TripleForArch) { // ─── install guidance ──────────────────────────────────────────────────── +// mcpp#746: the compiler version is read from cl.exe's VS_FIXEDFILEINFO, as +// clang reads it, and said on the command line. +namespace { +std::string fixed_file_info(std::uint32_t ms, std::uint32_t ls, + std::uint32_t strucVersion = 0x00010000u) { + std::string out; + auto put = [&](std::uint32_t v) { + for (int i = 0; i < 4; ++i) out.push_back(static_cast((v >> (8 * i)) & 0xFF)); + }; + put(0xFEEF04BDu); + put(strucVersion); + put(ms); + put(ls); + put(0); put(0); // product version + return out; +} +} // namespace + +TEST(MsvcCompilerVersion, ReadsTheFixedFileInfoAsClangDoes) { + // 19.51.36257.0: MS = 19 << 16 | 51, LS = 36257 << 16 | 0. + std::string image(300, 'x'); + image += fixed_file_info((19u << 16) | 51u, (36257u << 16) | 0u); + image += std::string(40, 'y'); + auto v = mcpp::toolchain::msvc::pe_file_version(image); + ASSERT_TRUE(v.has_value()); + EXPECT_EQ(*v, "19.51.36257"); +} + +TEST(MsvcCompilerVersion, SkipsASignatureThatIsNotTheStructure) { + // The four signature bytes can occur in code; only the structure, whose + // next word is 0x00010000, is read. + std::string image = fixed_file_info((1u << 16) | 2u, (3u << 16), 0xDEADBEEFu); + image += fixed_file_info((19u << 16) | 44u, (35211u << 16) | 7u); + EXPECT_EQ(mcpp::toolchain::msvc::pe_file_version(image).value_or(""), "19.44.35211"); + EXPECT_FALSE(mcpp::toolchain::msvc::pe_file_version("no version here").has_value()); +} + +TEST(MsvcCompilerVersion, IsReadFromTheCompilerClangReadsForTheTarget) { + // clang reads bin/Host{x64|x86}//cl.exe; each target's compiler + // here carries its own build, under both host directories, so the answer + // depends on the target and not on which host runs the test. + namespace fs = std::filesystem; + const auto tools = fs::temp_directory_path() + / std::format("mcpp-746-{}", std::chrono::steady_clock::now().time_since_epoch().count()) + / "VC" / "Tools" / "MSVC" / "14.51.36231"; + const std::pair builds[] = { + {"x64", 36260u}, {"arm64", 36261u}, {"x86", 36262u}}; + for (auto host : {"Hostx64", "Hostx86"}) + for (auto [target, build] : builds) { + fs::create_directories(tools / "bin" / host / target); + std::ofstream os(tools / "bin" / host / target / "cl.exe", std::ios::binary); + os << std::string(64, 'M') << fixed_file_info((19u << 16) | 51u, (build << 16)); + } + namespace msvc = mcpp::toolchain::msvc; + EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools, "x86_64"), "19.51.36260"); + EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools, "aarch64"), "19.51.36261"); + EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools, "i686"), "19.51.36262"); + // No compiler where clang looks: no version, and clang falls back itself. + EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools / "absent", "x86_64"), ""); + std::error_code ec; + fs::remove_all(tools.parent_path().parent_path().parent_path().parent_path(), ec); +} + TEST(MsvcGuidance, OffersBothOrigins) { auto g = msvc::install_guidance(); ASSERT_FALSE(g.empty()); From 17f0450f3b9f04c60b3a9325687c1ef918b8f4c8 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:06:05 +0800 Subject: [PATCH 2/2] e2e 850: the paths a fixture writes into a manifest go through named host-path variables --- tests/e2e/850_a_selected_member_declares_as_the_root.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/850_a_selected_member_declares_as_the_root.sh b/tests/e2e/850_a_selected_member_declares_as_the_root.sh index 0c4e0487..4889971e 100755 --- a/tests/e2e/850_a_selected_member_declares_as_the_root.sh +++ b/tests/e2e/850_a_selected_member_declares_as_the_root.sh @@ -52,6 +52,7 @@ REV_A=$(git -C "$FW" rev-parse HEAD) printf 'int framework_marker(void) { return 199; }\n' > "$FW/src/framework.c" # libg: a library outside every workspace that asks for framework at commit A. +LIBG_HOST="$(host_path "$TMP/libg")" mkdir -p "$TMP/libg/src" cat > "$TMP/libg/mcpp.toml" < "$TMP/c2.log" 2>&1); then fail "C: two members that point a dependency at two directories were accepted" "$TMP/c2.log" fi