|
| 1 | +# d2mcpp Provider 与练习脚手架 参考 |
| 2 | + |
| 3 | +- 日期:2026-07-20 |
| 4 | +- 分支:`feat/mcpp-provider` |
| 5 | +- 配套调研:[`2026-07-19-mcpp-replace-xmake-research.md`](2026-07-19-mcpp-replace-xmake-research.md)(xmake → mcpp 的可行性论证与 rustlings/cargo 横向对照) |
| 6 | +- d2x 侧协议规范:d2x 仓库 `.agents/docs/2026-07-20-d2x-architecture-reference.md` |
| 7 | +- 本文定位:**当前实现的参考手册**——目录约定、生成物、判定机制、维护须知 |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## 1. 全景 |
| 12 | + |
| 13 | +xmake 已完全退役。构建链路是: |
| 14 | + |
| 15 | +``` |
| 16 | +d2x(通用框架,零内置后端) |
| 17 | + └─ Provider Protocol (NDJSON) ─→ d2x/buildtools/mcpp/ ← 本仓库自己的 Provider(C++26) |
| 18 | + ├─ 目录约定发现练习 |
| 19 | + ├─ 生成 mcpp workspace 清单 |
| 20 | + ├─ 调 mcpp build/run |
| 21 | + └─ 读侧信道判定通过 |
| 22 | + ↓ |
| 23 | + mcpp(未做任何改动) |
| 24 | +``` |
| 25 | + |
| 26 | +### 提交进仓库的包 |
| 27 | + |
| 28 | +``` |
| 29 | +mcpp.toml [workspace] members = ["d2x/buildtools/mcpp", "dslings/harness"] |
| 30 | +d2x/buildtools/mcpp/ Provider,standard = "c++26" |
| 31 | +dslings/harness/ 练习脚手架库,standard = "c++23" |
| 32 | +``` |
| 33 | + |
| 34 | +### 生成物(gitignore) |
| 35 | + |
| 36 | +``` |
| 37 | +.d2x/build/mcpp.toml 独立 workspace 根 |
| 38 | +.d2x/build/<std>/mcpp.toml 该标准下全部 target —— 供 clangd |
| 39 | +.d2x/build/_current/mcpp.toml 只含当前一题 —— 供 checker |
| 40 | +.d2x/build/_current/result.ndjson 判定用侧信道 |
| 41 | +.d2x/state.json 学员进度(d2x 写) |
| 42 | +``` |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## 2. 引导:零脚本、Windows 安全 |
| 47 | + |
| 48 | +`.d2x.json`: |
| 49 | + |
| 50 | +```json |
| 51 | +{ "buildtools": "mcpp run -q -p d2x/buildtools/mcpp --" } |
| 52 | +``` |
| 53 | + |
| 54 | +d2x 拼接后得到 `mcpp run -q -p d2x/buildtools/mcpp -- check <id>`。**从仓库根执行、无需 cd、首次自动构建 Provider**,暖开销约 26ms。 |
| 55 | + |
| 56 | +d2x 从不 `chdir`(它在静态初始化时捕获 CWD),所以免 `cd` 是硬要求。注意 `mcpp` 的 `-p` 匹配的是**目录 basename 或完整相对路径**,不是包名。 |
| 57 | + |
| 58 | +### ⚠️ 嵌套 mcpp 与 `LD_LIBRARY_PATH` |
| 59 | + |
| 60 | +`mcpp run` 会把 `LD_LIBRARY_PATH` 指向它私有的 glibc 并注入子进程。Provider 再去 spawn 嵌套的 `mcpp` 时被迫加载错配的 glibc,**在动态链接器里段错误**(冷启动稳定复现,输出只剩 `<pid>:\t__vdso_time` 这样的 trace 残片)。 |
| 61 | + |
| 62 | +`runner.cppm` 在每次 spawn 前 `unsetenv("LD_LIBRARY_PATH")` 绕过;mcpp 会为它自己的子进程重新设置正确的值。 |
| 63 | + |
| 64 | +d2x 侧对同一问题早有相同处理(`platform.cppm`),仓库里还留着 `workaround_ld_library_path_issue` 分支——**这是 mcpp 的既有问题,值得单独向上游报**。 |
| 65 | + |
| 66 | +--- |
| 67 | + |
| 68 | +## 3. 练习发现:目录约定 + 就近指令 |
| 69 | + |
| 70 | +**没有声明文件。** 放一个 `.cpp` 进去就是全部工作。 |
| 71 | + |
| 72 | +rustlings 最贵的一课是 PR #1355:Rust edition 同时写在 `rustc` 参数和 `rust-project.json` 里,两边漂移酿成 bug。**任何独立声明文件都是第二套真相源。** 这里的真相只有两处,且都无法漂移:目录结构,和练习文件自己的头部注释。 |
| 73 | + |
| 74 | +### 目录布局 |
| 75 | + |
| 76 | +``` |
| 77 | +dslings/hello-mcpp.cpp 入门练习(chapter = intro) |
| 78 | +dslings/<std>/NN-topic-K.cpp zh 练习 |
| 79 | +dslings/en/<std>/NN-topic-K.cpp en 练习(lang=en 时启用,与 zh 互斥) |
| 80 | +solutions/<std>/NN-topic-K.cpp 参考答案(zh/en 共用同一份) |
| 81 | +``` |
| 82 | + |
| 83 | +### id / order / chapter 的推导 |
| 84 | + |
| 85 | +| 文件 | id | chapter | order | |
| 86 | +|---|---|---|---| |
| 87 | +| `dslings/cpp11/00-auto-and-decltype-0.cpp` | `cpp11-00-auto-and-decltype-0` | `cpp11/00-auto-and-decltype` | 1100000 | |
| 88 | +| `dslings/cpp11/03-trailing-return-type.cpp` | `cpp11-03-trailing-return-type` | `cpp11/03-trailing-return-type` | 1100300 | |
| 89 | +| `dslings/hello-mcpp.cpp` | `hello-mcpp` | `intro` | -100000 | |
| 90 | + |
| 91 | +`order = std_rank × 100000 + 章节号 × 100 + 练习序号`。尾部 `-<数字>` 是序号,没有就当 0(仓库里 03/04 两章确实缺 `-0` 后缀,规则把它们归一化)。 |
| 92 | + |
| 93 | +### 每练习编译选项 |
| 94 | + |
| 95 | +写在练习文件自己的头部注释里(只扫前 40 行): |
| 96 | + |
| 97 | +```cpp |
| 98 | +// d2x:cxxflags: -O0 -fno-elide-constructors |
| 99 | +``` |
| 100 | + |
| 101 | +仓库现存用例:`04-rvalue-references`(观察移动)、`07-constexpr-0`(`-Wpedantic -Werror` 让 VLA 成为错误)。 |
| 102 | + |
| 103 | +### id 白名单 |
| 104 | + |
| 105 | +id 只允许 `[A-Za-z0-9._-]`,违反者**被拒绝并打印到 stderr**,不进入枚举。 |
| 106 | + |
| 107 | +因为 id 有两个危险去向:d2x 拼进 shell 命令、我们写进生成的 TOML(`[targets.<id>]`)。在源头挡住比在两个下游各自转义更可靠——这类文件名本就是笔误或恶意 PR,与其想办法安全传递,不如让作者改名。 |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## 4. 双 member 布局 |
| 112 | + |
| 113 | +**硬约束:dslings 的练习默认就编译不过**(49 个带 `D2X_YOUR_ANSWER`),而 mcpp 的 `build` 和 `run` 都会先全量构建整个包——一个坏兄弟拖垮全部且零产物。实测: |
| 114 | + |
| 115 | +``` |
| 116 | +member 含全部 3 题(后两题未做完)→ mcpp build -p → exit=1 ← 当前这题被拖挂 |
| 117 | +只生成当前这一题 → 0.071s,exit=0 |
| 118 | +``` |
| 119 | + |
| 120 | +**member 粒度(标准/特性/练习)本身不解决逐题隔离。** 所以分两个: |
| 121 | + |
| 122 | +| 目录 | 内容 | 用途 | |
| 123 | +|---|---|---| |
| 124 | +| `.d2x/build/<std>/` | 该标准下全部 target | clangd —— 即使多数练习编译不过,mcpp 仍生成完整 `compile_commands.json`,IDE 对每题都有正确编译参数 | |
| 125 | +| `.d2x/build/_current/` | 只含要验证的那一题 | checker | |
| 126 | + |
| 127 | +实测动态改写清单代价极低:切题 0.118s、切回 0.018s,**fingerprint 目录始终只有 1 个**(改写 target 集合不会让缓存爆炸)。 |
| 128 | + |
| 129 | +### 其他生成细节 |
| 130 | + |
| 131 | +- `main` 用 `../../../` 逃逸出包根,**练习源文件原地不动** |
| 132 | +- 脚手架走 `[dependencies] path` 依赖,而非把仓库根塞进 include 搜索路径(后者会让练习能 `#include` 仓库里任何文件) |
| 133 | +- `-fmacro-prefix-map=<repo_root>/=` 让 `__FILE__` 相对仓库根,学员看到的报错不再顶着一长串 `/home/...` |
| 134 | +- 所有清单**内容比对后才落盘**,不推进 mtime,保住 mcpp 的快速路径 |
| 135 | +- `check` 会先 `write_full` 再 `write_current`——全新仓库上学员直接跑 `d2x checker` 时根清单还不存在,否则 mcpp 报退出码 2 |
| 136 | + |
| 137 | +--- |
| 138 | + |
| 139 | +## 5. 判定机制 |
| 140 | + |
| 141 | +### 侧信道,不是扫 stdout |
| 142 | + |
| 143 | +判定原先 100% 是带内信号:断言失败**并不改变退出码**(实测 exit=0),所以只能扫 stdout 找 `❌`。这会误判——一个断言全过的正确解答,只要在说明文字里打了个 `❌` 就被判失败(已复现)。反过来,输出截断或 Windows 控制台代码页不对,判定同样失效。 |
| 144 | + |
| 145 | +现在 stdout 回归「给人看」,判定走侧信道: |
| 146 | + |
| 147 | +```jsonc |
| 148 | +{"kind":"assert","ok":false,"expr":"a == b","expected":"1","actual":"2", |
| 149 | + "file":"dslings/cpp11/xx.cpp","line":33} |
| 150 | +{"kind":"wait","file":"dslings/cpp11/xx.cpp","line":40} |
| 151 | +``` |
| 152 | + |
| 153 | +**逐条追加而非退出时统一写** —— 练习段错误时,崩溃之前的断言结果照样保留。 |
| 154 | + |
| 155 | +`D2X_RESULT_FILE` 环境变量未设置时 harness 只打印不写文件:**学员直接跑二进制零摩擦**。 |
| 156 | + |
| 157 | +### 判定顺序 |
| 158 | + |
| 159 | +| 侧信道内容 | Verdict | |
| 160 | +|---|---| |
| 161 | +| 有 `ok:false` | `fail` + 每条失败转一个 `Diagnostic` | |
| 162 | +| 无失败但退出码非 0 | `fail` | |
| 163 | +| 无失败、有 `wait` | `blocked` | |
| 164 | +| **文件不存在**(练习没用 harness) | 退回「编译通过 + 退出 0 = `pass`」 | |
| 165 | + |
| 166 | +最后一条让 **harness 自动变成可选的**,不需要额外机制:纯观察型练习(现有 18 个只用 `D2X_WAIT`)可以写成零依赖的纯 C++ 文件,学员能原样拷进 Compiler Explorer。 |
| 167 | + |
| 168 | +### 路径:展示 vs 定位 |
| 169 | + |
| 170 | +侧信道里的 `file` 因 `-fmacro-prefix-map` 是相对路径(学员看着舒服),但协议要求绝对路径(d2x 靠它开编辑器、监听变更)。**在协议边界上还原成绝对。** |
| 171 | + |
| 172 | +--- |
| 173 | + |
| 174 | +## 6. 练习脚手架 `dslings/harness/` |
| 175 | + |
| 176 | +按库设计,目前住在本仓库,将来可整体抽成独立 mcpp 包给其他 C++ 课程复用。 |
| 177 | + |
| 178 | +### 两条路径不等价 |
| 179 | + |
| 180 | +| 设施 | `#include <d2x/cpp/common.hpp>` | `import d2x.harness;` | |
| 181 | +|---|---|---| |
| 182 | +| 断言 | `d2x_assert` / `d2x_assert_eq` 宏 | `d2x::check` / `check_eq`,`source_location` 自动带 file/line | |
| 183 | +| 路障 | `D2X_WAIT` | `d2x::wait()` | |
| 184 | +| 填空占位 | `D2X_YOUR_ANSWER` | **无等价物** | |
| 185 | + |
| 186 | +**宏无法跨模块导出**,这是 C++ 的硬约束而非疏漏。`D2X_YOUR_ANSWER` 必须展开为空才能制造编译错误,本质就是宏。**cpp20/cpp23 等模块化章节需要另设填空约定——这是个尚未决策的缺口。** |
| 187 | + |
| 188 | +`include/` 下保持 `d2x/cpp/common.hpp` 这个既有路径,所以 104 个练习和书本一行都不用改。路径改名可以以后单独做。 |
| 189 | + |
| 190 | +### 维护须知 |
| 191 | + |
| 192 | +`d2x_assert_eq` 的**所有分支**都必须走 `d2x::detail::show()`,不能用裸 `std::to_string`——后者没有 `std::string` / `const char*` / scoped enum 的重载,一旦有练习比较这些类型就会硬编译失败。(曾经上报分支用了 `show()` 而日志分支没用,是个潜伏的地雷。) |
| 193 | + |
| 194 | +--- |
| 195 | + |
| 196 | +## 7. 测试 |
| 197 | + |
| 198 | +### `d2x/buildtools/mcpp/tests/e2e.sh` |
| 199 | + |
| 200 | +断言两件事,缺一不可:**每个练习未完成时不通过**、**每个参考答案放进去后通过**。等价于 rustlings 的 `cargo dev check --require-solutions`。 |
| 201 | + |
| 202 | +走的是 `d2x checker` 内部同一条 Provider 路径,所以绿灯等价于学员本地能跑通,而非另一条平行路径。 |
| 203 | + |
| 204 | +**两道防线:** |
| 205 | + |
| 206 | +1. **防空转**——`pass == 0` 时直接判失败。旧 CI 就是这么绿了很久的:它只挑 `-ref` 目标,而 `solutions/` 早被注释掉,循环一次都没进,job 照样退出 0;第 60 行的 `wc -l` 对空串还打印 "Found 1 reference targets" 把空转掩盖了。同类问题在本脚本自己身上也犯过一次(`${sol#en/}` 剥离顺序错,导致所有英文答案静默 SKIP)。 |
| 207 | + |
| 208 | +2. **脏树检查**——练习目录有未提交改动时**拒绝运行**并列出文件。脚本会把参考答案覆盖到练习上再还原,天然会吃掉未提交的改动,这个陷阱咬过两次。 |
| 209 | + |
| 210 | +### 当前状态 |
| 211 | + |
| 212 | +| 项 | 结果 | |
| 213 | +|---|---| |
| 214 | +| Provider 枚举 | 52 题(zh:1 + 49 + 2;en 同样 52) | |
| 215 | +| 端到端 zh | 51/51 参考答案通过 | |
| 216 | +| 端到端 en | 51/51 参考答案通过 | |
| 217 | +| d2x session 单测 | 28/28 | |
| 218 | +| 事件流 | 纯 JSON,0 污染 | |
| 219 | +| 注入防护 | 恶意文件名被拒绝,命令未执行 | |
| 220 | + |
| 221 | +--- |
| 222 | + |
| 223 | +## 8. C++ 标准与教学漂移 |
| 224 | + |
| 225 | +**所有练习按 `c++23` 编译。** mcpp 目前硬拒 `c++11/14/17/20`(`src/manifest/types.cppm` 的白名单),本方案选择不改 mcpp 上游。因此 `cppNN/` 目录现在表示**特性引入于哪个标准**,不再改变编译参数。 |
| 226 | + |
| 227 | +### 已知代价 |
| 228 | + |
| 229 | +`04-rvalue-references` 的移动构造教学点。C++17 起 prvalue 直接初始化目标(保证复制省略),`-fno-elide-constructors` 再也无法让 `Object obj = Object();` 产生移动构造。 |
| 230 | + |
| 231 | +**修法:** 改成从具名对象 `std::move`(任何标准下都必然调用移动构造),并补一条 `d2x_assert(move_ctor_calls >= 1)`。 |
| 232 | + |
| 233 | +**更重要的是那条断言。** 漂移之所以能静默发生,正是因为从前没有任何断言检查它——输出少一行没人发现。现在它是 CI 可检测的事实。 |
| 234 | + |
| 235 | +> **教训:教学点如果没有断言覆盖,它的消失就是不可见的。** 新增练习时应问:这一课的核心观测点,有断言在守着吗? |
| 236 | +
|
| 237 | +量化过的风险面:51 个参考答案在「原标准 vs c++23」下逐一编译并 diff 运行输出,**50 个完全一致,仅此 1 个漂移**。但随着课程往 cpp17/20 扩写,这类静默漂移只会变多。 |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +## 9. 撰稿流程的变化 |
| 242 | + |
| 243 | +`.agents/skills/d2mcpp-authoring/` 已同步更新: |
| 244 | + |
| 245 | +- **练习不再需要注册**——放进 `dslings/<std>/` 即被目录约定发现 |
| 246 | +- 新增 `<std>` 章节只需建目录 + 改两个 `SUMMARY.md`,无构建文件接线 |
| 247 | +- 每练习编译选项改为文件头部 `// d2x:cxxflags:` 指令 |
| 248 | +- 验证命令改为 `mcpp run -q -p d2x/buildtools/mcpp -- check <id>` 或整体 `bash d2x/buildtools/mcpp/tests/e2e.sh` |
| 249 | + |
| 250 | +--- |
| 251 | + |
| 252 | +## 10. 已知缺口 |
| 253 | + |
| 254 | +| 缺口 | 影响 | |
| 255 | +|---|---| |
| 256 | +| **模块化练习的填空占位符无约定** | `D2X_YOUR_ANSWER` 是宏,跨不了模块边界;cpp20/cpp23 章节需先决策 | |
| 257 | +| **macOS / Windows 从未验证** | `_popen`、`_putenv_s` 的 `#ifdef` 分支全是纸面推断 | |
| 258 | +| **新 CI 从未真跑过** | workflow 是手写的,`xlings install -y` 在 CI 环境能否装上 mcpp 未验证 | |
| 259 | +| **`diagnostics` 只覆盖运行期断言** | 编译错误尚未解析成结构化诊断 | |
| 260 | +| **Provider 侧无单测** | discovery 的 id/order/chapter 推导、manifest 生成都只被端到端间接覆盖 | |
| 261 | +| **`hello-mcpp` 无参考答案** | e2e 只能 SKIP 它,入门练习实际未被验证 | |
| 262 | +| **`solutions/` 无 en 镜像** | 设计如此(共用一份),但意味着 en 专有的措辞错误无法被答案校验发现 | |
0 commit comments