From fd597b0d53749078b7be86d4a248750012c24fa6 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:24:18 +0800 Subject: [PATCH 1/4] feat(toolchain): MSVC gets a version axis -- `msvc@` alongside `msvc@system` (2026.8.16.1) gcc and llvm are installed by mcpp and resolved from what the manifest declares. MSVC was the one exception: EVERY msvc spec was a system spec, so a manifest could name a toolset and have that name discarded. The consequence is not inelegance. It is that the same source compiles with different compilers on different machines, and nothing says so. Measured on xrgui#3: in ONE CI run mcpp used 14.51 and xmake used 14.52, and it stayed invisible until 14.51 hit an ICE. Exporting a complete vcvars environment did not help -- the only way out was to move `vswhere.exe` aside so mcpp would fall through to VSINSTALLDIR, and that workaround is still in xrgui's workflow. ## The version axis decides the origin msvc@system (or bare msvc) the machine's own Visual Studio -- UNCHANGED msvc@ an xlings payload mcpp installs and pins `msvc@14.44.35207` is isomorphic to `gcc@16.1.0` in every respect: coexisting versions, `toolchain remove msvc@`, auto-install from a manifest. The payload brings the compiler, the STL, and -- through its `xim:windows-sdk` dependency -- the ucrt/um headers and libs, so nothing has to be preinstalled. Structurally, ACQUISITION and RESOLUTION are separated: acquisition is shared with gcc (the xim install), resolution is shared with `msvc@system` (`installation_from_tools_dir`). A pinned toolset is therefore not a second code path, and cannot grow its own bugs. What it does not share is 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). BREAKING: `msvc@19.44` was a pin-verify against the system install's cl banner -- checked by `toolchain default` and silently ignored by builds. The version axis now names a toolset everywhere. A `19.x` spelling errors out with this machine's actual cl version and both replacements. ## VSINSTALLDIR now outranks vswhere vswhere returns something on nearly every developer machine, which made VSINSTALLDIR effectively unreachable -- a build that had exported a complete vcvars environment still compiled with whatever vswhere ranked first. A guess must not silently override an answer. vswhere also gains `-prerelease`: without it a machine with only an Insiders VS is reported as "MSVC not found" while a perfectly good cl.exe sits on disk. VS*COMNTOOLS stays BELOW vswhere. Those are machine-wide leftovers -- a 2017 VS150COMNTOOLS must not outrank a current install -- whereas VSINSTALLDIR is someone setting it for this shell. The old `find_vs_via_env()` conflated the two, so promoting it would have promoted the leftovers too. ## The Windows SDK stops being two absolute paths Order: WindowsSdkDir (+ WindowsSdkVersion, both exported by vcvars) -> the `xim:windows-sdk` payload beside a pinned toolset in mcpp's own store -> the hardcoded roots, now a fallback. The second source needs no configuration and hardcodes no version: the COMPILER'S OWN PATH says which store it came from, and the SDK is its neighbour there. `sibling_sdk_roots()` returns empty for a system cl, so the two origins stay separate. ## cxx_runtime = "self-contained" now actually does something on MSVC Two knobs, one working: `linkage = "static"` really emitted /MT while `cxx_runtime = "self-contained"` reported "not implemented" -- for the same physical switch. Two comments contradicted each other about it (flags.cppm:605 vs distribution.cppm:202). On the MSVC ABI these are not alternatives: /MT links the C runtime and the C++ runtime out of the same library. Both spellings now select it through one `msvc_wants_static_crt()`, which the project's TUs and the std module both ask -- rather than each spelling out `linkage == "static"`, which is exactly how they diverged in #422. The default stays /MD: the predicate reads the WRITTEN manifest scalar, not the resolved contract, because most roles default to self-contained and keying off that would flip every Windows build to /MT. The CRT model is a whole-PROJECT property (one std module per project, and cl bakes _MSVC_MT/_MSVC_MD into it), so a per-role override is now refused with a message that says why, instead of failing later inside the ucrt headers. ## Tests MSVC discovery is testable off Windows for the first time: `installation_at()` takes a directory instead of probing, `find_windows_sdk()` takes a list of roots, and neither is behind a platform macro. Six new unit tests drive real fixture trees on Linux CI -- including "both toolsets present, ask for the OLDER one", which a latest-wins implementation fails and a real machine might pass by accident. New e2e `239_msvc_managed_toolset.sh`, written so that the SYSTEM compiler answering would FAIL rather than pass quietly: cl.exe must be inside mcpp's store, the toolset directory must be the one named, and switching the same project back to `msvc@system` must resolve to the system cl again. 83/83 unit targets pass; 95/96/103/183 e2e pass on Linux. Closes #432 --- .../2026-08-16-msvc-as-a-managed-toolchain.md | 421 ++++++++++++++++++ CHANGELOG.md | 90 ++++ docs/03-toolchains.md | 116 +++-- mcpp.toml | 2 +- src/build/distribution.cppm | 59 ++- src/build/flags.cppm | 19 +- src/build/prepare.cppm | 82 +++- src/toolchain/dialect.cppm | 31 +- src/toolchain/lifecycle.cppm | 74 ++- src/toolchain/msvc.cppm | 326 +++++++++++--- src/toolchain/registry.cppm | 55 ++- src/version.cppm | 2 +- tests/e2e/239_msvc_managed_toolset.sh | 108 +++++ tests/e2e/95_msvc_system_toolchain.sh | 49 +- tests/unit/test_distribution.cpp | 56 ++- tests/unit/test_toolchain_msvc.cpp | 281 +++++++++++- 16 files changed, 1566 insertions(+), 205 deletions(-) create mode 100644 .agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md create mode 100755 tests/e2e/239_msvc_managed_toolset.sh diff --git a/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md b/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md new file mode 100644 index 00000000..5b171cbc --- /dev/null +++ b/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md @@ -0,0 +1,421 @@ +# MSVC 纳入 mcpp 工具链体系 —— 设计 + 验证方案(2026-08-16) + +> **状态:已实现(2026.8.16.1)。** §6 的六个决策点全部有了答案,记在 §7; +> 其中**一条原方案的判断在实现时被推翻**,已在原处标注而不是悄悄改掉。 +> 落地结果与实测见 §7,跨仓库的任务拆分与依赖见 §8。 + +**三条缺陷全部在 HEAD 上核实过源码,不是照抄 issue; +上游依赖(xlings 包)已经就位并在 Windows CI 上装通。** + +对应 issue: #432。触发来源: [Sunrisepeak/xrgui#3](https://github.com/Sunrisepeak/xrgui/pull/3) +(用 mcpp 给 XRGUI 加 Windows 构建,已全绿——但代价是一个 workaround)。 + +| # | 缺陷 | 性质 | 改动面 | 是否阻塞 xrgui | +|---|---|---|---|---| +| A | `msvc` 是唯一不可声明版本的工具链 | 设计缺口 | 中 | **是** | +| B | `find_windows_sdk()` 写死两个绝对路径 | 可移植性 | 小 | 是(payload 化 SDK 后) | +| C | `cxx_runtime` 与 `linkage` 对静态 CRT 说法不一 | 接口一致性 | 小 | 否 | + +A 与 B 合起来才有意义:A 让 mcpp 能拿到指定的 toolset,B 让它能拿到配套的 SDK。 +C 独立,可并行。 + +--- + +## 0. TL;DR(给 review 的一页纸) + +- **MSVC 是 mcpp 工具链体系里唯一的例外**:gcc / llvm 由 mcpp 自己装、按声明解析; + 只有 MSVC 走 `msvc@system` 去**探测宿主**,而那个探测**无法被调用方覆盖**。 +- 后果不是「不够优雅」,是**同一份源码在两台机器上会被不同编译器编译,且不会报错**—— + xrgui#3 实测:同一轮 CI 里 mcpp 用 14.51、xmake 用 14.52,直到 14.51 ICE 才暴露。 +- **上游已经准备好了**:`xim:msvc` / `xim:windows-sdk` / `xim:curl` 已发布, + payload 布局刻意做成 mcpp 现状即可识别的形状,且 `xim:msvc@14.52.36629` + 正是 xrgui 需要的那个 toolset。 +- 提案:**`msvc@` 与 `gcc@` 同构**——非 system spec 走 `to_xim_package()` + 安装并解析;`msvc@system` 保留原义(用宿主已装的 VS)。 +- **验证不靠新写的测试,靠 xrgui**:它是这套东西的真实用例,且已经绿了—— + 验收标准是**删掉 workaround 之后仍然绿**,这是一个不能自证的判据。 + +--- + +## 1. 核实 + +### A. `msvc` 无法被指定 —— `src/toolchain/msvc.cppm` + +发现顺序: + +``` +1. vswhere -latest -products * -requires ...VC.Tools.x86.x64 +2. VSINSTALLDIR / VS*COMNTOOLS +3. Program Files\Microsoft Visual Studio\\ +``` + +两个事实叠加成缺口: + +1. 第 1 步**没有 `-prerelease`**,所以 Insider 实例对它完全不可见; +2. 第 1 步一旦成功,第 2 步**不会执行**——而那是调用方唯一能控制的入口。 + +`find_vs_via_env()` 的判据本身很宽松,只要求目录存在: + +```cpp +if (auto* dir = std::getenv("VSINSTALLDIR"); dir && *dir) { + std::filesystem::path p{dir}; + if (std::filesystem::exists(p / "VC" / "Tools" / "MSVC")) + return p; +} +``` + +**实测(xrgui#3)**:GitHub `windows-2025-vs2026` 镜像上同时存在 + +``` +C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Tools\MSVC\14.51.36231 ← 预装 +C:\VS2026Insider\VC\Tools\MSVC\14.52.36629 ← 装的 +``` + +同一轮 CI 里 mcpp 选 14.51、xmake 用 14.52。而 **14.51 编不了那个代码库**: + +``` +mo_yanxi_utility/src/utility/math/basic/vector2.ixx(67): + fatal error C1001: Internal compiler error. + (compiler file '...\CxxFE\sl\p1\c\template.cpp', line 26415) + note: IFC import detected. +``` + +**导出完整 vcvars 环境无效**,也是实测的:多花约 7 分钟装 Insider,构建日志里的 cl +一个字都没变。最终只能在 CI 里**把 `vswhere.exe` 挪开**,逼 mcpp 落到第 2 步。 +那个 workaround 现在还在 xrgui 的 workflow 里,是这份方案要消掉的东西。 + +### B. Windows SDK 路径写死 —— 同文件 + +```cpp +for (const char* base : {"C:\\Program Files (x86)\\Windows Kits\\10", + "C:\\Program Files\\Windows Kits\\10"}) { +``` + +无 `WindowsSdkDir` 覆盖、无注册表回退。toolset 那边没有对应问题: +`find_vs_via_env()` 只要求 `/VC/Tools/MSVC` 存在。 + +### C. 静态 CRT 有两个入口 —— 且注释互相矛盾 + +| 键 | MSVC 上的行为 | +|---|---| +| `[build] linkage = "static"` | **真的发 `/MT`** | +| `[build] cxx_runtime = "self-contained"` | **未实现**,warn 一次后退回 host-coupled | + +- `src/build/flags.cppm:605` — *"/MD default, **/MT under static linkage**"*,且确实发了 +- `src/build/distribution.cppm:202` — *"Under the MSVC runtime mcpp never made that promise + — **there is no /MT emission at all**"* + +`linkage` 那条实现得很扎实:`flags.cppm:611`(项目 TU)与 `prepare.cppm:4980`(std 模块) +共用同一个 `msvc_crt_flag()`——正是 #422 的修法,两边不可能再分叉。问题只在**入口有两个**, +而名字更像干这事的那个是无效的那个。 + +--- + +## 2. 上游已经就位(不需要 mcpp 侧配合的部分) + +xim-pkgindex 现已发布三个包(#626 / #627 / #628 均已合入,Windows CI 实测装/卸载通过): + +| 包 | 版本 | 说明 | +|---|---|---| +| `xim:msvc` | `14.44.35207`(latest)、**`14.52.36629`** | payload 包,多版本共存 | +| `xim:windows-sdk` | `10.0.26100` | ucrt/um/shared 头 + um 库 + rc/mt | +| `xim:curl` | `8.21.0` | 下载器本身也在生态内 | + +**payload 布局是刻意照着 mcpp 现状做的**: + +``` +/xpkgs/xim-x-msvc/14.52.36629/ +└── VC/Tools/MSVC/14.52.36629/{bin/Hostx64/x64/cl.exe, include/, lib/x64/, modules/std.ixx} + +/xpkgs/xim-x-windows-sdk/10.0.26100/ +├── Include/10.0.26100.0/{ucrt,um,shared}/ +└── Lib/10.0.26100.0/{ucrt,um}/x64/ +``` + +即 **toolset 那一半 mcpp 今天就能识别**(把 `VSINSTALLDIR` 指过去即可,前提是缺陷 A 修掉); +**SDK 那一半够不到**,因为缺陷 B。 + +--- + +## 3. 方案 + +### 3.1 A —— 让 `msvc@` 与 `gcc@` 同构 + +现状(`src/toolchain/lifecycle.cppm:503`): + +```cpp +// msvc@system: mcpp never installs MSVC — report what's there, or +// print installation guidance. +if (mcpp::toolchain::is_system_toolchain(*spec)) { ... return 1; } +``` + +`is_system_toolchain()` 对**所有** msvc spec 为真,所以 `msvc@14.52.36629` 也走这条死路。 + +**提案:按 spec 是否带具体版本分流** + +| spec | 语义 | 解析 | +|---|---|---| +| `msvc@system` | 用宿主已装的 VS(**保留现状,含探测顺序**) | `detect_installation()` | +| `msvc@` | 由 mcpp 安装并使用指定 toolset | `to_xim_package()` → `xim:msvc@` | + +改动集中在三处: + +1. `is_system_toolchain()` 只在 version 为 `system`/空时为真; +2. `to_xim_package()` 增加 msvc 行(`xim:msvc`,版本原样); +3. 解析已安装 payload 时,`VC/Tools/MSVC/` 直接由包版本拼出—— + **不再探测**,因为版本是声明的。 + +> **为什么这比"给 vswhere 加 `-prerelease`"更值得做**:后者只是让探测更可能猜对。 +> 而问题的本质是「这份构建用了哪个编译器」不该由机器状态回答。xlings V2 spec 的 +> R3 把这条写成了规则:*"If a fix ADDS a path rather than REMOVING one, it is a +> workaround. Making two independent answers more likely to agree is not a fix."* +> +> 不过 `-prerelease` 仍建议顺手加上:它让 `msvc@system` 在装了 Insider 的机器上 +> 至少能看见它。这是**兼容改进**,不是缺陷 A 的答案。 + +**兼容性**:`msvc@system` 行为不变,现有工程零影响。新增的是一条以前会报错的路径。 + +### 3.2 B —— `find_windows_sdk()` 认 `WindowsSdkDir` + +顺序改为: + +``` +1. WindowsSdkDir + WindowsSdkVersion (vcvars 本来就导出这两个) +2. 已解析 msvc 包的 windows-sdk 依赖 (走 3.1 的路径时) +3. 现有的两个绝对路径 (回退) +``` + +第 2 条是 3.1 的自然延伸:`xim:msvc` 声明了 `deps = { "xim:windows-sdk@10.0.26100" }`, +所以走包路径时 SDK 的位置是**已知的**,不必再探测。 + +### 3.3 C —— 消掉第二个入口 + +二选一: + +- **(推荐)** `cxx_runtime = "self-contained"` 在 MSVC 上直接映射到静态 CRT, + 与 `linkage = "static"` 走同一个 `msvc_crt_flag()`; +- 或保持未实现,但把诊断改成明确指向 `linkage`。 + +无论哪个,`distribution.cppm:202` 那句 *"there is no /MT emission at all"* 都要改—— +它与 `flags.cppm` 的实现直接矛盾。 + +--- + +## 4. 验证方案 —— 用 xrgui,不用新造的测试 + +xrgui#3 是这套东西的**真实用例**,而且**它已经是绿的**。这一点很重要: +验证不是"让它变绿",而是**在删掉 workaround 之后它是否仍然绿**—— +一个不能靠调整测试来自我满足的判据。 + +### 4.1 基线(今天的状态) + +``` +.github/workflows/mcpp-windows.yml + ├─ 装 VS 2026 Insider 到 C:\VS2026Insider ~7 min + ├─ 直接从该路径导出 vcvars(不经 vswhere) + ├─ 把 vswhere.exe 挪开 ← workaround,本方案要消掉的 + └─ 断言 mcpp 解析到 14.52,否则立刻失败 +``` + +两条腿(mcpp / xmake)全绿,`xrgui_tests` 54 项通过。 + +### 4.2 验收步骤 + +| 阶段 | 动作 | 通过标准 | +|---|---|---| +| V0 | 不改 xrgui,先在一台装了 VS 的机器上 `mcpp toolchain install msvc 14.52.36629` | payload 落在 `xpkgs/xim-x-msvc/14.52.36629`,`mcpp why toolchain` 报告它 —— **且宿主的 VS 仍在**(证明不是靠探测碰巧对) | +| V1 | xrgui 的 `mcpp.toml` 改 `[toolchain] windows = "msvc@14.52.36629"` | `mcpp why toolchain` 报告 14.52.36629 | +| V2 | **删掉 workflow 里「挪开 vswhere.exe」那一步** | 仍解析到 14.52.36629 —— 这是缺陷 A 修复的**唯一有效证据** | +| V3 | 删掉 Insider 安装与 vcvars 导出两步 | mcpp 自己装 toolset + SDK;`mcpp build --features tests` 通过,`xrgui_tests` 仍 54/54 | +| V4 | 对比 xmake 腿 | 两条腿仍然只差构建系统 —— 但**注意**:xmake 仍需 Insider 安装,V3 不能删它需要的那部分 | + +**V2 是关键**:workaround 还在的时候,缺陷 A 是否修好无法区分—— +`VSINSTALLDIR` 被采纳和 vswhere 找不到东西,现象一样。 + +### 4.3 反向验证(容易被忽略) + +- **`msvc@system` 未回归**:在同一台机器上把 spec 换回 `msvc@system`, + 应仍解析到宿主的 VS(而不是刚装的 payload)。两条路径必须互不污染。 +- **版本切换真的生效**:`xlings use msvc 14.44.35207` 后重新构建, + `cl` 报告的版本随之改变。多版本不是摆设。 +- **SDK 来自包而非宿主**:装完后临时重命名 `C:\Program Files (x86)\Windows Kits`, + 构建应仍然成功(缺陷 B 修好的判据)。这条在 CI 上做比在本机安全。 + +### 4.4 已知不被覆盖的部分 + +- **`14.52` 的安装路径 CI 没跑过**。xim-pkgindex 的 `windows-test` 装的是 `latest`(14.44)。 + 两者差异只在 payload URL 与目录版本,后者已逐个从真实 payload 读出核对 + (14.44 的三个 payload 版本 35228/35220/35226 都解到 `35207`;14.52 的 payload + 与目录一致,都是 `36629`),但**没有实际装过一次**。V0 会第一次覆盖它。 +- **Insiders payload 会轮换** —— 但这条正在被消掉,见 §4.5。 + +### 4.5 镜像:把"地址会失效"变成"地址锁定" + +原本这份方案把 Insiders 的轮换列为已知风险:14.52.36629 下架时 URL 404。 +**这一点可以直接消掉,而不是接受**——review 反馈给的方向是镜像到 gitcode。 + +关键在于:**镜像的是同一份字节,所以 sha256 不变**。于是 payload 条目从 + +```lua +{ name = "...", sha256 = "...", url = "https://download.visualstudio.microsoft.com/..." } +``` + +变成一个**来源列表**,校验规则完全不动: + +```lua +{ name = "...", sha256 = "...", + urls = { "https://gitcode.com/xlings-res/msvc/.../", -- 镜像优先 + "https://download.visualstudio.microsoft.com/..." } } -- 官方回退 +``` + +`fetch_verified()` 依次尝试,**无论从哪个来源取到,都按同一个 sha256 校验**。 +所以镜像不是新的信任源——它只是同一份字节的第二个地址。取不到就换下一个, +两个都失败才报错。 + +这比 `XLINGS_RES` 的 res 形状更合适:后者的自动 URL 约定是 +`{name}-{version}-{os}-{arch}.{ext}`,而 MSVC 一个版本是**一组**文件、各自带着 +微软自己的文件名(SDK 那边光 CAB 就 15 个)。而 payload 表本来就是 recipe 自己解析的, +加一个来源列表不需要框架配合。 + +**要镜像的量**(已实测): + +| 内容 | 文件数 | 体积 | +|---|---|---| +| `msvc@14.44.35207` | 4 个 vsix | 83.5 MB | +| `msvc@14.52.36629` | 4 个 vsix | 102.4 MB | +| `windows-sdk@10.0.26100` | 4 MSI + 15 CAB | 139.1 MB | +| 合计 | 27 | **325 MB** | + +**边界要说清楚**:把微软的编译器二进制放到第三方主机,与"安装时从微软 CDN 下载" +是两件不同的事——前者是再分发。这是维护者的决定,不是技术选择;本文只描述机制。 +上传本身需要 gitcode 凭据,不在本方案的执行范围内。 + +**落地拆两步**,因为它们的风险完全不同: + +1. **recipe 侧支持多来源**(纯代码,可先合):`urls` 列表 + 依次回退, + 单来源时行为与今天完全一致 → 零风险,且为镜像就绪; +2. **实际上传 + 填入镜像 URL**:需要凭据与上面那条边界的决定。 + +第 1 步做完之后,即使一个镜像都还没有,这套东西也不会变差;而一旦 14.52 真的下架, +补一行 URL 就能恢复,不必重新找一个还活着的 toolset 版本再验证一遍。 + +--- + +## 5. 落地顺序 + +``` +C(独立,小) ──────────────────────────────┐ + ├─→ 发布 ─→ xrgui V1..V4 +A(核心) ─→ B(依赖 A 的包路径) ─→ V0 ─────┘ +``` + +- C 可以随时单独落,不阻塞任何人; +- A 不修,B 修了也没用(SDK 找得到,编译器还是错的); +- V0 应在 A+B 发布**之前**用本地构建跑一次,否则 xrgui 那边的红会同时有两个可能来源。 + +--- + +## 6. 待 review 决策点(已全部拍板,答案见 §7.1) + +1. **`msvc@` 的语义**是否如 3.1 所提(按版本分流,`@system` 保留原义)? +2. `-prerelease` 是否顺手加上(改善 `msvc@system`,不替代 A)? +3. C 选哪一个:让 `cxx_runtime` 生效,还是把诊断指向 `linkage`? +4. B 的第 2 条(从已解析的包依赖里取 SDK)是否值得做,还是只做 `WindowsSdkDir` 就够? +5. 验证方案里 4.3 的三条反向验证是否都要进 CI —— 尤其"重命名 Windows Kits"那条, + 它最有说服力,也最容易在别的 job 上产生副作用。 +6. **镜像(§4.5)**:recipe 侧的多来源支持可以先合(零风险);实际镜像涉及再分发, + 需要维护者拍板,且上传需要 gitcode 凭据。是否现在就做第 1 步? + +--- + +## 7. 落地结果(2026.8.16.1) + +### 7.1 六个决策点的答案 + +| # | 决定 | 落地方式 | +|---|---|---| +| 1 | **按版本轴分流,`@system` 完全保留** | `is_system_toolchain()` 加版本判据;`msvc@system` 的语义与探测链一字未改(只有顺序修了,见 2) | +| 2 | **加** | vswhere 加 `-prerelease`,但顺序也变了 —— 见 §7.2 那条被推翻的判断 | +| 3 | **让 `cxx_runtime` 生效** | 两个键都走 `msvc_wants_static_crt()`;`distribution.cppm:202` 那句自相矛盾的注释删掉 | +| 4 | **做,但不是「从包依赖里取」** | 改成从**编译器自己的路径**反推 store —— 见 §7.2 | +| 5 | **不进 CI** | 见 §7.3 | +| 6 | **两步一起做了** | recipe 侧多来源 + 27 个 payload 实际镜像完成并逐个校验(xim-pkgindex#629) | + +### 7.2 两条实现时改掉的判断 + +**① 缺陷 B 的第 2 条来源不是「已解析的包依赖」,而是编译器自己的位置。** + +原方案说:走包路径时 `xim:msvc` 声明了 `deps = { "xim:windows-sdk@10.0.26100" }`, +所以 SDK 的位置是已知的。能做,但它要求 **mcpp 侧知道那个依赖的名字和版本** —— +把 SDK 版本写进 mcpp,而它本该只是包的事。 + +实际做法:`sibling_sdk_roots(clPath)` 用已有的 `xpkgs_from_compiler()` 从 cl.exe +的路径反推出它所在的 store,再取 `xim-x-windows-sdk/*`。**编译器自己的路径就说明了 +它来自哪个 store**,SDK 是它在那里的邻居。零配置、零版本硬编码,而且对 +`msvc@system` 自动返回空(系统 cl 不在任何 store 里)。 + +**② `VSINSTALLDIR` 与 vswhere 的顺序,原方案说得不够。** + +原方案把 `-prerelease` 列为「兼容改进,不是缺陷 A 的答案」,这是对的; +但它没说**顺序本身就是缺陷**。vswhere 在几乎每台开发机上都返回点什么,所以 +`VSINSTALLDIR` 事实上不可达 —— 这正是 xrgui 那个 workaround 存在的原因,而 +workaround 是要被删掉的。所以顺序改成: + +``` +VSINSTALLDIR → vswhere(-prerelease) → VS*COMNTOOLS → 绝对路径 +``` + +`VS*COMNTOOLS` 留在 vswhere **之后**,这一点是新的:它们是机器全局的残留 +(2017 的 `VS150COMNTOOLS` 不该压过当前安装),而 `VSINSTALLDIR` 是有人为这个 +shell 设的。原来的 `find_vs_via_env()` 把两者混在一起,提前它就会连带提前残留。 + +### 7.3 4.3 的三条反向验证:两条进了 e2e,一条没进 + +| 反向验证 | 结论 | +|---|---| +| `msvc@system` 未回归 | **进了** —— `239_msvc_managed_toolset.sh` 第 3 步:同一台机器、同一个项目,spec 换回 `msvc@system` 必须解析到系统的 cl。没有这条,「受管能用」与「受管把一切换掉了」这两种结果长得一模一样。 | +| 版本切换真的生效 | **进了(以更强的形式)** —— 单测 `ResolvesTheDeclaredToolsetAndNotItsNeighbour`:两个 toolset 都在,要**老的**那个。「取最新」的实现会在这里失败,而在真机上它可能碰巧对。 | +| 重命名 `Windows Kits` | **没进**。它确实最有说服力,但它会让同一个 runner 上**其它 job** 的 MSVC 构建随机失败,而 CI 上的 job 隔离不到目录改名这一层。改由单测覆盖:`ExtraRootsCoverTheManagedPayload` 在没有任何 env、没有任何 Windows Kits 的 Linux 上跑 —— 这比重命名更彻底,因为那台机器上**本来就没有** SDK 可以被找到。 | + +### 7.4 单测第一次能在 Windows 之外跑 + +改动前,msvc 的发现逻辑**一行都无法在 Linux 上测试**:每个入口都从探测机器开始, +而探测在 `#if defined(_WIN32)` 里。 + +`installation_at()` 接受目录而不是探测机器,`find_windows_sdk()` 接受 root 列表, +两者都不再被平台宏包住 —— 于是 fixture 目录树就能驱动真实代码路径。新增 6 个 +单测在 Linux CI 上跑,其中三条是这次改动的核心判据: + +``` +MsvcManaged.ResolvesTheDeclaredToolsetAndNotItsNeighbour 两个都在,要老的 +MsvcManaged.AbsentToolsetIsNulloptNotASubstitute 缺了就是缺了,不替换 +MsvcSdk.DeclaredRootOutranksTheManagedOne 声明压过探测 +``` + +`test_toolchain_msvc` 25 项全绿,全套单测 83/83。 + +--- + +## 8. 跨仓库任务拆分与依赖 + +四个仓库,三条可并行的链。边是**真实依赖**,不是先后偏好: + +``` +xim-pkgindex #629 ──────────────┐ (包必须先发布,mcpp 才装得到) + 多来源 urls + 27 payload 镜像 │ + windows-sdk 导出 WindowsSdkDir │ + ▼ +mcpp #4xx (2026.8.16.1) ────→ 发布 ────→ xrgui:删 workaround + 改 spec + A 受管 toolset (V2 是唯一不能自证的证据) + B SDK 搜索顺序 + C CRT 双入口 ← 与 A/B 无依赖,可并行 + D 发现顺序 ← 与 A 同文件,顺序上一起改 +``` + +**为什么 C 和 D 仍然放在同一个 PR 里**:C 与 A/B 确实无依赖,但它改的是同一个 +「MSVC 在 mcpp 里到底怎么被描述」的问题,分开发会让 CHANGELOG 读者以为是两件事; +D 与 A 改同一个函数,分开发第二个 PR 必然要重写第一个 PR 刚写的注释。 + +**唯一的硬依赖**是 xim-pkgindex → mcpp:`mcpp toolchain install msvc 14.44.35207` +装的就是那个包。所以 #629 必须先合并并发布,`239_msvc_managed_toolset.sh` 才 +可能通过 —— 在此之前它会走 SKIP 分支(索引取不到时干净跳过),而不是红。 diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c22053..4d6a2916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,96 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.16.1] — 2026-08-16 + +### 工具链 + +- **MSVC 不再是「唯一版本无法声明」的工具链。** + + gcc / llvm 由 mcpp 自己安装、按声明解析;MSVC 是体系里唯一的例外——**每一个** + msvc spec 都是系统 spec,manifest 写了版本也会被丢掉。后果不是不够优雅,是 + **同一份源码在两台机器上会被不同的编译器编译,而且不报错**。 + + xrgui#3 实测:同一轮 CI 里 mcpp 用 14.51、xmake 用 14.52,直到 14.51 触发 + ICE 才暴露。导出完整 vcvars 环境**无效**,最后只能在 CI 里把 `vswhere.exe` + 挪开,逼 mcpp 落到 `VSINSTALLDIR` 那一步。 + + 现在由 **spec 的版本轴**决定来源,两条来源并存: + + | spec | 来源 | 用哪个编译器 | + |---|---|---| + | `msvc@system`(或裸 `msvc`) | 机器自己的 Visual Studio | 这台机器上装的那个 | + | `msvc@`(如 `msvc@14.44.35207`) | mcpp 安装的 xlings payload | **声明的那个,每台机器都是** | + + `msvc@` 与 `gcc@16.1.0` 在每个方面都同构:多版本共存、 + `toolchain remove msvc@` 可卸载、manifest 里写了就自动安装。 + payload 自带编译器、STL,并通过 `xim:windows-sdk` 依赖带上 ucrt/um 头与库, + 机器上**什么都不必预装**。 + + 实现上,「获取」与「解析」被拆成两条正交的轴:获取与 gcc 共用一条 + (xim 安装),解析与 `msvc@system` 共用一条(`installation_from_tools_dir`)。 + 所以受管 toolset 不是第二条代码路径,也就不会长出自己的 bug。 + +- **⚠️ 破坏性变更:`msvc@19.44` 不再是 pin-verify。** + + 它过去表示「用系统 MSVC,并校验 banner 前缀」——而且只有 + `mcpp toolchain default` 会校验,**构建路径完全忽略它**。版本轴现在到处都表示 + toolset。写成 `19.x` 时,mcpp 会用这台机器自己的 cl 版本说清楚,并给出两个 + 替代写法(`msvc@system` 或该机器实际的 toolset 版本)。 + +- **`VSINSTALLDIR` 现在优先于 vswhere 探测。** + + vswhere 在几乎每台开发机上都能返回点什么,于是 `VSINSTALLDIR` 事实上不可达: + 一次已经导出了完整 vcvars 环境的构建,仍然用 vswhere 排第一的那个编译器。 + **猜测不该压过答案。** 顺带给 vswhere 加了 `-prerelease`——没有它,只装了 + Insiders 的机器会被报告成「没有 MSVC」,而磁盘上明明有一个可用的 cl.exe。 + + `VS*COMNTOOLS` 仍排在 vswhere **之后**:那是机器全局的残留(2017 的 + `VS150COMNTOOLS` 不该压过当前安装),而 `VSINSTALLDIR` 是有人为这个 shell + 设的。 + +- **Windows SDK 不再只认两个写死的绝对路径。** + + 顺序改为:`WindowsSdkDir`(+ `WindowsSdkVersion`,vcvars 本来就导出这两个) + → 受管 toolset 在 mcpp 自己 store 里的 `xim:windows-sdk` payload + → 原来的绝对路径(降为回退)。 + + 第二条不需要任何配置:**编译器自己的路径就说明了它来自哪个 store**, + SDK 是它在那里的邻居。所以 mcpp 里没有任何地方写死 SDK 版本。 + +### 接口一致性 + +- **`cxx_runtime = "self-contained"` 在 MSVC 上真的生效了。** + + 过去两个旋钮只有一个管用:`linkage = "static"` **确实发** `/MT`,而 + `cxx_runtime = "self-contained"` 报「未实现」——对着同一个物理开关。 + 两处注释也互相矛盾(`flags.cppm:605` 说发了 `/MT`,`distribution.cppm:202` + 说「根本没有 /MT」)。 + + 在 MSVC ABI 上这两条不是可以二选一的旋钮:`/MT` 把 C 运行时和 C++ 运行时 + 从同一个库里链进来,**它们本来就是一个开关**。现在两种写法都选中它,由 + `msvc_wants_static_crt()` 统一推导——项目的 TU 与 std 模块问的是同一个函数, + 不再各写各的表达式(#422 正是这样分叉的)。 + + 默认仍是 `/MD`:判据取的是**manifest 里写下的字面值**,不是解析后的 + contract——后者对多数 role 默认就是 self-contained,拿它做判据会把每一个 + Windows 构建都翻成 `/MT`。 + + MSVC 的 CRT 模型是**整个项目**的属性(一个项目只编一份 std 模块,cl 把 + `_MSVC_MT`/`_MSVC_MD` 烤进去),所以按 role 覆盖会被明确拒绝并说明原因, + 而不是在 ucrt 头文件里炸出 C5050/C2375。 + +### 测试 + +- msvc 的发现逻辑第一次可以在 Windows 之外测试:`installation_at()` 接受目录 + 而不是去探测机器,`find_windows_sdk()` 接受 root 列表。6 个新单测在 Linux CI + 上跑真实的 fixture 目录树,包括「两个 toolset 都在,要老的那个」这条—— + 「取最新」的实现会在这里失败。 +- 新增 e2e `239_msvc_managed_toolset.sh`。它的每一条断言都写成 + **系统编译器来应答就会失败**:cl.exe 必须在 mcpp 的 store 里、toolset 目录 + 必须是 spec 声明的那个、同一台机器上换回 `msvc@system` 必须仍然解析到系统 + 的 cl(两条来源互不污染)。 + ## [2026.8.13.1] — 2026-08-13 ### 性能 diff --git a/docs/03-toolchains.md b/docs/03-toolchains.md index 416cadcd..f89469a2 100644 --- a/docs/03-toolchains.md +++ b/docs/03-toolchains.md @@ -213,19 +213,40 @@ the current host can actually install, so if a target is missing from the Targets block, that host genuinely cannot serve it (implemented by `toolchain::host_can_serve`). -## MSVC (System Toolchain, Windows) +## MSVC (Windows) -MSVC is different from every other toolchain mcpp manages: it is a **system -toolchain**. mcpp locates and identifies an installed Visual Studio / Build -Tools — it never installs, updates, or removes MSVC itself. +An MSVC toolset reaches a build one of two ways, and the **version axis of the +spec** says which: + +| Spec | Origin | Which compiler you get | +|---|---|---| +| `msvc@system` (or bare `msvc`) | the machine's own Visual Studio | whatever is installed here | +| `msvc@` (e.g. `msvc@14.44.35207`) | an xlings payload mcpp installs | the one you named, on every machine | + +They are not alternatives to pick between once — they answer different +questions. `msvc@system` asks *"use what this developer already has"*; +`msvc@14.44.35207` asks *"build this project with exactly this compiler"*. +Pinned toolsets coexist with each other and with a system Visual Studio. + +### `msvc@system` — the machine's own Visual Studio + +mcpp locates and identifies an installed Visual Studio / Build Tools; it never +installs, updates, or removes one. ```bash mcpp toolchain default msvc ``` -On a machine with MSVC installed, mcpp auto-locates it (via `vswhere.exe`, -then `VSINSTALLDIR`/`VS*COMNTOOLS`, then the standard install paths), -identifies the versions involved, and persists the stable spec `msvc@system`: +mcpp auto-locates it in this order: + +1. **`VSINSTALLDIR`** — set by a developer command prompt or by a CI step that + ran `vcvarsall`. A declared answer, so it outranks the probes below. +2. `vswhere.exe` (including prerelease/Insiders instances) +3. `VS*COMNTOOLS` +4. the standard `Program Files\Microsoft Visual Studio\\` paths + +It then identifies the versions involved and persists the stable spec +`msvc@system`: ``` Detected msvc 19.44.35211 (VS 2022 BuildTools) (VC tools 14.44.35207) @@ -234,34 +255,75 @@ Detected msvc 19.44.35211 (VS 2022 BuildTools) (VC tools 14.44.35207) Default set to msvc@system (was: llvm@20.1.7) ``` -If MSVC is **not** installed, mcpp prints installation guidance instead -(Visual Studio Installer with the *Desktop development with C++* workload, or -`winget install Microsoft.VisualStudio.2022.BuildTools`) and exits non-zero — -install it yourself, then re-run the command. +If no Visual Studio is installed, mcpp says so and offers both routes — a +pinned toolset it can install for you, or the Visual Studio Installer / +`winget install Microsoft.VisualStudio.2022.BuildTools`. `mcpp toolchain list` shows the detected MSVC in a separate `System:` section, -and `mcpp self doctor` reports its status on Windows. In a manifest you can -pin it per-platform: +and `mcpp self doctor` reports its status on Windows. In a manifest: ```toml [toolchain] windows = "msvc@system" ``` -`msvc@` (e.g. `msvc@19.44`) acts as a pin-verify: mcpp still uses the -newest installed VC tools, but errors if the detected version doesn't match -the prefix. - -Since 0.0.90, **native cl.exe builds work**: mcpp synthesizes the -INCLUDE/LIB environment from the detected VC tools + Windows SDK (no -`vcvarsall` involved), stages `std.ixx`/`std.compat.ixx` as `.ifc` BMIs, -compiles `.cppm` module units via `/interface /TP /ifcOutput`, scans with -`/scanDependencies`, and links with `link.exe`/`lib.exe` through response -files. `[target.x86_64-windows-msvc] linkage = "static"` (or `mcpp build ---static`) selects the `/MT` CRT — not `[build] linkage`, which is not a key. -A missing Windows -SDK fails the build with installation guidance (`mcpp self doctor` reports -SDK status). +### `msvc@` — a toolset mcpp installs and pins + +```bash +mcpp toolchain list --available msvc # what can be pinned +mcpp toolchain install msvc 14.44.35207 +``` + +This works like `gcc@16.1.0` in every respect: the payload is downloaded into +mcpp's own store, several toolsets coexist, `mcpp toolchain remove +msvc@` uninstalls one, and a manifest that names one gets it +installed automatically on first build. + +```toml +[toolchain] +windows = "msvc@14.44.35207" +``` + +**The version is the toolset directory name** (`14.44.35207` — what +`VC\Tools\MSVC\` is named and what `-vcvars_ver` takes), *not* the cl banner +version (`19.44.35211`) and not the product year. Nothing needs to be +installed on the machine: the payload brings the compiler, the STL, and — via +its `xim:windows-sdk` dependency — the ucrt/um headers and libraries. + +> **Changed:** `msvc@19.44` used to mean "use the system MSVC and verify its +> banner starts with 19.44", which was checked by `mcpp toolchain default` and +> silently ignored by builds. The version axis now names a toolset everywhere. +> A `19.x` spelling gets an error naming both replacements — `msvc@system` or +> the toolset version that machine actually has. + +### Native cl.exe builds + +Since 0.0.90 these work on both origins: mcpp synthesizes the INCLUDE/LIB +environment from the VC tools + Windows SDK (no `vcvarsall` involved), stages +`std.ixx`/`std.compat.ixx` as `.ifc` BMIs, compiles `.cppm` module units via +`/interface /TP /ifcOutput`, scans with `/scanDependencies`, and links with +`link.exe`/`lib.exe` through response files. + +The Windows SDK is located in this order: **`WindowsSdkDir`** (+ +`WindowsSdkVersion`) if declared, then the `xim:windows-sdk` payload beside a +pinned toolset in mcpp's store, then `C:\Program Files (x86)\Windows Kits\10`. +A missing SDK fails the build with guidance (`mcpp self doctor` reports SDK +status). + +**CRT model.** `/MD` (host-coupled) by default; `/MT` when either + +```toml +[build] +linkage = "static" # the libc axis +cxx_runtime = "self-contained" # the C++ runtime axis +``` + +is written down. On the MSVC ABI these are one physical switch — `/MT` links +the C and C++ runtimes out of the same library — so both spellings select it +and mean the same thing. It is a **whole-project** property: one `std` module +is built per project and cl bakes `_MSVC_MT`/`_MSVC_MD` into it, so a +per-role override (`cxx_runtime = { tests = … }`) is refused with a message +saying so rather than producing a module mismatch inside the ucrt headers. ## Project-Level Version Pinning diff --git a/mcpp.toml b/mcpp.toml index ae01d056..613e813e 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.15.3" +version = "2026.8.16.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/distribution.cppm b/src/build/distribution.cppm index 3b4c667f..5f797342 100644 --- a/src/build/distribution.cppm +++ b/src/build/distribution.cppm @@ -199,13 +199,22 @@ struct MechanismInput { // // The distinction decides whether a cell with no mechanism SPEAKS. A // diagnostic is for a BROKEN PROMISE: mcpp said the artifact would be - // self-contained and it is not. Under the MSVC runtime mcpp never made - // that promise — there is no /MT emission at all — so warning on every - // Windows build would be noise nobody can act on. Write - // `cxx_runtime = "self-contained"` there and you get told, once, that it - // is not implemented. Cells where mcpp DOES promise something (a missing - // libc++.a under the default, say) report regardless. + // self-contained and it is not. Most roles DEFAULT to self-contained, so + // on a runtime where that default cannot be delivered, warning on every + // build would be noise nobody can act on. Cells where mcpp DOES promise + // something (a missing libc++.a under the default, say) report regardless. bool explicitRequest = false; + // MSVC only: is this project compiled with the static CRT (`/MT`)? + // + // A whole-PROJECT fact, not a per-role one, and that is a property of the + // platform rather than a simplification: cl bakes _MSVC_MT / _MSVC_MD + // into the std module, one std module is built per project, and a TU + // importing the other one fails inside the ucrt headers (#422). So the + // table can honour a project-level request and must refuse a per-role + // one — out loud, since silently ignoring it is how a knob becomes + // decoration. Derived by `msvc_wants_static_crt`, which is also what + // emits the flag. + bool msvcStaticCrt = false; // Toolchain capability id: "libstdc++", "libc++", or an MSVC STL spelling. std::string_view stdlibId; Format format = Format::Elf; @@ -389,26 +398,40 @@ Mechanism resolve(const MechanismInput& in) { // ---------------------------------------------------------------- PE case Format::Pe: { if (!detail::is_libstdcxx(in.stdlibId)) { - // MSVC STL (cl.exe, or clang on the MSVC ABI). The driver default - // is the DLL runtime and mcpp emits no runtime selection flag, so - // host-coupled is the only form that actually exists here. Both - // other contracts are refused BY NAME rather than quietly - // producing the same bytes and reporting success. - m.effective = Contract::HostCoupled; - if (in.requested == Contract::SelfContained) { + // MSVC STL (cl.exe, or clang on the MSVC ABI). The CRT model is + // the mechanism here, and it is a whole-project switch: /MT is + // self-contained (no vcruntime DLL dependency), /MD is + // host-coupled. `msvcStaticCrt` is that switch, already derived + // by whoever emits the flag — so what this table reports and what + // cl was actually told cannot disagree. + // + // No unit flags: the model is a COMPILE flag on every TU, not + // something added to the link line. + m.effective = in.msvcStaticCrt ? Contract::SelfContained + : Contract::HostCoupled; + if (in.requested == Contract::SelfContained && !in.msvcStaticCrt) { + // Asked for, not delivered. Only reachable from a per-ROLE + // override, because a project-level one would have set + // msvcStaticCrt — so name that, instead of the old "not + // implemented", which stopped being true and had already + // been contradicted by flags.cppm emitting /MT for + // `linkage = "static"`. m.degraded = in.explicitRequest; m.diagnostic = in.explicitRequest - ? "cxx_runtime = \"self-contained\" is not implemented for the " - "MSVC runtime yet (it would need the /MT runtime); using " - "host-coupled — the artifact needs the VC++ redistributable" + ? "on the MSVC runtime the CRT model is a whole-project " + "property — one std module is built per project and cl " + "bakes _MSVC_MT/_MSVC_MD into it, so a single role " + "cannot differ. Move it to [build] cxx_runtime = " + "\"self-contained\" (or linkage = \"static\") to apply " + "it everywhere; using host-coupled here" : ""; } else if (in.requested == Contract::ToolchainCoupled) { // Only reachable from an explicit request: it is never a default. m.degraded = true; - m.diagnostic = + m.diagnostic = std::format( "cxx_runtime = \"toolchain-coupled\" has no meaning for the " "MSVC runtime (it ships with the OS/redistributable, not with " - "the toolchain); using host-coupled"; + "the toolchain); using {}", to_string(m.effective)); } return m; } diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 758e88d1..d69895a8 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -601,15 +601,18 @@ CompileFlags compute_flags(const BuildPlan& plan) { if (prof.lto && !isMsvcDialect) opt_flag += " -flto"; // MSVC baseline: /nologo /EHsc /utf-8 (dialect alwaysFlags) + the CRT - // model — /MD default, /MT under static linkage (portable-by-default is - // impossible on MSVC-ABI; /MT at least removes the vcruntime DLL dep). + // model — /MD by default, /MT when either knob asks for the static CRT + // (portable-by-default is impossible on MSVC-ABI; /MT at least removes + // the vcruntime DLL dep). std::string msvc_base; if (isMsvcDialect) { msvc_base = std::format(" {}", d.alwaysFlags); // ONE derivation, shared with the std module build — see - // `msvc_crt_flag` in mcpp.toolchain.dialect and #422. + // `msvc_wants_static_crt` in mcpp.toolchain.dialect and #422. msvc_base += std::format(" {}", mcpp::toolchain::msvc_crt_flag( - d, plan.manifest.buildConfig.linkage == "static")); + d, mcpp::toolchain::msvc_wants_static_crt( + plan.manifest.buildConfig.linkage, + plan.manifest.buildConfig.cxxRuntime))); } // User link flags @@ -839,6 +842,14 @@ CompileFlags compute_flags(const BuildPlan& plan) { mi.stdlibId = caps.stdlib_id; mi.hostIsWindows = mcpp::platform::is_windows; mi.fullStaticLibc = (f.linkage == "static"); + // The CRT model this WHOLE project is being compiled with. Per-role + // contracts cannot move it: cl bakes _MSVC_MT / _MSVC_MD into the std + // module, one std module is built per project, and a TU importing the + // other one fails inside the ucrt headers (#422). The mechanism table + // needs to know so it can say that out loud rather than silently + // ignoring a role override. + mi.msvcStaticCrt = mcpp::toolchain::msvc_wants_static_crt( + bc.linkage, bc.cxxRuntime); mi.mingw = isMingwTc; mi.macosFloor = !macosDeploymentTarget.empty(); mi.format = format; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index d6189877..4715df91 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1248,8 +1248,12 @@ prepare_build(bool print_fingerprint, // resolution / fingerprinting. fold_build_defines_into_flags(m->buildConfig); - // msvc@system: a *system* toolchain — located on the machine, never - // resolved through xim packages. mcpp does not install MSVC. + // msvc@system: located on the machine, never resolved through xim + // packages. mcpp does not install the machine's Visual Studio. + // + // A VERSIONED msvc spec is a different origin and takes the xim path + // below — which is the whole point: what the manifest says is what gets + // used, on every machine, instead of whatever this one happens to have. bool tcSpecIsMsvc = false; if (tcSpec.has_value()) { if (auto s = mcpp::toolchain::parse_toolchain_spec(*tcSpec); @@ -1295,31 +1299,62 @@ prepare_build(bool print_fingerprint, mcpp::fetcher::InstallProgressHandler progress; auto payload = fetcher.resolve_xpkg_path(pkg.target(), /*autoInstall=*/true, &progress); 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)); } - explicit_compiler = mcpp::toolchain::toolchain_frontend(payload->binDir, pkg); - if (!std::filesystem::exists(explicit_compiler)) { - return std::unexpected(std::format( - "toolchain payload '{}' has no known C++ frontend in {}", - pkg.target(), payload->binDir.string())); + // 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) { + auto inst = mcpp::toolchain::msvc::installation_at( + payload->root, pkg.ximVersion); + if (!inst) { + return std::unexpected(std::format( + "msvc payload at '{}' has no cl.exe under VC/Tools/MSVC/{}", + payload->root.string(), pkg.ximVersion)); + } + explicit_compiler = inst->clPath; + mcpp::ui::info("Resolved", std::format( + "{} → msvc {} ({})", spec->display(), + inst->display_version(), inst->clPath.string())); + } else { + explicit_compiler = mcpp::toolchain::toolchain_frontend(payload->binDir, pkg); + if (!std::filesystem::exists(explicit_compiler)) { + return std::unexpected(std::format( + "toolchain payload '{}' has no known C++ frontend in {}", + pkg.target(), payload->binDir.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. + 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 → ". + mcpp::ui::info("Resolved", + std::format("{} → {}", spec->display(), + mcpp::ui::shorten_path(explicit_compiler, + mcpp::fetcher::make_path_ctx(&**get_cfg(), *root)))); } - // 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. - 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 → ". - mcpp::ui::info("Resolved", - std::format("{} → {}", spec->display(), - mcpp::ui::shorten_path(explicit_compiler, - mcpp::fetcher::make_path_ctx(&**get_cfg(), *root)))); } else if (tcSpec.has_value() && *tcSpec == "system") { // Explicit user opt-in to system PATH compiler — kept as escape hatch. } else if (mcpp::platform::env::offline_mode() @@ -4978,7 +5013,8 @@ prepare_build(bool print_fingerprint, // command is unchanged. const auto& stdDialect = mcpp::toolchain::dialect_for(*tc); const auto stdCrt = mcpp::toolchain::msvc_crt_flag( - stdDialect, m->buildConfig.linkage == "static"); + stdDialect, mcpp::toolchain::msvc_wants_static_crt( + m->buildConfig.linkage, m->buildConfig.cxxRuntime)); auto sm = mcpp::toolchain::ensure_built( *tc, m->package.standard, stdFlagAndDialect, mcpp::platform::macos::deployment_target( diff --git a/src/toolchain/dialect.cppm b/src/toolchain/dialect.cppm index 96d0b780..1adc549f 100644 --- a/src/toolchain/dialect.cppm +++ b/src/toolchain/dialect.cppm @@ -105,8 +105,35 @@ const CommandDialect& dialect_for(const Toolchain& tc); // // Same shape as `macos_deployment_target`, which `stdmod::ensure_built` already // takes for exactly this reason on the other platform. -constexpr std::string_view msvc_crt_flag(const CommandDialect& d, bool staticLinkage) { - return staticLinkage ? d.staticRuntime : d.dynamicRuntime; +constexpr std::string_view msvc_crt_flag(const CommandDialect& d, bool staticCrt) { + return staticCrt ? d.staticRuntime : d.dynamicRuntime; +} + +// Does this build want MSVC's STATIC CRT? TWO manifest keys answer it: +// +// [build] linkage = "static" the libc axis +// [build] cxx_runtime = "self-contained" the C++ runtime axis +// +// That is not a redundancy to be picked between — on the MSVC ABI they are +// physically ONE switch. `/MT` links the C runtime and the C++ runtime out of +// the same library, and there is no way to have one static and the other +// dynamic. distribution.cppm's PE/MinGW branch already says exactly this +// about `-static`; MSVC is the same statement, and used to disagree with +// itself about it (`linkage` emitted /MT while `cxx_runtime` reported "not +// implemented" — for the same physical outcome). +// +// `cxxRuntime` is the RAW manifest scalar, and must stay that way: the +// resolved Contract defaults to SelfContained for most roles, so keying off +// it would flip every Windows build from /MD to /MT. Empty here means nobody +// wrote it down, which is the only reading under which "explicit" is honest. +// +// Asking the question once is also what keeps the project's TUs and the std +// module from disagreeing (#422): both callers ask THIS, rather than each +// spelling out `linkage == "static"` and drifting the next time a key is +// added — which is precisely how the second key came to be ignored. +constexpr bool msvc_wants_static_crt(std::string_view linkage, + std::string_view cxxRuntime) { + return linkage == "static" || cxxRuntime == "self-contained"; } diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index c95ac215..cec3ed52 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -499,15 +499,19 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, mcpp::toolchain::print_compat_hint(*spec); if (int rc = attach_target_arg(*spec, targetArg); rc != 0) return rc; - // msvc@system: mcpp never installs MSVC — report what's there, or - // print installation guidance. + // msvc@system: mcpp never installs the machine's Visual Studio — + // report what's there, or point at both ways forward. + // (msvc@ is NOT a system spec and falls through to the xim + // package path below, like every other family.) if (mcpp::toolchain::is_system_toolchain(*spec)) { if (!mcpp::platform::is_windows) return msvc_wrong_host(); if (auto inst = mcpp::toolchain::msvc::detect_installation()) { msvc_print_detected(*inst); std::println(""); - std::println("MSVC is already installed — mcpp does not manage it."); - std::println("Tip: `mcpp toolchain default msvc` to make it the default."); + std::println("This is the machine's own Visual Studio — mcpp does not manage it."); + std::println("Tip: `mcpp toolchain default msvc` to make it the default,"); + std::println(" or `mcpp toolchain install msvc ` for a pinned one"); + std::println(" that does not depend on what this machine has installed."); return 0; } mcpp::ui::error(mcpp::toolchain::msvc::install_guidance()); @@ -575,6 +579,37 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, return 1; } + // A pinned MSVC toolset shares the ACQUISITION with gcc (the xim + // install above) and the RESOLUTION with msvc@system (below) — the + // two axes are independent, so neither gets a private copy of the + // other's logic. + // + // What it does not share is the bin/-shaped frontend lookup and the + // ELF post-install fixup: an msvc payload keeps cl.exe under + // VC/Tools/MSVC//bin/Hostx64/x64, and there is nothing to + // patchelf on a PE toolchain. + if (spec->family == mcpp::toolchain::Family::Msvc) { + auto inst = mcpp::toolchain::msvc::installation_at( + payload->root, pkg.ximVersion); + if (!inst) { + mcpp::ui::error(std::format( + "msvc payload installed at '{}', but no cl.exe under " + "VC/Tools/MSVC/{} — the payload is not what this version " + "claims to be", + payload->root.string(), pkg.ximVersion)); + return 1; + } + msvc_print_detected(*inst); + mcpp::ui::status("Installed", + std::format("{} → {}", pkg.display_spec(), inst->clPath.string())); + if (cfg.defaultToolchain.empty()) { + std::println(""); + std::println("Tip: `mcpp toolchain default {}` to make this the default.", + spec->spec_str()); + } + return 0; + } + auto bin = mcpp::toolchain::toolchain_frontend(payload->binDir, pkg); if (!std::filesystem::exists(bin)) { mcpp::ui::error(std::format( @@ -656,8 +691,9 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, mcpp::toolchain::print_compat_hint(*spec); if (int rc = attach_target_arg(*spec, targetArg); rc != 0) return rc; - // msvc@system: locate + identify the system MSVC, persist the stable - // spec (never a concrete version — config survives VS updates). + // msvc@system: locate + identify the machine's own MSVC, persist the + // stable spec (never a concrete version — config survives VS + // updates). A versioned msvc spec is a payload and falls through. if (mcpp::toolchain::is_system_toolchain(*spec)) { if (!mcpp::platform::is_windows) return msvc_wrong_host(); auto inst = mcpp::toolchain::msvc::detect_installation(); @@ -665,15 +701,6 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, mcpp::ui::error(mcpp::toolchain::msvc::install_guidance()); return 1; } - // `msvc@19.44` is a pin-verify against the detected install, not - // a selection among many — mcpp always uses the newest VC tools. - if (!spec->version.empty() && spec->version != "system" - && !inst->display_version().starts_with(spec->version)) { - mcpp::ui::error(std::format( - "msvc@{} requested, but the system MSVC is {} (VC tools {})", - spec->version, inst->display_version(), inst->toolsVersion)); - return 1; - } msvc_print_detected(*inst); auto wr = mcpp::config::write_default_toolchain(cfg, "msvc@system"); if (!wr) { @@ -707,6 +734,16 @@ export int toolchain_set_default(const mcpp::config::GlobalConfig& cfg, auto installDir = mcpp::xlings::paths::xim_tool(xlEnv, pkg.ximName, pkg.ximVersion); if (!std::filesystem::exists(installDir)) { + // Before "not installed", check whether this is the retired + // `msvc@` spelling — otherwise the advice is to + // install a toolset that does not exist and never will. + if (spec->family == mcpp::toolchain::Family::Msvc) { + if (auto hint = mcpp::toolchain::msvc::cl_version_spelling_hint( + spec->version)) { + mcpp::ui::error(*hint); + return 1; + } + } mcpp::ui::error(std::format( "{} is not installed. Run `mcpp toolchain install {} {}{}` first.", spec->display(), mcpp::toolchain::family_name(spec->family), @@ -741,8 +778,11 @@ export int toolchain_remove(const mcpp::config::GlobalConfig& cfg, auto xlEnv = mcpp::config::make_xlings_env(cfg); auto parsedSpec = mcpp::toolchain::parse_toolchain_spec(pos0); if (parsedSpec && mcpp::toolchain::is_system_toolchain(*parsedSpec)) { - mcpp::ui::error("msvc is a system toolchain managed by the Visual " - "Studio Installer — mcpp cannot remove it"); + mcpp::ui::error( + "msvc@system is the machine's own Visual Studio, managed by the " + "Visual Studio Installer — mcpp cannot remove it.\n" + " A toolset mcpp installed can be removed by naming it:\n" + " mcpp toolchain remove msvc@"); return 1; } if (!parsedSpec || parsedSpec->version.empty()) { diff --git a/src/toolchain/msvc.cppm b/src/toolchain/msvc.cppm index fe26cb22..a0417e7d 100644 --- a/src/toolchain/msvc.cppm +++ b/src/toolchain/msvc.cppm @@ -1,15 +1,25 @@ -// mcpp.toolchain.msvc — MSVC / Visual Studio discovery on Windows. +// mcpp.toolchain.msvc — locating an MSVC toolset, from either origin. // -// Provides reliable discovery of Visual Studio installations and MSVC -// toolchain components (std.ixx, cl.exe, lib.exe, etc.) using multiple -// strategies: -// 1. vswhere.exe (Microsoft's official VS locator) -// 2. Environment variables (VSINSTALLDIR, VS*COMNTOOLS) -// 3. Well-known installation paths (fallback) +// A toolset reaches a build one of two ways, and they answer different +// questions: // -// This module is used by clang.cppm to find MSVC STL's std.ixx when -// Clang targets x86_64-pc-windows-msvc. It will also serve as the -// foundation for future native MSVC (cl.exe) toolchain support. +// SYSTEM (`msvc@system`) — probed on this machine. The answer depends +// on what happens to be installed here. +// MANAGED (`msvc@`) — an xlings payload the manifest named. The +// answer is in the manifest; the machine only +// decides whether it has been downloaded yet. +// +// Everything below is one of those two, or shared between them. The shared +// part is `installation_from_tools_dir()`: given a `VC/Tools/MSVC/` +// directory, the record built from it is identical whichever origin produced +// it — which is what keeps a managed toolset from being a second code path +// with its own bugs. +// +// Discovery order for the SYSTEM origin is deliberate and is documented at +// find_vs_install_path(): a declared answer outranks a probe. +// +// Also used by clang.cppm to find MSVC STL's std.ixx when Clang targets +// x86_64-pc-windows-msvc. module; #include @@ -47,10 +57,7 @@ std::optional find_cl(); // the level gate reachable: every other provider answers 20. int std_module_min_level(const Toolchain& tc); -// ─── System-toolchain detection (msvc@system) ──────────────────────────── -// -// mcpp treats MSVC as a *system* toolchain: it locates and identifies an -// installed Visual Studio / Build Tools, but never installs or removes one. +// ─── Installation records (both origins) ───────────────────────────────── struct MsvcInstallation { std::filesystem::path vsRoot; // …\Microsoft Visual Studio\2022\BuildTools @@ -67,9 +74,28 @@ struct MsvcInstallation { } }; -// Locate the best (newest) usable installation. nullopt = MSVC absent. +// SYSTEM origin: locate the best (newest) usable installation on this +// machine. nullopt = MSVC absent. Everything about which toolset this picks +// is a property of the machine, not of the caller — see `installation_at` +// for the other origin. std::optional detect_installation(); +// MANAGED origin: build the record for an EXACT toolset under a VS-shaped +// root (`/VC/Tools/MSVC/`). +// +// Nothing is probed and nothing is ranked: `toolsVersion` is what the caller +// declared, so a missing directory is nullopt rather than a silent fallback +// to a neighbouring toolset. That is the whole difference from +// detect_installation(), and it is why a manifest pinning a toolset gets the +// same compiler on every machine. +// +// Not Windows-only: given a directory of that shape the record is the same +// anywhere, which is what makes the managed path testable off Windows. The +// cl banner simply stays unparsed there and `display_version()` falls back +// to the declared version. +std::optional installation_at(const std::filesystem::path& vsRoot, + std::string_view toolsVersion); + // Parse a cl.exe banner into (version, arch). Token-based so localized // banners work: first "d.d.d[.d]" run is the version, arch is the arm64/x64/ // x86 token. Pure and cross-platform for unit testing. @@ -79,10 +105,22 @@ parse_cl_banner(std::string_view banner); // Map a cl banner arch token to the canonical windows-msvc triple. std::string triple_for_arch(std::string_view arch); -// Multi-line guidance shown wherever MSVC is required but absent. -// States what was searched and how to install (mcpp does not install MSVC). +// Multi-line guidance shown wherever MSVC is required but absent: what was +// searched, and both ways to get a compiler (pin one, or use the machine's). std::string install_guidance(); +// A version-axis spelling that no longer means what it used to. +// +// `msvc@19.44` was a pin-verify against the SYSTEM install's cl banner. The +// version axis now names a toolset (`14.44.35207`), so that spelling has to +// say so — and say what the two things it might have meant are spelled as. +// +// Returns guidance only when this machine can PROVE that reading (its own cl +// banner matches the requested prefix), which makes the message a fact about +// this machine rather than a guess about a string. nullopt otherwise, so an +// ordinary "no such toolset" error is not decorated with speculation. +std::optional cl_version_spelling_hint(std::string_view requestedVersion); + // Classify + enrich an already-probed cl.exe binary for detect(): // version/arch from the banner, targetTriple, driverIdent, std.ixx lookup, // and the build env (INCLUDE/LIB/PATH from VC tools + Windows SDK) into @@ -98,8 +136,29 @@ struct WindowsSdk { std::string version; // "10.0.26100.0" (highest usable) }; -// Locate the Windows 10/11 SDK (highest version with ucrt headers). -std::optional find_windows_sdk(); +// Locate the Windows 10/11 SDK. Search order, most specific first: +// +// 1. WindowsSdkDir (+ WindowsSdkVersion) — what vcvars exports and what +// every other build system honours. A declared answer outranks a scan, +// for the same reason VSINSTALLDIR outranks vswhere. +// 2. `extraRoots` — roots the caller already knows about. In practice the +// windows-sdk payloads sitting beside a managed toolset in mcpp's own +// store; see sibling_sdk_roots(). +// 3. The conventional absolute install roots — a fallback, not the rule. +// They are still here because a system Visual Studio does put the SDK +// there, and nothing else would find it. +// +// Within a root the highest version carrying `ucrt/corecrt.h` wins, unless +// WindowsSdkVersion named one that is present. +std::optional find_windows_sdk( + std::span extraRoots = {}); + +// Windows SDK payload roots that belong to the same xlings store as this +// compiler. The compiler binary says which store it came from, so a managed +// toolset finds its own SDK with nothing configured and no version hardcoded +// anywhere in mcpp. Empty for a system cl.exe (it is not in a store). +std::vector +sibling_sdk_roots(const std::filesystem::path& clPath); // True only when BOTH halves of a usable MSVC C++ setup are present: the // STL's std module source AND the Windows SDK. @@ -165,15 +224,32 @@ std::string run_capture_line(const std::string& cmd) { return out; } -// Strategy 1: Use vswhere.exe to find VS installation. +// Strategy 1: VSINSTALLDIR — someone SAID which install to use. +// +// Set by a developer command prompt, by a CI step that ran vcvarsall, or by +// a tool that exported an environment on purpose. It is an answer, not a +// guess, which is why it now outranks vswhere. +std::optional find_vs_via_vsinstalldir() { + if (auto* dir = std::getenv("VSINSTALLDIR"); dir && *dir) { + std::filesystem::path p{dir}; + if (std::filesystem::exists(p / "VC" / "Tools" / "MSVC")) + return p; + } + return std::nullopt; +} + +// Strategy 2: vswhere.exe — Microsoft's locator, i.e. a ranked guess. std::optional find_vs_via_vswhere() { // vswhere.exe ships with the VS Installer at a well-known path std::filesystem::path vswhere = "C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe"; if (!std::filesystem::exists(vswhere)) return std::nullopt; + // `-prerelease` or an Insiders instance is invisible here. Without it a + // machine with only an Insiders VS reports "MSVC was not found" while a + // perfectly good cl.exe sits on disk. auto result = run_capture_line( - "\"" + vswhere.string() + "\" -latest -products * " + "\"" + vswhere.string() + "\" -latest -prerelease -products * " "-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 " "-property installationPath 2>nul"); @@ -182,15 +258,10 @@ std::optional find_vs_via_vswhere() { return std::nullopt; } -// Strategy 2: Use environment variables. -std::optional find_vs_via_env() { - // VSINSTALLDIR is set inside VS Developer Command Prompt - if (auto* dir = std::getenv("VSINSTALLDIR"); dir && *dir) { - std::filesystem::path p{dir}; - if (std::filesystem::exists(p / "VC" / "Tools" / "MSVC")) - return p; - } - +// Strategy 3: VS*COMNTOOLS — machine-wide leftovers, so they rank BELOW +// vswhere. VS150COMNTOOLS lingering from a 2017 install must not outrank a +// current one; unlike VSINSTALLDIR nobody set these for this shell. +std::optional find_vs_via_comntools() { // VS*COMNTOOLS: VS170COMNTOOLS (2022), VS160COMNTOOLS (2019), VS150COMNTOOLS (2017) for (auto* var : {"VS170COMNTOOLS", "VS160COMNTOOLS", "VS150COMNTOOLS"}) { if (auto* val = std::getenv(var); val && *val) { @@ -204,7 +275,7 @@ std::optional find_vs_via_env() { return std::nullopt; } -// Strategy 3: Scan well-known paths. +// Strategy 4: Scan well-known paths. std::optional find_vs_via_paths() { static constexpr std::string_view bases[] = { "C:\\Program Files\\Microsoft Visual Studio", @@ -256,10 +327,17 @@ std::optional find_latest_msvc_tools(const std::filesyste std::optional find_vs_install_path() { #if defined(_WIN32) - // Try strategies in order of reliability - if (auto p = find_vs_via_vswhere()) return p; - if (auto p = find_vs_via_env()) return p; - if (auto p = find_vs_via_paths()) return p; + // Declared before probed. vswhere used to run first, and because it + // returns something on almost every developer machine, VSINSTALLDIR was + // effectively unreachable — a build that had exported a complete vcvars + // environment still compiled with whatever vswhere ranked highest + // (measured on xrgui#3: vcvars said 14.52, the build used 14.51, and the + // only way out was to hide vswhere.exe). A guess must not silently + // override an answer. + if (auto p = find_vs_via_vsinstalldir()) return p; + if (auto p = find_vs_via_vswhere()) return p; + if (auto p = find_vs_via_comntools()) return p; + if (auto p = find_vs_via_paths()) return p; #endif return std::nullopt; } @@ -343,15 +421,44 @@ std::string triple_for_arch(std::string_view arch) { std::string install_guidance() { return - "MSVC was not found on this system.\n" - " searched: vswhere.exe, VSINSTALLDIR / VS*COMNTOOLS, and the standard\n" + "no Visual Studio installation was found on this system.\n" + " searched: VSINSTALLDIR, vswhere.exe, VS*COMNTOOLS, and the standard\n" " 'Program Files\\Microsoft Visual Studio\\\\' paths\n" - " mcpp does not install MSVC — install it yourself, then retry:\n" + "\n" + " mcpp can install a pinned MSVC toolset instead — no Visual Studio,\n" + " no installer, no elevation, and several toolsets can coexist:\n" + " mcpp toolchain install msvc 14.44.35207\n" + " mcpp toolchain list --available msvc # other toolsets\n" + " then pin it in mcpp.toml, so every machine builds with that one:\n" + " [toolchain]\n" + " windows = \"msvc@14.44.35207\"\n" + "\n" + " or install Visual Studio yourself and use msvc@system:\n" " - Visual Studio Installer: add the 'Desktop development with C++' workload\n" " (component: Microsoft.VisualStudio.Component.VC.Tools.x86.x64)\n" " - or Build Tools only: winget install Microsoft.VisualStudio.2022.BuildTools\n" " then add the C++ workload in the installer\n" - " afterwards run: mcpp toolchain default msvc"; + " afterwards run: mcpp toolchain default msvc"; +} + +std::optional cl_version_spelling_hint(std::string_view requestedVersion) { + if (requestedVersion.empty() || requestedVersion == "system") + return std::nullopt; + auto inst = detect_installation(); + if (!inst) return std::nullopt; + // Only when the requested string really is this machine's cl version. + // A toolset version ("14.44.35207") never prefixes a banner ("19.44.…"), + // so this cannot fire on a genuine typo'd toolset. + if (!inst->display_version().starts_with(requestedVersion)) return std::nullopt; + if (inst->display_version() == inst->toolsVersion) return std::nullopt; // banner unparsed + return std::format( + "msvc@{} names a COMPILER version, not a toolset.\n" + " This machine's Visual Studio reports cl {} (VC tools {}).\n" + " The version axis now selects the toolset mcpp installs and pins:\n" + " - to use this machine's install: msvc@system\n" + " - to pin a toolset mcpp manages: msvc@{}", + requestedVersion, inst->display_version(), inst->toolsVersion, + inst->toolsVersion); } namespace { @@ -368,6 +475,18 @@ namespace { return {}; } +// The one place an MsvcInstallation is built — from a VS root and a +// `VC/Tools/MSVC/` directory under it. Both origins land here, so a +// managed toolset and a system one are described by the same code and cannot +// drift apart in what they report or which cl.exe they pick. +// +// Deliberately not Windows-guarded: given a directory of that shape the +// record is the same anywhere, which is what makes the managed path testable +// on a Linux CI runner. +std::optional +installation_from_tools_dir(const std::filesystem::path& vsRoot, + const std::filesystem::path& tools); + // Capture cl.exe's banner. cl prints it (plus a usage complaint) when run // bare; the exit status is irrelevant — parse whatever came out. std::string capture_cl_banner(const std::filesystem::path& cl) { @@ -376,19 +495,13 @@ std::string capture_cl_banner(const std::filesystem::path& cl) { return r.output; } -} // namespace - -std::optional detect_installation() { -#if defined(_WIN32) - auto vs = find_vs_install_path(); - if (!vs) return std::nullopt; - auto tools = find_latest_msvc_tools(*vs); - if (!tools) return std::nullopt; - +std::optional +installation_from_tools_dir(const std::filesystem::path& vsRoot, + const std::filesystem::path& tools) { MsvcInstallation inst; - inst.vsRoot = *vs; - inst.vsProduct = product_from_vs_root(*vs); - inst.toolsVersion = tools->filename().string(); + inst.vsRoot = vsRoot; + inst.vsProduct = product_from_vs_root(vsRoot); + inst.toolsVersion = tools.filename().string(); // Host-native bin dir first (arm64 hosts run arm64 cl; everything else // x64), with the remaining pairs as fallback. @@ -399,9 +512,9 @@ std::optional detect_installation() { } else { pairs = {{"Hostx64", "x64"}, {"Hostarm64", "arm64"}, {"Hostx86", "x86"}}; } + std::error_code ec; for (auto [host, target] : pairs) { - auto cl = *tools / "bin" / host / target / "cl.exe"; - std::error_code ec; + auto cl = tools / "bin" / host / target / "cl.exe"; if (std::filesystem::exists(cl, ec)) { inst.clPath = cl; inst.arch = std::string(target); @@ -410,45 +523,105 @@ std::optional detect_installation() { } if (inst.clPath.empty()) return std::nullopt; - std::error_code ec; inst.hasStdModules = - std::filesystem::exists(*tools / "modules" / "std.ixx", ec); + std::filesystem::exists(tools / "modules" / "std.ixx", ec); // Version identification: banner is authoritative; tolerate failure // (clVersion stays empty and display_version() falls back to the - // tools-dir version). + // tools-dir version). Off Windows that failure is the normal case. if (auto parsed = parse_cl_banner(capture_cl_banner(inst.clPath))) { inst.clVersion = parsed->first; if (!parsed->second.empty()) inst.arch = parsed->second; } return inst; +} + +} // namespace + +std::optional installation_at(const std::filesystem::path& vsRoot, + std::string_view toolsVersion) { + if (toolsVersion.empty()) return std::nullopt; + auto tools = vsRoot / "VC" / "Tools" / "MSVC" / std::string(toolsVersion); + std::error_code ec; + if (!std::filesystem::is_directory(tools, ec)) return std::nullopt; + return installation_from_tools_dir(vsRoot, tools); +} + +std::optional detect_installation() { +#if defined(_WIN32) + auto vs = find_vs_install_path(); + if (!vs) return std::nullopt; + auto tools = find_latest_msvc_tools(*vs); + if (!tools) return std::nullopt; + return installation_from_tools_dir(*vs, *tools); #else return std::nullopt; #endif } -std::optional find_windows_sdk() { -#if defined(_WIN32) - // Directory scan of the conventional install roots; highest version dir - // that actually carries the UCRT headers wins. (Registry Installed - // Roots would be marginally more correct — the path scan covers every - // real installer layout seen so far and needs no Win32 API surface.) - for (const char* base : {"C:\\Program Files (x86)\\Windows Kits\\10", - "C:\\Program Files\\Windows Kits\\10"}) { - std::filesystem::path root{base}; +std::vector +sibling_sdk_roots(const std::filesystem::path& clPath) { + std::vector out; + auto xpkgs = mcpp::xlings::paths::xpkgs_from_compiler(clPath); + if (!xpkgs) return out; // a system cl.exe: not in any store + std::error_code ec; + auto pkgRoot = *xpkgs / "xim-x-windows-sdk"; + for (auto& e : std::filesystem::directory_iterator(pkgRoot, ec)) { + if (e.is_directory(ec)) out.push_back(e.path()); + } + // Newest payload version first, so the "highest usable" rule inside + // find_windows_sdk() sees them in the order it would have picked anyway. + std::sort(out.begin(), out.end(), std::greater<>{}); + return out; +} + +std::optional find_windows_sdk( + std::span extraRoots) { + // Highest version dir under `root/Include` that actually carries the UCRT + // headers; `want` (from WindowsSdkVersion) wins if it is one of them. + // (Registry Installed Roots would be marginally more correct — the path + // scan covers every real installer layout seen so far and needs no Win32 + // API surface.) + auto pick = [](const std::filesystem::path& root, + std::string_view want) -> std::optional { std::error_code ec; - if (!std::filesystem::exists(root / "Include", ec)) continue; + auto inc = root / "Include"; + if (!std::filesystem::is_directory(inc, ec)) return std::nullopt; std::string best; - for (auto& e : std::filesystem::directory_iterator(root / "Include", ec)) { - if (!e.is_directory()) continue; + for (auto& e : std::filesystem::directory_iterator(inc, ec)) { + if (!e.is_directory(ec)) continue; auto v = e.path().filename().string(); - if (std::filesystem::exists(e.path() / "ucrt" / "corecrt.h", ec) - && v > best) - best = v; + if (!std::filesystem::exists(e.path() / "ucrt" / "corecrt.h", ec)) + continue; + if (!want.empty() && v == want) return WindowsSdk{root, v}; + if (v > best) best = v; } - if (!best.empty()) return WindowsSdk{root, best}; + if (best.empty()) return std::nullopt; + return WindowsSdk{root, best}; + }; + + // 1. Declared: WindowsSdkDir (+ WindowsSdkVersion). vcvars exports both; + // WindowsSdkVersion carries a trailing backslash there, which is not + // part of the directory name. + std::string want; + if (auto* v = std::getenv("WindowsSdkVersion"); v && *v) { + want = v; + while (!want.empty() && (want.back() == '\\' || want.back() == '/')) + want.pop_back(); + } + if (auto* dir = std::getenv("WindowsSdkDir"); dir && *dir) { + if (auto s = pick(std::filesystem::path{dir}, want)) return s; + } + + // 2. Roots the caller knows about (managed toolset's own store). + for (const auto& root : extraRoots) + if (auto s = pick(root, want)) return s; + + // 3. The conventional absolute install roots. + for (const char* base : {"C:\\Program Files (x86)\\Windows Kits\\10", + "C:\\Program Files\\Windows Kits\\10"}) { + if (auto s = pick(std::filesystem::path{base}, want)) return s; } -#endif return std::nullopt; } @@ -628,7 +801,12 @@ std::expected enrich_toolchain_from_cl(Toolchain& tc) { // Build environment (INCLUDE/LIB/PATH/VSLANG). SDK absence keeps // detection working (selection UX on SDK-less boxes); the build path // errors with guidance when envOverrides is empty. - if (auto sdk = find_windows_sdk()) { + // + // A managed toolset carries its own SDK as an xlings dependency, and the + // compiler's own path says which store to look in — so the two halves of + // a pinned toolchain stay together without anything being configured. + auto extraSdkRoots = sibling_sdk_roots(tc.binaryPath); + if (auto sdk = find_windows_sdk(extraSdkRoots)) { tc.envOverrides = build_env_for_cl(tc.binaryPath, parsed->second, *sdk); } return {}; diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index b3f563ea..d21e0c88 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -116,15 +116,19 @@ struct PayloadIdentity { std::optional identify_xim_payload(std::string_view ximDirName); // Does an installed payload row match the configured default (toolchain axis; -// version exact)? msvc matches on family alone — the persisted spec is the -// stable "msvc@system", never a concrete version. +// version exact)? `msvc@system` names no version and so matches on family +// alone; a pinned toolset compares versions like every other family. bool spec_matches_payload(const ToolchainSpec& def, const PayloadIdentity& id, std::string_view payloadVersion); // System toolchains are located on the machine, never installed/removed by -// mcpp. Today that's MSVC (`msvc@system`); the PATH-compiler escape hatch -// (`[toolchain] … = "system"`) is a separate, older mechanism. +// mcpp: `msvc@system` and bare `msvc`. A VERSIONED msvc spec is NOT one of +// them — `msvc@14.44.35207` is an xim payload mcpp installs and pins, the +// same shape as `gcc@16.1.0`. +// +// (The PATH-compiler escape hatch, `[toolchain] … = "system"`, is a separate +// and older mechanism.) bool is_system_toolchain(const ToolchainSpec& spec); // Can THIS host serve that target — is there an installable payload for the @@ -239,7 +243,19 @@ XimToolchainPackage to_xim_package(const ToolchainSpec& spec) { pkg.ximVersion = spec.version; if (spec.family == Family::Msvc) { - pkg.ximName = "msvc"; // never resolved via xim + // `xim:msvc@`. Only reached for a VERSIONED spec — + // `msvc@system` never gets here, because nothing about it is a + // package (see is_system_toolchain). + // + // frontendCandidates is what the generic bin/-shaped resolution + // looks for, and an msvc payload is not bin/-shaped: cl.exe lives at + // VC/Tools/MSVC//bin/Hostx64/x64/. The managed path therefore + // resolves through msvc::installation_at() instead — the same code + // that describes a system install. Keeping the candidate here means + // a caller that does use the generic path gets nothing rather than + // the wrong thing. + pkg.ximName = "msvc"; + pkg.ximVersion = spec.version; pkg.frontendCandidates = {"cl.exe"}; return pkg; } @@ -344,6 +360,15 @@ std::filesystem::path toolchain_frontend(const std::filesystem::path& binDir, std::optional identify_xim_payload(std::string_view ximDirName) { if (ximDirName == "gcc") return PayloadIdentity{ Family::Gcc, {} }; + // A pinned toolset is an installed payload like any other, so it shows up + // in `toolchain list` under its toolset version. Without this row the + // install would succeed and then be invisible. + // + // Host-target (empty triple), like gcc and llvm: an msvc payload only + // ever targets the machine it runs on, and a spec for it carries no + // target axis either — so the two sides compare equal. + if (ximDirName == "msvc") + return PayloadIdentity{ Family::Msvc, {} }; if (ximDirName == mcpp::toolchain::llvm::package_name()) return PayloadIdentity{ Family::Llvm, {} }; if (ximDirName == "musl-gcc") @@ -362,12 +387,23 @@ bool spec_matches_payload(const ToolchainSpec& def, const PayloadIdentity& id, std::string_view payloadVersion) { if (def.family != id.family) return false; - if (def.family == Family::Msvc) return true; // msvc@system: family match + // msvc@system names no version, so it matches on family alone. A pinned + // toolset compares versions like every other family — that is the point + // of pinning it. + if (is_system_toolchain(def)) return true; return def.version == payloadVersion; } bool is_system_toolchain(const ToolchainSpec& spec) { - return spec.family == Family::Msvc; + // The VERSION axis decides, not the family. `msvc@system` (and bare + // `msvc`) means "whatever this machine has"; `msvc@14.44.35207` names a + // toolset mcpp installs and pins, exactly as `gcc@16.1.0` names a gcc. + // + // Before this split, every msvc spec was a system spec — so a manifest + // could ask for a specific toolset and silently get a different one, + // which is the defect this whole file's msvc handling exists to close. + return spec.family == Family::Msvc + && (spec.version.empty() || spec.version == "system"); } bool host_can_serve(const triple::Triple& target) { @@ -405,6 +441,11 @@ std::vector available_toolchain_indexes() { // The Windows-PE gcc payload is host-split at the distribution layer // (§4.3); each host lists the package it would actually install. if constexpr (mcpp::platform::is_windows) { + // Pinned MSVC toolsets. Listing them is what makes the managed origin + // discoverable at all: without a row here `toolchain list --available` + // says gcc and llvm can be pinned and msvc cannot, which stopped being + // true. `msvc@system` is reported separately, as an installation. + out.push_back({ "msvc", Family::Msvc }); out.push_back({ "mingw-gcc", Family::Gcc }); // The windows-hosted canadian cross to Linux. Named by triple, exactly // as to_xim_package() derives it (`-gcc`), so the Available diff --git a/src/version.cppm b/src/version.cppm index efe06158..a12d47e6 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.15.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.16.1"; } // namespace mcpp diff --git a/tests/e2e/239_msvc_managed_toolset.sh b/tests/e2e/239_msvc_managed_toolset.sh new file mode 100755 index 00000000..2f1dd2a1 --- /dev/null +++ b/tests/e2e/239_msvc_managed_toolset.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# requires: msvc +# 97_msvc_managed_toolset.sh — `msvc@`: the toolset the manifest +# names is the one that compiles, regardless of what this machine has. +# +# WHY THIS TEST CANNOT BE SATISFIED BY THE MACHINE'S COMPILER, which is the +# only reason it is worth running: the runner has its own Visual Studio, and +# every assertion below is written so that the system install answering +# instead would FAIL rather than pass quietly. +# +# - the resolved cl.exe must live under mcpp's payload store +# - its toolset directory must be the version the spec named +# - `msvc@system` on the same machine must still resolve to the SYSTEM cl, +# i.e. the two origins do not contaminate each other +# +# Network: installs xim:msvc (~85 MB) + xim:windows-sdk (~135 MB). Skips +# cleanly when the index cannot be reached, because an offline runner has +# nothing to say about this. +set -e + +TOOLSET="14.44.35207" # release channel; `latest` in xim:msvc + +CONF="${MCPP_HOME:-$HOME/.mcpp}/config.toml" +ORIG_DEFAULT="" +if [[ -f "$CONF" ]]; then + ORIG_DEFAULT=$(sed -n '/^\[toolchain\]/,/^\[/p' "$CONF" \ + | grep -E '^default[[:space:]]*=' | head -1 | cut -d'"' -f2 || true) +fi +TMP=$(mktemp -d) +restore() { + if [[ -n "$ORIG_DEFAULT" ]]; then + "$MCPP" toolchain default "$ORIG_DEFAULT" >/dev/null 2>&1 || true + fi + rm -rf "$TMP" +} +trap restore EXIT +cd "$TMP" + +# 0) the toolset must be OFFERED before it can be pinned. A managed origin +# nobody can discover is not a feature. +out=$("$MCPP" toolchain list --available 2>&1) || true +[[ "$out" == *"msvc"* ]] \ + || { echo "FAIL: msvc absent from --available: $out"; exit 1; } + +# 1) install it +rc=0; out=$("$MCPP" toolchain install msvc "$TOOLSET" 2>&1) || rc=$? +if [[ $rc -ne 0 ]]; then + case "$out" in + *"index"*|*"network"*|*"resolve"*|*"offline"*|*"connect"*) + echo "SKIP: xim:msvc@$TOOLSET unreachable: $out"; exit 0 ;; + *) echo "FAIL: install msvc $TOOLSET: $out"; exit 1 ;; + esac +fi +[[ "$out" == *"$TOOLSET"* ]] \ + || { echo "FAIL: install did not report the toolset: $out"; exit 1; } + +# 2) build with it, from a manifest — the path that matters, and the one +# where the version used to be accepted and then ignored. +"$MCPP" new hello_pinned >/dev/null 2>&1 +cd hello_pinned +cat >> mcpp.toml <&1) || { echo "FAIL: pinned build: $out"; exit 1; } + +# The resolved compiler must be the PAYLOAD's, not the machine's. Two +# independent facts, because either alone can be true by accident: the path +# is inside mcpp's store, AND the toolset directory is the one named. +resolved=$(echo "$out" | grep -iE "Resolved .*msvc" | head -1) +[[ -n "$resolved" ]] || { echo "FAIL: no Resolved line: $out"; exit 1; } +case "$resolved" in + *xpkgs*xim-x-msvc*) ;; + *) echo "FAIL: cl.exe is not from mcpp's store: $resolved"; exit 1 ;; +esac +case "$resolved" in + *"$TOOLSET"*) ;; + *) echo "FAIL: resolved toolset is not $TOOLSET: $resolved"; exit 1 ;; +esac + +out=$("$MCPP" run 2>&1) || { echo "FAIL: pinned run: $out"; exit 1; } +[[ "$out" == *"Hello"* || "$out" == *"hello"* ]] \ + || { echo "FAIL: hello output: $out"; exit 1; } + +# 3) THE REVERSE DIRECTION. Same machine, same project, spec switched back to +# msvc@system: it must resolve to the SYSTEM cl again. Without this, a +# "managed works" result is equally consistent with "managed replaced +# everything", and the system origin would be quietly gone. +sed -i "s|windows = \"msvc@$TOOLSET\"|windows = \"msvc@system\"|" mcpp.toml +out=$("$MCPP" build --verbose 2>&1) || { echo "FAIL: system build: $out"; exit 1; } +resolved=$(echo "$out" | grep -iE "Resolved .*msvc" | head -1) +[[ -n "$resolved" ]] || { echo "FAIL: no Resolved line (system): $out"; exit 1; } +case "$resolved" in + *xpkgs*xim-x-msvc*) + echo "FAIL: msvc@system resolved to the PAYLOAD — the origins leak: $resolved" + exit 1 ;; +esac + +# 4) a toolset mcpp installed is removable — the other half of the message +# `toolchain remove msvc` prints. +cd "$TMP" +out=$("$MCPP" toolchain remove "msvc@$TOOLSET" 2>&1) \ + || { echo "FAIL: remove pinned toolset: $out"; exit 1; } +[[ "$out" == *"Removed"* ]] || { echo "FAIL: remove message: $out"; exit 1; } + +echo "PASS: msvc@$TOOLSET installs, builds, stays distinct from msvc@system, and removes" diff --git a/tests/e2e/95_msvc_system_toolchain.sh b/tests/e2e/95_msvc_system_toolchain.sh index 9e67a485..12dd5e6f 100755 --- a/tests/e2e/95_msvc_system_toolchain.sh +++ b/tests/e2e/95_msvc_system_toolchain.sh @@ -4,8 +4,10 @@ # - `toolchain default msvc` locates + identifies the system MSVC and # persists the stable spec msvc@system # - `toolchain list` shows the detected MSVC in a System section, starred -# - version pin-verify: `msvc@99` mismatches the detected install -# - `toolchain remove/install msvc`: mcpp never manages MSVC itself +# - a VERSIONED msvc spec is NOT this origin: it is a payload, and an +# absent one says so instead of silently using the machine's compiler +# - the retired `msvc@` spelling names both replacements +# - `toolchain remove/install msvc`: mcpp never manages the machine's VS set -e # This test flips the global default toolchain; save + restore it so later @@ -43,18 +45,47 @@ out=$("$MCPP" toolchain list 2>&1) echo "$out" | grep -E '\*\s*msvc' >/dev/null \ || { echo "FAIL: msvc row not starred as default: $out"; exit 1; } -# 3) version pin-verify: an impossible major must mismatch -rc=0; out=$("$MCPP" toolchain default msvc@99 2>&1) || rc=$? -[[ $rc -ne 0 ]] || { echo "FAIL: msvc@99 should mismatch"; exit 1; } -[[ "$out" == *"requested"* ]] || { echo "FAIL: mismatch message: $out"; exit 1; } +# 3) a VERSIONED spec is a PAYLOAD, not this origin. +# +# This is the load-bearing assertion of the whole split: a toolset that is +# not installed must FAIL. Falling back to the machine's compiler is +# exactly the silent substitution the version axis exists to prevent, and +# it would look like success from the outside. +rc=0; out=$("$MCPP" toolchain default msvc@14.0.99999 2>&1) || rc=$? +[[ $rc -ne 0 ]] || { echo "FAIL: an absent toolset must not resolve: $out"; exit 1; } +[[ "$out" == *"not installed"* ]] \ + || { echo "FAIL: absent-toolset message: $out"; exit 1; } +# …and it must not have quietly become the default. +out=$("$MCPP" toolchain list 2>&1) +echo "$out" | grep -E '\*\s*msvc' >/dev/null \ + || { echo "FAIL: default was disturbed by a failed pin: $out"; exit 1; } + +# 3b) the retired spelling. `msvc@19.x` used to mean "use the system MSVC and +# verify its banner" — checked here and silently ignored by builds. It now +# names a toolset, so this machine's own cl version has to say so and +# point at both replacements. +CLVER=$("$MCPP" toolchain default msvc 2>&1 | grep -oE 'msvc 19\.[0-9]+' | head -1 | cut -d' ' -f2) +if [[ -n "$CLVER" ]]; then + rc=0; out=$("$MCPP" toolchain default "msvc@$CLVER" 2>&1) || rc=$? + [[ $rc -ne 0 ]] || { echo "FAIL: msvc@$CLVER should not resolve"; exit 1; } + [[ "$out" == *"COMPILER version"* ]] \ + || { echo "FAIL: no cl-version signpost: $out"; exit 1; } + [[ "$out" == *"msvc@system"* ]] \ + || { echo "FAIL: signpost omits msvc@system: $out"; exit 1; } +fi -# 4) mcpp never manages the MSVC installation +# 4) mcpp never manages the machine's own Visual Studio rc=0; out=$("$MCPP" toolchain remove msvc 2>&1) || rc=$? -[[ $rc -ne 0 && "$out" == *"system toolchain"* ]] \ +[[ $rc -ne 0 && "$out" == *"cannot remove it"* ]] \ || { echo "FAIL: remove msvc: rc=$rc out=$out"; exit 1; } +# …but it must say that a toolset it DID install is removable, or the message +# leaves the reader thinking msvc is simply un-removable. +[[ "$out" == *"msvc@"* ]] \ + || { echo "FAIL: remove msvc omits the managed route: $out"; exit 1; } +"$MCPP" toolchain default msvc >/dev/null 2>&1 out=$("$MCPP" toolchain install msvc 2>&1) \ || { echo "FAIL: install msvc (present) should exit 0: $out"; exit 1; } -[[ "$out" == *"already installed"* ]] \ +[[ "$out" == *"does not manage it"* ]] \ || { echo "FAIL: install msvc message: $out"; exit 1; } # 5) native cl.exe builds WORK (0.0.90; the 0.0.88 gate is gone) — the full diff --git a/tests/unit/test_distribution.cpp b/tests/unit/test_distribution.cpp index f9f08b6d..bbb089a3 100644 --- a/tests/unit/test_distribution.cpp +++ b/tests/unit/test_distribution.cpp @@ -215,32 +215,62 @@ TEST(Distribution, MingwParity) { EXPECT_EQ(dist::resolve(in).unitFlags, " -static"); } -// MSVC's self-contained form would be the /MT runtime, which mcpp does not -// emit. Before the table this cell simply produced nothing and claimed -// success; now it names the gap. -TEST(Distribution, MsvcSelfContainedIsAnHonestGap) { +// MSVC's self-contained form IS the /MT runtime, and mcpp emits it — the +// switch is `msvcStaticCrt`, derived once by `msvc_wants_static_crt` from the +// two manifest keys that mean the same physical thing on this ABI. +// +// What the table must get right is that the switch is whole-PROJECT: cl bakes +// _MSVC_MT/_MSVC_MD into the one std module a project builds, so a per-role +// request that disagrees cannot be honoured and must say so. +TEST(Distribution, MsvcCrtModelIsWholeProjectAndReportedAsSuch) { dist::MechanismInput in; in.format = dist::Format::Pe; in.stdlibId = "msvc"; in.explicitRequest = true; - auto m = dist::resolve(in); - EXPECT_EQ(m.effective, dist::Contract::HostCoupled); - EXPECT_TRUE(m.degraded); - EXPECT_NE(m.diagnostic.find("/MT"), std::string::npos); - EXPECT_TRUE(m.unitFlags.empty()); + + // Project compiled /MT: self-contained is DELIVERED, not degraded. + in.requested = dist::Contract::SelfContained; + in.msvcStaticCrt = true; + auto served = dist::resolve(in); + EXPECT_EQ(served.effective, dist::Contract::SelfContained); + EXPECT_FALSE(served.degraded); + EXPECT_TRUE(served.diagnostic.empty()); + // The CRT model is a COMPILE flag on every TU, never a link-line addition. + EXPECT_TRUE(served.unitFlags.empty()); + + // Project compiled /MD, one role asking for self-contained: refused, and + // the message has to name the whole-project constraint rather than claim + // the feature is missing. + in.msvcStaticCrt = false; + auto refused = dist::resolve(in); + EXPECT_EQ(refused.effective, dist::Contract::HostCoupled); + EXPECT_TRUE(refused.degraded); + EXPECT_NE(refused.diagnostic.find("whole-project"), std::string::npos) + << refused.diagnostic; + EXPECT_NE(refused.diagnostic.find("cxx_runtime"), std::string::npos) + << refused.diagnostic; + EXPECT_TRUE(refused.unitFlags.empty()); + + // `linkage = "static"` is the same switch seen from the libc axis. + in.fullStaticLibc = true; + in.msvcStaticCrt = true; // as msvc_wants_static_crt would report it + EXPECT_EQ(dist::resolve(in).effective, dist::Contract::SelfContained); + in.fullStaticLibc = false; + in.msvcStaticCrt = false; in.requested = dist::Contract::HostCoupled; EXPECT_FALSE(dist::resolve(in).degraded); - // ...but the DEFAULT must be quiet. mcpp never promised a self-contained - // MSVC artifact — it emits no /MT at all — so warning on every Windows - // build would be unactionable noise. A diagnostic is for a broken - // promise, not for a platform limit nobody asked about. + // ...and the DEFAULT must be quiet. Most roles default to the + // self-contained contract, so a project that never mentioned the CRT gets + // /MD and no complaint: a diagnostic is for a broken promise, not for a + // default nobody asked about. in.requested = dist::Contract::SelfContained; in.explicitRequest = false; auto quiet = dist::resolve(in); EXPECT_FALSE(quiet.degraded); EXPECT_TRUE(quiet.diagnostic.empty()); + EXPECT_EQ(quiet.effective, dist::Contract::HostCoupled); } // --------------------------------------------------------------------------- diff --git a/tests/unit/test_toolchain_msvc.cpp b/tests/unit/test_toolchain_msvc.cpp index af6a25e9..d5fc30a8 100644 --- a/tests/unit/test_toolchain_msvc.cpp +++ b/tests/unit/test_toolchain_msvc.cpp @@ -1,4 +1,5 @@ #include +#include // setenv / _putenv_s — not part of `import std;` import std; import mcpp.toolchain.model; @@ -56,18 +57,24 @@ TEST(MsvcBanner, TripleForArch) { // ─── install guidance ──────────────────────────────────────────────────── -TEST(MsvcGuidance, MentionsInstallRoutesAndOwnership) { +TEST(MsvcGuidance, OffersBothOrigins) { auto g = msvc::install_guidance(); ASSERT_FALSE(g.empty()); - EXPECT_NE(g.find("winget"), std::string::npos); - EXPECT_NE(g.find("does not install"), std::string::npos); - EXPECT_NE(g.find("mcpp toolchain default msvc"), std::string::npos); + // The managed origin has to be reachable from the message a user sees + // when nothing is installed — otherwise "mcpp can install a toolset" is + // true and undiscoverable at the same time. + EXPECT_NE(g.find("mcpp toolchain install msvc"), std::string::npos) << g; + EXPECT_NE(g.find("[toolchain]"), std::string::npos) << g; + // …and the system origin stays offered, with its own route. + EXPECT_NE(g.find("winget"), std::string::npos) << g; + EXPECT_NE(g.find("msvc@system"), std::string::npos) << g; + EXPECT_NE(g.find("mcpp toolchain default msvc"), std::string::npos) << g; } -// ─── spec layer ────────────────────────────────────────────────────────── +// ─── spec layer: the VERSION axis decides the origin ───────────────────── -TEST(MsvcSpec, SystemToolchainClassification) { - for (auto s : {"msvc", "msvc@system", "msvc@19.44"}) { +TEST(MsvcSpec, SystemOriginIsTheUnversionedSpec) { + for (auto s : {"msvc", "msvc@system"}) { auto spec = parse_toolchain_spec(s); ASSERT_TRUE(spec.has_value()) << s; EXPECT_TRUE(is_system_toolchain(*spec)) << s; @@ -77,6 +84,20 @@ TEST(MsvcSpec, SystemToolchainClassification) { EXPECT_FALSE(is_system_toolchain(*gcc)); } +TEST(MsvcSpec, ToolsetVersionIsAManagedPayloadNotASystemSpec) { + // The defect this closes: EVERY msvc spec used to be a system spec, so a + // manifest could name a toolset and silently get whatever the machine + // had. A version means a payload, exactly like gcc@16.1.0. + for (auto s : {"msvc@14.44.35207", "msvc@14.52.36629"}) { + auto spec = parse_toolchain_spec(s); + ASSERT_TRUE(spec.has_value()) << s; + EXPECT_FALSE(is_system_toolchain(*spec)) << s; + auto pkg = to_xim_package(*spec); + EXPECT_EQ(pkg.ximName, "msvc") << s; + EXPECT_EQ(pkg.ximVersion, std::string(s).substr(5)) << s; + } +} + TEST(MsvcSpec, StableDefaultMatchesAnyDetectedVersion) { // The persisted default is always the stable "msvc@system" — it matches // whatever concrete version detection reports (family-level match). @@ -92,6 +113,229 @@ TEST(MsvcSpec, StableDefaultMatchesAnyDetectedVersion) { EXPECT_FALSE(spec_matches_payload(*gccDef, msvcId, "19.44.35211")); } +TEST(MsvcSpec, PinnedToolsetMatchesOnlyItsOwnPayload) { + // The other half of the family-level match above: once a version is + // named, it has to be compared. A pin that matched any payload would be + // the same silent divergence wearing a version number. + auto pinned = parse_toolchain_spec("msvc@14.52.36629"); + ASSERT_TRUE(pinned.has_value()); + PayloadIdentity msvcId{ Family::Msvc, {} }; + EXPECT_TRUE(spec_matches_payload(*pinned, msvcId, "14.52.36629")); + EXPECT_FALSE(spec_matches_payload(*pinned, msvcId, "14.44.35207")); +} + +TEST(MsvcSpec, PayloadDirectoryIsIdentifiedAsMsvc) { + // Installed payloads are listed by walking `xim-x-` directories. + // Without this row an install would succeed and then be invisible. + auto id = identify_xim_payload("msvc"); + ASSERT_TRUE(id.has_value()); + EXPECT_EQ(id->family, Family::Msvc); + EXPECT_TRUE(id->target.empty()); // host-target, like gcc and llvm +} + +// ─── managed origin: resolution from a payload ─────────────────────────── +// +// These run on every platform, and that is the point. The managed path used +// to be untestable off Windows because every entry point started by probing +// the machine; `installation_at` takes the directory instead, so a fixture +// tree exercises the same code a real payload does. + +namespace { + +// A payload-shaped tree: /VC/Tools/MSVC//{bin/Hostx64/x64/cl.exe, +// modules/std.ixx}. Exactly what xim:msvc unpacks, minus the 80 MB. +struct FakeToolset { + std::filesystem::path root; + + explicit FakeToolset(std::string_view tag) { + root = std::filesystem::temp_directory_path() + / std::format("mcpp-msvc-{}-{}", tag, + std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::create_directories(root); + } + ~FakeToolset() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + FakeToolset(const FakeToolset&) = delete; + FakeToolset& operator=(const FakeToolset&) = delete; + + void add_toolset(std::string_view version, bool withStdIxx = true) { + auto tools = root / "VC" / "Tools" / "MSVC" / std::string(version); + std::filesystem::create_directories(tools / "bin" / "Hostx64" / "x64"); + std::ofstream{tools / "bin" / "Hostx64" / "x64" / "cl.exe"} << "not a compiler"; + if (withStdIxx) { + std::filesystem::create_directories(tools / "modules"); + std::ofstream{tools / "modules" / "std.ixx"} << "export module std;"; + } + } + void add_sdk(std::string_view version) { + auto inc = root / "Include" / std::string(version) / "ucrt"; + std::filesystem::create_directories(inc); + std::ofstream{inc / "corecrt.h"} << "#pragma once"; + } +}; + +void put_env(const char* name, const std::optional& value) { +#if defined(_WIN32) + // An empty value REMOVES the variable on Windows, which is what nullopt + // has to mean here. + ::_putenv_s(name, value ? value->c_str() : ""); +#else + if (value) ::setenv(name, value->c_str(), 1); + else ::unsetenv(name); +#endif +} + +// RAII for an environment variable. nullopt = unset it. +// +// Unsetting matters as much as setting: these tests run on a Windows CI +// runner that may have vcvars exported, and an ambient WindowsSdkDir would +// otherwise answer before the fixture ever got a turn — a green test proving +// nothing about the code under it. +struct ScopedEnv { + const char* name; + std::optional old; + ScopedEnv(const char* n, std::optional value) : name(n) { + if (auto* v = std::getenv(name)) old = v; + put_env(name, value); + } + ~ScopedEnv() { put_env(name, old); } + ScopedEnv(const ScopedEnv&) = delete; + ScopedEnv& operator=(const ScopedEnv&) = delete; +}; + +// Every SDK test starts from "nothing declared", then declares what it means +// to test. +struct NoSdkEnv { + ScopedEnv dir{"WindowsSdkDir", std::nullopt}; + ScopedEnv ver{"WindowsSdkVersion", std::nullopt}; +}; + +} // namespace + +TEST(MsvcManaged, ResolvesTheDeclaredToolsetAndNotItsNeighbour) { + FakeToolset t{"pick"}; + t.add_toolset("14.44.35207"); + t.add_toolset("14.52.36629"); + + // Both present, and the OLDER one is asked for. A "latest" scan would + // answer 14.52 — the exact substitution the managed origin exists to + // prevent. + auto older = msvc::installation_at(t.root, "14.44.35207"); + ASSERT_TRUE(older.has_value()); + EXPECT_EQ(older->toolsVersion, "14.44.35207"); + EXPECT_TRUE(older->hasStdModules); + EXPECT_EQ(older->clPath.filename(), "cl.exe"); + EXPECT_NE(older->clPath.string().find("14.44.35207"), std::string::npos); + + auto newer = msvc::installation_at(t.root, "14.52.36629"); + ASSERT_TRUE(newer.has_value()); + EXPECT_EQ(newer->toolsVersion, "14.52.36629"); +} + +TEST(MsvcManaged, AbsentToolsetIsNulloptNotASubstitute) { + FakeToolset t{"absent"}; + t.add_toolset("14.44.35207"); + // Nothing is "close enough": a pin that silently fell back would report + // success while building with a compiler nobody asked for. + EXPECT_FALSE(msvc::installation_at(t.root, "14.52.36629").has_value()); + EXPECT_FALSE(msvc::installation_at(t.root, "14.44").has_value()); + EXPECT_FALSE(msvc::installation_at(t.root, "").has_value()); +} + +TEST(MsvcManaged, VersionFallsBackToTheDeclaredOneWhenTheBannerCannotBeRead) { + // A fixture cl.exe produces no banner (and on Windows a real one would). + // display_version() must still name the toolset rather than an empty + // string — the declared version is a fact even when the probe fails. + FakeToolset t{"banner"}; + t.add_toolset("14.52.36629"); + auto inst = msvc::installation_at(t.root, "14.52.36629"); + ASSERT_TRUE(inst.has_value()); + EXPECT_EQ(inst->display_version(), "14.52.36629"); +} + +// ─── Windows SDK discovery ─────────────────────────────────────────────── + +TEST(MsvcSdk, WindowsSdkDirBeatsTheHardcodedPaths) { + NoSdkEnv clean; + FakeToolset t{"sdkenv"}; + t.add_sdk("10.0.26100.0"); + ScopedEnv dir{"WindowsSdkDir", t.root.string()}; + auto sdk = msvc::find_windows_sdk(); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->root, t.root); + EXPECT_EQ(sdk->version, "10.0.26100.0"); +} + +TEST(MsvcSdk, WindowsSdkVersionSelectsAmongInstalledOnes) { + NoSdkEnv clean; + FakeToolset t{"sdkver"}; + t.add_sdk("10.0.22621.0"); + t.add_sdk("10.0.26100.0"); + ScopedEnv dir{"WindowsSdkDir", t.root.string()}; + { // vcvars exports it with a trailing backslash; that is not part of + // the directory name, and comparing it raw finds nothing. + ScopedEnv ver{"WindowsSdkVersion", "10.0.22621.0\\"}; + auto sdk = msvc::find_windows_sdk(); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->version, "10.0.22621.0"); + } + // Unset again: highest usable wins. + auto sdk = msvc::find_windows_sdk(); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->version, "10.0.26100.0"); +} + +TEST(MsvcSdk, ExtraRootsCoverTheManagedPayload) { + NoSdkEnv clean; + FakeToolset t{"sdkextra"}; + t.add_sdk("10.0.26100.0"); + // Nothing declared — the payload root is the only way the SDK can be + // found, which is exactly the managed toolset's situation. + std::array roots{t.root}; + auto sdk = msvc::find_windows_sdk(roots); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->root, t.root); +} + +TEST(MsvcSdk, IncompleteSdkRootIsNotAnAnswer) { + // Include// exists but carries no ucrt/corecrt.h: a half-installed + // SDK must read as absent, not as a usable one that fails later inside + // the compiler. Checked through extraRoots so the assertion cannot be + // satisfied by a real SDK on the machine. + NoSdkEnv clean; + FakeToolset t{"sdkpartial"}; + std::filesystem::create_directories(t.root / "Include" / "10.0.26100.0" / "um"); + std::array roots{t.root}; + auto sdk = msvc::find_windows_sdk(roots); + if (sdk) EXPECT_NE(sdk->root, t.root) << "an SDK-less root was accepted"; +} + +TEST(MsvcSdk, DeclaredRootOutranksTheManagedOne) { + // Both available. WindowsSdkDir is someone saying which one to use, and + // it has to win — the same precedence VSINSTALLDIR has over vswhere. + NoSdkEnv clean; + FakeToolset declared{"sdkdeclared"}; + declared.add_sdk("10.0.22621.0"); + FakeToolset managed{"sdkmanaged"}; + managed.add_sdk("10.0.26100.0"); // newer, and still must not win + ScopedEnv dir{"WindowsSdkDir", declared.root.string()}; + std::array roots{managed.root}; + auto sdk = msvc::find_windows_sdk(roots); + ASSERT_TRUE(sdk.has_value()); + EXPECT_EQ(sdk->root, declared.root); + EXPECT_EQ(sdk->version, "10.0.22621.0"); +} + +TEST(MsvcSdk, SiblingSdkRootsAreEmptyForACompilerOutsideAnyStore) { + // A system cl.exe is not in an xlings store, so there is nothing to + // offer — and offering the wrong thing would be worse than nothing. + EXPECT_TRUE(msvc::sibling_sdk_roots( + "C:/Program Files/Microsoft Visual Studio/18/Enterprise/VC/Tools/" + "MSVC/14.51.36231/bin/Hostx64/x64/cl.exe").empty()); +} + // ─── model traits ──────────────────────────────────────────────────────── TEST(MsvcModel, BmiTraitsUseIfc) { @@ -185,8 +429,8 @@ TEST(ToolchainMsvc, CrtFlagHasASingleDerivation) { Toolchain cl; cl.compiler = CompilerId::MSVC; const auto& msvcDialect = dialect_for(cl); - EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticLinkage=*/true), "/MT"); - EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticLinkage=*/false), "/MD"); + EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticCrt=*/true), "/MT"); + EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticCrt=*/false), "/MD"); // GNU has no counterpart; the helper must yield nothing rather than invent // a flag that would be passed to gcc. @@ -195,3 +439,22 @@ TEST(ToolchainMsvc, CrtFlagHasASingleDerivation) { const auto& gnu = dialect_for(gcc); EXPECT_TRUE(msvc_crt_flag(gnu, false).empty()); } + +// Both manifest keys reach the SAME physical switch, because on the MSVC ABI +// they are the same switch. `cxx_runtime = "self-contained"` used to report +// "not implemented" while `linkage = "static"` quietly did the very thing it +// said was unimplemented. +TEST(ToolchainMsvc, BothKeysSelectTheStaticCrt) { + EXPECT_TRUE(msvc_wants_static_crt("static", "")); + EXPECT_TRUE(msvc_wants_static_crt("", "self-contained")); + EXPECT_TRUE(msvc_wants_static_crt("static", "self-contained")); + + // Default: neither written down. This has to stay /MD — most roles + // DEFAULT to the self-contained contract, so keying off the resolved + // contract instead of the written key would flip every Windows build to + // /MT and split the CRT across a project's dependencies. + EXPECT_FALSE(msvc_wants_static_crt("", "")); + EXPECT_FALSE(msvc_wants_static_crt("dynamic", "")); + EXPECT_FALSE(msvc_wants_static_crt("", "host-coupled")); + EXPECT_FALSE(msvc_wants_static_crt("", "toolchain-coupled")); +} From b36019a6fdd0e8a074f6b705a587bcdb282f9712 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:34:27 +0800 Subject: [PATCH 2/4] test(e2e): 239 asked for a flag that does not exist `toolchain list --available` is not a thing -- `toolchain list` already prints an "Available toolchains" section. Caught by ci-windows on the first run, which is the right place for it: this test only ever executes there. Also reordered: the install now runs BEFORE the discoverability check, so a runner that cannot reach the index skips cleanly instead of failing an assertion about a list the index would have filled in. And the check became stronger than the one it replaces -- it asserts the INSTALLED toolset shows up, not merely that some msvc row exists, because a toolset that installs and then never appears is indistinguishable from one that did not install. --- tests/e2e/239_msvc_managed_toolset.sh | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/e2e/239_msvc_managed_toolset.sh b/tests/e2e/239_msvc_managed_toolset.sh index 2f1dd2a1..c465086c 100755 --- a/tests/e2e/239_msvc_managed_toolset.sh +++ b/tests/e2e/239_msvc_managed_toolset.sh @@ -36,17 +36,13 @@ restore() { trap restore EXIT cd "$TMP" -# 0) the toolset must be OFFERED before it can be pinned. A managed origin -# nobody can discover is not a feature. -out=$("$MCPP" toolchain list --available 2>&1) || true -[[ "$out" == *"msvc"* ]] \ - || { echo "FAIL: msvc absent from --available: $out"; exit 1; } - -# 1) install it +# 1) install it. This runs BEFORE the discoverability check below, so that a +# runner which cannot reach the index skips instead of failing an assertion +# about a list the index would have filled in. rc=0; out=$("$MCPP" toolchain install msvc "$TOOLSET" 2>&1) || rc=$? if [[ $rc -ne 0 ]]; then case "$out" in - *"index"*|*"network"*|*"resolve"*|*"offline"*|*"connect"*) + *"index"*|*"network"*|*"resolve"*|*"offline"*|*"connect"*|*"not found"*) echo "SKIP: xim:msvc@$TOOLSET unreachable: $out"; exit 0 ;; *) echo "FAIL: install msvc $TOOLSET: $out"; exit 1 ;; esac @@ -54,6 +50,13 @@ fi [[ "$out" == *"$TOOLSET"* ]] \ || { echo "FAIL: install did not report the toolset: $out"; exit 1; } +# 1b) it must now be LISTED. A toolset that installs but never appears is +# indistinguishable from one that did not install, and `toolchain list` is +# where a user looks. +out=$("$MCPP" toolchain list 2>&1) +[[ "$out" == *"msvc"* && "$out" == *"$TOOLSET"* ]] \ + || { echo "FAIL: installed toolset absent from toolchain list: $out"; exit 1; } + # 2) build with it, from a manifest — the path that matters, and the one # where the version used to be accepted and then ignored. "$MCPP" new hello_pinned >/dev/null 2>&1 From d4a944eb350e0193038166129c2035e36db6da1d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:39:26 +0800 Subject: [PATCH 3/4] docs: the cross-repo plan, its one hard dependency, and which claims can self-certify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four repos, five changes, ONE real dependency edge: the packages must be published before mcpp can install them and before xrgui can use them. Everything else is parallel, and stringing it into a line is the usual waste in work shaped like this. The part worth reading is §4: which acceptance criteria a person could satisfy by adjusting a test, and which they could not. The mirror is the example that earned the distinction -- the criterion is not "the upload succeeded" but "the bytes came back with Microsoft's sha256", and that caught a real failure where the tool reported 16 files as failed while the release listing showed them present and they were in fact absent. --- ...26-08-16-msvc-ecosystem-cross-repo-plan.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md diff --git a/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md b/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md new file mode 100644 index 00000000..138adfa9 --- /dev/null +++ b/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md @@ -0,0 +1,93 @@ +# MSVC 在 xlings 生态里打通 —— 跨仓库计划、依赖与验收(2026-08-16) + +> 配套 `2026-08-16-msvc-as-a-managed-toolchain.md`(mcpp 侧设计)。 +> 那份讲**为什么这么改**;这份讲**改动落在哪几个仓库、谁挡着谁、以及每一步凭什么算通过**。 + +--- + +## 0. 一句话 + +四个仓库、五条改动,只有**一条硬依赖**:包必须先发布,mcpp 才装得到,xrgui 才用得上。 +其余都可以并行,而把它们排成一条线是这类工作最常见的浪费。 + +--- + +## 1. 从八个角度看这次改的到底是什么 + +用户点名的八个角度不是修辞,它们各自对应一条具体改动。逐条落到实处: + +| 角度 | 改前的具体事实 | 改后 | +|---|---|---| +| **架构** | MSVC 是工具链体系里唯一「无法声明版本」的家族;`is_system_toolchain()` 对**所有** msvc spec 为真 | 版本轴决定来源。**获取**与 gcc 同路(xim 安装),**解析**与 `msvc@system` 同路(`installation_from_tools_dir`)——两条轴正交,受管 toolset 不是第二条代码路径 | +| **稳定性** | 同一份源码在两台机器上被不同编译器编译**且不报错**;xrgui#3 实测 mcpp 用 14.51、xmake 用 14.52,直到 ICE 才暴露 | 声明了版本就必须拿到那个版本,拿不到是 nullopt 而不是替代品 | +| **优雅简洁** | 两个旋钮(`linkage` / `cxx_runtime`)指向同一个物理开关,只有一个管用,注释互相矛盾 | 一个 `msvc_wants_static_crt()`,项目 TU 与 std 模块问同一个函数 | +| **用户体验** | 没装 VS 时告诉你「mcpp 不安装 MSVC,自己去装」——而现在这句话是假的 | 两条路都给出:装一个 pin 住的 toolset,或用机器自己的 VS。`install_guidance()` 里两条命令都能直接抄 | +| **兼容性** | —— | `msvc@system` 语义一字未改;唯一的破坏性变更(`msvc@19.44`)有精确的替代指引,而它原本**只在一个命令里生效、构建路径完全忽略** | +| **跨平台** | msvc 发现逻辑一行都无法在 Windows 之外测试(入口全在 `#if defined(_WIN32)` 里) | `installation_at()` 收目录、`find_windows_sdk()` 收 root 列表,6 个单测在 Linux CI 上跑真 fixture | +| **一致性** | `search` 说 `xim:msvc` 在,`info` 说 `not found` —— 同一台机器、同一个索引 | xlings#550:区分「不存在」与「这个平台没有构建」,并说出它在哪些平台有 | +| **无感升级** | —— | 现有工程零影响:`msvc@system` 不变、默认 CRT 仍是 `/MD`(判据取 manifest 字面值而非解析后的 contract,否则每个 Windows 构建都会翻成 `/MT`) | + +--- + +## 2. 五条改动与它们的仓库 + +| # | 仓库 | 改动 | PR | +|---|---|---|---| +| **X** | xim-pkgindex | payload 多来源 `urls` + 27 个 payload 镜像;windows-sdk 导出 `WindowsSdkDir`/`WindowsSdkVersion` | #629 ✅ 已合 | +| **M** | mcpp | A 受管 toolset / B SDK 搜索顺序 / C CRT 双入口 / D 发现顺序 | #434 | +| **L1** | xlings | D4 的管道那一半量到了(文档) | #549 | +| **L2** | xlings | `info` 对别的平台的包说 "not found" | #550 | +| **V** | xrgui | 删掉 vswhere workaround,验证整条链 | 待 mcpp 发布 | + +--- + +## 3. 依赖图 —— 边是真实依赖,不是先后偏好 + +``` +X (xim-pkgindex #629) +│ 包必须先发布:`mcpp toolchain install msvc 14.44.35207` 装的就是它 +│ 在此之前 mcpp 的 e2e 239 走 SKIP 分支,而不是红 +▼ +M (mcpp #434) ──→ 发布 2026.8.16.1 ──→ V (xrgui) + │ + L1、L2 与上面这条链 无 依赖 ────────┘(可随时合) +``` + +**为什么 C、D 没有拆成独立 PR**:C 与 A/B 确实无依赖,但它回答的是同一个问题 +——「MSVC 在 mcpp 里到底怎么被描述」;拆开会让 CHANGELOG 的读者以为是两件事。 +D 与 A 改同一个函数,拆开的第二个 PR 必然要重写第一个 PR 刚写的注释。 + +**为什么 L2 不在 M 里**:它是 xlings 的缺陷,是**在验证 M 的过程中**被发现的 +(`xlings info msvc` 在 Linux 上说 not found),但它与 MSVC 无关 —— 每一个 +windows-only 包在 Linux 上都会这样,反之亦然。 + +--- + +## 4. 验收标准 —— 哪些能自证,哪些不能 + +这一节是这份计划的重点。**能被"调一下测试"满足的判据,不算证据。** + +| 判据 | 能否自证 | 说明 | +|---|---|---| +| 27 个 payload 镜像正确 | **不能** | 判据不是「上传成功」,是**下载回来 sha256 与微软一致**。而这恰好抓到了真问题:上传工具对 16 个文件报了失败、release 列表却显示它们在,实际 404。**谁都不能信,只能信字节。** | +| 受管 toolset 真的被用了 | **不能** | e2e 239 的每一条断言都写成「系统编译器来应答就会失败」:cl.exe 必须在 mcpp 的 store 里、toolset 目录必须是 spec 声明的那个 | +| 两条来源互不污染 | **不能** | 同一台机器、同一个项目,spec 换回 `msvc@system` 必须解析到系统 cl。少了这条,「受管能用」与「受管把一切都换掉了」长得一样 | +| 声明的 toolset 优先于"最新" | **不能** | 单测:两个 toolset 都在,要**老的**那个。「取最新」的实现会在这里失败,而真机上它可能碰巧对 | +| **xrgui 删掉 workaround 后仍然绿** | **不能** | 全套里最强的一条。workaround 还在时,「`VSINSTALLDIR` 被采纳」与「vswhere 找不到东西」现象完全一样 —— **无法区分缺陷 A 是否真修好**。而且删掉之后 vswhere 与 14.51 **都还在**:错误答案没有被拿走,它只是必须输 | +| 单测 83/83、静态检查 1788 项 | **能** | 有用,但它们证明的是「没有回归」,不是「这件事做成了」 | + +--- + +## 5. 已知不被覆盖的部分(不要当成已完成) + +1. **`msvc@14.52.36629` 的安装路径没有在 CI 上跑过**。index 的 `windows-test` + 装的是 `latest`(14.44)。两者只差 payload URL 与目录版本,后者已逐个从真实 + payload 读出核对,但**没有实际装过一次**。xrgui 的 V3 会第一次覆盖它。 +2. **镜像回退没有被真正触发过**。两个前提单独验过了 —— `curl -f` 遇 404 退 22 + 且不留文件(所以 `pcall` 会接住、`os.isfile` 为假),官方地址仍然服务同样的 + 字节 —— 但「镜像挂掉时自动走官方」这条完整路径没有被执行过。 + 现在至少它**不会静默**:走到第一个之后的地址会 `log.warn`。 +3. **D4 的控制台那一半仍然开着**。管道那条路量到了(xlings#549), + 而 CI 结不了控制台的案:runner 上 job 没有附着的控制台。 +4. **gitcode 的 probe 资产删不掉**。API 没有删除端点(两个路径都 404), + 已在镜像 README 里点名说明,而不是留一堆没人知道是什么的文件。 From ccdbd22884f24dfea447fc688aae765203644448 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:41:35 +0800 Subject: [PATCH 4/4] fix(toolchain): a managed toolset without its SDK reported success Self-review catch. `installation_at()` succeeding means cl.exe is where the declared version says it should be -- it says nothing about the ucrt/um headers, which arrive as a separate package dependency and can therefore fail on their own. The install printed "Installed", and the build died inside the ucrt headers much later. That is the half-installed state `has_usable_msvc()` was written for; this applies the same judgement to the managed origin, and names the dependency that must have failed rather than leaving the reader to work it out. Also: `msvc_print_detected` now takes the label. "Detected" is a claim about probing the machine, and printing it after unpacking a payload the caller NAMED describes the wrong thing -- quietly, and in exactly the direction this whole change is about. --- src/toolchain/lifecycle.cppm | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index cec3ed52..96877f72 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -186,8 +186,13 @@ int msvc_wrong_host() { return 1; } -void msvc_print_detected(const mcpp::toolchain::msvc::MsvcInstallation& inst) { - mcpp::ui::status("Detected", std::format( +// `label` names the ORIGIN, because the two are not the same event. +// "Detected" is a claim about probing the machine, and saying it after +// unpacking a payload the caller named would describe the wrong thing — +// quietly, and in exactly the direction this whole change is about. +void msvc_print_detected(const mcpp::toolchain::msvc::MsvcInstallation& inst, + std::string_view label = "Detected") { + mcpp::ui::status(label, std::format( "msvc {}{} (VC tools {})", inst.display_version(), inst.vsProduct.empty() ? "" : std::format(" (VS {})", inst.vsProduct), @@ -197,6 +202,26 @@ void msvc_print_detected(const mcpp::toolchain::msvc::MsvcInstallation& inst) { inst.hasStdModules ? "available (std.ixx)" : "not available"); } +// The Windows SDK is the OTHER half of a usable MSVC, and a payload that +// unpacked a compiler without it is a half-installed state: cl.exe is right +// there, so everything reports success, and the build dies inside the ucrt +// headers much later. `has_usable_msvc()` exists for exactly this reason; +// this is the same judgement applied to the managed origin, where the SDK +// arrives as a package dependency and can therefore fail on its own. +void msvc_warn_if_sdk_missing(const mcpp::toolchain::msvc::MsvcInstallation& inst) { + auto roots = mcpp::toolchain::msvc::sibling_sdk_roots(inst.clPath); + if (auto sdk = mcpp::toolchain::msvc::find_windows_sdk(roots)) { + std::println(" windows sdk: {} ({})", + sdk->version, sdk->root.string()); + return; + } + mcpp::ui::warning( + "the toolset installed, but no Windows SDK was found next to it.\n" + " cl.exe cannot compile anything without the ucrt/um headers.\n" + " The toolset declares `xim:windows-sdk` as a dependency, so this\n" + " means that dependency did not install — check `xlings list`."); +} + EffectiveDefault effective_default_toolchain(const mcpp::config::GlobalConfig& cfg) { std::error_code ec; auto mpath = std::filesystem::current_path(ec) / "mcpp.toml"; @@ -599,7 +624,8 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, payload->root.string(), pkg.ximVersion)); return 1; } - msvc_print_detected(*inst); + msvc_print_detected(*inst, "Installed"); + msvc_warn_if_sdk_missing(*inst); mcpp::ui::status("Installed", std::format("{} → {}", pkg.display_spec(), inst->clPath.string())); if (cfg.defaultToolchain.empty()) {