diff --git a/.agents/docs/2026-10-30-ecosystem-part2-ide-and-language-service-design.md b/.agents/docs/2026-10-30-ecosystem-part2-ide-and-language-service-design.md new file mode 100644 index 000000000..c23743214 --- /dev/null +++ b/.agents/docs/2026-10-30-ecosystem-part2-ide-and-language-service-design.md @@ -0,0 +1,836 @@ +--- +subject: editors +status: active +--- + +# 生态设计 part 2:mcpp 插件与 mcppls 的分层、入口、测试、版本感知与分发 + +日期:2026-10-30(rev 7,实施)。基线:mcpp 2026.10.30.1(`13e2da04`)、mcpp-ide #3(0.2.0,`734d856` + `49ae757`)、 +mcppls #75(0.0.20,`4660eb6`)。前文:[part 1](2026-10-29-ecosystem-target-sets-packaging-and-editors-design.md)(§9 mcppls 单包、 +§10 编辑器)。相关 issue:mcpp#868(锁文件键)、Sunrisepeak/mcppls-clangd#12(clangd 映像)。相关规范:mcpp SPEC-015 §4、docs/08、 +docs/50;mcppls S3、S4;mcpp-ide protocol。 + +**修订记录** + +- rev 1:综合 2026-10-30 的六轮讨论。已定:六项合为一份设计、一轮实现;mcppls 的状态经命令行提供;IDE 插件对 mcppls + 保留安装上的硬依赖、不绑定版本;按项目感知 mcpp 版本的缺陷在 mcpp-ide 0.2.0 先修;clangd 映像暂缓(mcppls-clangd#12);mcppls + 的包体积不是约束,分发与安装的稳定性是。 +- rev 2(本稿):多视角自我评审(架构、兼容与升级、安全、性能、跨平台、交付),18 条处理见 §16。主要变化:Neovim 的后端是独立 + 的 xlings 包,改为精确版本依赖(§3);探测清除 `XLINGS_PROJECT_DIR`、处理"锁定版本未安装"、按需且限并发(§4.2);`features` + 只收客户端据以开关的能力,并为早于它的 mcpp 冻结一张对照表(§4.3);0.2.0 先行修复改为"按项目的正确版本"比较(§4.7); + `mcppls status` 列出实例、动作 id 归 mcppls(§5.2、§5.3);入口与用例位置不再经 mcppls 命令行(§6.3、§7.3),mcppls 0.0.21 + 只新增 `status`;动态列举只对静态扫描到框架宏的程序执行(§7.3);后端在同一进程内以 LSP 服务规则层(§8);无后端回退保留为 + 声明的降级模式(§8);工作区信任(§4.2、§7.3)。 +- rev 3(本稿):按评审决定改写(不读 `.xlings.json`;首批框架取 mcpp-index 提供的;未发布的 PR 追加、已发布的仓库新 PR 分检查点; + #866、#867 合入,后续见 §17),并做第二轮多视角评审(架构、稳定性、简洁、跨平台、语义、框架、可观察性、兼容、性能、体验), + 22 条处理见 §18。主要变化:运行 mcpp 的约定写成一条生态规则,后端与 mcppls 共用(§4.0);版本变化从 job 的信封得知,不再在 + 每个 job 前另起进程(§4.2);`features` 首批减为两项,并定义它与 `kinds`、`commands` 的分工(§4.3);先行修复一节删除,§4 全部 + 进入 0.2.0(§4.7);IDE 与 mcppls 看到的 mcpp 不一致时报告(§5.2);后端崩溃的恢复与可观察性(§3.1、§3.2);用例的标识、 + 动态与静态的对应、过滤参数的转义(§7.3);自定义宏的覆盖改为按框架追加宏名(§7.3);规则层在不受信任的工作区可用(§8); + 协议升为 2(§8);状态栏不新增项(§6.1);#3 内部的检查点顺序(§14)。 +- rev 4(本稿):补测六项数据(§20),第三轮全局评审 12 条(§21)。主要变化:`features` 也写入每个信封的 `mcpp` 字段,探测只需一次 + `self env`(§4.3);mcppls 运行 mcpp 时同样清除 `XLINGS_PROJECT_DIR`(§4.6);`polyglot` 的签名顺序与缓存目录并入 SPEC-014 已有的 + 单文件规则(§19.6.2);`mcppls status` 预算有了实测依据(§5.2);设备的默认路径有了 xbus 的实测延迟(§19.7)。 +- rev 5(本稿):设备路线已定(§19.8);`polyglot` 以 `--pkg-format polyglot` 选择,不新增 `--carrier`(§19.6.2);设备与 ok 生态的 + 完整路线写入 openkal、openhal 各自的仓库与规范,本设计只保留分工与对它们的要求。 +- rev 6:本轮的规范清单与 openlifa 一轮(§23,openlifa/openlifa#1);第四轮多视角评审 10 条(§24),其中定下文件映射的 + 最小接口(§23.2)、`polyglot` 与 `--pkg-format` 列表、`mcpp run`、`mcpp emit xpkg` 的关系(§19.6.2)。 +- rev 7(本稿):评审通过(`mcpp.path` 优先、文件映射的最小接口、`polyglot` 的三条边界)。clangd 的 ok 映像进入本轮 + (mcppls-clangd#12,§12、§25.3);文件映射成为独立的可选接口 `openkal.map`(§23.2);实施计划与任务依赖(§25)。 + +## 0. 摘要 + +mcpp 生态的编辑器侧由四层组成:mcpp(构建与项目)、mcppls(C++ 语言语义)、mcpp-ide 后端(mcpp 的项目知识与 IDE 会话)、各 +IDE 插件(mcpp-vscode、mcpp-clion、mcpp-nvim)。0.2.0 之前,这四层的职责在三处不清楚:mcpp 的知识有一部分写在 VS Code 的 TS +里;插件对 mcppls 的依赖级别、版本门槛、信息通道三个 IDE 各不相同;后端对 mcpp 版本的判断不按项目,与项目实际运行的 mcpp +可能不是同一个。 + +本设计的模型: + +- **分层(§3)。** 编译器能回答的归 mcppls;mcpp 自己的知识(清单、构建规则、项目、job、测试框架适配)归后端;插件只把后端的 + 结果接到所在 IDE 的原生 API。 +- **按项目的 mcpp(§4)。** 后端在项目目录内经 xlings shim 探测 mcpp,得到该项目实际使用的版本与能力;功能按 mcpp 声明的能力 + 开关,不按版本号比较。 +- **mcppls 契约(§5)。** 插件对 mcppls 保留安装依赖,不绑定版本;mcppls 新增 `mcppls status --root --format json`,后端 + 读取并转给所有 IDE;功能按能力启用,缺一项只关一项。 +- **入口与测试(§6、§7)。** 两个 IDE 提供同一组入口(`main` 旁运行与调试、右键、标题栏或工具栏、状态栏),入口的位置由命令表 + 声明;测试有两个粒度:程序与用例,用例经后端的测试框架适配表发现与运行,只对确认属于该框架的程序列举。 +- **清单与构建程序(§8)。** `mcpp.toml` 与 `build.mcpp` 的规则层从 TS 下沉到后端;后端的会话进程本身成为 LSP 服务器,服务 + 四个编辑器;迁移以黄金输出保证 VS Code 不退化;无后端的平台保留声明的降级模式。 +- **CLion 原生化(§9)。** 结构化的界面(项目树、库、缓存、设置)用原生组件渲染后端的视图模型;JCEF 只用于图与包详情页;删除 + 浏览器里的 `vscode` 模拟层。 +- **分发(§10)。** mcppls 单包的升级路径、各渠道上限、Windows 与 macOS 的安装细节在发布流程中验证。 + +## 1. 现状(实测) + +| # | 事实 | 出处 | +|---|---|---| +| F1 | 后端每个会话只定位一个 mcpp(`mcpp.path`,PATH,xlings `subos/current/bin`),握手一次,握手不指定 cwd | mcpp-ide `src/mcpp.cppm` `locate`、`handshake`;`src/server.cppm` `locate_mcpp` | +| F2 | job 在项目根目录执行(`o.cwd = request.root`);项目可用 `.xlings.json` 的 `workspace.mcpp` 锁定 mcpp 版本,xlings shim 在项目目录内选用锁定版本 | `src/jobs.cppm:240`;mcpp-ide 自己的 `.xlings.json` | +| F3 | 功能开关按握手版本比较:target 集合要求 `>= 2026.10.30.1` | `src/server.cppm:887` | +| F4 | `mcpp --protocol-version` 给出 `envelope`、`kinds`、`commands`,没有特性列表 | mcpp 2026.10.30.1 | +| F5 | VS Code 对 mcppls 是 `extensionDependencies`(0.9.0 起);CLion 是 ``;Neovim 无依赖 | 各自清单 | +| F6 | 版本门槛两套:VS Code `VERIFIED_MCPPLS_MINIMUM = 0.0.4`,后端 `kRequired = 0.0.18` | `mcppls/contract.ts`、`src/languageservice.cppm` | +| F7 | VS Code 读 mcppls 扩展导出的 API 对象(`lastStatus()`、`otherCppFeatures()`),代码注释称"尽力而为,不是契约";CLion 与 Neovim 只有后端的版本状态 | `mcppls/stateSource.ts`、`otherCppFeatures.ts` | +| F8 | mcppls 的命令行有 `serve`、`check`、`report`(等待引擎就绪,默认 60 s)、`print-environment`、`version`,没有轻量的状态命令 | mcppls `src/cli/commands.cpp` | +| F9 | `mcpp.toml` 有两份实现:VS Code TS(`src/toml`,7 类诊断、补全、hover、跳转)与后端 `src/manifest.cppm`(补全、hover、大纲,411 行);0.2.0 中后端这份没有调用方;CLion 从未支持 `mcpp.toml` | `src/toml/*`、`src/manifest.cppm`、`plugin.xml` | +| F10 | `build.mcpp`:编译器层由 mcppls 提供;SPEC-007 规则、指令补全与片段在 VS Code TS(`src/buildscript`) | mcppls docs/10;mcpp-vscode docs/build-script.md | +| F11 | VS Code 没有 CodeLens、没有 `editor/context`;`editor/title` 只有 Run 与 Test;测试项的位置固定在文件第 0 行 | `package.json` menus;`src/ide/testing.ts:47` | +| F12 | CLion 有 `main` 旁 ▶ 与上下文运行配置,`main` 用正则识别;没有状态栏组件;锤子按钮对 mcpp 配置是否可用未验证 | `run/McppProducers.kt` | +| F13 | CLion 的工具窗口与设置页是 JCEF 中的 VS Code 页面,浏览器里模拟 `vscode` 模块;设置页 `isModified()` 恒为 false、`apply()` 为空 | `webui/src/vscode.ts`、`settings/McppConfigurable.kt` | +| F14 | 命令表 56 行;CLion 不处理 `forward`(21 行)、`problems`、`editor`、`quickMenu`,点击后回答"本 IDE 不可用" | `spec/commands.json`、`actions/Dispatch.kt` | +| F15 | VS Code 在没有后端时用 `execFile` 直接运行 mcpp 与 mcppls(`runMcpp` 的 7 个调用方) | `src/cli/process.ts` | +| F16 | 后端已有视图模型(项目摘要、库、缓存、工具链、targets、两种图)、`settings/registry`、`commands/list` | `src/views.cppm`、`src/server.cppm` | +| F17 | mcpp 只认程序级测试:一个 `tests/**/*.cpp` 是一个程序,退出码为约定,用例选择经 `--` 交给程序 | mcpp docs/08 "Current limitations" | +| F18 | xlings canary 47 min,其中测试程序的构建 1750 s、运行 57 s;与前五次发布(40–51 min)一致 | release run 40999598911 | + +## 2. 原则 + +1. **一份知识一个归属。** mcpp 的知识(清单的键、构建程序的规则、测试框架的参数、项目与 job)只在后端实现一次;C++ 语义只在 + mcppls 实现一次。插件不实现判断。 +2. **按能力,不按版本。** 一方的版本只用于说明;功能是否可用由对方声明的能力决定。缺一项能力只关掉依赖它的功能。 +3. **硬依赖不等于强绑定。** 安装上可以必需;运行时任何版本组合都能工作,差异只表现为能力的有无,并在界面上说明。 +4. **原生优先。** 结构化的界面用 IDE 的原生组件;只有富可视化内容用网页。 +5. **入口一致。** 两个 IDE 的入口集合相同,形式各用原生方式。 +6. **VS Code 不退化。** 迁移以黄金输出与 0.9.0 一致性门禁验收。 + +## 3. 分层 + +| 层 | 负责 | 不负责 | +|---|---|---| +| mcpp | 构建、项目、测试程序、机器输出与能力声明(`--protocol-version`)、`self env` | 编辑器、测试框架的用例 | +| mcppls | C++ 语义(含 `build.mcpp` 的编译器层)、kit、engine;状态命令与动作声明(§5.2);编辑器中的文档符号(§6.3 的位置来源) | mcpp 的清单与规则、测试框架 | +| mcpp-ide 后端 | 按项目的 mcpp(§4);mcppls 状态的汇总(§5);清单与构建规则(§8);测试框架适配(§7);视图模型;入口判定(§6) | IDE 的界面 | +| 插件 | 把后端的结果接到 IDE 的原生 API;入口与界面的原生形式 | 任何判断 | + +VS Code 与 CLion 的插件包内携带后端,插件与后端版本恒等。Neovim 的后端是独立的 xlings 包(`mcpp-ide-nvim` 依赖 +`xim:mcpp-ide`,当前不带版本):改为依赖同一版本(`xim:mcpp-ide@`),插件在 `initialize` 时核对后端的 `protocol` 并报告 +不一致。由此插件与后端之间不存在版本错配;跨版本的边界只有两条:后端与 mcpp(§4)、后端与 mcppls(§5)。 + +### 3.1 后端的生命周期 + +后端由插件启动并持有。后端退出时,它启动的 mcpp job 随之结束(job 以 `bound_lifetime` 启动),界面把这些 job 报告为"后端已退出" +而不是失败;插件以退避重启后端(1 s、4 s、16 s),3 分钟内第 4 次退出后停止重启,进入 §8 的降级模式并给出"重新启动后端"与 +"导出报告"两个动作。 + +### 3.2 可观察性 + +- **为什么不可用。** 每个被隐藏或置灰的功能都带原因:缺哪一项能力、来自哪一方(项目的 mcpp、mcppls、后端所在平台)、需要的 + 版本;界面的提示与 `selfCheck` 读同一份数据(后端 `capabilities/explain`)。 +- **报告。** 命令"mcpp:导出 IDE 报告"写一个脱敏的 zip:后端版本与协议、每个项目的探测结果(`self env` 与能力)、`mcppls status`、 + 生效的设置、最近的后端日志与 job 记录;三个 IDE 同一个命令(命令表一行)。 +- **日志。** 后端日志级别由设置 `mcpp.backend.logLevel` 决定,日志写入各 IDE 的日志位置(VS Code 的 output channel、CLion 的 + `idea.log`、Neovim 的 `:messages` 与日志文件)。 + +## 4. 按项目的 mcpp + +### 4.0 运行 mcpp 的约定(生态规则) + +任何为某个项目运行 mcpp 的工具(mcpp-ide 后端、mcppls、IDE 插件的降级模式)都这样运行:当前目录为项目根;程序名为 `mcpp`, +经 PATH 找到(即 xlings 的 shim),或用户显式设置的路径;环境中清除 `XLINGS_PROJECT_DIR`。这样版本的选择完全由项目目录决定, +不需要任何工具理解锁定文件。这条规则写入 mcpp docs/50("工具怎样调用 mcpp"),后端与 mcppls 各自引用并以测试固定。 + +### 4.1 缺陷 + +F1–F3 合起来:握手在项目外执行,得到全局版本;job 在项目内执行,用锁定版本。面板显示的版本是错的;功能开关按错误的版本判断, +可能提供项目的 mcpp 不支持的功能;`mcpp.path` 指向具体文件时项目的锁定被静默绕过。 + +### 4.2 探测 + +- 探测以**项目**为单位:在项目根目录执行一次 `mcpp self env --format json`(信封带版本与 `features`,§4.3;更早的 mcpp 另执行 + `--protocol-version`)。执行始终经 shim、在项目 + 目录中进行;后端不把 shim 解析成具体路径后缓存直接调用。 +- 缓存以项目根为键,值为探测结果(版本、`program`、能力)。 +- 失效:项目的 `mcpp.toml` 变化时、窗口重新获得焦点时重新探测。版本在两次探测之间改变(`xlings use`、锁定改动)时,下一个 job + 的信封会说出来:mcpp 的每个 JSON 信封都带 `mcpp.version`,与缓存不同即重新探测,不在 job 前另起进程。后端不读取、不监视 + `.xlings.json`:在项目目录内运行时 mcpp 的回答就是该项目的事实。 +- 环境:按 §4.0 清除 `XLINGS_PROJECT_DIR`。xlings 先从 cwd 向上找 `.xlings.json`,找不到才读这个变量;IDE 从某个项目的终端启动时 + 继承了它,没有锁定的项目就会用上另一个项目的锁定。subos 的选择(`XLINGS_ACTIVE_SUBOS` 等)保留,那是用户的选择。 +- 锁定的版本未安装:shim 拒绝运行并给出 xlings 统一的"未安装"信息,不自动安装。探测结果为 `pinned-missing`,界面给出 + `xlings install mcpp@`;构建入口在此状态下置灰并说明原因。 +- 代价:按需探测(项目第一次被用到时),并发不超过 2;含多个项目的工作区启动时不一次性探测全部。 +- 信任:探测执行项目锁定的 mcpp,只在受信任的工作区进行(VS Code workspace trust、CLion trusted project);不受信任时显示全局 + mcpp 的信息并标注"未在项目内探测"。 +- 设置了 `mcpp.path` 时执行按它(用户的显式选择),界面标注"由设置指定,不经项目的版本选择";后端不另去探测 shim 作比较。 + +### 4.3 能力(mcpp 侧的改动) + +- `mcpp --protocol-version` 新增 `features`:客户端据以开关功能的能力,名字稳定、只增不改;一个名字在有客户端依据它开关 + 某项功能时才加入,文档写明使用方。首批两项:`target-sets`(后端的 target 选择与腿,SPEC-015 §1)、`pkg-format-list`(打包 + 界面的多选,SPEC-014 §4.6)。 +- 三者的分工:`kinds` 说输出的 schema,`commands` 说有哪些命令及其效果,`features` 说已有命令中的语义能力;一项能力只放在 + 其中一处。 +- `features` 同时写入每个 JSON 信封的 `mcpp` 字段(今天为 `{"version", "protocol"}`,§20 D2):后端的探测只需一次 + `self env --format json`(经 shim 每次调用约 25–29 ms,§20 D1),任何 job 的信封也带着版本与能力,版本或能力变化时重新探测。 +- 早于 `features` 的 mcpp:后端带一张冻结的对照表(已发布版本 → 能力;2026.10.30.1 为上面两项),只用于这些旧版本; + 有 `features` 的 mcpp 一律以其回答为准。 +- `mcpp self env --format json` 的 `data` 新增 `program`(实际运行的程序的绝对路径)。 +- 后端的所有版本比较(F3)改为查 `features`;`kContractFloor` 保留为"契约存在"的判断(早于契约的 mcpp 没有 `--protocol-version`)。 + +### 4.4 展示 + +后端的 `project/mcpp` 方法返回下表,三个 IDE 的项目视图"基本信息"与状态栏读它: + +| 字段 | 来源 | +|---|---| +| 版本、程序路径 | `self env`(`mcppVersion`、`program`) | +| `mcpp.path` 是否生效 | 设置项;生效时标注"由设置指定" | +| 项目要求的下限及是否满足 | `mcpp.toml` 的 `mcpp = ">=…"`(由 mcpp 的 `manifest/check` 报告,后端不解析清单) | +| 协议能力 | `--protocol-version` 的 `features`、`kinds` | +| MCPP_HOME、默认工具链、xlings 的位置 | `self env` | +| 可用的新版本 | 可选;只提示,不改锁定 | + +### 4.5 冲突与修复 + +| 情形 | 显示 | 修复动作 | +|---|---|---| +| 项目内的 mcpp 低于项目下限(`mcpp.toml` 的 `mcpp = ">=…"`,由 mcpp 报告) | "项目需要 ≥ X,项目内为 Y" | 复制 `xlings install mcpp@X` | +| 设置了 `mcpp.path` | "由设置指定:X" | 清除设置 | +| 锁定的版本未安装(`pinned-missing`) | "项目所需的 mcpp 未安装" 及 xlings 的原话 | 复制 `xlings install mcpp@` | +| 缺少某个 feature | 对应功能隐藏或置灰,提示需要的 mcpp 版本 | — | + +### 4.6 mcppls 与同一个 mcpp + +mcppls 调用 mcpp(`emit build-database`)时按 §4.0 运行,使 IDE 后端与 mcppls 看到同一个 mcpp。核实(§20 D4):mcppls 经 PATH +找到 `mcpp`(shim 本身)、以项目根为工作目录运行,符合;它使用捕获的登录 shell 环境,未清除 `XLINGS_PROJECT_DIR`,本轮补上,并以 +conformance 夹具固定(§13)。两者仍不一致时(例如 mcppls 设置了自己的 mcpp 路径),后端比较探测结果与 `mcppls status` 的 +`workspace.mcpp`,在项目视图中报告"IDE 与语言服务使用的 mcpp 不同"。 + +### 4.7 版本归属 + +§4 全部进入 mcpp-ide 0.2.0(#3,§14)。mcpp 2026.10.31.1 发布前,0.2.0 对 2026.10.30.1 及更早的 mcpp 使用冻结对照表; +发布后以 `features` 为准,两条路径都有测试。 + +## 5. mcppls 的依赖与契约 + +### 5.1 依赖级别 + +| IDE | 安装依赖 | 运行时 | +|---|---|---| +| VS Code | 保留 `extensionDependencies`(安装 mcpp-vscode 即装 mcppls) | 按能力 | +| CLion | 保留 `` | 按能力 | +| Neovim | 不变(提示 `xlings install mcppls-nvim`) | 按能力 | + +两种清单的依赖声明都不带版本,版本只在运行时处理。VS Code 中删除 `VERIFIED_MCPPLS_MINIMUM`(F6),门槛只在后端一处:最低 +支持 0.0.18(第一个读取 `.last-result.json` 的版本);0.0.18 至 0.0.19 没有 `status`,使用 §5.3 的冻结能力表。 + +### 5.2 `mcppls status` + +新命令:`mcppls status --root --format json`,毫秒级、不启动引擎、不等待就绪,输出 schema 版本化(S3 新增一节): + +```json +{ + "schema": 1, + "server": { "version": "0.0.20", "platform": "linux-x64", "payload": "" }, + "capabilities": ["status", "kit-install", "module-graph", "other-cpp-features"], + "workspace": { + "root": "", + "state": "ready | starting | degraded | stopped | none", + "since": "2026-10-30T12:00:00Z", + "engine": { "name": "clangd", "version": "23.1.0-mcppls.5", "restarts": 0 }, + "kit": { "name": "mcppls-kit-x86_64-linux-gnu", "state": "ok | missing", "install": "xlings install mcppls-kit-…@…" }, + "mcpp": { "version": "2026.10.30.1", "program": "" }, + "problems": [{ "code": "kit-missing", "message": "…" }] + }, + "otherCppFeatures": [{ "extension": "ms-vscode.cpptools", "active": true }] +} +``` + +- 运行中的服务器在工作区缓存目录维护自己的状态,`status` 读取它并补上静态事实(版本、payload、kit 的解析);状态文件是 mcppls + 的实现细节,契约是命令的输出。没有服务器在运行时 `state` 为 `none`。 +- 同一工作区可能有多个服务器(两个编辑器同时打开):输出 `instances[]`(`pid`、`editor`、`state`、`since`),进程已不存在的实例 + 忽略;`workspace.state` 取存活实例中最好的一个。 +- `actions[]`:mcppls 声明自己的动作及其在各编辑器中的 id(`{"id": "restart", "vscode": "mcppls.restartServer", "jetbrains": + "…", "nvim": "…"}`)。动作 id 归 mcppls,mcpp-ide 不保存另一个产品的 id。 +- 后端的 `languageService/status` 改为执行 `mcppls status`(经后端已有的 mcppls 定位),返回版本、能力、状态与可用动作;三个 + IDE 显示同一份信息。VS Code 不再读 mcppls 扩展的 API 对象(F7)。 +- 刷新由事件驱动:job 结束、窗口获得焦点、面板打开时执行一次;面板可见时另有不短于 30 s 的轮询,不可见时不轮询。一次执行包括 + okrun 启动 okb,预算 200 ms 以内;实测 okrun 启动 15 MB 的 `mcppls.okb` 执行一条轻量命令约 35 ms(Linux x64,§20 D3)。 +- `--root` 的比较按系统规范化:Windows 上盘符与分隔符统一、Windows 与 macOS 上不区分大小写;后端与 mcppls 用同一规则(S3 写明)。 + +### 5.3 动作 + +| 动作 | VS Code | CLion | Neovim | +|---|---|---|---| +| 重启、重启引擎、重置缓存、导出诊断包、显示日志 | 能力表转发 mcppls 命令(已有) | `ActionManager` 按 id 调用 mcppls 插件的 action;不在时隐藏 | 调用 mcppls-nvim 的命令 | +| 安装 kit | 转发 `mcppls.installKit` | mcppls 插件的安装 action | `xlings install …` | +| 关闭其他 C++ 服务 | 转发(已有) | 不适用 | 不适用 | + +转发项的目标 id 来自 `mcppls status` 的 `actions[]`(§5.2);VS Code 现有的能力表(`contract.ts`)改为读这份数据,确认等级 +(`danger`)仍由 mcpp-ide 决定。没有 `status` 的 mcppls(0.0.18–0.0.19)用当前的能力表作为冻结数据。CLion 不再回答"本 IDE 不可用", +没有对应 action 的行不出现在菜单中(F14)。 + +### 5.4 升级规则 + +1. 契约版本化(`schema` 加 `capabilities`),后端按能力启用功能;未知能力忽略,缺失能力隐藏对应功能。 +2. 后端不持有 mcppls 进程,只在调用时执行;mcppls 升级、重启、路径改变不影响 mcpp 插件,路径变化经 VS Code + `extensions.onDidChange` 与 CLion 的插件状态监听重新定位。 +3. mcppls 被禁用或未安装时,mcpp 插件的构建、运行、测试、项目页面照常工作;面板显示"C++ 语言服务未启用"与启用或安装动作。 +4. 兼容性 CI:mcpp-ide 对 mcppls 最新发布版与最低支持版(0.0.18)各运行一次契约测试;mcppls 对最新的 mcpp-ide 后端运行一次。 + +## 6. 入口 + +### 6.1 对齐表 + +| 入口 | VS Code | CLion | +|---|---|---| +| `main` 旁运行与调试 | CodeLens "Run \| Debug"(新增,设置 `mcpp.codeLens.enable` 默认开) | gutter ▶(已有,改判定,§6.3) | +| 测试程序的 `main` | 测试项位置为 `main` 所在行(改 F11) | 同一 producer 生成 `mcpp test `(已有) | +| 右键 | `editor/context`:Run、Debug、Build、Test(新增) | Run、Debug(已有);Build(新增) | +| 标题栏或工具栏 | `editor/title`:Build、Run、Debug、Test(补 Build、Debug) | 运行组件(已有);锤子接到 mcpp build(验证后实现) | +| 状态栏 | 已有的 mcpp 项显示项目的 mcpp 版本(文字与提示),不新增项;job 状态与快速菜单不变 | 一个状态栏组件,内容与 VS Code 的项相同(新增) | +| 快捷键 | `Ctrl+Alt+B/R/T`(已有) | 同一组,按 CLion 的 keymap 方式注册 | + +### 6.2 命令表声明位置 + +`spec/commands.json` 每行新增 `placements`(`title`、`context`、`statusMenu`、`gutter`)。两个插件按表生成原生入口;新增命令时 +两边同时出现。VS Code 的清单由命令表生成(`mcpp-ide-dev gen vscode`),0.9.0 的一致性门禁允许新增、不允许改变:0.9.0 已有的条目 +(例如 `editor/title` 的 Run、Test 及其 `when` 中的 `mcpp.editorTitleButtons`)逐字保留,新增的入口另起条目。 + +### 6.3 `main` 的判定 + +`main` 的位置来自正在服务这个文件的语言服务:CLion 用 PSI,VS Code 用当前语言服务器(mcppls)给出的文档符号。mcppls 的命令行 +查询(`query`)要启动引擎,不适合在打开文件时调用,不采用。后端新增 `source/entries`(文件 → 程序还是测试、测试名、框架),只回答 +种类,不回答位置。CLion 的正则(F12)与 VS Code 的第 0 行(F11)都改为"位置取自语言服务、种类取自后端"。语言服务不在 +(mcppls 被禁用)时,位置取自后端的文本扫描(与 §7.3 的扫描同一实现),并在提示中标注为近似。 + +## 7. 测试:程序与用例 + +### 7.1 两个粒度 + +| 粒度 | 定义 | 运行 | +|---|---|---| +| 程序 | 一个 `tests/**/*.cpp`(mcpp 的约定,不变) | `mcpp test ` | +| 用例 | 程序内某个测试框架的一个测试 | `mcpp test -- <框架的过滤参数> <框架的报告参数>` | + +mcpp 核心不变(F17):退出码是约定,`--` 后的参数原样交给程序;SPEC-015 §4 的归档运行同样透传。 + +### 7.2 测试框架适配表(后端) + +| 框架 | 识别(依赖 + 宏) | 列举 | 过滤 | 报告 | +|---|---|---|---|---| +| GoogleTest | `compat.gtest`;宏 `TEST`、`TEST_F`、`TEST_P`、`TYPED_TEST` | `--gtest_list_tests` | `--gtest_filter=Suite.Name` | `--gtest_output=json:` | +| Catch2 | `compat.catch2`;宏 `TEST_CASE`、`SCENARIO` | `--list-tests --reporter json` | 测试名作参数 | `--reporter json::out=` | +| doctest | `compat.doctest`;宏 `TEST_CASE`、`SUBCASE` | `--list-test-cases` | `--test-case=` | `--reporters=junit --out=` | + +首批是 mcpp-index 提供的框架(gtest 1.18.0、Catch2 3.15.2、doctest 2.4.12;Catch2 的 JSON reporter 自 3.5 起可用,§20 D6)。 +`boost-ext.ut`(2.3.1)在核实其 runner 支持命令行过滤后加入,否则只提供程序级;index 不提供的框架(如 +Boost.Test)不适配,其测试按程序级运行。 + +适配表是后端的数据文件(`spec/test-frameworks.json`),新增框架只加一行。一个测试程序属于某个框架,需要两个条件同时成立: +它链接该框架的包(经 mcpp 的 build database),且它的源文件含该框架的宏(§7.3)。 + +### 7.3 发现 + +- **静态**:后端按适配表对文件做文本扫描(跳过注释与字符串),找出框架宏的调用位置;宏名有辨识度(行首的 `TEST(`、 + `TEST_CASE(`),打开文件即可显示 ▶,不需要构建,也不依赖 mcppls。 +- **动态**:构建后运行列举命令校正测试树(参数化、类型化与宏展开生成的用例)。**只对静态扫描找到了该框架宏的测试程序执行**: + 一个依赖了 gtest 却只写 `main` 的测试程序,收到 `--gtest_list_tests` 会把测试当场运行一遍,因此不能仅凭依赖判定。 +- 框架按测试程序判定(同一项目中可以有框架测试与无框架测试并存)。用例写在自定义宏里时,设置 `mcpp.test.macros`(例如 + `{"gtest": ["MY_TEST"]}`)为该框架追加宏名;不提供"强制指定框架",以免对不含该框架的程序执行列举。 +- **用例的标识**:(项目、测试程序、框架、全名),全名取框架的写法(gtest 的 `Suite.Name`,Catch2 与 doctest 的测试名)。动态列举 + 的用例按全名挂到静态节点上:gtest 的参数化用例去掉实例前缀与参数后缀(`Inst/Suite.Name/0` → `Suite.Name`)后匹配;匹配不到的 + 挂在测试程序节点下。同一程序中重名的用例以出现顺序区分。 +- **过滤参数**按框架的语法转义:gtest 的 `:`、`*`、`?`、`-`,Catch2 测试名中的 `,`、`[`、`]`、`\`,doctest 的 `,`;参数以 argv + 传递,不经 shell。报告文件写在每次运行独立的临时目录,运行结束后删除。 +- 列举与运行都执行项目的程序,只在受信任的工作区进行。 +- 后端方法:`test/cases`(文件或程序 → 用例、位置、来源)、`test/runCases`(程序与用例 → job,结果按用例映射)。 + +### 7.4 IDE + +| | VS Code | CLion | +|---|---|---| +| 用例节点 | Testing API 的子项,位置为宏所在行;▶ 与 ✓/✗ 由 VS Code 显示 | 每个宏一个 `RunLineMarkerContributor`;上次结果来自 `TestStateStorage` | +| 结果树 | Testing 视图 | SMTestRunner,后端事件转为 TeamCity service message | +| 调试一个用例 | 同一组参数,调试类型 `mcpp` | 同一组参数,CLion 调试器 | +| 无框架的测试 | 粒度为程序,▶ 在 `main` 行 | 同左 | + +## 8. 清单与构建程序的规则层 + +- **归属**:后端。`mcpp.toml` 的补全、hover、跳转与 7 类诊断规则,`build.mcpp` 的 SPEC-007 规则、指令补全与片段,从 TS(F9、F10) + 移到后端,与现有 `src/manifest.cppm` 合并为一份;`manifest/check`(mcpp 自己的校验)保留。 +- **入口:同一进程。** 后端已是 LSP 基础协议(Content-Length 分帧)上的 JSON-RPC 2.0。会话本身成为 LSP 服务器:`initialize` + 采用 LSP 的形状,mcpp-ide 的参数放在 `initializationOptions`;现有方法改名为自定义请求 `mcpp-ide/*`;规则层以标准的 + `textDocument/*` 提供。VS Code 用 languageclient、CLion 用平台 LSP API 连接这一个进程;Neovim 与 Zed 把它配置为 `mcpp.toml` + 的语言服务器。不另起 `mcpp-ide lsp` 进程,避免两份项目状态与两次 mcpp 探测。 +- **协议**:`initialize` 改形与方法改名使后端协议升为 2(`kProtocol = 2`,`protocol/protocol.schema.json` 与夹具同步)。插件与后端 + 版本恒等(§3),无需兼容协议 1。 +- **信任**:规则层只分析文本、不执行任何程序,在不受信任的工作区同样可用(与 0.9.0 的 TS 实现相同);`manifest/check` 运行 mcpp, + 只在受信任时执行。 +- **分工**:`build.mcpp` 的编译器层仍归 mcppls,两个服务器同时服务该文件,按 F10 的让位规则分工(mcppls 在场时后端不报 + `unknownSymbol`、不补全 import 后的模块名)。 +- **迁移验收**:以现有 TS 实现在固定样例集(`mcpp.toml`、`build.mcpp` 各一组,覆盖 7 类规则与全部补全位置)上生成黄金输出(诊断、 + 补全项、hover 文本、跳转位置),后端输出逐项一致后删除 TS 实现;设置开关(`mcpp.buildScript.*`、严重度)原样保留,作为参数 + 传给后端。 +- **无后端时(F15)**:后端经 okrun 运行,okrun 覆盖 Linux x86_64 与 aarch64、macOS arm64、Windows x86_64;VS Code 还运行在 + macOS x86_64、Windows arm64、Linux armhf 上。回退保留,定义为声明的降级模式:界面显示"后端不可用(本平台)",只提供构建、运行、 + 测试与项目页面,CI 有一个任务专门以无后端方式运行 Extension Host 测试。okrun 覆盖新平台后,降级模式在该平台自动不再出现。 + +## 9. CLion 原生化 + +沿用 mcpp-vscode 的信息结构(项目、库、缓存三个视图,面板,命令表,设置),实现用 CLion 的方式: + +| 部分 | 实现 | 数据 | +|---|---|---| +| 项目树、库列表、缓存 | 工具窗口中的原生 Tree、List | 后端视图模型(F16) | +| 设置 | 原生 `Configurable`,由 `settings/registry` 生成表单;支持应用、取消与设置搜索(改 F13) | `settings/registry`、`settings/update` | +| 模块图、依赖图、包详情页 | JCEF 中的独立小页面,只接收后端数据 | `graph/*`、`library/*` | +| 运行、调试、测试 | 运行配置、producer、SMTestRunner(§6、§7) | 后端 | +| `mcpp.toml`、`build.mcpp` | 平台 LSP API 接后端(§8);高亮由 CLion 自带的 TOML 插件提供 | 后端 | + +界面字符串来自后端的 `i18n/bundle`,与 VS Code 页面共用一份翻译。删除 `webui/src/vscode.ts` 与桥接层;`webui` 只保留图与包详情页。VS Code 的界面不变。测试:后端视图模型的单元测试,加 CLion +headless 测试(运行配置的生成、gutter 入口、设置的应用与取消、工具窗口的渲染)。 + +## 10. mcppls 的分发与安装 + +体积不是约束;以下各项在 mcppls 0.0.20(#75)中逐项验证或实现: + +| # | 项 | 做法 | +|---|---|---| +| D1 | 从 0.0.19 升级(每平台一个 payload → 一个资源;`mcppls-nvim` 依赖 `mcppls`) | 干净环境中先装 0.0.19 再升级,校验 `mcppls`、`mcppls-nvim` 与 VS Code、CLion 插件 | +| D2 | 渠道上限 | VS Marketplace、Open VSX、JetBrains Marketplace、GitCode release 附件的大小上限,发布前核对 | +| D3 | Windows 路径长度 | payload 中最长路径加安装目录不超过 260;CI 检查最长路径 | +| D4 | Windows 未签名程序 | `okrun.exe`、启动程序的 SmartScreen 与杀毒软件行为写入文档;签名另立 | +| D5 | 用户目录含空格或非 ASCII | CI 的安装测试使用这样的目录 | +| D6 | macOS quarantine | 浏览器下载的单文件与 zip:启动器检测并给出 `xattr -d com.apple.quarantine` 的说明;VSIX 与 xlings 安装不受影响 | +| D7 | Linux 覆盖 | 原生 clangd 的 glibc 基线(2.31)写入文档;`clangd.okb`(§25.3)使覆盖等于 okrun 的宿主,musl 发行版随之覆盖 | +| D8 | 运行中更新 | 只涉及 xlings 安装(IDE 插件更新由 IDE 放到新目录):替换 payload 前停止服务器;Windows 上被占用的文件以"新目录 + 切换"替换 | + +## 11. CI 效率 + +- `release_canaries.py` 在每个 canary 结束后打印 `.ninja_log` 中耗时最长的 20 个步骤(编译或链接、目标)。 +- 依据数据再定:dev 配置的链接使用 lld 或 `-gsplit-dwarf`(mcpp 的优化,所有项目受益),或 canary 只运行代表性分片。F18 的 + 1750 s 构建中链接所占比例在下一次发布后写入本节。 + +## 12. 不在本轮 + +- mcpp-vscode 0.9.0 的字节级一致性门禁何时解除:本轮不变。 + +clangd 的 ok 映像(mcppls-clangd#12)在本轮:三个阻塞项按所属的层拆分,见 §25.3。 + +## 13. 测试与验收 + +| 项 | 验收 | +|---|---| +| §4 | 后端单元测试:项目内外 mcpp 版本不同(以两个 mockmcpp 与假 shim 模拟)、`pinned-missing`、`mcpp.path`、继承的 `XLINGS_PROJECT_DIR` 被清除、job 信封的版本变化触发重新探测;`features` 与冻结对照表两条路径;VS Code、CLion 显示项目的实际版本 | +| §3 | 后端被杀后的退避重启与降级;"导出 IDE 报告"的内容与脱敏;每个置灰功能都有原因 | +| §4.6 | mcppls conformance 夹具:锁定版本的项目中 mcppls 调用的 mcpp 版本等于锁定版本 | +| §5 | `mcppls status` 的 schema 测试(含两个实例、一个已退出的实例);后端对 0.0.18、0.0.20(无 `status`)与最新 mcppls 的契约测试;禁用 mcppls 后 mcpp 插件的构建与运行正常 | +| §6 | 入口对齐表每格一个 IDE 级测试(VS Code Extension Host、CLion headless) | +| §7 | gtest、Catch2、doctest 示例项目:每个用例旁 ▶ 可运行、可调试,运行后显示 ✓/✗;gtest 参数化用例挂到 `TEST_P` 行;名字含特殊字符的用例可单独运行;无框架测试的 ▶ 在 `main` 行;依赖 gtest 而只写 `main` 的测试程序不被列举;归档运行按用例过滤 | +| §8 | 黄金输出逐项一致;不受信任的工作区中规则层可用;Neovim 与 Zed 经 LSP 获得 `mcpp.toml` 的补全与诊断;无后端的 Extension Host 任务通过 | +| §9 | CLion headless:设置的应用与取消、设置搜索、原生树的渲染 | +| §10 | D1–D8 各一条发布检查 | + +## 14. 交付与级联 + +未发布的 PR 追加本设计的内容,版本号不变;已发布的仓库新开一个 PR,按检查点分多个提交。 + +| 仓库 | PR | 版本 | 内容 | +|---|---|---|---| +| mcpp-ide | #3(未发布,追加) | 0.2.0 | §4 按项目探测;§5 契约与动作;§6 入口;§7 测试;§8 规则层与 LSP;§9 CLion 原生化;`mcpp-ide-nvim` 精确依赖同版本后端;mcppls 0.0.20 单包依赖(`49ae757`) | +| mcppls | #75(未发布,追加) | 0.0.20 | §5.2 `status`(含 `instances`、`actions`);§4.6 核实与夹具;§10 D3–D8 | +| mcpp | 新 PR(2026.10.30.1 已发布) | 2026.10.31.1 | 检查点提交:§4.3 `features` 与 `self env` 的 `program`;mcpp#868;§17 的后续;§11 canary 诊断与 §4.0 的 docs/50 规则;§19.6.2 的载体模型 `polyglot`(`--pkg-format polyglot`、SPEC-014 的条款);§25.3 的 C1(预构建归档是链接输入) | +| openkal 家族 | 规范、openkal-linux、openkal-macos、openkal-windows、openkal-image、openkal-musl、openkal-llvm-runtime、openkal-platform、mcpplibs.os 各一个 PR,按家族的精确钉住级联发布 | 次版本 | `openkal.map`(§23.2)及其实现;okrun 以映射读入映像;§25.3 定位到这些仓库的修复 | +| mcppls-clangd | 一个 PR | 0.0.18 | clangd 的 `-openkal` 映像与 `clangd.okb`(§25.3) | +| xim-pkgindex | 一个 PR | — | mcpp、openkal 家族、mcppls、mcpp-ide 的条目 | + +按能力解耦后发布之间没有硬顺序;mcpp 先发布,使 mcppls 与 mcpp-ide 发布时即可用上 `features`。 + +mcpp 的新 PR 按检查点提交:① `features` 与 `program`;② #868;③ §17 的两项;④ §11 与 docs/50 的 §4.0 规则;⑤ 载体模型 `polyglot`。 +#3 内部按检查点提交,每个检查点 CI 通过后再进行下一个:① §4 与 §3.1、§3.2;② §5 的消费端;③ §6 入口;④ §7 测试;⑤ §8 规则层 +(先生成黄金输出,再迁移,再删除 TS)与协议 2;⑥ §9 CLion 原生化。 + +## 15. 评审项 + +1. 分层;Neovim 的后端精确版本依赖;后端的生命周期与退避重启(§3、§3.1)。 +2. 可观察性:功能不可用的原因、"导出 IDE 报告"、日志级别(§3.2)。 +3. 运行 mcpp 的生态规则:项目根为 cwd、经 PATH 或显式路径、清除 `XLINGS_PROJECT_DIR`;写入 mcpp docs/50(§4.0)。 +4. 按项目探测:以项目根为键;版本变化从 job 信封得知;`pinned-missing`;按需、并发 ≤ 2;只在受信任的工作区;`mcpp.path` 优先且不比较(§4.2、§4.5)。 +5. `features` 首批两项(`target-sets`、`pkg-format-list`)及其与 `kinds`、`commands` 的分工;冻结对照表;`self env` 新增 `program`(§4.3)。 +6. IDE 与 mcppls 的 mcpp 不一致时报告(§4.6)。 +7. 保留安装硬依赖;最低 mcppls 0.0.18,0.0.18–0.0.19 用冻结能力表(§5.1、§5.3)。 +8. `mcppls status`:字段、`instances[]`、`actions[]`、事件驱动刷新与 200 ms 预算、`--root` 的规范化(§5.2)。 +9. 入口对齐表;状态栏不新增项;CodeLens 有开关;`placements` 与 0.9.0 条目逐字保留(§6)。 +10. `main` 的位置:语言服务优先,缺席时用后端的文本扫描并标注近似(§6.3)。 +11. 测试:首批三个框架;"链接 + 含宏";`mcpp.test.macros` 追加宏名而非强制框架;用例标识与动态—静态的对应;过滤参数的转义;报告文件的位置(§7)。 +12. 规则层下沉、会话进程即 LSP 服务器、协议升为 2、不受信任时可用(§8)。 +13. 黄金输出与"一致后删除 TS"(§8)。 +14. 无后端回退为声明的降级模式(§8)。 +15. CLion 原生化(§9)。 +16. 分发 D1–D8 全部在 mcppls 0.0.20(§10)。 +17. canary 诊断先行(§11)。 +18. 交付:未发布的 PR 追加、新 PR 分检查点;#3 与 mcpp 新 PR 的检查点顺序(§14)。 +19. #866、#867 的后续(§17)。 + +## 16. 自我评审的处理(rev 2) + +| # | 视角 | 发现 | 处理 | +|---|---|---|---| +| 1 | 架构 | rev 1 称后端随所有插件打包;Neovim 的后端是独立 xlings 包,依赖不带版本 | `xim:mcpp-ide@` 精确依赖,`initialize` 核对 `protocol`(§3) | +| 2 | 正确性 | xlings 先从 cwd 找 `.xlings.json`、找不到才读 `XLINGS_PROJECT_DIR`;IDE 继承这个变量时,无锁定的项目会用别的项目的锁定 | 探测与 job 清除该变量(§4.2) | +| 3 | 体验 | 锁定版本未安装时 shim 拒绝运行,rev 1 未处理 | `pinned-missing` 状态与安装动作(§4.2) | +| 4 | 性能 | 每个项目一次探测,多项目工作区启动时会并发几十个进程 | 按需、并发 ≤ 2(§4.2) | +| 5 | 安全 | 探测执行项目锁定的程序;列举测试执行项目的程序 | 只在受信任的工作区(§4.2、§7.3) | +| 6 | 简洁 | rev 1 的 `features` 六项中三项没有客户端据以开关 | 只收三项,并写明使用方(§4.3) | +| 7 | 兼容 | 早于 `features` 的 mcpp 会被判定为没有任何能力,2026.10.30.1 的 target 集合会被隐藏 | 冻结对照表(§4.3) | +| 8 | 简洁 | rev 1 的 0.2.0 先行修复用 `self env` 的 `legs` 判定 target 集合,需要额外传 `--target` 运行一次,是绕路 | 0.2.0 比较项目的正确版本,0.3.0 改能力(§4.7) | +| 9 | 正确性 | 两个编辑器同时打开一个工作区时有两个 mcppls 服务器;服务器崩溃后状态文件过期 | `instances[]`,按进程存活过滤(§5.2) | +| 10 | 架构 | rev 1 让 mcpp-ide 的命令表保存 mcppls 的 action id,是跨产品的硬编码 | 动作 id 由 mcppls 在 `status` 中声明(§5.2、§5.3) | +| 11 | 性能 | rev 1 的入口与用例位置经 mcppls 的 `query` 命令行,而 `query` 要启动引擎,不适合打开文件时调用 | 位置取自编辑器中已在服务的语言服务(PSI、文档符号)与后端的文本扫描;mcppls 0.0.21 只新增 `status`(§6.3、§7.3) | +| 12 | 安全与正确性 | 依赖了 gtest 但只写 `main` 的测试程序,收到 `--gtest_list_tests` 会被当场运行 | 动态列举只对静态扫描到框架宏的程序执行(§7.3) | +| 13 | 架构 | rev 1 另起 `mcpp-ide lsp` 进程,与会话进程各有一份项目状态、各探测一次 mcpp | 后端已是 LSP 基础协议上的 JSON-RPC,会话进程本身成为 LSP 服务器(§8) | +| 14 | 跨平台 | rev 1 删除无后端回退;okrun 不覆盖 macOS x86_64、Windows arm64、Linux armhf,这些平台上的 VS Code 会失去全部功能 | 回退保留为声明的降级模式,CI 专门覆盖(§8) | +| 15 | 一致性 | 命令表生成的入口可能改动 0.9.0 已有的条目 | 0.9.0 条目逐字保留,新增另起(§6.2) | +| 16 | 体验 | CLion 原生界面的字符串来源未定 | 共用后端的 `i18n/bundle`(§9) | +| 17 | 范围 | D8 写成也覆盖 IDE 插件更新,而插件更新由 IDE 放到新目录 | 只涉及 xlings 安装(§10) | +| 18 | 交付 | rev 1 的交付表有硬顺序(0.3.0 依赖 mcpp 与 mcppls 的新版本),与"按能力"的原则矛盾 | 冻结表使 0.3.0 能用现有版本,发布无硬顺序(§14) | + +## 17. 外部贡献的后续(#866、#867) + +两个 PR 按原样合入(安全检查:只改 `version_req`、`toml` 两个模块、它们的单元测试与文档;无工作流、脚本、网络与进程操作; +`Lexer::peek` 越界返回 `'\0'`,`\u` 的读取不越界)。以下在 mcpp 的新 PR 中补上: + +| 来源 | 缺口 | 补法 | +|---|---|---| +| #866 | 兼容性说明称"裸版本不变";而裸版本即 caret(`version_req` 中 `bare = caret`),解析器只在它恰为已发布的键时作精确地址,部分写法 `"0"`、`"0.0"` 同样改变(只匹配 0.0.0 → `<1.0.0`、`<0.1.0`),依赖冲突检查(`graph.cpp`)与地址核对(`address_set`)亦然 | 补裸部分版本的单元测试;docs/05 两种语言写明"裸版本即 caret,部分写法同样按通配";CHANGELOG | +| #867 | 读取接受 `\b`、`\f`、`\u`、`\U` 后,值中可出现控制字符;`escape_string` 只转义 `"`、`\`、`\n`、`\t`、`\r`,`serialize`(`mcpp publish` 的规范化清单,#690)会写出含未转义控制字符的基本字符串,不是合法的 TOML | U+0000–U+001F 与 U+007F 全部转义(`\b`、`\f` 用短形式,其余 `\uXXXX`);读入—写出—读入的往返测试;`\0` 是本读取器的扩展而非 TOML 1.0,写入 docs;CHANGELOG | + +## 18. 第二轮自我评审的处理(rev 3) + +| # | 视角 | 发现 | 处理 | +|---|---|---|---| +| 1 | 一致性 | §4.7 仍写"0.2.0 先修、0.3.0 改能力",与"未发布的 PR 追加"的决定矛盾;§10 仍写 0.0.21 | §4 全部进入 0.2.0,冻结表覆盖发布前的间隙(§4.7);§10 改为 0.0.20 | +| 2 | 一致性 | 不再读 `.xlings.json` 后,§4.5 的"设置指向 X,项目锁定 Y"无从得知 Y | `mcpp.path` 生效时只标注"由设置指定",不比较(§4.2、§4.5) | +| 3 | 架构 | "在项目目录内经 shim 运行"只写在后端一侧,mcppls 另有一份描述,两处可能分叉 | 写成一条生态规则,写入 mcpp docs/50,后端与 mcppls 引用(§4.0) | +| 4 | 性能、简洁 | 每个 job 前另起一次 `--version` | 版本从 job 信封的 `mcpp.version` 得知,变化时才重新探测(§4.2) | +| 5 | 简洁 | 缓存键含"shim 解析出的程序路径",而这个路径要探测后才知道 | 以项目根为键(§4.2) | +| 6 | 语义 | `features` 与已有的 `kinds`、`commands` 的边界未定;`test-archive` 没有 IDE 使用方,违反"有客户端开关才加名字" | 定义三者分工;首批减为两项(§4.3) | +| 7 | 兼容 | 最低支持 0.0.18,而冻结能力表只写了 0.0.20 | 冻结表覆盖 0.0.18–0.0.19(§5.1、§5.3) | +| 8 | 可观察性 | 后端与 mcppls 各探测 mcpp,两者不一致时无人知晓 | 比较两者,在项目视图报告(§4.6) | +| 9 | 稳定性 | 后端崩溃后的行为未定义;后端持有的 job 随之结束 | 退避重启、上限后降级,job 报告为"后端已退出"(§3.1) | +| 10 | 可观察性 | 功能被隐藏时用户不知原因;没有统一的问题报告 | 每个不可用功能带原因;"导出 IDE 报告";日志级别(§3.2) | +| 11 | 性能 | `mcppls status` 的刷新频率未定,轮询会反复启动 okrun | 事件驱动,面板可见时 ≥ 30 s 轮询,单次 200 ms 预算(§5.2) | +| 12 | 跨平台 | `--root` 在 Windows(盘符、分隔符)与 macOS、Windows(大小写)上与 mcppls 记录的根可能比较不等 | 双方同一规范化规则,写入 S3(§5.2) | +| 13 | 体验 | 状态栏新增 mcpp 版本项会让 VS Code 状态栏多一项 | 并入已有的 mcpp 项;CLion 一个组件(§6.1) | +| 14 | 体验 | CodeLens 在每个 `main` 上方出现,有人不想要 | `mcpp.codeLens.enable`,默认开(§6.1) | +| 15 | 稳定性 | mcppls 被禁用时 VS Code 没有文档符号,CodeLens 位置无来源 | 退回后端的文本扫描,标注近似(§6.3) | +| 16 | 框架 | 用例的标识与动态—静态的对应未定义;gtest 参数化用例的名字与 `TEST_P` 不同 | 标识四元组;去前缀与后缀后匹配;匹配不到挂到程序节点(§7.3) | +| 17 | 跨平台、正确性 | 过滤参数含框架的元字符(gtest 的 `:`、`*`,Catch2 的 `,`、`[`)时会选错用例 | 按框架语法转义,argv 传递不经 shell(§7.3) | +| 18 | 语义、安全 | `mcpp.test.framework` 强制指定框架,会对不含该框架的程序执行列举,恰好违反 rev 2 的安全约束 | 改为 `mcpp.test.macros` 按框架追加宏名(§7.3) | +| 19 | 体验、安全 | 规则层并入会话进程后,是否在不受信任的工作区工作未写明;0.9.0 的 TS 实现是可用的 | 规则层只分析文本,不受信任时可用;`manifest/check` 仍受信任约束(§8) | +| 20 | 兼容 | `initialize` 改形与方法改名是协议的不兼容变化,rev 2 未给出协议版本 | 协议升为 2;插件与后端版本恒等,不兼容协议 1(§8) | +| 21 | 稳定性、交付 | #3 的范围变大,一次合入风险高 | #3 与 mcpp 新 PR 内部按检查点提交,每个检查点 CI 通过(§14) | +| 22 | 可观察性 | 测试报告文件与 job 的临时文件位置未定,失败后难以排查 | 每次运行独立的临时目录,结束删除;"导出 IDE 报告"包含最近的 job 记录(§7.3、§3.2) | + +## 19. 一个文件到处运行(mcpp#849):probe 数据与方案(待讨论) + +### 19.1 probe + +mcpplibs/openkal#54(临时,不合入):cosmocc 4.0.2 构建一个 APE(998 KB,含 x86_64 与 aarch64 两份代码、x86_64 的 Mach-O), +在 Linux x64/arm64、macOS arm64/x64、Windows x64/arm64 上运行,测量 openkal 各区域对应的 libc 原语(52 项)与 APE 本身的行为。 + +| 项 | Linux x64 | Linux arm64 | macOS arm64 | macOS x64 | Windows x64 | Windows arm64 | +|---|---|---|---|---|---|---| +| 原语(通过/总数) | 52/52 | 50/50 | 48/50 | 51/52 | 52/55 | 52/55 | +| 不通过的原语 | — | — | RWX 映射被拒(W^X;RW→RX 可行) | — | 长路径 > 260 失败;以执行权限映射文件被拒 | 同左 | +| 运行的代码 | x86_64 | aarch64 | aarch64 | x86_64 | x86_64 | x86_64(经系统模拟) | +| 不经 shell 直接 execve | **ENOEXEC** | **ENOEXEC** | **ENOEXEC** | **ENOEXEC** | 可以 | 可以 | +| 经 `sh` 首次运行 | 41 ms,写出 9 KB 的 `$TMPDIR/.ape-1.10` | 87 ms,同左 | **2012 ms**:写出 loader 的 C 源码并用系统的 `cc` 编译 | 102 ms,写出 9 KB 的 Mach-O loader | 7.5 ms,不写盘 | 33 ms,不写盘 | +| 之后的启动(中位数) | 1.1 ms(原生 0.7) | 1.3 ms(0.7) | 6.0 ms(1.4) | 49.5 ms(10.9) | 7.4 ms(8.4) | 29.4 ms(14.2) | +| `assimilate` 转为本机格式后直接 execve | 0.3 ms | 0.4 ms | **不支持**(Apple Silicon 上 APE 只以 ELF 经 loader 运行) | 26.1 ms | — | — | +| 注册了 MZ 的 binfmt(如 wine-binfmt) | 被接管而失败;经 `sh` 可行 | 同左 | — | — | — | — | +| 签名与放行 | — | — | quarantine 不拦截命令行启动;`codesign -s -` 成功 | 同左 | **Authenticode 无法签名**(PowerShell 与 signtool 都拒绝:`0x800700C1`);Defender 未报 | 同左 | +| 调用约定 | — | — | — | — | cosmo 代码在 Windows 上也是 SysV:调用 SysV 的被调者无需 thunk,`ms_abi` 显式调用同样可行 | 同左 | + +(`fcntl` 记录锁在 macOS、Windows 上的失败是 probe 自身对同一文件先加了 `flock` 所致,不计入。) + +### 19.2 读数 + +1. **APE 在 POSIX 上不是可执行文件,而是一个 shell 脚本。** 编辑器、Python、IDE 后端用 execve 启动它会得到 ENOEXEC;只有经 shell + (或安装 `ape` loader、注册 binfmt、`assimilate` 转换)才能运行。作为被其他程序启动的服务器(mcppls、mcpp-ide 后端),APE 不可直接用。 +2. **"什么都不用装"在 Apple Silicon 上不成立。** 首次运行要用系统的 `cc` 编译 loader(2 s),没有 Xcode 命令行工具的 Mac 无法运行; + `assimilate` 在这里也不支持。 +3. **首次运行写盘。** 每个 POSIX 系统写出一个 loader 到 `$TMPDIR`,与 SPEC-014 单文件"运行时不写盘"的条款不一致。 +4. **Windows 上 APE 无法 Authenticode 签名**,发布可信的 Windows 程序时不可用;未签名程序的 SmartScreen 与杀毒问题(part 2 D4)无解。 +5. **libc 原语的覆盖很好**(每个平台 48–52 项);Windows 上的缺口是 MAX_PATH 与可执行的文件映射。#849 原文"Cosmopolitan 在 Windows 上以 MS ABI + 运行、需要 thunk"不成立:cosmo 的代码在 Windows 上也用 SysV。 +6. 启动开销:Linux 与 Windows 上接近原生;macOS x64 约为原生的 4 倍。 + +### 19.3 Cosmopolitan 与 openkal + +| 维度 | Cosmopolitan(APE) | openkal | +|---|---|---| +| 可移植的单位 | 一个程序,各系统的适配编译进程序 | 与系统无关的映像(ok image),各系统一个 runner | +| 接口 | POSIX libc 的模拟:面宽,现有代码可直接编译 | 封闭的 `kal_*` 规范:面窄、可验证(一致性测试)、能力式(目录授权) | +| 系统适配的位置 | 每个程序各带一份;修一个系统问题要重新构建程序 | runner 与实现中,一次实现,所有映像共用 | +| 多架构 | 一个文件两份代码 | okb(两份映像);单文件按行 | +| 直接运行 | Windows 直接;POSIX 需 shell 或 loader | 需 runner,或单文件载体(原生程序,内存加载,不写盘) | +| 签名 | Windows 不能;macOS ad-hoc 可以 | 载体与 runner 是原生程序,各系统正常签名 | +| 主机库(GPU) | `cosmo_dlopen` 加 `ms_abi` 函数指针 | 规范中没有 | +| 沙箱 | `pledge`、`unveil` 自我约束(加载 GPU 库时关闭) | runner 授权的目录(`kal_fs_preopen`),由构造保证 | +| 系统 | Linux、macOS、Windows、三种 BSD | Linux、macOS、Windows | +| C++ | cosmocc | mcpp 的 LLVM 23、libc++ 与 `import std` | + +### 19.4 同类项目 + +| 项目 | 做法 | 一个文件 | 所有系统 | 所有架构 | 需要安装运行时 | 首次写盘 | +|---|---|---|---|---|---|---| +| llamafile(Mozilla) | APE;llama.cpp 构建两次;权重以页对齐的 ZIP 嵌入并 mmap;GPU 用预构建的 DSO(`ggml-cuda.dll` 717 MB)经 `cosmo_dlopen` 加载 | 是(Windows 上限 4 GB) | 6 种(Windows 只有 AMD64) | x86_64、aarch64 | 否 | loader(POSIX) | +| redbean、Actually Portable Python/Lua | APE | 是 | 6 种 | 两种 | 否 | loader | +| WebAssembly + WASI(wasmtime、wasmer) | 字节码 + 运行时;`create-exe` 按系统产出 | 否(需运行时) | 是 | 是 | 是 | — | +| JVM JAR | 字节码 + JVM | 是 | 是 | 是 | 是 | — | +| Apple universal、Windows ARM64X | 一个系统内多架构 | 是 | 否 | 是 | 否 | — | +| PyInstaller、Nuitka onefile、.NET single-file、AppImage | 每系统一个,自解压 | 每系统一个 | 否 | 否 | 否 | 是(临时目录) | +| openkal 今天 | okb + okrun;单文件按行 | okb 是 / 单文件按行 | runner 所在的系统 | 是 | okb 需 okrun | 单文件不写盘 | + +openkal 的位置:映像与 runner 的分工接近 WASM 与 JAR(可移植的单位加宿主),但映像是原生代码、无 JIT;单文件按行与 +PyInstaller 一类相当,且不写盘。 + +### 19.5 openkal 能直接做的部分 + +| llamafile 的能力 | openkal 体系 | +|---|---| +| 一个文件同时含两个架构 | 已有:okb | +| 一个文件在所有系统运行 | 可做,不需要 Cosmopolitan:方案 B(下) | +| 嵌入大数据并 mmap(权重) | 需要 openkal 规范增加文件映射(与 mcppls-clangd#12 共用的前置项) | +| 运行时按 CPU 特性分派 | 编译器的 `target` 属性与 `cpuid`;ok 映像中可用,待验证 | +| 沙箱 | 已有,且更强(能力式) | +| GPU 与主机库 | 没有;需要规范增加"主机模块"(固定函数表、runner 加载原生 DSO 并转换调用约定),是一个独立的设计题 | + +### 19.6 两条实现路线 + +- **A:openkal-cosmo。** 用 Cosmopolitan 的 libc 实现全部 `kal_*`,okrun 构建为一个 APE。一份 runner 覆盖 6 种系统(含 BSD), + 体积最小;代价:把规范重新实现一遍(Windows 上是 POSIX 模拟之上再实现,转换两次),并继承 19.2 的 1–4。 +- **B:openkal 原生多格式载体。** 只借用 APE 的文件格式技巧,不用它的 libc:文件开头是原生的 Windows okrun(其 DOS 头里放 shell + 前导),其后是各系统的原生 okrun 与 okb。Windows 直接运行;POSIX 上前导把对应的原生 okrun 解到按摘要命名的缓存目录(SPEC-014 + 启动器已有缓存目录的规则)并执行。复用经一致性测试的原生实现;macOS arm64 解出的是预构建、已签名的 Mach-O,不需要 `cc`; + 没有 cosmo 在 Windows 上的 MAX_PATH 与可执行映射的缺口。代价:体积为各系统 runner 之和;POSIX 上同样首次写盘、同样不能被直接 + execve;Windows 部分能否签名取决于 PE 的布局(APE 的 PE 布局被 signtool 拒绝,标准链接器产出的 PE 只替换 DOS 存根可能可以), + 需要第二个 probe。 + +两条路线都不适合作为被其他程序启动的服务器(19.2 第 1 点):mcppls、mcpp-ide 后端与 IDE 载荷仍使用原生 runner 与按系统的包, +"一个文件"定位为命令行工具的下载形式。 + +### 19.6.1 路线 B 的 probe + +mcpplibs/openkal#54 的第二个工作流(`probe-polyglot`):mcpp 2026.10.30.1 在一台 Linux 上为 `--target openkal` 打出四个系统的单文件 +(Linux x64 1.76 MB、Linux arm64 1.92 MB、macOS arm64 2.51 MB、Windows x64 1.90 MB;x86_64-macos 与 aarch64-windows-gnu 不构建,写为未覆盖), +`probe/polyglot/make_polyglot.py` 把 Windows 单文件的 PE 头后移、在 DOS 存根处写入 POSIX 前导(节表、调试目录与 +`SizeOfHeaders` 随之修正),其余三个单文件附在 PE 之后,得到 8.08 MB 的一个文件(`file`:PE32+)。 + +| 项 | Linux x64 | Linux arm64 | macOS arm64 | macOS x64 | Windows x64 | Windows arm64 | +|---|---|---|---|---|---|---| +| 运行 | 是 | 是 | 是 | 文件中无该系统的载体:退出 126 并写明 | 是 | 是(x64 模拟) | +| 不经 shell 直接 execve | ENOEXEC | ENOEXEC | ENOEXEC | — | 可以 | 可以 | +| 首次运行(经 `sh`) | 18 ms,解出 1.76 MB 到 `~/.cache/openkal-polyglot/<摘要>/` | 14 ms | 120–280 ms | — | 6 ms,不写盘 | 46 ms,不写盘 | +| 之后的启动(中位数;原生单文件) | 9.4 ms(6.3) | 6.6 ms(4.0) | 14 ms(7.4) | — | 5.6 ms(5.6) | 36 ms(36) | +| Authenticode | — | — | — | — | **signtool 签名成功**(自签根证书不受信是唯一的验证错误);签名后运行;Defender 无告警 | 同左 | +| 签名后的文件在 POSIX 上 | 运行 | 运行 | 运行 | — | — | — | +| macOS 放行 | — | — | 带 quarantine 的文件经 `sh` 运行;解出的 Mach-O 保留原有签名,不继承 quarantine | — | — | — | + +与 APE 相比:Windows 上可以签名且启动等同原生;macOS arm64 不需要编译器;POSIX 上首次写盘与"不能被直接 execve"两点相同; +体积为各系统载体之和(8.08 MB 对 APE 的约 1 MB 加映像)。macOS x64 与 Windows arm64 原生的缺口来自 mcpp 不构建这两行,而不是格式。 + +### 19.6.2 决定(2026-10-30) + +- 采用路线 B,作为载体模型之一:平台的 `carrier` 列表可声明多个模型,`row`(每个 `-` 一个原生单文件,不写盘, + 能被直接 execve)为默认;`polyglot`(路线 B)可选,以容器名选择:`--pkg-format polyglot`(或 `[pack] format`),与 + `single-file` 并列,`--systems` 同样适用,不新增选项;`ape`(路线 A)保留为以后可加的模型。不认识的模型跳过并警告 + (SPEC-014 已有的规则)。 +- SPEC-014 为 `polyglot` 增加一条:POSIX 上首次运行把本系统的载体写入 SPEC-014 §5.2 已有的单文件缓存目录 + (`<缓存根>/mcpp/single-file/<载荷的 sha256>/`,权限与校验规则相同),之后复用;`row` 的"运行时不写盘"不变。probe 中的 + `~/.cache/openkal-polyglot/` 只是 probe 的路径。 +- 与其余选项:`polyglot` 与 `single-file` 一样不进 `--pkg-format` 列表(SPEC-014 §5.4);`mcpp run --pkg-format polyglot` 在 POSIX + 上经 `sh` 启动它(ENOEXEC,§19.6.1);`mcpp emit xpkg` 不接受 `polyglot`,xlings 的条目按系统使用 `row` 的单文件或树, + `polyglot` 是直接下载的形式。 +- 签名顺序:各系统的载体先按 SPEC-014 的签名钩子各自签名(macOS 的 codesign 等),再组装;组装后的整个文件最后做 Windows 的 + Authenticode(它把证书附在文件末尾,前面的字节与偏移不变,§19.6.1 实测签名后 POSIX 仍可运行)。 +- 覆盖:`polyglot` 的系统与架构即各行单文件的并集(今天四对:Linux x64/arm64、macOS arm64、Windows x64),`runs_on` 照此写出; + 缺少的行在文件中如实报告(退出 126 并写明)。一台 Linux 即可构建全部四个载体(§20 D7)。 +- openkal 规范增加文件映射(同时解除 mcppls-clangd#12)。 + +### 19.8 设备与 ok 生态的路线(已定,2026-10-30) + +- **归属**:GPU、显示、音频、摄像头等按"我的 / 哪一个"的判别归 openhal;openkal 不增加设备能力。 +- **事实**:ok 映像的导入只能是规范声明的名字与其他映像导出的名字,C 库与环境的名字一律拒绝(openkal SPEC §12.2);映像 → okso + (加载器查找与 C 库的 `dlopen`)与原生程序 → okso(`okrun_open_library` 与 openkal-bind)已有;映像 → 主机原生库没有。 +- **路线**:R0 原生目标经 openhal 的 concept 与 `openhal-host` 直接使用主机库(今天可用);R1 ok 映像经 openbus(`kal_net_connect_at` + 连本机路由、共享区域)使用设备,openkal 只补文件映射;R2 进程内快速路径为 openkal §12 的"加载器提供的具名扩展接口":由 C 头文件 + 定义、带版本,主机模块实现,加载器按头文件机械生成调用约定的转换(openkal-bind 的反向模式,与它现有的边界规则相同),映像的 note + 声明所需的扩展接口,缺少即在加载时拒绝;R2 在 R1 跑通并实测"ok 映像经 okrun 连 xbus"的开销后进入规范。不做:openkal 内的领域 + 扩展(`kal_gpu_*`)、映像任意加载主机库。 +- **归档**:R1、R2 与文件映射的设计写入 openkal 的仓库与规范;设备接口(GPU 等)写入 openhal 的规范;本设计只保留分工与上述要求。 + +### 19.7 待讨论 + +1–4 已定(19.6.2)。 +5. 设备(GPU 等):按"我的 / 哪一个"的判别归 openhal;ok 映像默认经 openbus 访问(`kal_net_connect_at` 连本机路由,大数据经 + 共享区域,依赖文件映射);进程内主机模块作为 openhal 的第三种绑定是否现在定义;GPU 计算接口的设计归 luban/specs/openhal。 + 待确认。xbus 的实测(原生进程、经路由、本机 release):调用往返 p50 32.8 µs、p99 65.5 µs,8 MB 区域按引用往返 p50 65.5 µs + (§20 D5)。批量提交命令缓冲的用法在这个量级内;每个小操作一次调用的用法(CUDA 式细粒度启动,进程内约为个位数 µs)需要快速路径。 + +## 20. 补测数据(rev 4) + +| # | 假设 | 测量 | 结果 | +|---|---|---|---| +| D1 | 按项目探测经 shim 的代价与锁定的生效方式(§4.2) | 干净环境中装 2026.10.29.1 与 2026.10.30.1,项目目录以 `.xlings.json` 锁定 2026.10.29.1 | 锁定的目录内 shim 选中 2026.10.29.1;**未锁定的目录在 `XLINGS_PROJECT_DIR` 指向锁定项目时也选中 2026.10.29.1**(证实 §4.2 清除该变量的必要);经 shim 每次调用 25–29 ms,直接调用 5.8 ms | +| D2 | 信封带版本(§4.2) | `self env --format json` 的顶层 | `"mcpp": {"version": "2026.10.29.1", "protocol": {"min": 1, "max": 1}}`,每个信封都有 | +| D3 | `mcppls status` 的预算(§5.2) | okrun 启动 mcppls 0.0.19 的 15 MB okb,执行 `version` 等三条命令各 15 次 | 中位数 35 ms(Linux x64) | +| D4 | mcppls 调用 mcpp 的方式(§4.6) | 读 mcppls `src/project/mcpp.cpp` | 经 PATH 找到 `mcpp`、工作目录为项目根:符合;环境为捕获的登录 shell 环境,未清除 `XLINGS_PROJECT_DIR`:需补 | +| D5 | 设备经 openbus 的延迟(§19.7) | xbus 设计 §9 的 B4 实测 | 调用往返 p50 32.8 µs、p99 65.5 µs;stream 1.8 GB/s;8 MB 区域按引用往返 p50 65.5 µs | +| D6 | 测试框架的参数(§7.2) | mcpp-index 的版本 | gtest 1.18.0、Catch2 3.15.2、doctest 2.4.12、boost-ext.ut 2.3.1 | +| D7 | `polyglot` 的载体能否在一台机器上构建(§19.6.2) | 干净环境中 `mcpp pack --target openkal --pkg-format single-file --systems all` | 一台 Linux 写出 Linux x64/arm64、macOS arm64、Windows x64 四个单文件;macOS x64 与 Windows arm64 写为未覆盖 | + +未测:ok 映像经 okrun 连 xbus 的额外开销;CLion 运行组件的锤子按钮对 mcpp 运行配置的行为(§6.1);`boost-ext.ut` 的命令行过滤。 + +## 21. 第三轮全局评审的处理(rev 4) + +| # | 视角 | 发现 | 处理 | +|---|---|---|---| +| 1 | 简洁、性能 | 探测要两次调用(`self env` 与 `--protocol-version`),经 shim 共约 55 ms;能力只在 `--protocol-version` 中 | `features` 写入信封的 `mcpp` 字段,一次 `self env` 即可;job 的信封同样携带(§4.3) | +| 2 | 一致性 | 运行 mcpp 的规则(§4.0)只落实在后端一侧,mcppls 未清除 `XLINGS_PROJECT_DIR`(D4) | mcppls 补上并以夹具固定(§4.6) | +| 3 | 一致性 | probe 的 `polyglot` 缓存在 `~/.cache/openkal-polyglot/`,与 SPEC-014 单文件已有的缓存目录与规则不同 | 并入已有的 `mcpp/single-file//` 与其规则(§19.6.2) | +| 4 | 安全、发布 | `polyglot` 的签名顺序未定:Authenticode 附在文件末尾,而各系统载体的签名在组装前 | 先签各载体、再组装、最后签整个文件(§19.6.2) | +| 5 | 可观察性 | `polyglot` 在缺少本系统载体时的行为只在 probe 中出现 | `runs_on` 为各行并集;缺少的行退出 126 并写明(§19.6.2) | +| 6 | 体验 | `--carrier polyglot` 是新增的选项名;单文件已有 `--pkg-format single-file` | 已定:`--pkg-format polyglot`,不新增选项(§19.6.2) | +| 7 | 性能 | `mcppls status` 的 200 ms 预算没有依据 | 实测 35 ms(D3),预算保留为跨平台上限(§5.2) | +| 8 | 架构 | 设备的默认路径(openbus)没有延迟依据 | xbus 实测写入,并给出默认路径与快速路径的界线(§19.7) | +| 9 | 范围 | 文件映射的级联(openkal 规范与三个实现)不被 mcpp、mcppls、mcpp-ide 本轮的任何一项依赖 | 作为并行的独立级联,不阻塞其余交付(§14) | +| 10 | 兼容 | `polyglot` 的载体覆盖受 mcpp 构建的行限制(macOS x64、Windows arm64 没有) | 与 `row` 相同,如实写为未覆盖;补行属于平台与目标表,不在本设计 | +| 11 | 范围 | §6.1 的 CLion 锤子按钮与 `boost-ext.ut` 的过滤未实测 | 实现时先测;不满足则锤子按钮改为工具栏的 mcpp build 动作,ut 只提供程序级 | +| 12 | 可读性 | 文档已近 650 行,已定与待定分散在各节 | 新增 §22 汇总待定项 | + +## 22. 待定项汇总 + +0. 本轮的规范清单与 openlifa 一轮:已定(§23)。 +1. 设备:已定(§19.8)。 +2. `polyglot` 的选择方式:已定,`--pkg-format polyglot`(§19.6.2)。 +3. `mcpp.path` 与项目锁定冲突时执行按设置(§4.2):已定。 +4. 交付:未发布的 #3、#75 追加本设计的内容,推迟 mcpp-ide 0.2.0 与 mcppls 0.0.20 的发布(§14):已定。 +5. 文件映射的最小接口(§23.2)与 `polyglot` 的三条边界(§19.6.2):已定。 + +## 23. 本轮的规范清单与 openlifa 一轮(已定,2026-10-30) + +### 23.1 本轮必须写出的规范 + +| part 2 的条目 | 规范 | 仓库 | +|---|---|---| +| §4.0 工具怎样运行 mcpp;§4.3 `features`(含信封的 `mcpp` 字段)、`self env` 的 `program` | docs/50(机器输出) | mcpp | +| §19.6.2 `--pkg-format polyglot`、缓存、签名顺序、与列表及 `run`、`emit xpkg` 的关系 | SPEC-014 新增条款 | mcpp | +| §5.2 `mcppls status`(实例、动作、能力,`--root` 的规范化) | S3 新增一节 | mcppls | +| §6–§9 入口、用例、规则层、CLion 原生化 | 协议 2(`protocol/protocol.schema.json`)、`spec/test-frameworks.json`、`spec/commands.json` 的 `placements` | mcpp-ide | +| §19.8 文件映射 | openkal SPEC 新增可选接口 `openkal.map`(23.2) | openkal | + +openhal、openbus、opencaps、openas、openmui 不在本轮的依赖之内;xlings 不改。 + +### 23.2 文件映射的最小接口 + +openkal 新增一个可选接口 `openkal.map`(资源:一个文件的一段在地址空间中的映像;次版本,只增不改)。不并入 `openkal.space`: +openkal 的接口整体提供或整体不提供(§3),`openkal.space` 的资源是地址空间与在其中执行的上下文,一个能复制地址空间而没有文件的 +环境若因这一项而不能提供 `openkal.space`,是把两个资源绑在了一起。`openkal.map` 以 `openkal.fs` 的已打开文件为输入。 + +| 操作 | 语义 | +|---|---| +| `kal_map_file(file, offset, length, access, sharing, out)` | 把一个已打开文件的一段映射进地址空间;`offset` 是 `KAL_MAP_ALIGNMENT`(65536,各环境分配粒度的最大者,Windows 为 64 KiB)的整数倍,否则以 `kal_err_invalid` 拒绝;`access` 为只读或读写;`sharing` 为私有(写入不回到文件)或共享(写入回到文件,其他映射与读取可见) | +| `kal_map_release(region)` | 解除映射 | +| `kal_map_sync(region)` | 把共享映射的写入写回文件 | + +- 不提供可执行的文件映射:执行代码只经 `kal_exec_*`(W^X;§19.1 的 probe 中 Windows 拒绝可执行的文件映射)。 +- 共享区域(openbus 的 region、进程间的大数据)由"授权目录下的文件 + 共享映射"构成,不另设操作。 +- 映射之后文件被截短时,访问文件末页之外的部分是一次故障:程序以故障终止(Linux、macOS 的 SIGBUS,Windows 的页内错误),实现不以零页代替;访问是内存读写而不是调用,故没有可返回的错误值。末页中文件末尾之后的字节为零,与各环境一致。 +- 一致性测试覆盖:只读与读写、私有与共享、偏移对齐的拒绝、两个映射之间的可见性、同步之后另一进程读到写入、截短之后的访问以故障终止(子进程中进行)。 +- 级联与依赖见 §25。 + +### 23.3 openlifa 一轮(不在本轮,记录于 openlifa/openlifa#1) + +- 合入 openas 0.2、openmui 0.3 的 PR #1,并在 openlifa/openlifa 写出准入与维护准则:文本、版本、ABI、一致性、依赖、实现、平台、 + 许可证、流程、分发。 +- mcpp 的 SPEC-001 修订:"一个包、两个名字"。新描述文件以 `formerly` 声明旧身份,两个名字解析到同一规范身份,锁文件写规范身份并 + 两者都读,旧名永久有效;旧描述文件不变,老版本 mcpp 不受影响;C++ 的 `import` 名不变。 +- 规则发布后,把 `mcpplibs/openkal`(规范、`SURFACE.txt`、头文件、一致性测试)转移到 openlifa:GitHub 重定向旧地址与 tag 归档,sha256 + 不变,`mcpplibs/openkal` 之名此后不再使用;实现、openkal-image、openkal-platform 留在 mcpplibs。 +- openbus、opencaps 待有第二个实现;openhal 先把两个定义统一为一份规范;opensal 以后;openwx 留在产品中;xlings 本轮不改。 + +## 24. 第四轮多视角评审的处理(rev 6) + +| # | 视角 | 发现 | 处理 | +|---|---|---|---| +| 1 | 依赖 | 文件映射已定为本轮并行的级联,但没有最小接口,openkal、mcppls-clangd#12、#849 的数据嵌入都无从对齐 | 23.2 定下最小面与语义 | +| 2 | 跨平台 | Windows 的映射偏移必须是 64 KiB 的整数倍,其他系统是页大小 | 粒度取各环境最大者(23.2) | +| 3 | 安全 | 文件映射若允许执行,就绕过了 `kal_exec_*` 的 W^X | 不提供可执行的文件映射(23.2) | +| 4 | 一致性 | `polyglot` 与 `--pkg-format` 列表的关系未写;`single-file` 不进列表 | `polyglot` 同样不进列表(§19.6.2) | +| 5 | 体验 | `mcpp run --pkg-format polyglot` 在 POSIX 上直接执行会得到 ENOEXEC | 经 `sh` 启动(§19.6.2) | +| 6 | 分发 | `polyglot` 是否进 xlings 条目未定;xlings 的 shim 直接 execve 会遇到 ENOEXEC | `emit xpkg` 不接受 `polyglot`,xlings 用 `row` 或树(§19.6.2) | +| 7 | 兼容 | 信封的 `mcpp` 字段新增 `features` 是否破坏已有客户端 | docs/50 的信封只增不改,客户端忽略不认识的键;新增是兼容的 | +| 8 | 范围 | 双名字的机制若放进本轮,会让 mcpp 的 PR 多出一个与 part 2 无关的 SPEC 修订 | 放在 openlifa 一轮(23.3) | +| 9 | 交付风险 | #3 合入了 part 2 的全部 mcpp-ide 内容,体量大 | 维持检查点顺序(§14),每个检查点 CI 通过;0.9.0 的一致性门禁全程保留 | +| 10 | 可读性 | 文档按评审轮次累积,已定的条款分散 | §22、§23 汇总已定与待定;实现时以 §23.1 为规范清单 | + + +## 25. 实施计划与任务依赖(rev 7) + +### 25.1 各视角对计划的约束 + +| 视角 | 约束 | +|---|---| +| 架构 | 每个修复落在它所属的层;消费者(clangd、mcppls)不绕过下层的缺陷 | +| 稳定性 | openkal 家族以精确钉住逐层推进:一层 CI 通过并发布后,下一层才钉住它 | +| 简洁 | `openkal.map` 三个操作、一个常量;okrun 改为映射读入后删除整文件读入的路径 | +| 兼容与无感升级 | openkal 次版本只增不改;openkal-musl 以弱引用使用 `kal_map_*`,实现未提供时文件 `mmap` 仍为 `ENOSYS`,LLVM 照旧回退为读取,映像不因此要求新的 okrun | +| 跨平台 | 对齐取 64 KiB;一致性测试在 Linux、macOS、Windows 上运行 | +| 用户体验 | mcppls 的包形状不变,`clangd.okb` 只替换引擎(part 1 §9.4);IDE 插件与 mcppls 的契约不因引擎形式而变 | +| 测试覆盖 | 规范的一致性测试;musl 的 `mmap` 测试;okrun 的大映像常驻内存测试;clangd 四列 × 四系统 | + +### 25.2 任务与依赖 + +| 任务 | 仓库(PR) | 内容 | 依赖 | +|---|---|---|---| +| M | mcpp(新 PR,2026.10.31.1) | 检查点 ① `features` 与 `program`;② mcpp#868;③ §17;④ §11 与 docs/50 的 §4.0;⑤ `polyglot`;⑥ C1(§25.3) | — | +| K1 | openkal(0.18.0) | `openkal.map`:SPEC 条款、头文件与模块、`SURFACE.txt`、一致性测试 | — | +| K2 | openkal-linux、openkal-macos、openkal-windows | 实现 `openkal.map`;§25.3 定位到实现的修复 | K1 | +| K3 | openkal-image | okrun 以 `openkal.map` 读入映像与 okb(§25.3 B1) | K2 | +| K4 | openkal-musl | 文件 `mmap`、`munmap`、`msync` 经 `openkal.map`;§25.3 定位到 musl 的修复 | K1、K3(musl 钉住 openkal-ld) | +| K5 | openkal-llvm-runtime、openkal-platform、mcpplibs.os | 钉住新版本;platform 的版本组 | K4 | +| C | mcppls-clangd(0.0.18) | clangd 的 `x86_64-openkal`、`aarch64-openkal` 映像与 `clangd.okb`(§25.3) | K5;补丁与隔离实验不依赖,先行 | +| L | mcppls #75(0.0.20) | §5.2 `status`;§4.6;§10 D3–D8;引擎改为 `clangd.okb`(§25.3 的验收) | C | +| I | mcpp-ide #3(0.2.0) | §14 的检查点 ①–⑥;mcppls 0.0.20 单包依赖 | L 的发布(CLion 的 mcppls 依赖);开发不依赖 | +| X | mcpp-index、xim-pkgindex | openkal 家族的条目随级联逐个进入 mcpp-index(下游 CI 从已发布的索引解析,故不能合为一个 PR);xim-pkgindex 一个 PR:okrun、mcppls、mcppls-nvim、mcpp-ide、mcpp-ide-nvim | 各自的发布 | + +关键路径:K1 → K2 → K3 → K4 → K5 → C → L → I。M 与关键路径无依赖,最先发布,使 L 与 I 发布时即可用上 `features`。 + +### 25.3 clangd 的 ok 映像:阻塞项按层拆分 + +依据:mcppls-clangd#11 的 probe(run 40908632199)与本轮对 okrun、openkal-musl、openkal-linux 源码的核对。 + +| # | 阻塞项 | 事实 | 所属 | 做法 | 依赖 | +|---|---|---|---|---|---| +| B1 | 文件 mmap 与常驻内存(多 90–125 MiB) | (a) LLVM 对文件的 `mmap` 得到 `ENOSYS`,回退为读入;(b) okrun 的 `read_all` 以倍增读入整个文件(71.5 MB 的映像占 128 MiB 缓冲),映像运行期间不释放;(c) 映像的段复制进 `kal_exec` 区域 | (a) openkal 规范 → 三个实现 → openkal-musl;(b) openkal-image;(c) 不改,代码只经 `kal_exec_*` | (a) `openkal.map`,musl 的文件 `mmap` 经它;(b) okrun 映射映像文件,okb 中未压缩的成员直接在映射中装入,压缩成员解压到恰好大小的缓冲,装入后解除映射或释放;(c) 计入验收的基线 | K1 → K2 → K3、K4 | +| B2 | Windows 路径 | probe 第 8 条:路径风格在编译时决定,`C:\`、`C:/` 被当作相对名,资源目录找不到;第 12 条:默认三元组在编译时决定;第 9 条:形式正确的 `C:/…` 读取得到 `EINVAL`,发生在 LLVM 之下(openkal-musl 的 `root_length` 已认盘符) | 第 8、12 条:LLVM 补丁(mcppls-clangd);第 9 条:openkal-musl 或 openkal-windows | 补丁:`Style::native` 与默认三元组在运行时由 `kal_env_system` 决定;第 9 条以 probe 的 llvmprobe 映像在 Windows 上逐步(`open`、`fstat`、`read`、`lseek`、`MemoryBuffer::getFile`)隔离,修在所属仓库并加一致性测试 | 补丁无依赖;第 9 条的修复进入 K2 或 K4 | +| B3 | Linux 的 SIGSEGV | 两个架构都在解析 libstdc++ 头文件时崩溃;同一份 aarch64 映像在 macOS(SDK 的 libc++ 头文件)通过;openkal-linux 按请求的大小给任务栈(`stack_for`) | 未定:openkal-linux 或 okrun 的 Linux 部分;或与系统无关的一层(LLVM 补丁、openkal-musl、openkal-llvm-runtime) | 判别实验:在 macOS 上让同一映像解析 Linux 的 libstdc++ 头文件,崩溃则与系统无关,否则在 Linux 一侧;okrun 报告故障地址、所在任务与其栈界;修在所属仓库并加回归测试 | 修复进入 K2、K4 或 C | +| B4 | 模块 worker | probe 第 11 条:worker 直接运行 `.oke` 而崩溃;`getrlimit`、`setrlimit` 不可用 | LLVM 补丁(series 0017) | worker 经 `kal_env_loader` 以"加载器 + 映像"启动;openkal 没有地址空间上限,worker 的内存上限在 ok 映像上由监督方按常驻内存执行 | — | +| C1 | 链接输入 | probe 第 13 条:`[build] ldflags` 中的预构建归档不是链接输入,改动它不重新链接 | mcpp | 链接参数中存在的文件记为链接步骤的隐式输入 | — | +| C2 | 构建途径 | probe 第 14 条:约定从编译命令读出,工具链文件由 Python 生成 | mcppls-clangd | 约定取自 `${mcpp.contract}`;生成器是一个小的 mcpp 项目 | — | + +mcpplibs.os 不直接涉及:clangd 映像只用 openkal-musl 与 libc++;os 因精确钉住随 K5 升版本,mcppls 随之升级 os。 + +**验收**(mcppls-clangd#12 的完成条件,量化):四个系统(Linux x64、Linux arm64、macOS arm64、Windows x64)× 四列 +(`--version`、无头文件、`import m`、系统的 C++ 标准库)全部通过;解析时间不超过原生的 1.3 倍;常驻内存与原生之差不超过映像 +装入的段的大小(B1(c) 的固有代价)。mcppls 的包中,满足验收的系统使用 `clangd.okb`,引擎的选择按系统写在包的清单中; +不满足的系统保留原生 clangd 并在 mcppls 的文档中写明差距,不阻塞其余系统。 + +### 25.4 并行 + +| 等待 | 同时进行 | +|---|---| +| M 的 CI | K1 的规范与一致性测试;B2、B3 的隔离实验(mcppls-clangd 的 probe 分支,CI) | +| K1、K2 的 CI | M 的后续检查点;L 的 `status` 与 D3–D8 | +| K3–K5 的 CI 与索引发布 | C 的构建途径与 LLVM 补丁;I 的检查点 | +| C、L 的发布(各约 1.5 h) | I 的检查点;GitCode 镜像 | + +### 25.5 发布与验证 + +发布顺序:M(随即 gtc 补 GitCode、重新触发 main CI、xim-pkgindex 的 mcpp 条目)→ openkal 家族按 §25.2 的级联(每层发布后补 +GitCode、mcpp-index 条目)→ C → L → I → xim-pkgindex PR。之后在干净的 xlings 环境中验证:裸 `xlings install` 解析到新版本; +mcpp 新建、构建、运行、`--pkg-format polyglot`;mcppls 在四种编辑器入口中可用,`mcppls status` 的输出;mcpp-ide 的按项目版本 +感知(锁定版本的项目与未锁定的项目)。 diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 9c75a083e..3772791ac 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -342 records. +343 records. ## By subject @@ -69,6 +69,10 @@ Records that declare one. Everything else is listed by date below. - [生态设计 part 2:openkal 的最小原子与二进制映像、其上的 `mcpplibs.os`,以及 part 1 的收尾与回归记录](2026-10-26-ecosystem-part2-os-library-over-openkal-design.md) — active - [控制契约、并发与性能、单一 C++ 运行时,以及 IDE 生态(生态级,#838、#839)](2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md) — active +### editors + +- [生态设计 part 2:mcpp 插件与 mcppls 的分层、入口、测试、版本感知与分发](2026-10-30-ecosystem-part2-ide-and-language-service-design.md) — active + ### heterogeneous - [A dlopen surface no closure walks, and a process with two unwinders](2026-09-09-dlopen-surface-and-two-unwinders.md) — landed @@ -151,6 +155,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-10 +- [生态设计 part 2:mcpp 插件与 mcppls 的分层、入口、测试、版本感知与分发](2026-10-30-ecosystem-part2-ide-and-language-service-design.md) — active - [另一架构的 ok 映像的运行、测试与打包,以及 ok 映像与 Web 的关系(2026.10.29.1)](2026-10-29-openkal-run-test-pack-design.md) — active - [生态设计:目标集合与腿、打包模型 rev、运行覆盖、不回落、外部构建接入、mcppls 单包与编辑器](2026-10-29-ecosystem-target-sets-packaging-and-editors-design.md) — active - [平台 part 2:打包与分发——三维模型、通用包、单文件、okb、体积,以及 openkal 0.17.2 轮](2026-10-28-platforms-part2-packaging-and-distribution-design.md) — landed diff --git a/.github/tools/release_canaries.py b/.github/tools/release_canaries.py index 071acf273..dbf32b4cc 100644 --- a/.github/tools/release_canaries.py +++ b/.github/tools/release_canaries.py @@ -5,6 +5,7 @@ release_canaries.py matrix the GitHub Actions matrix, as `matrix=` release_canaries.py unpin remove the project's own mcpp pin from its checkout release_canaries.py run run a canary's commands in the current directory + release_canaries.py steps [] the longest build steps of the builds under `run` reads the candidate from `$MCPP`. Each command runs under bash with `set -eo pipefail`, so a failure is the command's own; a command listed under @@ -16,6 +17,11 @@ launcher, because the loader searches the system directory before PATH (the first release run of these canaries printed "Windows Subsystem for Linux has no installed distributions" for every command). + +After its commands, `run` prints the longest steps (compile or link, and the +output) of every build the canary made, read from each `.ninja_log` beneath the +current directory: a canary's time is mostly its project's build, and the steps +say where (design 2026-10-30 part 2 §11). """ from __future__ import annotations @@ -79,6 +85,46 @@ def shell_argv(command: str) -> list[str]: return [shell, "-c", f"set -eo pipefail\n{command}"] +def longest_steps(root: Path, count: int = 20) -> list[tuple[int, str, str]]: + """(milliseconds, build directory, output) of the longest steps in every + `.ninja_log` beneath `root`. A log holds every build of its directory; the + last line for an output is its latest step.""" + steps: list[tuple[int, str, str]] = [] + for log in root.rglob(".ninja_log"): + latest: dict[str, int] = {} + try: + lines = log.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + for line in lines: + if line.startswith("#"): + continue + fields = line.split("\t") + if len(fields) < 4 or not fields[0].isdigit() or not fields[1].isdigit(): + continue + latest[fields[3]] = int(fields[1]) - int(fields[0]) + where = str(log.parent.relative_to(root)) if log.parent != root else "." + steps += [(ms, where, out) for out, ms in latest.items()] + steps.sort(key=lambda s: s[0], reverse=True) + return steps[:count] + + +def print_steps(root: Path) -> None: + steps = longest_steps(root) + if not steps: + print(f"no .ninja_log beneath {root}") + return + print("::group::the longest build steps", flush=True) + for ms, where, out in steps: + print(f"{ms / 1000:9.1f} s {where} {out}") + print("::endgroup::", flush=True) + + +def cmd_steps(root: str) -> int: + print_steps(Path(root).resolve()) + return 0 + + def cmd_run(name: str) -> int: mcpp = os.environ.get("MCPP", "") if not mcpp: @@ -103,6 +149,7 @@ def cmd_run(name: str) -> int: want = expect.get(command) if want and want not in proc.stdout + proc.stderr: failed.append(f"`{command}` did not print `{want}`") + print_steps(Path.cwd()) for f in failed: print(f"::error::canary {name}: {f}") print(f"canary {name}: {len(c['commands']) - len(failed)} of {len(c['commands'])} command(s) held") @@ -119,6 +166,8 @@ def main(argv: list[str]) -> int: return cmd_unpin(argv[1]) if argv[0] == "run" and len(argv) == 2: return cmd_run(argv[1]) + if argv[0] == "steps" and len(argv) <= 2: + return cmd_steps(argv[1] if len(argv) == 2 else ".") print(__doc__) return 2 diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 0d7e39d58..709cd4dc6 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -443,6 +443,17 @@ jobs: grep -qxF "PASS: 988 the Windows launcher finds its package from a Windows path" "$log" \ || { echo "::error::988 did not print its PASS line"; exit 1; } + # A polyglot file (SPEC-014 §5.3) is the Windows program as it is: its + # criterion H runs the file under wine, which only this job has. + - name: "e2e: a polyglot file runs on every system it holds (992)" + run: | + set -o pipefail + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + log="$RUNNER_TEMP/992.log" + bash tests/e2e/992_a_polyglot_file_runs_on_every_system_it_holds.sh 2>&1 | tee "$log" + grep -qxF "ok: the file is a Windows program" "$log" \ + || { echo "::error::992 did not run the file under wine"; exit 1; } + # 257 needs a Linux-hosted MinGW compiler AND wine, which only this job # has; on the shards it skipped, and it had run on no runner (finding F9 # of the 2026-10-02 CI record). diff --git a/CHANGELOG.md b/CHANGELOG.md index 69dec8814..24150e453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,61 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.10.31.1] - 2026-10-31 + +### Added + +- **`features` in `mcpp --protocol-version`.** A client gates on a feature + rather than on a version number: `target-sets` and `pkg-format-list` name the + meanings 2026.10.30.1 introduced. A name is added when a client gates on it, + and is never renamed or removed (docs/50 §4). Every envelope's `mcpp` field + carries the same list beside the version, so one `self env` gives a client + both, and a job's envelope says when either has changed. +- **`program` in `mcpp self env --format json`**: the file that is running. A + tool that ran `mcpp` through a version manager's shim in a project's directory + reads which mcpp the shim chose for that project. docs/50 states how a tool + runs mcpp for a project: in its root, by the name on `PATH` or a set path, + with `XLINGS_PROJECT_DIR` removed (design 2026-10-30 part 2 §4.0). + +- **One file for several systems: `--pkg-format polyglot`** (SPEC-014 §5.3, + #849). An ok image's single files of every leg and system are assembled into + one `-.exe`: the Windows program leads with a POSIX shell + script in its DOS header, the other systems' programs follow unchanged. + Windows starts it as itself (Authenticode signs it); Linux and macOS start it + through `sh`, which copies the system's program to the single-file cache once, + checks its sha256 and executes it. `mcpp run --pkg-format polyglot` starts it + through `sh`; `mcpp emit xpkg` refuses it; it is not in a `--pkg-format` list + (e2e 992). +- **`mcpp run --pkg-format single-file`** runs the single file directly; it was + refused as an unknown format. `tar`, `zip` and `dir` are refused as containers + that are not programs. + +- **A tree's POSIX launcher names macOS's quarantine mark.** A package a browser + downloaded carries `com.apple.quarantine` on every file, and macOS refuses to + start an unsigned runner so marked; the launcher says so before it starts the + runner, with `xattr -dr com.apple.quarantine ` (SPEC-014 §4.3). + +### Fixed + +- **A workspace's lock entry is keyed the same way whatever members a command + plans** (#868). Two members spelling one dependency two ways + (`tinyhttps.workspace = true`, `[dependencies.mcpplibs] tinyhttps.workspace = + true`) made `-p` of each write its own key and hash. The key is the + workspace's spelling: its `[dependencies]`, `[workspace.dependencies]`, then + the members in the order `members` lists them (docs/07, e2e 990). +- **A file `[build] ldflags` names by absolute path is a link input.** A + replaced prebuilt archive did not relink the program that names it; now every + link that carries the word depends on the file (docs/04, e2e 991). +- **A partial caret or tilde requirement treats unwritten segments as + wildcards** (#866, NopeCharon): `^0` is `< 1.0.0`, `^0.0` is `< 0.1.0`, `~1` is + `< 2.0.0`. A bare requirement is a caret, so the bare partial spellings `"0"` + and `"0.0"` take the same bounds; before, they matched 0.0.0 alone (docs/05). +- **The TOML reader accepts every escape TOML 1.0 defines** (#867, NopeCharon): + `\b`, `\f`, `\uXXXX` and `\UXXXXXXXX`. The writer escapes every control + character in turn (`\uXXXX` where TOML has no short form), so a normalised + manifest that holds one is still TOML; `\0` is this reader's extension and is + never written. + ## [2026.10.30.1] - 2026-10-30 ### Added diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index cb6f32cb1..33208322f 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -594,6 +594,12 @@ jobs = "auto" # Concurrent compiles: a positive number, or " bmi_schedule = "auto" # Module-edge scheduling: auto (= off) | on | off (§ below) ``` +A word of `[build] ldflags` that names an existing file by absolute path (a +prebuilt archive, an object) is an input of every link that carries it: a +changed file relinks (mcpp 2026.10.31.1+). A relative word is read by the +linker against the build directory and is not tracked. + + #### Compile-flag syntax *(mcpp 2026.9.17.1+)* An element of `cflags`, `cxxflags` or `asmflags` stands for one or more compiler diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 6729a5c0b..b14600239 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -121,6 +121,13 @@ baz = "=1.2.3" # Exact match qux = ">=1.0, <2.0" # Range combination ``` +A bare requirement is a caret: `foo = "1.2.3"` is `^1.2.3`, except that a bare +version equal to a published key addresses that key exactly. A bare partial +spelling follows the caret's wildcard rule as well: `"0"` is `>= 0.0.0, < 1.0.0` +and `"0.0"` is `>= 0.0.0, < 0.1.0` (before 2026.10.31.1 both matched 0.0.0 +alone). The dependency conflict check and the address check of `--locked` read +requirements the same way. + ### `visibility` — whether a dependency's usage crosses this package's own boundary ```toml diff --git a/docs/07-workspace.md b/docs/07-workspace.md index 43afd0442..5c5a85416 100644 --- a/docs/07-workspace.md +++ b/docs/07-workspace.md @@ -712,7 +712,11 @@ myproject/ one, covers every member that has been built or configured. - `mcpp.lock` at the workspace root records the resolution of every member. `mcpp build --workspace` writes the whole record; `mcpp build -p X` updates - the entries of X's graph. + the entries of X's graph. An entry's key is the spelling under which the + workspace declares the dependency: its own `[dependencies]`, then + `[workspace.dependencies]`, then each member in the order `members` lists + them, whether the command selects it or not. The key and its hash therefore + do not depend on which members a command plans. - A member's build program writes to `/target/.build-mcpp/`. The members' build programs are compiled in the workspace's build programs' graph, `target/.build-mcpp/host/`, and a library several of them import is diff --git a/docs/10-pack-and-release.md b/docs/10-pack-and-release.md index cf144ad8a..5175d24e9 100644 --- a/docs/10-pack-and-release.md +++ b/docs/10-pack-and-release.md @@ -63,7 +63,7 @@ A pack is decided by three independent choices and a set of legs (2026.10.27.1+) |---|---|---|---| | linkage | `--linkage` | are the libraries inside the program or beside it | `static`, `shared` | | closure | `--mode` | what the machine that runs the package provides | `system`, `vendored` (default), `self-contained` | -| container | `--pkg-format` | what is delivered | `tar`, `zip`, `dir`, `single-file`, a format a package provides, or a list of containers of one tree (`tar,okb`); unstated: `zip` for a Windows program, `tar` elsewhere | +| container | `--pkg-format` | what is delivered | `tar`, `zip`, `dir`, `single-file`, `polyglot`, a format a package provides, or a list of containers of one tree (`tar,okb`); unstated: `zip` for a Windows program, `tar` elsewhere | | legs | `--target`, repeatable | which targets the pack covers | targets and target sets (`x86_64-openkal`, `openkal`, `linux-musl`) | | systems | `--systems` | which systems a self-contained package's runners, or single files, are for | `linux`, `linux-aarch64`, `all` | @@ -135,7 +135,9 @@ choices: | several images of a platform that states no `runner`, not as single files | one pack per target, or `--pkg-format single-file` | | `--target` outside the platform's targets | a target the platform serves | | a target set that expands to nothing, or whose rows select different platforms | a target, or another set | -| `dir` or `single-file` in a `--pkg-format` list; a list with `-o` naming one file; a list on `mcpp run` | one format, or `-o /` | +| `dir`, `single-file` or `polyglot` in a `--pkg-format` list; a list with `-o` naming one file; a list on `mcpp run` | one format, or `-o /` | +| `--pkg-format polyglot` for a program of one system (a native target) | `--pkg-format single-file`, or `--target openkal` | +| `mcpp run --pkg-format tar`, `zip` or `dir` | `--pkg-format single-file` or `polyglot`, or a format a package provides | | `--mode self-contained` of an image with `okb` as the only format | `--pkg-format tar,okb`: okb holds the images alone, and the runner would be dropped | | a tree that runs nowhere (`runs_on` empty) | `--mode self-contained --systems ` | | a dispatched format for a `lib` or `shared` target | the library route, [12](12-binary-distribution.md) | @@ -910,6 +912,36 @@ A WebAssembly target has no single file: `--pkg-format single-file` with `--target wasm32-emscripten` is refused before anything compiles (2026.10.29.1+), and its page is the `dist-web` provider's format. +### One file for several systems (`polyglot`, mcpp 2026.10.31.1+) + +`--pkg-format polyglot` assembles an image's single files of every leg and +system (`--systems all` unless `--systems` says otherwise) into one file, +`target/dist/-.exe`: + +```bash +mcpp pack --target openkal --pkg-format polyglot -o dist/ +# Runs on linux-x86_64, windows-x86_64, linux-aarch64, macos-aarch64 +``` + +The Windows program leads, with a POSIX shell script in its DOS header; the +other systems' programs follow it unchanged. Windows starts the file as the +program it is, and Authenticode signs it. A Linux or macOS system starts it +through a shell — `sh pg-0.1.0.exe`, or `./pg-0.1.0.exe` typed in a shell, which +runs a file the kernel refuses as a script; a program that calls `execve` on it +directly gets `Exec format error`. The script copies this system's program to +the single-file cache, `/mcpp/single-file//`, +with the rules of a native single file (a name of its own, `.mcpp-complete`, +rename without replacing, the user's directories only), checks its sha256 there +(`sha256sum`, `shasum` or `openssl`), and executes it; later runs start it from +the cache. A system the file holds no program for exits with status 126 and +names the systems it holds. Without `windows` among `--systems` the file is a +script (`#!/bin/sh`) the system executes directly, named without `.exe`. + +`polyglot` is for a download a person runs. It is not in a `--pkg-format` list, +a native target is refused (its single file is one system's), `mcpp run +--pkg-format polyglot` starts it through `sh`, and `mcpp emit xpkg` refuses it: +an xlings entry installs one system's package, the tree or the single files. + ### `mcpp-package.json` Every staged tree carries `mcpp-package.json`: the package, its platform, its @@ -964,7 +996,10 @@ inside an okb are stored so that the loader maps them in place. `[pack] sign = ["", …, "{}"]` runs once on each program, image, library and loader of the staged tree and on the Windows launcher before the container is written, and on each -single file; `{}` is the file. A failure stops the pack, naming the argv and its +single file; `{}` is the file. A polyglot file's programs are signed one by one +before they are assembled, except the Windows program, and the assembled file +is signed last: Authenticode appends its certificate, which leaves every byte +and offset before it as it was. A failure stops the pack, naming the argv and its output, and `mcpp-package.json` states the digests of the signed files. ## Configuration @@ -1012,8 +1047,9 @@ The `static` mode additionally requires a musl toolchain configured under - Distribution formats such as `.deb`, `.rpm`, AppImage and `.msi` come from packages through `--pkg-format `; the engine provides `tar`, `dir`, `zip` and `single-file`. -- One file that runs on several systems is not produced; a single-file package - serves one system and one architecture. +- A polyglot file holds the systems and architectures this mcpp builds a + single file for (today Linux x86_64 and aarch64, macOS aarch64, Windows + x86_64), and only for ok images; a POSIX system starts it through a shell. - A WebAssembly package is neither a single file nor an xlings entry; its page is a provider's format (`dist-web`). - An ELF program's closure is resolved by running its dynamic linker, so a Linux diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index 69ee1cb82..35a3517e2 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -45,7 +45,8 @@ Every enveloped response has this shape: "effects": [], // what running the command did — see §4 "mcpp": { "version": "2026.8.8.3", - "protocol": { "min": 1, "max": 1 } + "protocol": { "min": 1, "max": 1 }, + "features": ["target-sets", "pkg-format-list"] // since 2026.10.31.1 — see §4 }, "data": { /* specific to `kind` */ }, "diagnostics": [] @@ -200,7 +201,8 @@ mcpp --protocol-version "self env": { "effects": [] }, "xpkg parse": { "effects": [] }, "cache list": { "effects": [] } - } + }, + "features": ["target-sets", "pkg-format-list"] } ``` @@ -225,6 +227,34 @@ listed whenever the run started an index refresh, an install or a git remote operation, including one that failed or was stopped by its bound, and never for a run under `--offline`. +### Features + +`kinds` say which schemas the output follows and `commands` which commands exist +and what they do. `features` name meanings inside commands that already exist, +which a client turns something on or off by. A client gates on a feature rather +than on a version number. A name is added when a client gates on it, and it is +never renamed or removed. A mcpp older than the list has no `features`; a client +holds a table of the released versions before it, and nothing after it. + +The same list is in every envelope's `mcpp` field (§2). A client that runs one +`self env` for a project learns its version and its features at once, and a job's +envelope says when either has changed since. + +| feature | since | meaning | read by | +|---|---|---|---| +| `target-sets` | 2026.10.31.1 (meaning from 2026.10.30.1) | `--target openkal` and the other sets expand to their legs (SPEC-015 §1) | an IDE's target picker | +| `pkg-format-list` | 2026.10.31.1 (meaning from 2026.10.30.1) | `--pkg-format tar,okb` writes each container of one tree (SPEC-014 §5.4) | an IDE's pack form | + +### How a tool runs mcpp for a project + +A tool that runs mcpp for a project (an IDE backend, a language server, a CI +script) runs it in the project's root directory, by the name `mcpp` found on +`PATH` (a version manager's shim) or by a path the user set, with +`XLINGS_PROJECT_DIR` removed from its environment. The mcpp that answers is then +the one the project uses: a project that pins a version selects it by its +directory, and no tool reads the pin itself. `mcpp self env --format json` +reports that mcpp's `mcppVersion` and its `program`, the file that is running. + ## 5. `--json` is not `--format json` Two commands shipped a `--json` flag before this protocol existed: @@ -322,6 +352,7 @@ mcpp self env --format json "config": "/home/u/.mcpp/config.toml", "buildCache": "/home/u/.mcpp/build-cache/v1", "mcppVersion": "2026.8.8.3", + "program": "/home/u/.mcpp/registry/data/xpkgs/xim-x-mcpp/2026.10.31.1/bin/mcpp", // 2026.10.31.1+ "defaultToolchain": "gcc@16.1.0", // 2026.9.28.2+ "engineModules": "/home/u/.mcpp/engine/2026.10.11.1/modules" // 2026.10.11.1+ } diff --git a/docs/specs/README.md b/docs/specs/README.md index 7efdf98a2..9427707a1 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -43,7 +43,7 @@ | [SPEC-011](concurrency.md) | 并发运行:三段、构建目录锁、原子发布、`mcpp.lock` 的乐观写入、取消 | 评审中 v1.0 | 2026-10-26 | mcpp >= 2026.10.26.1 | | [SPEC-012](human-output.md) | 面向人的输出:两条流、两种介质、行的种类、颜色 | 评审中 v1.0 | 2026-10-26 | mcpp >= 2026.10.26.1 | | [SPEC-013](platforms.md) | 平台:`[platform]` 的七组键、版本组与 `hosted`,三种写法与层次,目标图与宿主图的选择,`cfg(platform)`,依赖图的补全,链接、命名与封装,运行器与载体,平台阶段与协议 17 | 评审中 v1.2 | 2026-10-29 | mcpp >= 2026.10.27.1;§10 的执行宿主与 §11 的载体架构 mcpp >= 2026.10.29.1 | -| [SPEC-014](packaging.md) | 打包与分发:链接、闭包、容器三个维度与构建腿,腿的合并,暂存树与 `mcpp-package.json`,平台的树、运行覆盖、单文件、容器列表、签名、压缩、合并与分发描述 | 评审中 v2.0 | 2026-10-30 | mcpp >= 2026.10.30.1;逐条标注 | +| [SPEC-014](packaging.md) | 打包与分发:链接、闭包、容器三个维度与构建腿,腿的合并,暂存树与 `mcpp-package.json`,平台的树、运行覆盖、单文件、polyglot、容器列表、签名、压缩、合并与分发描述 | 评审中 v2.1 | 2026-10-31 | mcpp >= 2026.10.31.1;逐条标注 | | [SPEC-015](target-sets.md) | 目标集合与腿:集合的写法与展开,命令对多条腿的语义,串行规划与一起构建,测试归档,机器输出 | 评审中 v1.0 | 2026-10-30 | mcpp >= 2026.10.30.1 | ## 文档约定 diff --git a/docs/specs/packaging.md b/docs/specs/packaging.md index 7b5d135d4..bbf72fc11 100644 --- a/docs/specs/packaging.md +++ b/docs/specs/packaging.md @@ -4,11 +4,11 @@ |---|---| | 规范编号 | SPEC-014 | | 标题 | 打包与分发:链接、闭包、容器三个维度与构建腿,腿的合并,暂存树与包元数据,运行覆盖、单文件、容器列表、签名、压缩、合并与分发描述 | -| 状态 | 评审中 v2.0 | -| 版本 | 2.0 | -| 最后修改 | 2026-10-30 | -| 对应实现 | mcpp >= 2026.10.30.1 | -| 相关设计文档 | `.agents/docs/2026-10-29-ecosystem-target-sets-packaging-and-editors-design.md`(§4、§13)、`.agents/docs/2026-10-28-platforms-part2-packaging-and-distribution-design.md`(§2–§7、§14)、`.agents/docs/2026-10-28-platforms-design.md`(§4.7)、`.agents/docs/2026-10-29-openkal-run-test-pack-design.md`(§2.2、§2.6) | +| 状态 | 评审中 v2.1 | +| 版本 | 2.1 | +| 最后修改 | 2026-10-31 | +| 对应实现 | mcpp >= 2026.10.31.1 | +| 相关设计文档 | `.agents/docs/2026-10-30-ecosystem-part2-ide-and-language-service-design.md`(§19.6)、`.agents/docs/2026-10-29-ecosystem-target-sets-packaging-and-editors-design.md`(§4、§13)、`.agents/docs/2026-10-28-platforms-part2-packaging-and-distribution-design.md`(§2–§7、§14)、`.agents/docs/2026-10-28-platforms-design.md`(§4.7)、`.agents/docs/2026-10-29-openkal-run-test-pack-design.md`(§2.2、§2.6) | | 相关 issue | mcpp#847、mcpp#849、mcpp#860 | | 相关规范 | [SPEC-007](build-plugins.md)(包格式提供者、artifact action)、[SPEC-011](concurrency.md)(原子发布)、[SPEC-013](platforms.md)(平台、运行器、载体)、[SPEC-015](target-sets.md)(目标集合与腿) | | 使用文档 | [10 —— 发布打包](../zh/10-pack-and-release.md) | @@ -32,7 +32,7 @@ ok 映像是 x86_64 与 aarch64 上位置无关的 ELF,只导入 `kal_*`,运行 |---|---|---|---| | 链接 `--linkage` | `static`、`shared` | 库在程序里,还是在程序旁边 | 程序属性 | | 闭包 `--mode` | `system`、`vendored`、`self-contained` | 运行的机器要提供什么 | 打包属性 | -| 容器 `--pkg-format` | `dir`、`tar`、`zip`、`single-file`,提供者的格式,或一棵树的容器列表(§5.4) | 交付成什么 | 打包属性 | +| 容器 `--pkg-format` | `dir`、`tar`、`zip`、`single-file`、`polyglot`,提供者的格式,或一棵树的容器列表(§5.4) | 交付成什么 | 打包属性 | | 腿 `--target`(可重复) | 目标或目标集合(SPEC-015) | 一次打包覆盖哪些目标 | 打包属性 | | 系统 `--systems` | ``、`-`、`all` | 自带的运行器与单文件为哪些系统而做 | 打包属性 | @@ -68,6 +68,7 @@ openkal 目标上 `static` 为一个 `.oke`、无 `.okso`,`shared` 为 `.oke` | `tar` | 一个归档,单一顶层目录与归档同名;缺省压缩为 gzip(§5.1) | | `zip` | 一个 ZIP 归档,单一顶层目录;条目以 DEFLATE 压缩,`--compress none` 时存储(§5.1) | | `single-file` | 一个可执行文件(§5.2) | +| `polyglot` | 映像的各系统单文件组装成的一个文件(§5.3) | | 其他名字 | 依赖图中的提供者以 `pack-format` 声明的格式(§6) | | 逗号分隔的列表 | 同一棵暂存树的各个容器,依次写出(§5.4) | @@ -113,7 +114,9 @@ openkal 目标上 `static` 为一个 `.oke`、无 `.okso`,`shared` 为 `.oke` | 平台未声明 `runner` 的映像,多条腿,且不是单文件 | 拒绝:多条映像腿合并为一个包只经平台的运行器;出路每腿一次打包,或 `--pkg-format single-file` | | 腿不是所选平台的目标 | 拒绝 | | 目标集合展开为空,或候选选到不同平台 | 拒绝(SPEC-015 §1) | -| `--pkg-format` 列表含 `dir` 或 `single-file`;列表与命名一个文件的 `-o`;`mcpp run --pkg-format` 的列表 | 拒绝(§5.4) | +| `--pkg-format` 列表含 `dir`、`single-file` 或 `polyglot`;列表与命名一个文件的 `-o`;`mcpp run --pkg-format` 的列表 | 拒绝(§5.4) | +| `--pkg-format polyglot` 而目标不是 ok 映像(一个系统的程序) | 拒绝:其单文件只属于一个系统;出路 `--pkg-format single-file` 或 `--target openkal` | +| `mcpp run --pkg-format` 为 `tar`、`zip` 或 `dir` | 拒绝:它们不是程序;出路 `single-file`、`polyglot` 或提供者的格式 | | `self-contained` 的树而格式只有 `okb` | 拒绝:okb v1 只含映像,运行器会被丢弃;出路 `--pkg-format tar,okb` | | 树的 `runs_on` 为空(`self` 与 `with` 都空,§4.7) | 拒绝 | | `--merge` 的输入缺少 `mcpp-package.json`,或名字、版本、平台、链接、模式不同 | 拒绝,写出不一致的字段 | @@ -206,7 +209,8 @@ openkal 目标上 `static` 为一个 `.oke`、无 `.okso`,`shared` 为 `.oke` 时映像追加在末尾;其余词原样传递,随后是启动器自己的参数;运行器先在 `libexec//-/` 中找, 再找 `PATH`。 - POSIX 启动器把每个词写成单引号字符串,沿符号链接找到自身的位置(不依赖 `readlink -f`);`uname -s` 为 `MINGW*`、 - `MSYS*`、`CYGWIN*` 时交给 `bin/.exe`。 + `MSYS*`、`CYGWIN*` 时交给 `bin/.exe`。macOS 上运行器带有 `com.apple.quarantine`(浏览器下载的包)时,启动器在启动它之前写出 + 这一点与去除标记的命令(`xattr -dr com.apple.quarantine <包>`)(2026.10.31.1)。 - Windows 启动器是一个程序:源码随引擎提供,写明运行器的词与入口映像的名字,在打包时为 `x86_64-windows-gnu` 构建(平台的托管安排服务该行时以它构建);它从自身路径(`GetModuleFileNameW`)找到包,按本机架构选 `lib//`,包内没有该架构时写出包含的架构。它不含腿的列表,因而对任一组腿相同。本 mcpp 不构建该行时包中 @@ -342,18 +346,38 @@ openkal 目标的单文件:载体携带一条腿的映像本身(映像不引用 系统(缺省为本机系统)各写一个文件 `---`(Windows 上带 `.exe`),即覆盖"腿 × 系统"的最少 载体文件;多于一个文件时 `-o` 只能是目录。`--systems all` 略去本 mcpp 不构建的行,各写为未覆盖。运行时不写盘。 -### 5.3 一个文件在多个系统上运行 - -一个文件同时在多个系统与架构上运行(APE)不在本规范内(mcpp#849)。 +### 5.3 一个文件在多个系统上运行(`polyglot`) + +单文件的载体模型(SPEC-013 §11)有两种:`row`(§5.2,每个 `-` 一个原生程序,运行时不写盘,能被直接 execve) +为缺省;`polyglot` 以 `--pkg-format polyglot`(或 `[pack] format`)选择(mcpp#849)。 + +1. **范围**:只用于 ok 映像的目标;它把 §5.2 的映像单文件(每条腿 × `--systems` 的每个系统;未给出 `--systems` 与 + `[pack] systems` 时为 `all`)组装为一个文件 `-.exe`;不含 Windows 程序时为 `-`。 + `runs_on.self` 为各单文件的 `-` 之并;本 mcpp 不构建的行写为未覆盖(§4.7)。 +2. **布局**:至多一个 Windows 程序(PE32+,未签名)在前:其 DOS 头写为 `MZqFpD='` 开头的 shell 赋值,PE 头按文件对齐 + 后移,节表、`SizeOfHeaders`、符号表与调试目录中的文件偏移随之修正,PE 本身的字节不变;其余系统的单文件原样依次附在 + PE 之后;最后是 POSIX 脚本。头部的脚本只读取文件末尾的脚本并求值。不含 Windows 程序时文件以 `#!/bin/sh` 开头,其余相同。 +3. **启动**:Windows 把文件作为 PE 启动。POSIX 上文件**不能**被直接 execve(ENOEXEC),经 `sh <文件>` 启动,或由把被拒绝的 + 文件作为脚本运行的 shell 启动。脚本按 `uname -s`、`uname -m` 选出本系统的程序;文件不含它时以状态 126 退出并写出所含的 + 系统。 +4. **缓存**:脚本把本系统的程序写入 §5.2 第 3、4 条的缓存目录 `<缓存根>/mcpp/single-file/<程序的 sha256>/`,规则相同: + 临时名 `.<摘要>-<进程号>`、写完后写入 `.mcpp-complete`、不替换地改名到位、另一次运行先到位时使用它的结果、 + `mcpp/` 与 `single-file/` 与摘要目录为符号链接或不属于当前用户时拒绝、`single-file/` 只对当前用户可写。写入后**必须** + 以 `sha256sum`、`shasum -a 256` 或 `openssl dgst -sha256` 校验,不符或无校验工具时不放置。之后的运行直接执行缓存中的 + 程序。脚本自身失败时写出原因并以 127 退出。`row` 的"运行时不写盘"不变。 +5. **签名**(§7):各系统的程序在组装前各自签名(Windows 程序除外),组装后的文件最后签名;Authenticode 把证书附在文件末尾, + 之前的字节与偏移不变,POSIX 上照常运行。 +6. **与其他选项**:`polyglot` 不进 `--pkg-format` 列表(§5.4);`--compress` 与 `--mode` 的规则同 `single-file`; + `mcpp run --pkg-format polyglot` 在 POSIX 上经 `sh` 启动它;`mcpp emit xpkg` 拒绝它(§8)。 ### 5.4 容器列表 `--pkg-format`(与 `[pack] format`)可以是逗号分隔的列表,如 `tar,okb`:一次构建、一棵暂存树,依次写出各容器。 -- 列表只能含读同一棵暂存树的容器:`tar`、`zip` 与提供者的格式;`dir`、`single-file` 不进列表。 +- 列表只能含读同一棵暂存树的容器:`tar`、`zip` 与提供者的格式;`dir`、`single-file`、`polyglot` 不进列表。 - 列表时 `-o` 只能是目录(§1.4)。`--compress` 只作用于列表中的引擎容器;列表中没有引擎容器时拒绝。 - `self-contained` 的树而列表只有 `okb` 时拒绝(okb v1 只含映像)。okb 含哪些架构由腿决定。 -- `mcpp run --pkg-format` 不接受列表。 +- `mcpp run --pkg-format` 不接受列表;`single-file` 与 `polyglot` 直接运行(`polyglot` 在 POSIX 上经 `sh`),`tar`、`zip`、`dir` 被拒绝。 ## 6. 包格式提供者 @@ -371,7 +395,7 @@ openkal 目标的单文件:载体携带一条腿的映像本身(映像不引用 ## 7. 签名 `[pack] sign = ["", ..., "{}"]` 在写容器之前对暂存树中角色为 `program`、`image`、`library`、`loader` 的每个 -文件与 Windows 启动器(`bin/.exe`)运行一次,也对每个单文件运行一次,`{}` 为文件路径;一个包格式提供者或构建插件**可以**代替它签名。任一次失败 +文件与 Windows 启动器(`bin/.exe`)运行一次,也对每个单文件运行一次,`{}` 为文件路径;`polyglot` 的顺序见 §5.3 第 5 条;一个包格式提供者或构建插件**可以**代替它签名。任一次失败 使打包失败,写出 argv 与输出。签名后 `mcpp-package.json` 重新计算摘要。 ## 8. 分发描述 @@ -397,6 +421,9 @@ openkal 目标的单文件:载体携带一条腿的映像本身(映像不引用 `bin/.exe`;单腿原生包为程序所在的目录)。各系统的包把程序放在不同目录时(Windows 的程序在包根,POSIX 的程序在 `bin/`,包根为便利脚本),条目按系统写出各自的目录(`bindir[os.host()]`)。 +- `polyglot` 文件(§5.3)被拒绝,消息写出它是 polyglot 文件与出路(树或单文件):条目安装一个系统的包,xlings 直接启动 + 所安装的程序,POSIX 系统不直接执行该文件。 + 不带 `--package` 时 `mcpp emit xpkg` 照旧生成源码包的描述。 ## 9. 机器输出 @@ -436,6 +463,10 @@ openkal 目标的单文件:载体携带一条腿的映像本身(映像不引用 `self-contained` 只配 `okb` 被拒绝(e2e 985)。 12. 映像的单文件覆盖腿 × 系统;`--systems all` 写出未覆盖的组合;多个文件与一个文件的 `-o` 被拒绝(e2e 986)。 13. `--no-universal`、`[pack] universal`、`[platform] universal` 按 §2 迁移;`mcpp pack --help` 分组(e2e 987)。 +14. `--target openkal --pkg-format polyglot` 写出一个 PE,经 `sh` 运行本机的程序;首次运行放置一个程序与 + `.mcpp-complete`,之后不再放置;损坏的文件与作为链接的缓存目录以 127 拒绝;不含 Windows 时为可直接执行的脚本; + `mcpp run --pkg-format polyglot` 经 `sh` 运行;列表中的 `polyglot`、原生目标、`mcpp run --pkg-format tar`、 + `mcpp emit xpkg` 被拒绝;有 wine 时同一文件作为 Windows 程序运行(e2e 992)。 ## 变更记录 @@ -446,3 +477,4 @@ openkal 目标的单文件:载体携带一条腿的映像本身(映像不引用 | 1.2 | 2026-10-28 | 包名的模式后缀;单文件启动器自身失败的退出状态 127;`--merge` 取 `[pack] format`,合并后的目录以输出命名;`--linkage static` 改为 musl 的行;`mcpp emit xpkg --package` 的每系统程序目录、带命名空间的运行器依赖、以归档名定位的顶层目录与多余词的拒绝。 | | 1.3 | 2026-10-29 | mcpp 2026.10.29.1:§0 ok 映像与 Web;§1.2 单腿 `self-contained` 包的载体为映像架构的程序;§1.5、§5.2 WebAssembly 目标上的单文件被拒绝;§4.5 单腿 `self-contained` ok 映像包的 `systems` 记录其载体;§6 `--list-formats` 规划所选目标;§8 单腿包按其程序运行的系统与架构描述(系统的三元组,或 ok 映像的载体),程序不是任何系统的程序的被拒绝;判据 8、9(e2e 961、962)。 | | 2.0 | 2026-10-30 | mcpp 2026.10.30.1(设计 2026-10-29 §4、§13):§1.4 腿只是命令命名的目标(目标集合按 SPEC-015 展开),腿按目标的种类与平台合并,原生程序每腿一个包,`-o <目录>/`;删除通用包与 `[platform] universal`、`[pack] universal`、`--no-universal` 的作用(迁移见 §2、docs/51);§1.2、§4.2 声明运行器的平台的映像只有一种布局(树),M3 只属于单文件,`--mode static` 为静态树;§4.3 Windows 启动器为引擎的程序,不再生成 `.cmd`,启动器为派生文件;§4.4 `--systems` 接受 ``、`-`、`all` 与 `[pack] systems`;§4.6 合并重新生成启动器、合并 `runs_on`、树以平台名命名;§4.7 运行覆盖 `runs_on` 与 `Runs on`、未覆盖的摘要,空覆盖被拒绝;§5.2 单文件覆盖腿 × 系统;§5.4 容器列表;§7 签名含 Windows 启动器;§8 条目按 `runs_on` 描述;§9 `pkgFormats`、`runsOn`;判据 3、4、7、9 改写,判据 10–13(e2e 983–987)。 | +| 2.1 | 2026-10-31 | mcpp 2026.10.31.1(设计 2026-10-30 part 2 §19.6):§5.3 载体模型 `row` 与 `polyglot`,`--pkg-format polyglot` 的范围、布局、启动、缓存、签名与其他选项;§1.3、§1.5、§5.4、§7、§8 相应条目;`mcpp run --pkg-format` 直接运行 `single-file` 与 `polyglot`、拒绝 `tar`、`zip`、`dir`;§4.3 macOS 上带 quarantine 标记的运行器;判据 14(e2e 992)。 | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 3dc863887..ae9d279d4 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -565,6 +565,10 @@ jobs = "auto" # Concurrent compiles: a positive number, or " bmi_schedule = "auto" # Module-edge scheduling: auto (= off) | on | off (§ below) ``` +`[build] ldflags` 中以绝对路径指向一个已存在文件的词(预构建的归档、目标文件)是带有它的每次链接的输入:文件改变即重新 +链接(mcpp 2026.10.31.1+)。相对路径的词由链接器相对构建目录读取,不跟踪。 + + #### 编译 flag 的写法 *(mcpp 2026.9.17.1+)* `cflags`、`cxxflags` 或 `asmflags` 里的一个元素代表一个或多个编译器 diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 70a964b4b..a660ecd5f 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -111,6 +111,10 @@ baz = "=1.2.3" # Exact match qux = ">=1.0, <2.0" # Range combination ``` +不带运算符的要求即 caret:`foo = "1.2.3"` 等于 `^1.2.3`;唯一的例外是不带运算符的版本恰为已发布的键时,它精确地指向该键。 +不带运算符的部分写法同样按 caret 的通配规则:`"0"` 为 `>= 0.0.0, < 1.0.0`,`"0.0"` 为 `>= 0.0.0, < 0.1.0`(2026.10.31.1 之前 +两者都只匹配 0.0.0)。依赖冲突检查与 `--locked` 的地址核对以同样的方式读取要求。 + ### `visibility` —— 一个依赖的用法是否跨越本包自己的边界 ```toml diff --git a/docs/zh/07-workspace.md b/docs/zh/07-workspace.md index 81fdafeb8..942ab2c95 100644 --- a/docs/zh/07-workspace.md +++ b/docs/zh/07-workspace.md @@ -611,7 +611,8 @@ myproject/ - 放入多个产物目录的共享库,在支持硬链接的文件系统上是同一个文件的多个名字,其他情况下被复制。 - 工作空间根的编译数据库(在 `[build] compile_commands` 指定时)覆盖所有已构建或已配置的成员。 - 工作空间根的 `mcpp.lock` 记录全体成员的解析结果。`mcpp build --workspace` 写入完整记录; - `mcpp build -p X` 更新 X 所在图中的条目。 + `mcpp build -p X` 更新 X 所在图中的条目。条目的键取工作空间声明该依赖时的写法:先看自身的 `[dependencies]`,再看 + `[workspace.dependencies]`,再按 `members` 列出的顺序看各成员,无论命令是否选中它。键与其散列因此不随命令规划哪些成员而变。 - 成员的构建程序写入 `/target/.build-mcpp/`。各成员的构建程序在 workspace 的构建程序图 `target/.build-mcpp/host/` 中编译,多个程序 import 的库只编一次。 - 早期版本的 mcpp 在成员自己的 `target/` 下留下的构建目录不再被读取;`mcpp clean --stale` diff --git a/docs/zh/10-pack-and-release.md b/docs/zh/10-pack-and-release.md index b71a511cd..7415864d9 100644 --- a/docs/zh/10-pack-and-release.md +++ b/docs/zh/10-pack-and-release.md @@ -57,7 +57,7 @@ glibc 专有行为(NSS 查询、`dlopen` 宿主插件)时的首选。 |---|---|---|---| | 链接 | `--linkage` | 库在程序里,还是在程序旁边 | `static`、`shared` | | 闭包 | `--mode` | 运行该包的机器提供什么 | `system`、`vendored`(默认)、`self-contained` | -| 容器 | `--pkg-format` | 交付成什么 | `tar`、`zip`、`dir`、`single-file`、某个包提供的格式,或一棵树的容器列表(`tar,okb`);未给出时 Windows 程序为 `zip`,其余为 `tar` | +| 容器 | `--pkg-format` | 交付成什么 | `tar`、`zip`、`dir`、`single-file`、`polyglot`、某个包提供的格式,或一棵树的容器列表(`tar,okb`);未给出时 Windows 程序为 `zip`,其余为 `tar` | | 腿 | `--target`,可重复 | 打包覆盖哪些目标 | 目标与目标集合(`x86_64-openkal`、`openkal`、`linux-musl`) | | 系统 | `--systems` | 自带的运行器或单文件为哪些系统而做 | `linux`、`linux-aarch64`、`all` | @@ -120,7 +120,9 @@ glibc 专有行为(NSS 查询、`dlopen` 宿主插件)时的首选。 | 未声明 `runner` 的平台的多条映像腿,且不是单文件 | 每个目标打一次,或 `--pkg-format single-file` | | 平台目标之外的 `--target` | 平台服务的目标 | | 展开为空的目标集合,或其行选到不同平台 | 一个目标,或另一个集合 | -| `--pkg-format` 列表含 `dir` 或 `single-file`;列表与命名一个文件的 `-o`;`mcpp run` 的列表 | 一个格式,或 `-o <目录>/` | +| `--pkg-format` 列表含 `dir`、`single-file` 或 `polyglot`;列表与命名一个文件的 `-o`;`mcpp run` 的列表 | 一个格式,或 `-o <目录>/` | +| 一个系统的程序(原生目标)使用 `--pkg-format polyglot` | `--pkg-format single-file`,或 `--target openkal` | +| `mcpp run --pkg-format tar`、`zip` 或 `dir` | `--pkg-format single-file` 或 `polyglot`,或某个包提供的格式 | | 映像的 `--mode self-contained` 而格式只有 `okb` | `--pkg-format tar,okb`:okb 只含映像,运行器会被丢弃 | | 在任何地方都不能运行的树(`runs_on` 为空) | `--mode self-contained --systems <本 mcpp 构建的系统>` | | 对 `lib` 或 `shared` 目标使用被分派的格式 | 库的路线,[12](12-binary-distribution.md) | @@ -769,6 +771,27 @@ ok 映像的文件为 `target/dist/---`(Windows 上 WebAssembly 目标没有单文件:`--pkg-format single-file` 与 `--target wasm32-emscripten` 在任何编译之前被拒绝 (2026.10.29.1+),它的页面是 `dist-web` 提供者的格式。 +### 一个文件对应多个系统(`polyglot`,mcpp 2026.10.31.1+) + +`--pkg-format polyglot` 把映像的每条腿与每个系统的单文件(未给 `--systems` 时为 `all`)组装成一个文件 +`target/dist/-.exe`: + +```bash +mcpp pack --target openkal --pkg-format polyglot -o dist/ +# Runs on linux-x86_64, windows-x86_64, linux-aarch64, macos-aarch64 +``` + +Windows 程序在前,其 DOS 头中是一段 POSIX shell 脚本;其余系统的程序原样附在其后。Windows 把该文件作为程序本身启动, +Authenticode 可以签名它。Linux 与 macOS 经 shell 启动它:`sh pg-0.1.0.exe`,或在 shell 中输入 `./pg-0.1.0.exe`(shell +把内核拒绝执行的文件作为脚本运行);直接以 `execve` 启动它的程序得到 `Exec format error`。脚本把本系统的程序复制到单文件 +缓存 `/mcpp/single-file/<程序的 sha256>/`,规则与原生单文件相同(独立的临时名、`.mcpp-complete`、不替换的改名、 +只用本用户的目录),在缓存中校验其 sha256(`sha256sum`、`shasum` 或 `openssl`)后执行;之后的运行从缓存启动。文件中没有 +本系统的程序时以状态 126 退出,并写出它包含的系统。`--systems` 不含 `windows` 时文件是一个脚本(`#!/bin/sh`),系统可直接 +执行,文件名不带 `.exe`。 + +`polyglot` 是供人下载运行的形式:它不进 `--pkg-format` 列表;原生目标被拒绝(其单文件只属于一个系统);`mcpp run +--pkg-format polyglot` 经 `sh` 启动它;`mcpp emit xpkg` 拒绝它(xlings 的条目安装一个系统的包,即树或单文件)。 + ### `mcpp-package.json` 每棵暂存树都带 `mcpp-package.json`:包、它的平台、三个选择、腿与系统、启动方式,以及每个文件的角色与 sha256。 @@ -804,7 +827,8 @@ URL 与每个架构的 sha256。安装把归档的顶层目录移到位,并向 (`none`);单文件的载荷为 `zstd` 或 `none`;okb 中的映像以 stored 存放,使加载器原地映射。 `[pack] sign = ["", …, "{}"]` 在写容器之前对暂存树中的每个程序、映像、库、加载器与 Windows 启动器各运行一次,也对每个单文件 -运行一次;`{}` 为该文件。失败使打包停止,写出 argv 与其输出;`mcpp-package.json` 写出签名后文件的摘要。 +运行一次;`{}` 为该文件。polyglot 文件的各程序在组装前逐个签名(Windows 程序除外),组装后的文件最后签名:Authenticode +把证书附在文件末尾,之前的字节与偏移不变。失败使打包停止,写出 argv 与其输出;`mcpp-package.json` 写出签名后文件的摘要。 ## 配置项 @@ -846,6 +870,7 @@ force_bundle = ["libfoo.so"] # bundle even if matched by the PEP 600 list - `.deb`、`.rpm`、AppImage、`.msi` 等分发格式经 `--pkg-format ` 由包提供;引擎提供 `tar`、`dir`、 `zip` 与 `single-file`。 -- 不产出在多个系统上运行的单个文件;一个单文件包服务一个系统与一个架构。 +- polyglot 文件只包含本 mcpp 能构建单文件的系统与架构(今天为 Linux x86_64 与 aarch64、macOS aarch64、Windows x86_64), + 且只用于 ok 映像;POSIX 系统经 shell 启动它。 - WebAssembly 包既不是单文件,也不是 xlings 条目;它的页面是提供者的格式(`dist-web`)。 - ELF 程序的闭包由其动态链接器解析,因此不能在 Windows 上打包 Linux 程序。 diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index ba519c690..8c2bda996 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -36,7 +36,8 @@ CI 脚本,以及任何解析 mcpp 输出的程序,都可以依赖本章陈 "effects": [], // what running the command did — see §4 "mcpp": { "version": "2026.8.8.3", - "protocol": { "min": 1, "max": 1 } + "protocol": { "min": 1, "max": 1 }, + "features": ["target-sets", "pkg-format-list"] // 自 2026.10.31.1 起 —— 见 §4 }, "data": { /* specific to `kind` */ }, "diagnostics": [] @@ -168,7 +169,8 @@ mcpp --protocol-version "self env": { "effects": [] }, "xpkg parse": { "effects": [] }, "cache list": { "effects": [] } - } + }, + "features": ["target-sets", "pkg-format-list"] } ``` @@ -192,6 +194,27 @@ effects 采用具名的方式而不是一个 `destructive: true|false`,因为 刷新、安装,或者一次 git 远程操作,就会被列出,包括失败了的或被期限终止的 那一次;在 `--offline` 下运行时永远不会列出。 +### 特性(features) + +`kinds` 说明输出遵循哪些 schema,`commands` 说明有哪些命令以及它们做什么;`features` 是已有命令中的语义能力, +客户端据以开关某项功能。客户端按特性判断,而不是比较版本号。一个名字在有客户端依据它开关功能时才加入, +永不改名、永不删除。早于该列表的 mcpp 没有 `features`;客户端为此之前的已发布版本保留一张对照表,之后的版本不再需要。 + +同一列表也在每个信封的 `mcpp` 字段中(§2):客户端为一个项目执行一次 `self env`,即同时得知版本与特性;任何 job 的信封 +都说明两者是否已变。 + +| 特性 | 起始 | 含义 | 使用方 | +|---|---|---|---| +| `target-sets` | 2026.10.31.1(语义自 2026.10.30.1) | `--target openkal` 等集合展开为其腿(SPEC-015 §1) | IDE 的 target 选择 | +| `pkg-format-list` | 2026.10.31.1(语义自 2026.10.30.1) | `--pkg-format tar,okb` 写出一棵树的各个容器(SPEC-014 §5.4) | IDE 的打包界面 | + +### 工具为项目运行 mcpp 的方式 + +为某个项目运行 mcpp 的工具(IDE 后端、语言服务器、CI 脚本)在项目根目录中运行它,程序名为 `PATH` 上的 +`mcpp`(版本管理器的 shim)或用户设置的路径,并从环境中去掉 `XLINGS_PROJECT_DIR`。这样应答的就是该项目使用的 +mcpp:锁定了版本的项目由其目录选定版本,任何工具都不读取锁定文件。`mcpp self env --format json` 报告该 mcpp +的 `mcppVersion` 与 `program`(正在运行的文件)。 + ## 5. `--json` 不等于 `--format json` 有两条命令在本协议存在之前就已经发布了 `--json`: @@ -282,6 +305,7 @@ mcpp self env --format json "config": "/home/u/.mcpp/config.toml", "buildCache": "/home/u/.mcpp/build-cache/v1", "mcppVersion": "2026.8.8.3", + "program": "/home/u/.mcpp/registry/data/xpkgs/xim-x-mcpp/2026.10.31.1/bin/mcpp", // 2026.10.31.1+ "defaultToolchain": "gcc@16.1.0", // 2026.9.28.2+ "engineModules": "/home/u/.mcpp/engine/2026.10.11.1/modules" // 2026.10.11.1+ } diff --git a/mcpp.toml b/mcpp.toml index e9116a5da..2885341f9 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.10.30.1" +version = "2026.10.31.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/libs/src/toml.cppm b/modules/libs/src/toml.cppm index 39bcca312..556dc3abb 100644 --- a/modules/libs/src/toml.cppm +++ b/modules/libs/src/toml.cppm @@ -841,16 +841,28 @@ std::string serialize(const Document& doc) { return w.out; } +// A basic string TOML 1.0 reads back as `raw`. Every control character +// (U+0000-U+001F, U+007F) is escaped, since TOML forbids them unescaped in a +// basic string: the reader accepts `\b`, `\f` and `\uXXXX` (#867), so a value +// may hold one, and `serialize` (the normalised manifest of `mcpp publish`) +// would otherwise write a document no TOML reader accepts. The short forms are +// used where TOML has one, `\uXXXX` elsewhere; `\0`, which this reader also +// accepts, is not TOML 1.0 and is never written. std::string escape_string(std::string_view raw) { std::string out = "\""; for (char c : raw) { + const auto u = static_cast(c); switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; - case '\n': out += "\\n"; break; + case '\b': out += "\\b"; break; case '\t': out += "\\t"; break; + case '\n': out += "\\n"; break; + case '\f': out += "\\f"; break; case '\r': out += "\\r"; break; - default: out.push_back(c); + default: + if (u < 0x20 || u == 0x7F) out += std::format("\\u{:04X}", static_cast(u)); + else out.push_back(c); } } out += '"'; diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index a1cf7c107..e50972f22 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.10.30.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.10.31.1"; } // namespace mcpp diff --git a/src/build/execute.cppm b/src/build/execute.cppm index cb6b1bc0c..68952b1a2 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -3453,6 +3453,54 @@ export int build_run_target(const std::optional& targetName, // `build_and_pack` naming what is available, exactly as `mcpp pack // --format bogus` is), the build, the staged tree, which action claimed // the request. + // A SINGLE FILE IS A PROGRAM OF THIS SYSTEM, RUN AS IT IS (SPEC-014 §5.2, + // §5.3): packed by the engine's own containers, not dispatched to a + // provider, and started directly -- its runner, if the target has one, is + // inside it. A polyglot file is started by `sh` on a POSIX system, which + // does not execute it (ENOEXEC). The containers that are not programs are + // not run. + if (format == "single-file" || format == "polyglot") { + mcpp::pack::Options popts; + popts.targetTriple = target; + popts.profile = profile; + popts.features = features; + popts.format = mcpp::pack::Format::SingleFile; + popts.formatStated = true; + popts.polyglot = format == "polyglot"; + popts.compression.algorithm = mcpp::pack::compress::Algorithm::Zstd; + if (popts.polyglot) popts.systems = {"all"}; + auto outcome = mcpp::pack::build_and_pack( + std::move(popts), /*modeFromUser=*/false, targetName.value_or(std::string{})); + if (outcome.rc != 0) return kRunBuildFailed; + if (outcome.artifacts.size() != 1) { + std::println(stderr, "error: --pkg-format {} produced {} files; mcpp run runs one", format, + outcome.artifacts.size()); + return 1; + } + const auto file = outcome.artifacts.front(); + std::vector argv; + if (format == "polyglot" && !mcpplibs::os::is_windows) argv.push_back("sh"); + argv.push_back(file.string()); + for (auto const& a : passthrough) argv.push_back(a); + yield_terminal(); + mcpp::ui::status("Running", std::format("`{}{}`", argv.front() == "sh" ? "sh " : "", + mcpp::ui::shorten_path(file, mcpp::fetcher::make_path_ctx(nullptr, std::filesystem::current_path())))); + run_separator(); + int spawnErr = 0; + const int rc = mcpp::run::run_foreground(argv, {}, &spawnErr); + if (spawnErr != 0) { + std::println(stderr, "error: {}", + mcpp::build::runner_lookup::spawn_failed_message(argv.front(), spawnErr)); + return launcher_status(spawnErr); + } + return rc; + } + if (format == "tar" || format == "zip" || format == "dir") { + std::println(stderr, + "error: mcpp run --pkg-format runs a program, and {} is a container of one.\n" + " use: --pkg-format single-file | polyglot, or a format a package provides", format); + return 2; + } if (!format.empty()) { mcpp::pack::Options popts; popts.targetTriple = target; diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 1dc88e531..906ad16bf 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -71,6 +71,11 @@ struct CompileFlags { // The link words that name the island's C++ runtime (design 2026-10-26 // §6.3), placed after the build's own runtime in each C++ unit's tail. std::string ldForeignRuntime; + // The files the user's link flags name by absolute path (a prebuilt + // archive, an object): inputs of every link that carries the flags, so a + // changed archive relinks (design 2026-10-30 part 2 §25.3 C1; it did not, + // and the clangd probe deleted its image to take a new archive). + std::vector ldFileInputs; // `trim_paths` (mcpp#844): the rendered prefix map of each package, by // `CompileUnit::packageName`, appended to that package's C and C++ units. // Empty on cl.exe, which has no such switch. @@ -1258,6 +1263,13 @@ CompileFlags compute_flags(const BuildPlan& plan) { } user_ldflags += ' '; user_ldflags += rendered; + // A word that names an existing file by absolute path is a link + // input. A relative word is read by the linker against the build + // directory, and an option is no file. + if (!word.starts_with('-') && std::filesystem::path(word).is_absolute()) { + std::error_code ec; + if (std::filesystem::is_regular_file(word, ec)) f.ldFileInputs.emplace_back(word); + } } // C standard. The file-level `$cflags` carries the engine default, a diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index d5b831a40..a448cebab 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -3460,6 +3460,9 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, for (auto& input : lu.implicitInputs) { implicit += " " + escape_ninja_path(input); } + if (lu.kind != LinkUnit::StaticLibrary) + for (auto const& input : uflags.ldFileInputs) + implicit += " " + escape_ninja_path(input); // Windows runtime-DLL deployment: an executable takes an ORDER-ONLY // dependency on each staged dep DLL (bin/), so ninja copies them // beside the .exe whenever the .exe is built. Order-only rather than diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp index 7c0e24c9b..ee3a2eb97 100644 --- a/src/build/prepare/records.cpp +++ b/src/build/prepare/records.cpp @@ -132,22 +132,45 @@ std::expected step13_lockfile(PrepareState& state, BuildConte // the spelling that existing committed locks hold, and it is the one // #329 already fixes for git entries, so it stays; the merge and the // `--locked` comparison work on the identity, not on the spelling. + // + // THE SPELLING DOES NOT DEPEND ON WHICH MEMBERS A COMMAND PLANS (#868). + // Reading the selected members made `mcpp build -p pack` write + // `tinyhttps` and `mcpp build -p model-gateway` `mcpplibs.tinyhttps` + // (and a different hash) from one tree: two members spelled it two + // ways. Where the project is a workspace (its lock is the workspace's) + // the manifests read are therefore the workspace's own, `[dependencies]` + // then `[workspace.dependencies]`, and then every member in the order + // `[workspace] members` lists them, selected or not; the first that + // declares the dependency gives its spelling. + std::vector declaring; + std::vector memberManifests; + if (state.wsManifest && !state.runtimeWorkspaceRoot.empty()) { + memberManifests.reserve(state.wsManifest->workspace.members.size()); + // Each member's manifest as written, the way `workspace_members` + // reads it: the spelling is the member's own key, and the + // inheritance a planned member receives (with its notes) is not + // this question's. + for (auto const& mp : state.wsManifest->workspace.members) + if (auto mm = mcpp::manifest::load(state.runtimeWorkspaceRoot / mp / "mcpp.toml", + {.insideWorkspace = true})) + memberManifests.push_back(std::move(*mm)); + declaring.push_back(&*state.wsManifest); + for (auto const& mm : memberManifests) declaring.push_back(&mm); + } else { + declaring.push_back(&*state.m); + } auto lock_name_for = [&](const ResolvedKey& k) -> std::string { - auto declared = [&](const mcpp::manifest::Manifest& mf) + auto declared_in = [&](const std::map& deps) -> std::optional { - for (auto const& [n, s] : mf.dependencies) { + for (auto const& [n, s] : deps) { const std::string sn = s.shortName.empty() ? n : s.shortName; if (s.namespace_ == k.ns && sn == k.shortName) return n; } return std::nullopt; }; - if (auto n = declared(*state.m)) return *n; - if (state.workspacePlan()) { - if (state.wsManifest) - if (auto n = declared(*state.wsManifest)) return *n; - for (std::size_t i = 1; i < state.packages.size(); ++i) - if (state.packages[i].selectedMember) - if (auto n = declared(state.packages[i].manifest)) return *n; + for (const auto* mf : declaring) { + if (auto n = declared_in(mf->dependencies)) return *n; + if (auto n = declared_in(mf->workspace.dependencies)) return *n; } return mcpp::pm::compat::qualified_name(k.ns, k.shortName); }; diff --git a/src/cli.cppm b/src/cli.cppm index 4f919681b..50467ce65 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -126,7 +126,7 @@ void print_pack_usage() { std::println(" repeatable: the legs (default: [pack] targets, else [build] target)"); std::println(" --mode What the machine provides: system | vendored (default) |"); std::println(" self-contained | static (= --linkage static --mode self-contained)"); - std::println(" --pkg-format tar | zip | dir | single-file | a format the resolved graph provides"); + std::println(" --pkg-format tar | zip | dir | single-file | polyglot | a format the resolved graph provides"); std::println(" (okb, appimage, msi); a list of containers of one tree: tar,okb"); std::println(" --systems The runners a self-contained package carries, or the systems single"); std::println(" files are made for: linux | linux-aarch64 | all (comma-separated)"); @@ -978,7 +978,7 @@ int run(int argc, char** argv) { // it; an unknown value names what IS available rather than a // constant, so the help text does not have to enumerate them. .option(cl::Option("pkg-format").takes_value().value_name("FORMAT") - .help("The package format: tar | zip | dir | single-file | any format the " + .help("The package format: tar | zip | dir | single-file | polyglot | any format the " "resolved graph provides (e.g. appimage, msi, okb), or a list of containers " "of one tree (tar,okb); default: zip for a Windows program, tar elsewhere")) .option(cl::Option("format").takes_value() diff --git a/src/cli/cmd_publish.cppm b/src/cli/cmd_publish.cppm index 4e1d4dba3..3b1223ef7 100644 --- a/src/cli/cmd_publish.cppm +++ b/src/cli/cmd_publish.cppm @@ -691,8 +691,8 @@ int cmd_pack_body(const mcpplibs::cmdline::ParsedArgs& parsed, std::vector formatList; if (auto v = pack_format_option(parsed)) { if (v->empty()) { - mcpp::ui::error("--pkg-format needs a value: tar | zip | dir | single-file | a format " - "the resolved graph provides, or a list of containers (tar,okb)"); + mcpp::ui::error("--pkg-format needs a value: tar | zip | dir | single-file | polyglot | a " + "format the resolved graph provides, or a list of containers (tar,okb)"); return 2; } formatList = split_list(*v); @@ -708,11 +708,13 @@ int cmd_pack_body(const mcpplibs::cmdline::ParsedArgs& parsed, } if (formatList.size() > 1) { for (auto const& f : formatList) - if (f == "dir" || f == "single-file") { + if (f == "dir" || f == "single-file" || f == "polyglot") { mcpp::ui::error(std::format( "--pkg-format lists the containers of one staged tree (tar, zip, a provider's " "format), and {} is not one: {}.\n use: --pkg-format {} alone", - f, f == "dir" ? "it is the staged tree itself" : "it carries the tree in a program", + f, f == "dir" ? "it is the staged tree itself" + : f == "polyglot" ? "it carries the programs of several systems in one file" + : "it carries the tree in a program", f)); return 2; } @@ -731,7 +733,12 @@ int cmd_pack_body(const mcpplibs::cmdline::ParsedArgs& parsed, else if (formatText == "dir") opts.format = mcpp::pack::Format::Dir; else if (formatText == "zip") opts.format = mcpp::pack::Format::Zip; else if (formatText == "single-file") opts.format = mcpp::pack::Format::SingleFile; - else { + else if (formatText == "polyglot") { + // The single files of an image's legs and systems, in one file + // (SPEC-014 §5.3): every system unless `--systems` says which. + opts.format = mcpp::pack::Format::SingleFile; + opts.polyglot = true; + } else { opts.format = mcpp::pack::Format::Dispatched; opts.formatName = formatText; } @@ -839,12 +846,14 @@ int cmd_pack_body(const mcpplibs::cmdline::ParsedArgs& parsed, } if (std::ranges::find(opts.systems, word) == opts.systems.end()) opts.systems.push_back(word); } + if (opts.polyglot && opts.systems.empty()) opts.systems.push_back("all"); opts.sign = packCfg.sign; // A single file carries what it runs: the machine provides nothing. if (opts.format == mcpp::pack::Format::SingleFile && opts.mode == mcpp::pack::Mode::None) { - mcpp::ui::error("--pkg-format single-file carries what the program runs, and --mode " + mcpp::ui::error(std::format("--pkg-format {} carries what the program runs, and --mode " "system leaves it to the machine.\n" - " use: --mode vendored | self-contained | static"); + " use: --mode vendored | self-contained | static", + opts.polyglot ? "polyglot" : "single-file")); return 2; } } diff --git a/src/cli/cmd_self.cppm b/src/cli/cmd_self.cppm index bbdad5985..edf348365 100644 --- a/src/cli/cmd_self.cppm +++ b/src/cli/cmd_self.cppm @@ -16,6 +16,7 @@ import mcpp.doctor; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.home; import mcpplibs.os.platform; +import mcpplibs.os.env; // image(): the program that is running import mcpplibs.os.windows; import mcpp.ui.terminal; import mcpp.toolchain.triple; // pins::host_default_toolchain (WS8) @@ -62,6 +63,13 @@ nlohmann::json env_data_readonly() { // by an editor (`mcpp:core` in the build database). {"engineModules", s(mcpp::build::engine::package_dir())}, {"mcppVersion", std::string(mcpp::toolchain::MCPP_VERSION)}, + // The program that is running, whatever started it: a client that ran + // `mcpp` through a version manager's shim reads which mcpp the shim + // chose for the directory (design 2026-10-30 part 2 §4.2). + {"program", [] { + auto self = mcpplibs::os::env::image(); + return self ? self->generic_string() : std::string(); + }()}, // What a build with nothing configured resolves on this host (WS8): // the one function the first run uses. Probing for a usable MSVC is // read-only, as the rest of this path is. diff --git a/src/pack/distribution.cppm b/src/pack/distribution.cppm index c742cf4f4..53c42d62b 100644 --- a/src/pack/distribution.cppm +++ b/src/pack/distribution.cppm @@ -15,6 +15,7 @@ import std; import mcpp.libs.json; import mcpp.pack.compress; import mcpp.pack.zip; +import mcpp.pack.polyglot; // a polyglot file is refused by name (§8) import mcpp.toolchain.triple; // the system of a package's leg (§8) import mcpplibs.os; @@ -902,10 +903,24 @@ std::expected unpack(const fs::path& package, const fs::p using mcpp::pack::compress::Algorithm; const bool zip = name.ends_with(".zip"); const std::optional a = mcpp::pack::compress::tar_algorithm(name); - if (!zip && !a) + if (!zip && !a) { + // A POLYGLOT FILE (SPEC-014 §5.3, §8) is a download for a person: an + // entry installs one system's package, and xlings starts what it + // installs directly, which a POSIX system does not do with this file. + std::string head(16, '\0'); + std::ifstream in(package, std::ios::binary); + in.read(head.data(), static_cast(head.size())); + head.resize(static_cast(std::max(in.gcount(), 0))); + if (mcpp::pack::polyglot::is_polyglot(head)) + return std::unexpected(std::format( + "'{}' is a polyglot file, a download that holds the programs of several systems; an " + "entry installs one system's package, and a POSIX system does not execute this file " + "directly.\n use: the tree (mcpp pack --target openkal) or the single files " + "(--pkg-format single-file)", name)); return std::unexpected(std::format( "'{}' is not a package this command writes (.zip, .tar, .tar.gz, .tar.zst, .tar.xz, " "or a directory)", name)); + } if (fs::exists(fs::symlink_status(into, ec)) && !(fs::is_directory(fs::symlink_status(into, ec)) && fs::is_empty(into, ec))) return std::unexpected(std::format("'{}' is not an empty directory to unpack {} into", @@ -1045,6 +1060,11 @@ std::string posix_launcher(const Launch& l) { "esac\n" "image=\"$top/lib/$arch/$entry\"\n" "{3}" + "# A package a browser downloaded carries macOS's quarantine mark on every\n" + "# file, and macOS refuses to start a runner so marked that is not signed.\n" + "if [ \"$os\" = macos ] && xattr -p com.apple.quarantine \"$runner\" >/dev/null 2>&1; then\n" + " printf '%s: %s carries com.apple.quarantine (a browser downloaded it), and macOS may refuse to start it; remove the mark with: xattr -dr com.apple.quarantine %s\\n' {0} \"$runner\" \"$top\" >&2\n" + "fi\n" "exec \"$runner\"{4}\n", sh_word(l.name), cases, sh_word(arch_list(l)), find, args); } diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 6ae472d0b..945bbd269 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -47,6 +47,7 @@ import mcpp.pack.relocate; import mcpp.pack.stage_tree; import mcpp.pack.strip; import mcpp.pack.zip; +import mcpp.pack.polyglot; import mcpp.pack.compress; import mcpp.pack.distribution; import mcpp.modgraph.glob; // [pack] include / exclude @@ -160,6 +161,9 @@ struct Options { // was built against becomes the C library floor the package states. bool baseStated = false; Format format = Format::Tar; + // `--pkg-format polyglot` (SPEC-014 §5.3): the single files of an image's + // legs and systems (`format` is `SingleFile`), assembled into one file. + bool polyglot = false; // The `--format` value when `format == Dispatched`. Empty otherwise. // // Not validated by the CLI, and deliberately: the set of valid values is a @@ -3478,10 +3482,16 @@ std::expected, Error> single_file(const Plan& auto output_for = [&](std::string_view qualifier, std::string_view suffix) { if (plan.opts.output.empty()) return (plan.opts.outputDir.empty() ? distDir : plan.opts.outputDir) - / std::format("{}-{}-{}{}", plan.packageName, plan.packageVersion, qualifier, suffix); + / std::format("{}-{}{}{}{}", plan.packageName, plan.packageVersion, + qualifier.empty() ? "" : "-", qualifier, suffix); if (!plan.opts.output.has_parent_path()) return distDir / plan.opts.output; return plan.opts.output; }; + // A polyglot file's programs are written beside the build, not as the + // pack's products: the file is (SPEC-014 §5.3). + const auto polyglotDir = plan.projectRoot / "target" / "polyglot" + / fs::path(plan.binaryName).stem().string(); + std::vector carried; std::vector written; if (plan.targetIsLoaded) { // THE IMAGE, OR THE IMAGES AS ONE STORED ZIP: what the carrier's @@ -3531,7 +3541,7 @@ std::expected, Error> single_file(const Plan& return std::unexpected(Error{std::string( "--systems names no system this mcpp makes a single file for, of the legs' " "architectures")}); - if (files.size() > 1 && !plan.opts.output.empty()) + if (files.size() > 1 && !plan.opts.output.empty() && !plan.opts.polyglot) return std::unexpected(Error{std::format( "--output names one file, and this pack makes {} single files (one per leg and " "system).\n use: -o / (each file is named ---)", @@ -3545,13 +3555,63 @@ std::expected, Error> single_file(const Plan& if (!fs::is_regular_file(program, ec)) return std::unexpected(Error{std::format("the carrier built for {}-{} has no {}", f.system, f.arch, program.filename().string())}); + if (plan.opts.polyglot) { + std::ifstream in(program, std::ios::binary); + std::string bytes{std::istreambuf_iterator(in), std::istreambuf_iterator()}; + if (!in && !in.eof()) + return std::unexpected(Error{std::format("cannot read {}", program.string())}); + const auto kept = polyglotDir / std::format("{}-{}-{}{}", stem, f.system, f.arch, suffix); + fs::create_directories(polyglotDir, ec); + fs::copy_file(program, kept, fs::copy_options::overwrite_existing, ec); + // Each system's program is signed as the row's file is, before + // the file is assembled; the Windows program is not, since the + // assembled file is it and is signed whole (SPEC-014 §7). + if (f.system != "windows" && !plan.opts.sign.empty()) { + std::vector argv; + for (auto const& a : plan.opts.sign) argv.push_back(a == "{}" ? kept.string() : a); + int spawnError = 0; + auto r = mcpp::run::capture_exec(argv, {}, {}, &spawnError); + if (spawnError != 0 || r.exit_code != 0) + return std::unexpected(Error{std::format("[pack] sign failed on {} (exit {}): {}\n{}", + kept.string(), r.exit_code, + mcpp::build::command_line::display(argv), r.output)}); + std::ifstream again(kept, std::ios::binary); + bytes.assign(std::istreambuf_iterator(again), std::istreambuf_iterator()); + } + const auto digest = d::sha256(bytes); + carried.push_back({ f.system, f.arch, std::move(bytes), digest }); + continue; + } const auto out = output_for(std::format("{}-{}", f.system, f.arch), suffix); fs::create_directories(out.parent_path(), ec); fs::copy_file(program, out, fs::copy_options::overwrite_existing, ec); if (ec) return std::unexpected(Error{std::format("cannot write {}: {}", out.string(), ec.message())}); written.push_back(out); } + if (plan.opts.polyglot) { + const auto stem = fs::path(plan.binaryName).stem().string(); + auto file = polyglot::assemble(stem, carried); + if (!file) return std::unexpected(Error{file.error()}); + const bool windows = std::ranges::any_of(carried, [](auto const& p) { return p.system == "windows"; }); + const auto out = output_for({}, windows ? ".exe" : ""); + fs::create_directories(out.parent_path(), ec); + { + std::ofstream o(out, std::ios::binary | std::ios::trunc); + o.write(file->data(), static_cast(file->size())); + if (!o) return std::unexpected(Error{std::format("cannot write {}", out.string())}); + } + fs::permissions(out, fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec, + fs::perm_options::add, ec); + std::string held; + for (auto const& p : carried) held += (held.empty() ? "" : ", ") + std::format("{}-{}", p.system, p.arch); + mcpp::ui::info("Assembled", std::format("{} programs into one file ({})", carried.size(), held)); + written.push_back(out); + } } else { + if (plan.opts.polyglot) + return std::unexpected(Error{std::string( + "--pkg-format polyglot holds the single files of an ok image for several systems, and " + "this target is a program of one system.\n use: --pkg-format single-file")}); // THE ENGINE'S LAUNCHER CARRYING THE TREE. The entry: the program a // person runs from the unpacked tree -- a Windows program at the root, // a self-contained Linux tree's wrapper at the root, `bin/` diff --git a/src/pack/pipeline.cppm b/src/pack/pipeline.cppm index a687366bf..2919cbff6 100644 --- a/src/pack/pipeline.cppm +++ b/src/pack/pipeline.cppm @@ -894,6 +894,13 @@ std::optional check_format(PackRun& run, GroupJob& g) { if (!run.opts.systems.empty()) { auto t = mcpp::toolchain::triple::parse(ctx.tc.targetTriple); const bool loaded = t && t->loader_supplies_os(); + // A polyglot file holds an image's single files of several systems; a + // program of one system has one (SPEC-014 §5.3). + if (run.opts.polyglot && !loaded) + return Refusal{2, std::format( + "--pkg-format polyglot holds the single files of an ok image for several systems, and " + "{} is a program of one system.\n use: --pkg-format single-file, or --target openkal", + t ? t->str() : ctx.tc.targetTriple)}; const bool tree = packs_tree(ctx, run.opts); const bool selfContained = run.opts.mode == mcpp::pack::Mode::BundleAll || run.opts.mode == mcpp::pack::Mode::Static; @@ -947,7 +954,7 @@ std::optional check_format(PackRun& run, GroupJob& g) { else if (!all) unbuilt.push_back(std::format("{}-{} ({})", system, arch, row)); if (row.empty() || !all) ++files; } - if (files > 1 && !run.opts.output.empty()) + if (files > 1 && !run.opts.output.empty() && !run.opts.polyglot) return Refusal{2, std::format( "--output names one file, and this pack makes {} single files (one per leg and " "system).\n use: -o / (each file is named ---)", @@ -1191,7 +1198,8 @@ std::optional stage_member(PackRun& run, GroupJob& g, MemberJob& m) { ? (plan->archive == mcpp::pack::ArchiveKind::Zip ? std::string("zip") : std::string("tar")) : opts.format == mcpp::pack::Format::Dir ? std::string("dir") : opts.format == mcpp::pack::Format::Zip ? std::string("zip") - : opts.format == mcpp::pack::Format::SingleFile ? std::string("single-file") + : opts.format == mcpp::pack::Format::SingleFile + ? std::string(opts.polyglot ? "polyglot" : "single-file") : opts.formatName; out.linkage = plan->linkageName; out.mode = std::string(mcpp::pack::mode_cli_name(opts.mode)); diff --git a/src/pack/polyglot.cppm b/src/pack/polyglot.cppm new file mode 100644 index 000000000..a51f50ad2 --- /dev/null +++ b/src/pack/polyglot.cppm @@ -0,0 +1,272 @@ +// mcpp.pack.polyglot — one file holding the single files of several systems +// (SPEC-014 §5.3; design 2026-10-30 part 2 §19.6). +// +// THE FILE IS THE WINDOWS PROGRAM, AND A POSIX SHELL SCRIPT. A PE begins with +// a DOS header whose first bytes are free but for `MZ` and whose word at 0x3C +// names the PE header. Written as `MZqFpD='` the header is also the start of +// a shell assignment whose quoted value runs to a `'` after that word; the +// lines after it are the script, and the PE header and sections follow, moved +// down by a multiple of the file alignment. Windows starts the PE as it is. +// A POSIX shell reads the script, which reads the rest of itself from the end +// of the file, copies this system's program to the single file's cache (the +// rules of SPEC-014 §5.2) and executes it there. +// +// THE PROGRAMS ARE THE ROW'S SINGLE FILES, UNCHANGED. Each is a native +// program the platform's carrier made and signed (a Mach-O keeps its +// signature, since the bytes the cache receives are the bytes that were +// signed); the file adds no runtime of its own. What it cannot do is be +// executed directly by `execve` on a POSIX system (ENOEXEC): it is started by +// `sh `, or by a shell that runs a file the kernel refused as a script. +// +// NOT COSMOPOLITAN'S APE. Measured against it (part 2 §19.1, §19.6.1): the PE +// here is a standard linker's, so Authenticode signs it and Windows starts it +// as fast as the row's file; macOS needs no compiler; the programs are the +// conformance-tested native implementations. + +export module mcpp.pack.polyglot; + +import std; + +export namespace mcpp::pack::polyglot { + +// One system's program: `system` is `linux`, `macos` or `windows`, `arch` +// `x86_64` or `aarch64`. +struct Program { + std::string system; + std::string arch; + std::string bytes; + std::string sha256; // of `bytes`: the cache directory's name +}; + +// The file: the Windows program (at most one) leads, the others follow it. +// `name` is the program's name in the cache and in messages. +std::expected assemble(std::string_view name, const std::vector& programs); + +// Whether `head`, the first bytes of a file, are a file `assemble` wrote. +bool is_polyglot(std::string_view head); + +} // namespace mcpp::pack::polyglot + +namespace mcpp::pack::polyglot { + +namespace { + +std::uint16_t u16(std::string_view b, std::size_t o) { + return static_cast(static_cast(b[o]) + | static_cast(b[o + 1]) << 8); +} +std::uint32_t u32(std::string_view b, std::size_t o) { + std::uint32_t v = 0; + for (int i = 3; i >= 0; --i) v = v << 8 | static_cast(b[o + static_cast(i)]); + return v; +} +void put32(std::string& b, std::size_t o, std::uint32_t v) { + for (int i = 0; i < 4; ++i) b[o + static_cast(i)] = static_cast(v >> (8 * i) & 0xFF); +} + +// `uname -s`-`uname -m` spellings of a system and architecture. +std::string uname_pattern(std::string_view system, std::string_view arch) { + const std::string s = system == "macos" ? "Darwin" : "Linux"; + if (arch == "x86_64") return std::format("{0}-x86_64|{0}-amd64", s); + return std::format("{0}-aarch64|{0}-arm64", s); +} + +constexpr std::string_view kMarker = "# mcpp polyglot"; + +// The script the file's head evaluates: the program of this system, copied +// to the single file's cache by SPEC-014 §5.2's rules, executed with the +// arguments. `$o` is the file, set by the head. +std::string stage_two(std::string_view name, const std::vector& posix, + const std::vector& offsets, bool leadsWindows) { + std::string held; + for (auto const& p : posix) held += (held.empty() ? "" : ", ") + std::format("{}-{}", p.system, p.arch); + if (leadsWindows) held += (held.empty() ? "" : ", ") + std::string("windows (run directly)"); + std::string cases; + for (std::size_t i = 0; i < posix.size(); ++i) + cases += std::format("{}) k={}-{} a={} b={} h={};;\n", uname_pattern(posix[i].system, posix[i].arch), + posix[i].system, posix[i].arch, offsets[i], posix[i].bytes.size(), posix[i].sha256); + if (leadsWindows) cases += "MINGW*|MSYS*|CYGWIN*) exec \"$o\" \"$@\";;\n"; + return std::format(R"SH({0}: the programs of several systems in one file (SPEC-014 §5.3) +n='{1}' +f() {{ echo "$n: $*" >&2; exit 127; }} +m() {{ [ ! -L "$1" ] && [ -d "$1" ] && [ "$(ls -ldn "$1" | awk '{{print $3}}')" = "$(id -u)" ]; }} +case $(uname -s)-$(uname -m) in +{2}*) echo "$n: this file holds no program for $(uname -s)-$(uname -m); it holds {3}" >&2; exit 126;; +esac +[ -n "$HOME" ] || f "this machine names no cache directory for its user" +case $(uname -s) in +Darwin) r=$HOME/Library/Caches;; +*) case $XDG_CACHE_HOME in /*) r=$XDG_CACHE_HOME;; *) r=$HOME/.cache;; esac;; +esac +u=$r/mcpp; s=$u/single-file; d=$s/$h +mkdir -p "$s" 2>/dev/null +for x in "$u" "$s"; do m "$x" || f "$x is not a directory of this user's; nothing is run from it"; done +if [ ! -f "$d/.mcpp-complete" ]; then + chmod 700 "$s" || f "cannot restrict $s to this user" + t=$s/.$h-$$ + rm -rf "$t"; mkdir "$t" || f "cannot write under $s" + tail -c +$((a+1)) "$o" | head -c "$b" > "$t/$n" || {{ rm -rf "$t"; f "cannot write $t/$n"; }} + if command -v sha256sum >/dev/null 2>&1; then g=$(sha256sum "$t/$n") + elif command -v shasum >/dev/null 2>&1; then g=$(shasum -a 256 "$t/$n") + elif command -v openssl >/dev/null 2>&1; then g=$(openssl dgst -sha256 -r "$t/$n") + else rm -rf "$t"; f "no sha256sum, shasum or openssl here to check the program for $k with"; fi + case $g in "$h"*) ;; *) rm -rf "$t"; f "the program for $k does not match its digest: the file is damaged";; esac + chmod 755 "$t/$n" && echo "$h" > "$t/.mcpp-complete" || {{ rm -rf "$t"; f "cannot write under $t"; }} + mv "$t" "$d" 2>/dev/null + rm -rf "$t" "$d/.$h-$$" + [ -f "$d/.mcpp-complete" ] || f "cannot place the program under $d" +fi +m "$d" || f "$d is not a directory of this user's; nothing is run from it" +exec "$d/$n" "$@" +)SH", kMarker, name, cases, held); +} + +// The head's script: the file's own path, and the rest read from its end. +std::string stage_one(std::uint64_t at, std::uint64_t length) { + return std::format("o=$0;case $o in */*);;*)if [ -f \"$o\" ];then o=./$o;else o=$(command -v \"$0\");fi;;esac\n" + "eval \"$(tail -c +{} \"$o\"|head -c {})\";exit 127\n", at + 1, length); +} + +} // namespace + +bool is_polyglot(std::string_view head) { + return head.starts_with("MZqFpD='\n") || head.starts_with("#!/bin/sh\n" "o=$0;"); +} + +std::expected assemble(std::string_view name, const std::vector& programs) { + if (name.empty() || name.find_first_of("'/\\\n") != std::string_view::npos) + return std::unexpected(std::format("'{}' cannot name a program in a polyglot file", name)); + const Program* windows = nullptr; + std::vector posix; + for (auto const& p : programs) { + if (p.system == "windows") { + if (windows) + return std::unexpected(std::format( + "a polyglot file leads with one Windows program, and this pack has two ({}, {})", + windows->arch, p.arch)); + windows = &p; + } else if (p.system == "linux" || p.system == "macos") { + posix.push_back(p); + } else { + return std::unexpected(std::format("a polyglot file holds no program of {}", p.system)); + } + } + if (programs.empty()) return std::unexpected(std::string("a polyglot file holds at least one program")); + + // Without a Windows program the head is a plain script. + if (!windows) { + std::uint64_t headSize = 0; + std::string out; + for (int pass = 0; pass < 8; ++pass) { + std::vector offsets; + std::uint64_t at = headSize; + for (auto const& p : posix) { offsets.push_back(at); at += p.bytes.size(); } + const auto two = stage_two(name, posix, offsets, false); + const auto head = "#!/bin/sh\n" + stage_one(at, two.size()); + if (head.size() == headSize) { + out = head; + for (auto const& p : posix) out += p.bytes; + out += two; + return out; + } + headSize = head.size(); + } + return std::unexpected(std::string("the polyglot head did not settle")); + } + + // THE PE (PE32+, unsigned): its header moves down by a multiple of the + // file alignment, and every file offset it holds moves with it. + std::string_view pe = windows->bytes; + if (pe.size() < 0x40 || pe[0] != 'M' || pe[1] != 'Z') return std::unexpected(std::string("the Windows program is no PE")); + const std::uint32_t lfanew = u32(pe, 0x3C); + if (lfanew + 24 > pe.size() || pe.substr(lfanew, 4) != std::string_view("PE\0\0", 4)) + return std::unexpected(std::string("the Windows program is no PE")); + const std::size_t coff = lfanew + 4; + const std::uint16_t nsec = u16(pe, coff + 2); + const std::uint16_t optSize = u16(pe, coff + 16); + const std::size_t opt = coff + 20; + if (opt + optSize > pe.size() || u16(pe, opt) != 0x20B) + return std::unexpected(std::string("the Windows program is not PE32+")); + const std::uint32_t fileAlign = u32(pe, opt + 36); + const std::size_t sizeHeadersAt = opt + 60; + const std::uint32_t sizeHeaders = u32(pe, sizeHeadersAt); + const std::size_t dirs = opt + 112; + const std::size_t sec = opt + optSize; + if (sec + 40ull * nsec > pe.size() || fileAlign == 0) + return std::unexpected(std::string("the Windows program's section table is truncated")); + if (u32(pe, dirs + 8 * 4) != 0) + return std::unexpected(std::string( + "the Windows program is already signed; a polyglot file is signed after it is assembled")); + std::uint32_t firstVa = 0xFFFFFFFF; + for (std::uint16_t i = 0; i < nsec; ++i) firstVa = std::min(firstVa, u32(pe, sec + 40u * i + 12)); + + auto build = [&](std::uint64_t twoAt, std::uint64_t twoSize) { + std::string head = "MZqFpD='\n"; + head.resize(0x3C, ' '); + head += std::string(4, '\0'); // e_lfanew, written below + head += "'\n" + stage_one(twoAt, twoSize); + return head; + }; + std::string head; + std::uint32_t shift = 0; + std::uint64_t twoAt = 0, twoSize = 0; + std::string two; + for (int pass = 0;; ++pass) { + if (pass == 16) return std::unexpected(std::string("the polyglot head did not settle")); + head = build(twoAt, twoSize); + const std::uint64_t want = (head.size() + 7) & ~std::uint64_t{7}; + shift = want > lfanew + ? static_cast((want - lfanew + fileAlign - 1) / fileAlign * fileAlign) : 0; + // The quoted header must hold no `'`, and e_lfanew is in it. + while (((lfanew + shift) & 0xFF) == '\'' || ((lfanew + shift) >> 8 & 0xFF) == '\'' + || ((lfanew + shift) >> 16 & 0xFF) == '\'') + shift += fileAlign; + std::vector offsets; + std::uint64_t at = pe.size() + shift; + for (auto const& p : posix) { offsets.push_back(at); at += p.bytes.size(); } + two = stage_two(name, posix, offsets, true); + if (at == twoAt && two.size() == twoSize) break; + twoAt = at; + twoSize = two.size(); + } + if (sizeHeaders + shift > firstVa) + return std::unexpected(std::format( + "the polyglot head ({} bytes) does not fit before the Windows program's first section", head.size())); + + std::string body(pe.substr(lfanew)); + auto patch = [&](std::size_t fileOffset) { + const std::size_t o = fileOffset - lfanew; + if (const auto v = u32(body, o); v != 0) put32(body, o, v + shift); + }; + patch(sizeHeadersAt); + patch(coff + 8); // PointerToSymbolTable + for (std::uint16_t i = 0; i < nsec; ++i) { + const std::size_t s = sec + 40u * i; + patch(s + 20); // PointerToRawData + patch(s + 24); // PointerToRelocations + patch(s + 28); // PointerToLinenumbers + } + // The debug directory's entries name file offsets (PointerToRawData). + const std::uint32_t dbgRva = u32(pe, dirs + 8 * 6), dbgSize = u32(pe, dirs + 8 * 6 + 4); + if (dbgRva && dbgSize) + for (std::uint16_t i = 0; i < nsec; ++i) { + const std::size_t s = sec + 40u * i; + const std::uint32_t va = u32(pe, s + 12), raw = u32(pe, s + 20); + const std::uint32_t vsz = std::max(u32(pe, s + 8), u32(pe, s + 16)); + if (va <= dbgRva && dbgRva < va + vsz) + for (std::uint32_t e = 0; e < dbgSize / 28; ++e) + if (const std::size_t ptr = raw + (dbgRva - va) + 28u * e + 24; ptr + 4 <= pe.size()) + patch(ptr); + } + + std::string out = head; + out.resize(lfanew + shift, '\n'); + put32(out, 0x3C, lfanew + shift); + out += body; + for (auto const& p : posix) out += p.bytes; + if (out.size() != twoAt) return std::unexpected(std::string("the polyglot layout is inconsistent")); + out += two; + return out; +} + +} // namespace mcpp::pack::polyglot diff --git a/src/pack/stage_tree.cppm b/src/pack/stage_tree.cppm index f1e682fc3..dfa484c29 100644 --- a/src/pack/stage_tree.cppm +++ b/src/pack/stage_tree.cppm @@ -215,7 +215,7 @@ bool write_stage_manifest(const std::filesystem::path& stagingRoot, // parser decides whether a value is a built-in or a dispatch, and the // declaration check refuses a package that claims one of them. A package // claiming `tar` would be silently unreachable, since the built-in wins. -constexpr std::array kBuiltinPackFormats{"tar", "zip", "dir", "single-file"}; +constexpr std::array kBuiltinPackFormats{"tar", "zip", "dir", "single-file", "polyglot"}; bool is_builtin_pack_format(std::string_view name) { return std::ranges::find(kBuiltinPackFormats, name) != kBuiltinPackFormats.end(); diff --git a/src/wire.cppm b/src/wire.cppm index 8e1d871c6..862637098 100644 --- a/src/wire.cppm +++ b/src/wire.cppm @@ -258,6 +258,26 @@ inline nlohmann::json to_json(const Diagnostic& in) { return j; } +// THE FEATURES A CLIENT GATES ON (docs/50 §4, design 2026-10-30 part 2 §4.3). +// `kinds` say which schemas the output follows and `commands` which commands +// exist and what they do; a feature is a meaning inside commands that already +// exist, which a client turns something on or off by. A name is added when a +// client gates on it, is never renamed and never removed; docs/50 names the +// release that added it and the client that reads it. +inline constexpr std::array kFeatures{{ + "target-sets", // SPEC-015 §1: `--target openkal` is the set's legs (an IDE's target picker) + "pkg-format-list", // SPEC-014 §5.4: `--pkg-format tar,okb` writes each container (an IDE's pack form) +}}; + +// The same list as JSON: `--protocol-version` and every envelope's `mcpp` field +// carry it, so a client's one `self env` learns the version and the features +// together (design 2026-10-30 part 2 §4.3). +inline nlohmann::json features_json() { + nlohmann::json f = nlohmann::json::array(); + for (auto n : kFeatures) f.push_back(std::string(n)); + return f; +} + inline nlohmann::json to_json(const Envelope& e) { nlohmann::json effects = nlohmann::json::array(); for (auto f : e.effects) effects.push_back(std::string(effect_name(f))); @@ -279,6 +299,7 @@ inline nlohmann::json to_json(const Envelope& e) { {"mcpp", { {"version", std::string(mcpp::MCPP_VERSION)}, {"protocol", {{"min", kEnvelopeVersion}, {"max", kEnvelopeVersion}}}, + {"features", features_json()}, }}, }; if (!e.data.is_null()) out["data"] = e.data; @@ -324,6 +345,7 @@ inline nlohmann::json protocol_document( {"envelope", {{"min", kEnvelopeVersion}, {"max", kEnvelopeVersion}}}, {"kinds", std::move(kinds)}, {"commands", std::move(cmds)}, + {"features", features_json()}, }; } diff --git a/tests/e2e/638_pack_format_dispatch.sh b/tests/e2e/638_pack_format_dispatch.sh index cf8d338dc..17ebe3e1b 100755 --- a/tests/e2e/638_pack_format_dispatch.sh +++ b/tests/e2e/638_pack_format_dispatch.sh @@ -133,7 +133,7 @@ grep -q "unknown --pkg-format 'bogus'" b2.log \ || { cat b2.log; echo "FAIL: the refusal does not name the value"; exit 1; } # THE LIST IS THE POINT. A refusal that named a fixed list would be the # coupling this mechanism removes; this one names the graph's own answer. -grep -q "available in this build: tar, zip, dir, single-file, zap" b2.log \ +grep -q "available in this build: tar, zip, dir, single-file, polyglot, zap" b2.log \ || { cat b2.log; echo "FAIL: the refusal does not name what is available"; exit 1; } # Nothing was compiled to find that out. grep -q "Compiling app" b2.log \ diff --git a/tests/e2e/990_a_lock_key_does_not_depend_on_the_selected_members.sh b/tests/e2e/990_a_lock_key_does_not_depend_on_the_selected_members.sh new file mode 100755 index 000000000..d710bc247 --- /dev/null +++ b/tests/e2e/990_a_lock_key_does_not_depend_on_the_selected_members.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# requires: gcc fresh-sandbox +# 990 -- a lock entry's key and hash do not depend on which members a command +# plans (#868). +# +# Two members take one workspace dependency under two spellings: `a` writes +# `widget2.workspace = true`, `c` writes `[dependencies.mcpplibs] +# widget2.workspace = true`. The key was the spelling of a selected member, so +# `-p a` wrote `[package."widget2"]` and `-p c` `[package."mcpplibs.widget2"]`, +# each with its own hash (the hash is taken over the key), and the lock changed +# between commands with no change to a manifest. +# +# Criteria: `--workspace`, `-p a` and `-p c` write the same key and hash; it is +# the workspace's own spelling (`[workspace.dependencies] widget2`), which +# precedes every member's. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +mkdir -p "$TMP/ws" +cd "$TMP/ws" + +# A package of the default namespace, from a project-local index, with its +# payload already in place: nothing is fetched. +mkdir -p local-index/pkgs/w .mcpp/.xlings/data/xpkgs/mcpplibs.widget2/1.0.0/src +cat > local-index/pkgs/w/widget2.lua <<'EOF' +package = { + spec = "1", + namespace = "mcpplibs", + name = "widget2", + description = "a package of the default namespace from a local index", + licenses = {"MIT"}, + type = "package", + xpm = { + linux = { ["1.0.0"] = { url = "https://example.invalid/w.tar.gz" } }, + macosx = { ["1.0.0"] = { url = "https://example.invalid/w.tar.gz" } }, + windows = { ["1.0.0"] = { url = "https://example.invalid/w.zip" } }, + }, + mcpp = { + language = "c++23", + import_std = false, + sources = { "src/widget2.cppm" }, + targets = { ["widget2"] = { kind = "lib" } }, + deps = {}, + }, +} +EOF +printf 'export module widget2;\nexport int widget2_value() { return 35; }\n' \ + > .mcpp/.xlings/data/xpkgs/mcpplibs.widget2/1.0.0/src/widget2.cppm + +cat > mcpp.toml <<'EOF' +[workspace] +members = ["a", "c"] + +[workspace.indices] +mcpplibs = { path = "local-index" } + +[workspace.dependencies] +widget2 = "1.0.0" +EOF + +mkdir -p a/src c/src +cat > a/mcpp.toml <<'EOF' +[package] +name = "a" +version = "0.1.0" + +[dependencies] +widget2.workspace = true + +[targets.a] +kind = "lib" +EOF +printf 'export module a;\nimport widget2;\nexport int a_value() { return widget2_value() + 7; }\n' > a/src/a.cppm + +cat > c/mcpp.toml <<'EOF' +[package] +name = "c" +version = "0.1.0" + +[dependencies.mcpplibs] +widget2.workspace = true + +[targets.c] +kind = "bin" +main = "src/main.cpp" +EOF +printf 'import widget2;\nint main() { return widget2_value() == 35 ? 0 : 1; }\n' > c/src/main.cpp + +entry() { # the lock's widget2 entry: its header and its hash + grep -E '^\[package\..*widget2"\]' mcpp.lock + awk '/widget2"\]/{f=1} f && /^hash/{print; exit}' mcpp.lock +} + +declare -A seen +for sel in "--workspace" "-p a" "-p c"; do + rm -f mcpp.lock + # shellcheck disable=SC2086 + "$MCPP" build $sel --configure-only > "build.log" 2>&1 || { + cat build.log + echo "FAIL: mcpp build $sel --configure-only" + exit 1 + } + seen[$sel]="$(entry)" +done + +for sel in "--workspace" "-p a" "-p c"; do + [[ "${seen[$sel]}" == "${seen[--workspace]}" ]] || { + for s in "--workspace" "-p a" "-p c"; do echo "$s: ${seen[$s]}"; done + echo "FAIL: the lock entry of widget2 depends on the members a command plans" + exit 1 + } +done +grep -q '^\[package\."widget2"\]' <<<"${seen[--workspace]}" || { + echo "${seen[--workspace]}" + echo "FAIL: the key is not the workspace's own spelling (widget2)" + exit 1 +} + +echo "OK" diff --git a/tests/e2e/991_a_prebuilt_archive_in_ldflags_is_a_link_input.sh b/tests/e2e/991_a_prebuilt_archive_in_ldflags_is_a_link_input.sh new file mode 100755 index 000000000..ed59a1a7b --- /dev/null +++ b/tests/e2e/991_a_prebuilt_archive_in_ldflags_is_a_link_input.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell +# 991 -- a file `[build] ldflags` names by absolute path is an input of the link +# (design 2026-10-30 part 2 §25.3 C1). +# +# The clangd probe linked clangd's main over prebuilt static libraries named in +# `[build] ldflags`. A changed library did not relink the program: the words +# reached the linker but were no input of the link edge, so ninja saw nothing +# out of date, and the probe deleted its image to take a new library. +# +# Criteria: the program runs with the archive's first contents; after the +# archive is replaced, `mcpp build` relinks and the program runs with the new +# contents; a further build with nothing changed links nothing. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# The prebuilt archive, made by mcpp from a library project. +mkdir -p "$work/val/src" "$work/prebuilt" +cat > "$work/val/mcpp.toml" <<'TOML' +[package] +name = "val" +version = "0.1.0" + +[targets.val] +kind = "lib" +TOML +make_archive() { # make_archive + printf 'extern "C" int val() { return %s; }\n' "$1" > "$work/val/src/val.cpp" + (cd "$work/val" && "$MCPP" build > "$work/val.log" 2>&1) || { cat "$work/val.log"; echo "FAIL: the library did not build"; exit 1; } + local a + a="$(find "$work/val/target" -name 'libval*.a' -type f | head -1)" + [[ -n "$a" ]] || { find "$work/val/target" -type f | head -40; echo "FAIL: no archive of the library"; exit 1; } + cp "$a" "$work/prebuilt/libval.a" +} +make_archive 41 + +mkdir -p "$work/app/src" +cat > "$work/app/mcpp.toml" < "$work/app/src/main.cpp" <<'CPP' +import std; +extern "C" int val(); +int main() { std::println("val {}", val()); } +CPP +cd "$work/app" + +"$MCPP" run > run1.log 2>&1 || { cat run1.log; echo "FAIL: the first run"; exit 1; } +grep -q 'val 41' run1.log || { cat run1.log; echo "FAIL: the first run did not print val 41"; exit 1; } + +make_archive 42 +"$MCPP" run > run2.log 2>&1 || { cat run2.log; echo "FAIL: the second run"; exit 1; } +grep -q 'val 42' run2.log || { + cat run2.log + echo "FAIL: a replaced archive named in [build] ldflags did not relink the program" + exit 1 +} + +"$MCPP" build -v > build3.log 2>&1 || { cat build3.log; echo "FAIL: the third build"; exit 1; } +# `-v` prints each step ninja runs (`[1/1] … -o bin/app.tmp`); none may link. +if grep -qE '^\[[0-9]+/[0-9]+\] .*bin/app' build3.log; then + cat build3.log + echo "FAIL: a build with nothing changed linked the program again" + exit 1 +fi + +echo "OK" diff --git a/tests/e2e/992_a_polyglot_file_runs_on_every_system_it_holds.sh b/tests/e2e/992_a_polyglot_file_runs_on_every_system_it_holds.sh new file mode 100755 index 000000000..6c108c3eb --- /dev/null +++ b/tests/e2e/992_a_polyglot_file_runs_on_every_system_it_holds.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# 992 -- one file holds the single files of an image for several systems +# (SPEC-014 §5.3; design 2026-10-30 part 2 §19.6). +# +# `--pkg-format polyglot` assembles the single files of the legs and systems +# (`--systems all` unless stated) into one file: the Windows program leads +# with a POSIX shell script in its DOS header, the others follow it. A POSIX +# system starts it through `sh`; the script copies this system's program to the +# single file's cache (SPEC-014 §5.2's rules) once and executes it. +# +# Criteria: +# A. `mcpp pack --target openkal --pkg-format polyglot -o out/` writes one +# file, `-.exe`, a PE (MZqFpD, PE at e_lfanew) whose +# `Runs on` names this machine's pair. +# B. `sh ` runs this machine's program with the arguments; the first +# run places one program and `.mcpp-complete` under +# /mcpp/single-file//; the second places nothing more; a +# shell that runs a refused file as a script (bash) runs it too. +# C. A damaged file is refused with status 127 and places nothing. +# D. A cache directory that is a link is refused with status 127. +# E. `--systems ` without Windows writes a plain script, which the +# system executes directly. +# F. `mcpp run --pkg-format polyglot` runs it (through `sh`). +# G. Refused before a build: `polyglot` in a `--pkg-format` list; a native +# target; `mcpp run --pkg-format tar`; `mcpp emit xpkg` names the file. +# H. (wine) the same file runs as a Windows program. +set -e + +TMP=$(mktemp -d) +trap 'chmod -R u+w "$TMP" 2>/dev/null; rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +case "$(uname -m)" in + x86_64|amd64) HOST_ARCH=x86_64 ;; + aarch64|arm64) HOST_ARCH=aarch64 ;; + *) echo "SKIP: no ok-image row for $(uname -m)"; exit 0 ;; +esac +case "$(uname -s)" in Linux) OS=linux ;; Darwin) OS=macos ;; *) echo "SKIP: a POSIX host"; exit 0 ;; esac + +mkdir -p "$TMP/app/src" +cd "$TMP/app" +printf '[package]\nname = "pg"\nversion = "0.1.0"\n' > mcpp.toml +printf 'import std;\nint main(int argc, char** argv) { std::println("pg {} args, first [{}]", argc, argc > 1 ? argv[1] : ""); }\n' > src/main.cpp + +# A +"$MCPP" pack --target openkal --pkg-format polyglot -o out/ > a.log 2>&1 || fail "A: pack" a.log +[ "$(ls out)" = "pg-0.1.0.exe" ] || fail "A: the files are not one pg-0.1.0.exe: $(ls out)" a.log +F="$TMP/app/out/pg-0.1.0.exe" +[ "$(head -c 8 "$F")" = "MZqFpD='" ] || fail "A: the file does not begin with the DOS header's script" a.log +off=$(od -An -tu4 -j60 -N4 "$F" | tr -d ' ') +[ "$(tail -c +$((off + 1)) "$F" | head -c 2)" = "PE" ] || fail "A: no PE header at e_lfanew ($off)" a.log +grep -q "Runs on .*$OS-$HOST_ARCH" a.log || fail "A: Runs on does not name $OS-$HOST_ARCH" a.log +echo "ok: one file, a PE with the script in its DOS header" + +# B -- the cache of an empty home +REAL_HOME="$HOME" +export HOME="$TMP/home" +unset XDG_CACHE_HOME +mkdir -p "$HOME" +if [ "$OS" = macos ]; then CACHE="$HOME/Library/Caches/mcpp/single-file"; else CACHE="$HOME/.cache/mcpp/single-file"; fi +sh "$F" first > b1.log 2>&1 || fail "B: the first run" b1.log +grep -q "pg 2 args, first \[first\]" b1.log || fail "B: the first run's output" b1.log +n1=$(find "$CACHE" -type f | wc -l | tr -d ' ') +[ "$n1" = 2 ] || { find "$CACHE" >&2; fail "B: the first run placed $n1 files, expected the program and .mcpp-complete"; } +ls "$CACHE"/*/.mcpp-complete > /dev/null 2>&1 || fail "B: no .mcpp-complete" +sh "$F" second > b2.log 2>&1 || fail "B: the second run" b2.log +grep -q "first \[second\]" b2.log || fail "B: the second run's output" b2.log +n2=$(find "$CACHE" | wc -l | tr -d ' ') +bash -c "'$F' viabash" > b3.log 2>&1 || fail "B: bash running the file" b3.log +grep -q "first \[viabash\]" b3.log || fail "B: bash's run" b3.log +[ "$(find "$CACHE" | wc -l | tr -d ' ')" = "$n2" ] || fail "B: a later run placed more files" +echo "ok: placed once, run from the cache after" + +# C -- one byte of this machine's program changed +# This machine's program lies at `a=` of its line in the script at the end. +D="$TMP/damaged.exe" +cp "$F" "$D" +at=$(tail -c 4096 "$F" | grep -a "k=$OS-$HOST_ARCH " | sed 's/.* a=\([0-9]*\) .*/\1/') +[ -n "$at" ] || fail "C: no line for $OS-$HOST_ARCH in the script" +pos=$((at + 4096)) +byte=$(tail -c +$((pos + 1)) "$F" | head -c 1 | od -An -tu1 | tr -d ' ') +printf "\\$(printf '%03o' $(( (byte + 1) % 256 )))" | dd of="$D" bs=1 seek="$pos" conv=notrunc 2>/dev/null +cmp -s "$F" "$D" && fail "C: the byte was not changed" +HOME="$TMP/home-c" XDG_CACHE_HOME= sh "$D" x > c.log 2>&1 && rc=0 || rc=$? +[ "$rc" = 127 ] || fail "C: a damaged file exited $rc, expected 127" c.log +grep -q "does not match its digest" c.log || fail "C: the refusal does not say why" c.log +[ -z "$(find "$TMP/home-c" -name .mcpp-complete 2>/dev/null)" ] || fail "C: a damaged program was placed" c.log +echo "ok: a damaged program is refused" + +# D -- a cache directory that is a link +mkdir -p "$TMP/home-d/elsewhere" +if [ "$OS" = macos ]; then mkdir -p "$TMP/home-d/Library/Caches"; ln -s "$TMP/home-d/elsewhere" "$TMP/home-d/Library/Caches/mcpp"; +else mkdir -p "$TMP/home-d/.cache"; ln -s "$TMP/home-d/elsewhere" "$TMP/home-d/.cache/mcpp"; fi +HOME="$TMP/home-d" sh "$F" x > d.log 2>&1 && rc=0 || rc=$? +[ "$rc" = 127 ] || fail "D: a linked cache directory exited $rc, expected 127" d.log +grep -q "is not a directory of this user's" d.log || fail "D: the refusal does not say why" d.log +echo "ok: a linked cache directory is refused" + +# E -- without Windows the file is a script the system executes +"$MCPP" pack --target openkal --pkg-format polyglot --systems "$OS" -o plain/ > e.log 2>&1 || fail "E: pack" e.log +[ -f plain/pg-0.1.0 ] || fail "E: no pg-0.1.0 (without .exe)" e.log +[ "$(head -c 9 plain/pg-0.1.0)" = "#!/bin/sh" ] || fail "E: not a script" e.log +plain/pg-0.1.0 direct > e-run.log 2>&1 || fail "E: executed directly" e-run.log +grep -q "first \[direct\]" e-run.log || fail "E: the output" e-run.log +echo "ok: a file without Windows is a script" + +# F +"$MCPP" run --target "$HOST_ARCH-openkal" --pkg-format polyglot -- viarun > f.log 2>&1 || fail "F: mcpp run" f.log +grep -q "first \[viarun\]" f.log || fail "F: the program's output" f.log +grep -q 'Running `sh ' f.log || fail "F: not started through sh" f.log +echo "ok: mcpp run --pkg-format polyglot" + +# G +"$MCPP" pack --target openkal --pkg-format tar,polyglot -o g/ > g1.log 2>&1 && fail "G: a list with polyglot was accepted" g1.log +grep -q "polyglot is not one" g1.log || fail "G: the list's refusal" g1.log +"$MCPP" pack --pkg-format polyglot > g2.log 2>&1 && fail "G: a native target was accepted" g2.log +grep -q "is a program of one system" g2.log || fail "G: the native refusal" g2.log +grep -q "Compiling" g2.log && fail "G: the native refusal came after a build" g2.log +"$MCPP" run --pkg-format tar > g3.log 2>&1 && fail "G: mcpp run --pkg-format tar was accepted" g3.log +grep -q "runs a program, and tar is a container" g3.log || fail "G: run's refusal" g3.log +"$MCPP" emit xpkg --package "$F" --url https://example.invalid > g4.log 2>&1 && fail "G: emit xpkg accepted the file" g4.log +grep -q "is a polyglot file" g4.log || fail "G: emit xpkg does not name the polyglot file" g4.log +echo "ok: the refusals" + +# H +if command -v wine > /dev/null 2>&1 && [ "$OS" = linux ]; then + HOME="$REAL_HOME" WINEDEBUG=-all timeout 300 wine "$F" onwine > h.log 2>&1 || fail "H: wine" h.log + grep -q "first \[onwine\]" h.log || fail "H: the Windows program's output" h.log + echo "ok: the file is a Windows program" +fi + +echo "OK" diff --git a/tests/e2e/timings/linux.tsv b/tests/e2e/timings/linux.tsv index 1b881be97..f9d34787c 100644 --- a/tests/e2e/timings/linux.tsv +++ b/tests/e2e/timings/linux.tsv @@ -602,3 +602,6 @@ 987_the_universal_spellings_are_migrated.sh 107535 988_the_windows_launcher_finds_its_package_from_a_windows_path.sh 5 98_reflection_import_std.sh 7170 +990_a_lock_key_does_not_depend_on_the_selected_members.sh 20000 +991_a_prebuilt_archive_in_ldflags_is_a_link_input.sh 30000 +992_a_polyglot_file_runs_on_every_system_it_holds.sh 240000 diff --git a/tests/unit/test_pack_distribution.cpp b/tests/unit/test_pack_distribution.cpp index fea136ebd..819f81c41 100644 --- a/tests/unit/test_pack_distribution.cpp +++ b/tests/unit/test_pack_distribution.cpp @@ -215,6 +215,13 @@ TEST(PackDistribution, LaunchersPickTheArchitectureAndTheRunner) { EXPECT_EQ(sh.find("readlink -f"), std::string::npos); // A POSIX shell on Windows hands over to the program of the same name. EXPECT_NE(sh.find("MINGW*|MSYS*|CYGWIN*) exec \"$top/bin/\"'hello'.exe \"$@\""), std::string::npos) << sh; + // On macOS a runner carrying the quarantine mark of a browser's download is + // named, with the command that removes the mark (mcpp design 2026-10-30 + // part 2 §10 D6), before it is started. + const auto mark = sh.find("xattr -p com.apple.quarantine \"$runner\""); + EXPECT_NE(mark, std::string::npos) << sh; + EXPECT_NE(sh.find("xattr -dr com.apple.quarantine"), std::string::npos) << sh; + EXPECT_LT(mark, sh.find("exec \"$runner\"")) << sh; } // The runner's words expand as `mcpp run` expands them: a word's `{}` is the diff --git a/tests/unit/test_pack_polyglot.cpp b/tests/unit/test_pack_polyglot.cpp new file mode 100644 index 000000000..2c3fa71f3 --- /dev/null +++ b/tests/unit/test_pack_polyglot.cpp @@ -0,0 +1,79 @@ +#include + +import std; +import mcpp.pack.polyglot; + +// A polyglot file's layout (SPEC-014 §5.3). The PE half is asserted end to +// end by e2e 992, against the carriers a pack makes; what is asserted here is +// the arithmetic a wrong answer to would still run on the machine that wrote +// it: every program lies at the offset its line names, byte for byte, and the +// script's head names where the script is. + +namespace pg = mcpp::pack::polyglot; + +namespace { + +pg::Program program(std::string system, std::string arch, std::string bytes) { + return { std::move(system), std::move(arch), std::move(bytes), std::string(64, 'a') }; +} + +std::uint64_t number_after(std::string_view text, std::string_view key) { + const auto at = text.find(key); + if (at == std::string_view::npos) return ~std::uint64_t{0}; + std::uint64_t v = 0; + for (auto i = at + key.size(); i < text.size() && text[i] >= '0' && text[i] <= '9'; ++i) + v = v * 10 + static_cast(text[i] - '0'); + return v; +} + +} // namespace + +TEST(PackPolyglot, WithoutWindowsTheFileIsAScriptAndEachProgramLiesWhereItsLineSays) { + const std::string linux64(1000, 'L'), mac(777, 'M'); + auto file = pg::assemble("app", { program("linux", "x86_64", linux64), program("macos", "aarch64", mac) }); + ASSERT_TRUE(file.has_value()) << file.error(); + EXPECT_TRUE(file->starts_with("#!/bin/sh\no=$0;")); + EXPECT_TRUE(pg::is_polyglot(*file)); + + // The head names the script: `tail -c +` and `head -c `. + const auto at = number_after(*file, "tail -c +") - 1; + const auto length = number_after(*file, "|head -c "); + ASSERT_EQ(at + length, file->size()); + const std::string_view script = std::string_view(*file).substr(at); + EXPECT_TRUE(script.starts_with("# mcpp polyglot")); + EXPECT_NE(script.find("n='app'"), std::string_view::npos); + EXPECT_EQ(script.find("MINGW*"), std::string_view::npos) << "no Windows program, no MSYS line"; + + for (auto [key, bytes] : { std::pair{std::string_view("k=linux-x86_64 a="), std::string_view(linux64)}, + std::pair{std::string_view("k=macos-aarch64 a="), std::string_view(mac)} }) { + const auto line = script.find(key); + ASSERT_NE(line, std::string_view::npos) << key; + const auto off = number_after(script.substr(line), key); + const auto len = number_after(script.substr(line), " b="); + EXPECT_EQ(len, bytes.size()) << key; + EXPECT_EQ(std::string_view(*file).substr(off, len), bytes) << key; + } + // `uname` spells an architecture two ways on some systems. + EXPECT_NE(script.find("Linux-x86_64|Linux-amd64)"), std::string_view::npos); + EXPECT_NE(script.find("Darwin-aarch64|Darwin-arm64)"), std::string_view::npos); +} + +TEST(PackPolyglot, RefusesWhatItCannotLayOut) { + EXPECT_FALSE(pg::assemble("app", {}).has_value()); + EXPECT_FALSE(pg::assemble("a'b", { program("linux", "x86_64", "x") }).has_value()) + << "the name is quoted in the script"; + EXPECT_FALSE(pg::assemble("app", { program("freebsd", "x86_64", "x") }).has_value()); + auto notPe = pg::assemble("app", { program("windows", "x86_64", std::string(256, 'x')) }); + ASSERT_FALSE(notPe.has_value()); + EXPECT_NE(notPe.error().find("no PE"), std::string::npos) << notPe.error(); + auto twoWindows = pg::assemble("app", { program("windows", "x86_64", "MZ"), program("windows", "aarch64", "MZ") }); + ASSERT_FALSE(twoWindows.has_value()); + EXPECT_NE(twoWindows.error().find("one Windows program"), std::string::npos) << twoWindows.error(); +} + +TEST(PackPolyglot, RecognisesItsOwnHeadsOnly) { + EXPECT_TRUE(pg::is_polyglot("MZqFpD='\n ")); + EXPECT_FALSE(pg::is_polyglot("MZ\x90\x00")); + EXPECT_FALSE(pg::is_polyglot("#!/bin/sh\necho hi\n")); + EXPECT_FALSE(pg::is_polyglot("")); +} diff --git a/tests/unit/test_pack_stage_tree.cpp b/tests/unit/test_pack_stage_tree.cpp index f8973118f..07cf81124 100644 --- a/tests/unit/test_pack_stage_tree.cpp +++ b/tests/unit/test_pack_stage_tree.cpp @@ -115,20 +115,20 @@ TEST(PackStageTree, TheOrderOfADirectoryWalkIsNotAPromise) { read_file(mcpp::pack::stage_manifest_path(b.path / "s"))); } -TEST(PackStageTree, TheEngineOwnsFourFormatNames) { - // `tar`, `zip`, `dir` and `single-file` are the shapes `mcpp pack` owns +TEST(PackStageTree, TheEngineOwnsFiveFormatNames) { + // `tar`, `zip`, `dir`, `single-file` and `polyglot` are the shapes `mcpp pack` owns // (SPEC-014 §1.3); every other value of `--pkg-format` is a name a // package provides. The list lives beside the staged tree because two // layers need the same answer -- the listing and the refusal of an // unknown name, and the declaration check, which warns of a package that // claims one of them and would therefore be silently unreachable. - for (auto name : { "tar", "zip", "dir", "single-file" }) + for (auto name : { "tar", "zip", "dir", "single-file", "polyglot" }) EXPECT_TRUE(mcpp::pack::is_builtin_pack_format(name)) << name; EXPECT_FALSE(mcpp::pack::is_builtin_pack_format("appimage")); EXPECT_FALSE(mcpp::pack::is_builtin_pack_format("okb")); EXPECT_FALSE(mcpp::pack::is_builtin_pack_format("msi")); EXPECT_FALSE(mcpp::pack::is_builtin_pack_format("")); - EXPECT_EQ(mcpp::pack::kBuiltinPackFormats.size(), 4u); + EXPECT_EQ(mcpp::pack::kBuiltinPackFormats.size(), 5u); } // ── #630 §3: the `closure` field ───────────────────────────────────────── diff --git a/tests/unit/test_toml_serialize.cpp b/tests/unit/test_toml_serialize.cpp index 704861ba1..ca9217cf6 100644 --- a/tests/unit/test_toml_serialize.cpp +++ b/tests/unit/test_toml_serialize.cpp @@ -125,6 +125,32 @@ version = "0.1.0" )", "implicit container"); } +// The reader accepts every TOML 1.0 escape (#867), so a value may hold any +// control character; the writer escapes each one, so the text it produces is +// TOML and reads back as the same value. `\0` is this reader's extension and +// not TOML 1.0, so it is written as `\u0000`. +TEST(TomlSerialize, ControlCharactersAreEscaped) { + expect_round_trip(R"( +[package] +name = "p" +version = "0.1.0" +description = "b\b f\f t\t n\n r\r e\u001B d\u007F z\u0000 nul\0 ué" +)", "control characters"); + + std::string all; + for (int c = 0; c < 0x20; ++c) all.push_back(static_cast(c)); + all.push_back('\x7F'); + const auto text = t::escape_string(all); + for (char c : text) { + const auto u = static_cast(c); + EXPECT_FALSE(u < 0x20 || u == 0x7F) << "an unescaped control character in " << text; + } + EXPECT_EQ(text.find("\\0"), std::string::npos) << "\\0 is not TOML 1.0: " << text; + auto back = t::parse("v = " + text + "\n"); + ASSERT_TRUE(back.has_value()) << back.error().message << "\n" << text; + EXPECT_EQ(back->root().at("v").as_string(), all); +} + TEST(TomlSerialize, RepositoryManifestsRoundTrip) { // Every manifest in the repository's examples, and the project's own. The // repository is located from this file's own path rather than from the diff --git a/tests/unit/test_version_req.cpp b/tests/unit/test_version_req.cpp index e7d5fba4f..c6108ff46 100644 --- a/tests/unit/test_version_req.cpp +++ b/tests/unit/test_version_req.cpp @@ -96,6 +96,24 @@ TEST(VersionReq, PartialCaretBoundsFollowTheWrittenSegments) { EXPECT_FALSE(matches(*zeroFive, *parse_version("0.6.0"))); } +// A bare requirement is a caret (#866's follow-up), so a bare partial spelling +// takes the same wildcard bounds: "0" is <1.0.0 and "0.0" is <0.1.0, where +// before #866 both matched 0.0.0 alone. +TEST(VersionReq, BarePartialRequirementsAreCarets) { + for (auto [bare, caret] : {std::pair{"0", "^0"}, std::pair{"0.0", "^0.0"}, + std::pair{"1", "^1"}, std::pair{"1.2", "^1.2"}}) { + auto b = parse_req(bare); ASSERT_TRUE(b) << bare; + auto c = parse_req(caret); ASSERT_TRUE(c) << caret; + for (auto v : {"0.0.0", "0.0.7", "0.1.0", "0.9.3", "1.0.0", "1.2.0", "1.2.9", "1.3.0", "2.0.0"}) + EXPECT_EQ(matches(*b, *parse_version(v)), matches(*c, *parse_version(v))) + << bare << " against " << v; + } + EXPECT_TRUE (matches(*parse_req("0"), *parse_version("0.9.3"))); + EXPECT_FALSE(matches(*parse_req("0"), *parse_version("1.0.0"))); + EXPECT_TRUE (matches(*parse_req("0.0"), *parse_version("0.0.7"))); + EXPECT_FALSE(matches(*parse_req("0.0"), *parse_version("0.1.0"))); +} + TEST(VersionReq, PartialTildeWithOnlyAMajorHoldsTheMajor) { auto r = parse_req("~1"); ASSERT_TRUE(r); EXPECT_TRUE (matches(*r, *parse_version("1.0.0"))); diff --git a/tests/unit/test_wire.cpp b/tests/unit/test_wire.cpp index 845e424ea..825179088 100644 --- a/tests/unit/test_wire.cpp +++ b/tests/unit/test_wire.cpp @@ -102,6 +102,15 @@ TEST(WireEnvelope, ReportsMcppVersionAndProtocolWindow) { EXPECT_EQ(j["mcpp"]["protocol"]["max"], w::kEnvelopeVersion); } +// Every envelope carries the features beside the version, so a client's one +// `self env` (or any job's envelope) says what the project's mcpp can do +// (design 2026-10-30 part 2 §4.3); the list is the protocol document's. +TEST(WireEnvelope, CarriesTheFeaturesOfTheProtocolDocument) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); + ASSERT_TRUE(j["mcpp"].contains("features") && j["mcpp"]["features"].is_array()); + EXPECT_EQ(j["mcpp"]["features"], w::protocol_document({})["features"]); +} + // ── Negotiation ──────────────────────────────────────────────────────────── TEST(WireProtocol, ListsKindsAndTheirVersions) { @@ -130,6 +139,17 @@ TEST(WireProtocol, DeclaresEffectsPerCommandStatically) { << "measured on a fresh MCPP_HOME: it creates nothing"; } +// A client gates on a feature, not on a version number (design 2026-10-30 +// part 2 §4.3): the list is present, and the names are the stable ones. +TEST(WireProtocol, NamesTheFeaturesAClientGatesOn) { + auto j = w::protocol_document({}); + ASSERT_TRUE(j.contains("features") && j["features"].is_array()); + std::vector names; + for (auto const& f : j["features"]) names.push_back(f.get()); + EXPECT_NE(std::ranges::find(names, "target-sets"), names.end()); + EXPECT_NE(std::ranges::find(names, "pkg-format-list"), names.end()); +} + TEST(WireProtocol, IsItselfSelfIdentifying) { auto j = w::protocol_document({}); EXPECT_EQ(j["kind"], "mcpp.protocol"); @@ -188,7 +208,7 @@ TEST(WireGolden, EnvelopeKeySet) { auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); expect_has_keys(j, {"schemaVersion", "kind", "kindVersion", "effects", "mcpp", "data", "diagnostics"}, "the envelope"); - expect_has_keys(j["mcpp"], {"version", "protocol"}, "envelope.mcpp"); + expect_has_keys(j["mcpp"], {"version", "protocol", "features"}, "envelope.mcpp"); expect_has_keys(j["mcpp"]["protocol"], {"min", "max"}, "envelope.mcpp.protocol"); } @@ -208,7 +228,7 @@ TEST(WireGolden, DiagnosticKeySet) { TEST(WireGolden, ProtocolDocumentKeySet) { auto j = w::protocol_document({{"self env", {w::Effect::InitMcppHome}}}); expect_has_keys(j, {"schemaVersion", "kind", "mcpp", "envelope", "kinds", - "commands"}, "the protocol document"); + "commands", "features"}, "the protocol document"); expect_has_keys(j["envelope"], {"min", "max"}, "protocol.envelope"); expect_has_keys(j["commands"]["self env"], {"effects"}, "a command entry"); }