From 7f088aef4907ae14306e84fea3dd42ceabfbd684 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 19 Jul 2026 23:49:26 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(buildtools):=20=E6=96=B0=E5=A2=9E=20mc?= =?UTF-8?q?pp=20Provider=EF=BC=88C++26=EF=BC=89=EF=BC=8C=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=20xmake=20=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d2x 改为通用练习框架后,构建工具由具体课程实现。本 Provider 用 C++26 + mcpp 编写——教 C++ 的仓库用 C++ 写自己的工具,且 mcpp 本就是 必需品,零新增运行时依赖。 发现方式刻意不引入声明文件:真相只有目录结构和练习文件自己的头部注释 (// d2x:cxxflags:)。rustlings PR #1355 的教训是 edition 同时写在 rustc 参数和 rust-project.json 里、两边漂移酿成 bug——任何独立声明文件都是 第二套真相源。 双 member 布局解决一个硬约束:dslings 的练习默认就编译不过,而 mcpp 的 build/run 都会先全量构建整个包,一个坏兄弟拖垮全部且零产物。于是 .d2x/build// 持全量 target 供 clangd 拿到完整 compile_commands.json, .d2x/build/_current/ 每次只写当前一题供 checker。清单写入前做内容比对, 不推进 mtime,mcpp 的快速路径得以保留。 练习源文件原地不动:main 用 ../ 逃逸出包根。 引导零脚本、Windows 安全:.d2x.json 指向 `mcpp run -q -p d2x/buildtools/mcpp --`,从仓库根执行无需 cd,首次自动 构建 Provider。 runner 在每次 spawn 前清 LD_LIBRARY_PATH:mcpp run 会把它指向自己私有的 glibc 并注入子进程,嵌套的 mcpp 被迫加载错配运行时后会在动态链接器里 段错误(冷启动稳定复现)。d2x 侧对同一问题早有相同处理。 tests/e2e.sh 断言每个参考答案通过、每个练习不通过——rustlings `dev check --require-solutions` 的等价物。实测 51/51 通过。 --- .d2x.json | 6 +- .gitignore | 9 +- d2x/buildtools/mcpp/mcpp.toml | 6 + d2x/buildtools/mcpp/src/discovery.cppm | 173 +++++++++++++++++++++++++ d2x/buildtools/mcpp/src/emit.cppm | 95 ++++++++++++++ d2x/buildtools/mcpp/src/main.cpp | 131 +++++++++++++++++++ d2x/buildtools/mcpp/src/manifest.cppm | 129 ++++++++++++++++++ d2x/buildtools/mcpp/src/runner.cppm | 109 ++++++++++++++++ d2x/buildtools/mcpp/tests/e2e.sh | 66 ++++++++++ mcpp.toml | 7 + 10 files changed, 727 insertions(+), 4 deletions(-) create mode 100644 d2x/buildtools/mcpp/mcpp.toml create mode 100644 d2x/buildtools/mcpp/src/discovery.cppm create mode 100644 d2x/buildtools/mcpp/src/emit.cppm create mode 100644 d2x/buildtools/mcpp/src/main.cpp create mode 100644 d2x/buildtools/mcpp/src/manifest.cppm create mode 100644 d2x/buildtools/mcpp/src/runner.cppm create mode 100755 d2x/buildtools/mcpp/tests/e2e.sh create mode 100644 mcpp.toml diff --git a/.d2x.json b/.d2x.json index 7424e6b..4ece6aa 100644 --- a/.d2x.json +++ b/.d2x.json @@ -1,7 +1,7 @@ { "version": "0.1.1", - "buildtools": "xmake d2x-buildtools", - "lang": "en", + "buildtools": "mcpp run -q -p d2x/buildtools/mcpp --", + "lang": "zh", "llm": { "api_key": "", "api_url": "https://api.deepseek.com/v1", @@ -9,4 +9,4 @@ "system_prompt": "" }, "ui_backend": "tui" -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index 683495e..19e8da7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,11 @@ llm.config.xlings media build .cache/ -dslings/compile_commands.json \ No newline at end of file +dslings/compile_commands.json +# d2x Provider 生成物(mcpp workspace 清单)与学习进度 +.d2x/build/ +.d2x/state.json + +# mcpp 构建产物 +target/ +compile_commands.json diff --git a/d2x/buildtools/mcpp/mcpp.toml b/d2x/buildtools/mcpp/mcpp.toml new file mode 100644 index 0000000..5aeb0fb --- /dev/null +++ b/d2x/buildtools/mcpp/mcpp.toml @@ -0,0 +1,6 @@ +[package] +name = "d2x-buildtools-mcpp" +version = "0.1.0" +description = "d2mcpp 的 d2x Provider:枚举练习、生成 mcpp 清单、构建/运行/判定" +license = "Apache-2.0" +standard = "c++26" diff --git a/d2x/buildtools/mcpp/src/discovery.cppm b/d2x/buildtools/mcpp/src/discovery.cppm new file mode 100644 index 0000000..a9e2d87 --- /dev/null +++ b/d2x/buildtools/mcpp/src/discovery.cppm @@ -0,0 +1,173 @@ +// 练习发现:目录约定 + 练习文件内的就近指令。 +// +// 刻意不引入独立的声明文件(exercises.toml 之类)。rustlings 最贵的一课 +// 是 PR #1355:edition 同时写在 rustc 参数和 rust-project.json 里,两边漂移 +// 酿成 bug。任何独立声明文件都是第二套真相源。这里的真相只有两处,且都 +// 无法漂移:目录结构,和练习文件自己的头部注释。 +export module d2x.provider.discovery; + +import std; + +namespace fs = std::filesystem; + +namespace d2x::discovery { + +export struct Exercise { + std::string id; // cpp11-00-auto-and-decltype-0 + int order{}; // 显式顺序,不依赖字典序 + std::string title; // auto and decltype (0) + std::string chapter; // cpp11/00-auto-and-decltype + std::string std_dir; // cpp11 —— 决定它属于哪个 member + fs::path file; // 绝对路径 + std::vector cxxflags; // 来自 // d2x:cxxflags: 指令 +}; + +// .d2x.json 里我们只关心 "lang"。写一个完整 JSON 解析器是杀鸡用牛刀, +// 但也不能假装解析——这里只做一件明确的事:取出 "lang" 的字符串值。 +export std::string read_lang(const fs::path& root) { + std::ifstream in(root / ".d2x.json"); + if (!in) return "en"; + std::string text((std::istreambuf_iterator(in)), {}); + + auto key = text.find("\"lang\""); + if (key == std::string::npos) return "en"; + auto colon = text.find(':', key); + if (colon == std::string::npos) return "en"; + auto open = text.find('"', colon); + if (open == std::string::npos) return "en"; + auto close = text.find('"', open + 1); + if (close == std::string::npos) return "en"; + return text.substr(open + 1, close - open - 1); +} + +// cpp11 -> 11。用于排序,让 cpp11 的练习永远排在 cpp14 之前。 +int std_rank(std::string_view dir) { + if (dir.starts_with("cpp")) { + int n = 0; + auto rest = dir.substr(3); + auto [_, ec] = std::from_chars(rest.data(), rest.data() + rest.size(), n); + if (ec == std::errc{}) return n; + } + return -1; // 根目录下的 hello-mcpp +} + +// 从练习文件头部读 `// d2x:cxxflags: -O0 -fno-elide-constructors`。 +// 只扫前 40 行——指令属于文件头的元信息区,不该藏在代码中间。 +std::vector read_cxxflags(const fs::path& file) { + std::ifstream in(file); + if (!in) return {}; + + constexpr std::string_view kMarker = "d2x:cxxflags:"; + std::string line; + for (int n = 0; n < 40 && std::getline(in, line); ++n) { + auto pos = line.find(kMarker); + if (pos == std::string::npos) continue; + + std::vector flags; + std::istringstream rest(line.substr(pos + kMarker.size())); + for (std::string flag; rest >> flag; ) flags.push_back(flag); + return flags; + } + return {}; +} + +// 00-auto-and-decltype-0 -> { chapter_no=0, topic="auto-and-decltype", index=0 } +// 03-trailing-return-type -> { chapter_no=3, topic="trailing-return-type", index=0 } +// +// 尾部的 -<数字> 是练习序号;没有就当 0。注意 d2mcpp 现有命名里 +// 03/04 两章确实缺了 -0 后缀,这个规则把它们归一化掉。 +struct NameParts { int chapter_no; std::string topic; int index; }; + +std::optional parse_stem(std::string_view stem) { + auto dash = stem.find('-'); + if (dash == std::string_view::npos) return std::nullopt; + + int chapter_no = 0; + auto head = stem.substr(0, dash); + if (std::from_chars(head.data(), head.data() + head.size(), chapter_no).ec != std::errc{}) + return std::nullopt; + + auto rest = stem.substr(dash + 1); + int index = 0; + auto last = rest.rfind('-'); + if (last != std::string_view::npos) { + auto tail = rest.substr(last + 1); + int parsed = 0; + auto [_, ec] = std::from_chars(tail.data(), tail.data() + tail.size(), parsed); + if (ec == std::errc{} && !tail.empty()) { + index = parsed; + rest = rest.substr(0, last); + } + } + return NameParts{chapter_no, std::string(rest), index}; +} + +std::string humanize(std::string_view topic) { + std::string out(topic); + std::ranges::replace(out, '-', ' '); + return out; +} + +// 扫描练习根目录。lang=zh 用 dslings/,lang=en 用 dslings/en/。 +export std::vector scan(const fs::path& repo_root, std::string_view lang) { + fs::path base = repo_root / "dslings"; + if (lang == "en") base /= "en"; + if (!fs::is_directory(base)) return {}; + + std::vector found; + + auto add = [&](const fs::path& file, const std::string& std_dir) { + auto stem = file.stem().string(); + auto parts = parse_stem(stem); + + Exercise ex; + ex.file = fs::absolute(file).lexically_normal(); + ex.std_dir = std_dir; + ex.cxxflags = read_cxxflags(file); + + if (parts) { + ex.id = std_dir.empty() ? stem : std::format("{}-{}", std_dir, stem); + ex.chapter = std_dir.empty() + ? std::format("{:02}-{}", parts->chapter_no, parts->topic) + : std::format("{}/{:02}-{}", std_dir, parts->chapter_no, parts->topic); + ex.title = std::format("{} ({})", humanize(parts->topic), parts->index); + ex.order = std_rank(std_dir) * 100'000 + parts->chapter_no * 100 + parts->index; + } else { + // 不符合 NN-topic 约定的(如根目录的 hello-mcpp.cpp) + ex.id = std_dir.empty() ? stem : std::format("{}-{}", std_dir, stem); + ex.chapter = std_dir.empty() ? "intro" : std_dir; + ex.title = humanize(stem); + ex.order = std_rank(std_dir) * 100'000; + } + found.push_back(std::move(ex)); + }; + + // 根目录下的入门练习(hello-mcpp.cpp) + for (const auto& entry : fs::directory_iterator(base)) { + if (entry.is_regular_file() && entry.path().extension() == ".cpp") add(entry.path(), ""); + } + + // 按标准分的子目录 + std::vector std_dirs; + for (const auto& entry : fs::directory_iterator(base)) { + if (entry.is_directory() && entry.path().filename().string().starts_with("cpp")) + std_dirs.push_back(entry.path()); + } + std::ranges::sort(std_dirs); + + for (const auto& dir : std_dirs) { + auto std_dir = dir.filename().string(); + std::vector files; + for (const auto& entry : fs::directory_iterator(dir)) { + if (entry.is_regular_file() && entry.path().extension() == ".cpp") + files.push_back(entry.path()); + } + std::ranges::sort(files); + for (const auto& f : files) add(f, std_dir); + } + + std::ranges::sort(found, {}, &Exercise::order); + return found; +} + +} // namespace d2x::discovery diff --git a/d2x/buildtools/mcpp/src/emit.cppm b/d2x/buildtools/mcpp/src/emit.cppm new file mode 100644 index 0000000..63a6bbb --- /dev/null +++ b/d2x/buildtools/mcpp/src/emit.cppm @@ -0,0 +1,95 @@ +// NDJSON 事件输出。 +// +// 协议要求每一行都是一个完整的 JSON 对象,解析不了的行由 d2x 忽略—— +// 这正好吞掉 `mcpp run` 的前导空行和可能混入的编译输出,所以这里不需要 +// 任何哨兵前缀。唯一的硬要求是:一个事件绝不能跨行。 +module; + +// stdout / fputs / fflush 是宏与 C 运行时符号,import std 不提供, +// 必须走全局模块片段。 +#include + +export module d2x.provider.emit; + +import std; + +namespace d2x::emit { + +// JSON 字符串转义。练习输出里有 ANSI 转义序列、换行、引号,必须全部处理, +// 否则一个彩色编译错误就能把整行 JSON 打碎。 +export std::string escape(std::string_view s) { + std::string out; + out.reserve(s.size() + s.size() / 8); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + default: + // ANSI 的 ESC(0x1b) 落在这里,按  编码 + if (c < 0x20) out += std::format("\\u{:04x}", static_cast(c)); + else out += static_cast(c); + } + } + return out; +} + +export std::string str(std::string_view v) { + return std::format("\"{}\"", escape(v)); +} + +export std::string array(const std::vector& items) { + std::string out = "["; + for (std::size_t i = 0; i < items.size(); ++i) { + if (i) out += ","; + out += items[i]; + } + out += "]"; + return out; +} + +// 一行一个事件,立刻 flush——d2x 是逐行读的,缓冲会让「实时看到编译输出」失效。 +export void line(std::string_view json_object) { + std::fputs(std::string(json_object).c_str(), stdout); + std::fputc('\n', stdout); + std::fflush(stdout); +} + +export void describe(std::string_view name, int protocol) { + line(std::format(R"({{"event":"describe","protocol":{},"name":{}}})", + protocol, str(name))); +} + +export void exercise(std::string_view id, int order, std::string_view title, + std::string_view chapter, const std::vector& files) { + std::vector quoted; + quoted.reserve(files.size()); + for (const auto& f : files) quoted.push_back(str(f)); + + line(std::format(R"({{"event":"exercise","id":{},"order":{},"title":{},"chapter":{},"files":{}}})", + str(id), order, str(title), str(chapter), array(quoted))); +} + +export void stage(std::string_view name) { + line(std::format(R"({{"event":"stage","name":{}}})", str(name))); +} + +export void output(std::string_view chunk) { + if (chunk.empty()) return; + line(std::format(R"({{"event":"output","chunk":{}}})", str(chunk))); +} + +export void verdict(std::string_view outcome, std::string_view stage_name, int exit_code) { + line(std::format(R"({{"event":"verdict","outcome":{},"stage":{},"exit_code":{},"diagnostics":[]}})", + str(outcome), str(stage_name), exit_code)); +} + +export void error(std::string_view message) { + line(std::format(R"({{"event":"error","message":{}}})", str(message))); +} + +} // namespace d2x::emit diff --git a/d2x/buildtools/mcpp/src/main.cpp b/d2x/buildtools/mcpp/src/main.cpp new file mode 100644 index 0000000..80be0f8 --- /dev/null +++ b/d2x/buildtools/mcpp/src/main.cpp @@ -0,0 +1,131 @@ +// d2mcpp 的 d2x Provider。 +// +// describe 自我描述 +// exercises 枚举练习(有序) +// check 验证一道练习 +// +// 全部输出为 NDJSON 事件流,一行一个事件。d2x 忽略解析不了的行, +// 所以 `mcpp run` 的前导空行之类的噪声不会破坏协议。 +// +// 用法(由 .d2x.json 的 buildtools 字段驱动,从仓库根执行,无需 cd): +// mcpp run -q -p d2x/buildtools/mcpp -- check cpp11-00-auto-and-decltype-0 + +import std; + +import d2x.provider.emit; +import d2x.provider.discovery; +import d2x.provider.manifest; +import d2x.provider.runner; + +namespace fs = std::filesystem; + +namespace { + +constexpr int kProtocolVersion = 1; + +// 向上找到仓库根:认 .d2x.json + dslings/ 同时存在。 +// 不能依赖可执行文件位置——它在 target///bin/ 里,层数不固定。 +std::optional find_repo_root() { + auto dir = fs::current_path(); + for (int depth = 0; depth < 12; ++depth) { + if (fs::exists(dir / ".d2x.json") && fs::is_directory(dir / "dslings")) + return dir; + if (!dir.has_parent_path() || dir.parent_path() == dir) break; + dir = dir.parent_path(); + } + return std::nullopt; +} + +int cmd_describe() { + d2x::emit::describe("mcpp", kProtocolVersion); + return 0; +} + +int cmd_exercises(const fs::path& root) { + auto lang = d2x::discovery::read_lang(root); + auto all = d2x::discovery::scan(root, lang); + if (all.empty()) { + d2x::emit::error(std::format("no exercises found under {}/dslings (lang={})", + root.string(), lang)); + return 1; + } + + // 顺手刷新全量清单:clangd 依赖它拿到每个练习的编译参数。 + // 这里只写文件不构建——构建是 check 的事,枚举必须快。 + d2x::manifest::write_full(root, all); + + for (const auto& ex : all) { + std::vector files{ex.file.string()}; + d2x::emit::exercise(ex.id, ex.order, ex.title, ex.chapter, files); + } + return 0; +} + +int cmd_check(const fs::path& root, std::string_view id) { + auto lang = d2x::discovery::read_lang(root); + auto all = d2x::discovery::scan(root, lang); + + auto it = std::ranges::find(all, id, &d2x::discovery::Exercise::id); + if (it == all.end()) { + d2x::emit::error(std::format("unknown exercise: {}", id)); + return 1; + } + + // 先保证整个 workspace 存在。check 可能是全新仓库上的第一条命令 + // (学员直接跑 d2x checker),此时根清单还没生成,mcpp 会以退出码 2 + // 报 "workspace member not found"。write_* 都是内容比对后才落盘, + // 重复调用不会推进 mtime、不会让 mcpp 的快速路径失效。 + d2x::manifest::write_full(root, all); + + // 再把这一题单独写进 _current,其余练习(多半还编译不过)不参与构建 + d2x::manifest::write_current(root, *it); + d2x::runner::enter(d2x::manifest::build_dir(root)); + + d2x::emit::stage("compile"); + auto compiled = d2x::runner::build_current(); + d2x::emit::output(compiled.output); + if (compiled.exit_code != 0) { + d2x::emit::verdict("fail", "compile", compiled.exit_code); + return 0; // 练习没通过是正常业务路径,不是 Provider 出错 + } + + d2x::emit::stage("run"); + auto ran = d2x::runner::run_current(); + d2x::emit::output(ran.output); + + auto outcome = d2x::runner::judge_run(ran.exit_code, ran.output); + d2x::emit::verdict(d2x::runner::to_string(outcome), "run", ran.exit_code); + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + std::vector args(argv + 1, argv + argc); + + if (args.empty()) { + d2x::emit::error("usage: describe | exercises | check "); + return 2; + } + + if (args[0] == "describe") return cmd_describe(); + + auto root = find_repo_root(); + if (!root) { + d2x::emit::error("repo root not found (looking for .d2x.json + dslings/)"); + return 2; + } + + if (args[0] == "exercises") return cmd_exercises(*root); + + if (args[0] == "check") { + if (args.size() < 2) { + d2x::emit::error("check requires an exercise id"); + return 2; + } + return cmd_check(*root, args[1]); + } + + d2x::emit::error(std::format("unknown command: {}", args[0])); + return 2; +} diff --git a/d2x/buildtools/mcpp/src/manifest.cppm b/d2x/buildtools/mcpp/src/manifest.cppm new file mode 100644 index 0000000..bcf5843 --- /dev/null +++ b/d2x/buildtools/mcpp/src/manifest.cppm @@ -0,0 +1,129 @@ +// 生成 mcpp workspace 清单。 +// +// 双 member 布局,解决一个硬约束:dslings 的练习默认就编译不过,而 mcpp 的 +// build/run 都会先全量构建整个包——一个坏兄弟拖垮全部且零产物。所以: +// +// .d2x/build// 全量 target,供 clangd 拿到完整 compile_commands.json +// .d2x/build/_current/ 每次只写当前一题,checker 只构建它 +// +// 生成物全部在 .d2x/build/ 下,练习源文件原地不动(main 用 ../ 逃逸出包根)。 +export module d2x.provider.manifest; + +import std; +import d2x.provider.discovery; + +namespace fs = std::filesystem; + +namespace d2x::manifest { + +using discovery::Exercise; + +// 所有练习统一按 c++23 编译。mcpp 目前硬拒 c++11/14/17/20 +// (src/manifest/types.cppm 的白名单),本方案选择不改 mcpp 上游。 +constexpr std::string_view kStandard = "c++23"; + +// 生成的 member 位于 .d2x/build//,回到仓库根要退三层。 +constexpr std::string_view kToRoot = "../../.."; + +export fs::path build_dir(const fs::path& repo_root) { + return repo_root / ".d2x" / "build"; +} + +std::string member_name(const Exercise& ex) { + return ex.std_dir.empty() ? "intro" : ex.std_dir; +} + +// 把绝对路径转成相对 member 目录的形式。mcpp 的 main 接受 ../ 逃逸。 +std::string relative_to_member(const fs::path& repo_root, const fs::path& file) { + auto rel = fs::relative(file, repo_root).generic_string(); + return std::format("{}/{}", kToRoot, rel); +} + +void write_package_header(std::ostream& out, std::string_view name) { + std::println(out, "# 由 d2x-buildtools-mcpp 生成,请勿手工编辑。"); + std::println(out, "[package]"); + std::println(out, "name = \"{}\"", name); + std::println(out, "version = \"0.1.0\""); + std::println(out, "standard = \"{}\"", kStandard); + std::println(out, ""); + std::println(out, "[build]"); + // 显式清空源码 glob:练习不是这个包的「源码」,它们只是各自 target 的入口。 + std::println(out, "sources = []"); + // 仓库根进搜索路径——155 个练习全部 #include + std::println(out, "include_dirs = [\"{}\"]", kToRoot); +} + +void write_target(std::ostream& out, const fs::path& repo_root, const Exercise& ex) { + std::println(out, ""); + std::println(out, "[targets.{}]", ex.id); + std::println(out, "kind = \"bin\""); + std::println(out, "main = \"{}\"", relative_to_member(repo_root, ex.file)); + if (!ex.cxxflags.empty()) { + std::string list; + for (std::size_t i = 0; i < ex.cxxflags.size(); ++i) { + if (i) list += ", "; + list += std::format("\"{}\"", ex.cxxflags[i]); + } + std::println(out, "cxxflags = [{}]", list); + } +} + +// 只有内容真的变了才落盘。避免无谓地推进 mtime,让 mcpp 的快速路径失效。 +bool write_if_changed(const fs::path& path, std::string_view content) { + if (fs::exists(path)) { + std::ifstream in(path); + std::string existing((std::istreambuf_iterator(in)), {}); + if (existing == content) return false; + } + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << content; + return true; +} + +// 全量清单:每个 C++ 标准一个 member,持有该标准下的全部练习。 +// 供 clangd —— 即使大多数练习编译不过,mcpp 仍会生成完整的 +// compile_commands.json(已实测),所以 IDE 对每个练习都有正确的编译参数。 +export void write_full(const fs::path& repo_root, const std::vector& all) { + auto root = build_dir(repo_root); + + std::map> by_member; + for (const auto& ex : all) by_member[member_name(ex)].push_back(&ex); + + std::vector members; + for (const auto& [name, list] : by_member) { + std::ostringstream buf; + write_package_header(buf, name); + for (const auto* ex : list) write_target(buf, repo_root, *ex); + write_if_changed(root / name / "mcpp.toml", buf.str()); + members.push_back(name); + } + + // _current 也是这个 workspace 的成员,占位清单先写空壳 + members.push_back("_current"); + write_if_changed(root / "_current" / "mcpp.toml", + [&] { std::ostringstream b; write_package_header(b, "_current"); return b.str(); }()); + + std::ostringstream ws; + std::println(ws, "# 由 d2x-buildtools-mcpp 生成,请勿手工编辑。"); + std::println(ws, "[workspace]"); + std::print(ws, "members = ["); + for (std::size_t i = 0; i < members.size(); ++i) { + if (i) std::print(ws, ", "); + std::print(ws, "\"{}\"", members[i]); + } + std::println(ws, "]"); + write_if_changed(root / "mcpp.toml", ws.str()); +} + +// 单题清单:checker 每次 check 前重写它,只含要验证的那一题。 +// 实测改写 target 集合不会让 fingerprint 目录爆炸(始终只有 1 个), +// 切题 0.118s、切回 0.018s。 +export void write_current(const fs::path& repo_root, const Exercise& ex) { + std::ostringstream buf; + write_package_header(buf, "_current"); + write_target(buf, repo_root, ex); + write_if_changed(build_dir(repo_root) / "_current" / "mcpp.toml", buf.str()); +} + +} // namespace d2x::manifest diff --git a/d2x/buildtools/mcpp/src/runner.cppm b/d2x/buildtools/mcpp/src/runner.cppm new file mode 100644 index 0000000..7bcb3ef --- /dev/null +++ b/d2x/buildtools/mcpp/src/runner.cppm @@ -0,0 +1,109 @@ +// 调用 mcpp 构建/运行单个练习,并判定结果。 +module; + +// popen/pclose 是 POSIX,WIFEXITED 等是宏——都不在 import std 的范围内。 +#include +#ifndef _WIN32 +# include +# include // unsetenv +#endif + +export module d2x.provider.runner; + +import std; + +namespace fs = std::filesystem; + +namespace d2x::runner { + +export struct Captured { + int exit_code{}; + std::string output; +}; + +// 合并 stderr —— 编译错误和练习的运行输出都要原样呈现给学员。 +// 这些内容最终进 JSON 的 output 字段,不会污染协议。 +// +// 必须先清掉 LD_LIBRARY_PATH:本 Provider 由 `mcpp run` 启动时,mcpp 会把 +// LD_LIBRARY_PATH 指向它私有的 glibc(~/.mcpp/registry/.../xim-x-glibc/*/lib64) +// 并注入子进程。我们接着去 spawn 嵌套的 mcpp —— 那是另一个二进制,被迫加载 +// 错配的 glibc 后会在动态链接器里段错误。清空后 mcpp 会为它自己的子进程重新 +// 设置正确的值,所以练习程序照常能跑。 +// +// d2x 侧对同一问题有相同的处理(platform.cppm 的 run_command_capture)。 +export Captured capture(const std::string& cmd) { + Captured result; +#ifndef _WIN32 + ::unsetenv("LD_LIBRARY_PATH"); +#endif + std::string full = cmd + " 2>&1"; + +#ifdef _WIN32 + FILE* pipe = ::_popen(full.c_str(), "r"); +#else + FILE* pipe = ::popen(full.c_str(), "r"); +#endif + if (!pipe) return {127, std::format("failed to spawn: {}", cmd)}; + + char buf[4096]; + while (std::fgets(buf, sizeof(buf), pipe)) result.output += buf; + +#ifdef _WIN32 + int status = ::_pclose(pipe); + result.exit_code = status; +#else + int status = ::pclose(pipe); + // pclose 回的是 wait status,转成真实退出码,否则 exit 1 会变成 256 + result.exit_code = (status == -1) ? 127 + : WIFEXITED(status) ? WEXITSTATUS(status) + : WIFSIGNALED(status) ? 128 + WTERMSIG(status) + : status; +#endif + return result; +} + +// d2mcpp 的练习断言协议,定义在 d2x/cpp/common.hpp: +// d2x_assert / d2x_assert_eq 失败时打印 ❌ +// D2X_WAIT 宏打印一句提示,学员删掉它才算真正完成 +// +// 这套判定是「课程约定」,不是「构建工具行为」——所以它属于 Provider, +// 而不是 d2x 框架。换一门 Rust 课程,这里会完全不同。 +export constexpr std::string_view kFailMark = "\xE2\x9D\x8C"; // U+274C ❌ +export constexpr std::string_view kWaitMark = "D2X_WAIT"; + +export enum class Outcome { Pass, Fail, Blocked }; + +export std::string_view to_string(Outcome o) { + switch (o) { + case Outcome::Pass: return "pass"; + case Outcome::Fail: return "fail"; + case Outcome::Blocked: return "blocked"; + } + return "fail"; +} + +// 运行阶段的输出判定。退出码为 0 只是必要条件: +// 出现 ❌ → 断言没过,Fail +// 出现 D2X_WAIT → 答案已对但路障还在,Blocked(既非失败也不该前进) +export Outcome judge_run(int exit_code, std::string_view output) { + if (exit_code != 0) return Outcome::Fail; + if (output.find(kFailMark) != std::string_view::npos) return Outcome::Fail; + if (output.find(kWaitMark) != std::string_view::npos) return Outcome::Blocked; + return Outcome::Pass; +} + +// mcpp 需要在 workspace 根(.d2x/build/)下执行。Provider 是独立进程, +// 直接 chdir 比拼 `cd X && ...` 更干净,也避开 Windows 的 shell 差异。 +export void enter(const fs::path& workspace_root) { + fs::current_path(workspace_root); +} + +export Captured build_current() { + return capture("mcpp build -q -p _current"); +} + +export Captured run_current() { + return capture("mcpp run -q -p _current"); +} + +} // namespace d2x::runner diff --git a/d2x/buildtools/mcpp/tests/e2e.sh b/d2x/buildtools/mcpp/tests/e2e.sh new file mode 100755 index 0000000..f0ef46d --- /dev/null +++ b/d2x/buildtools/mcpp/tests/e2e.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Provider 端到端测试。 +# +# 断言两件事,缺一不可: +# 1. 每个练习「未完成时」不通过 —— 否则学员会被直接跳过,练习形同虚设 +# 2. 每个参考答案「放进去后」通过 —— 否则参考答案本身是错的 +# +# 这是 rustlings `cargo dev check --require-solutions` 的等价物。d2mcpp 现有的 +# dslings-ref-ci.yml 因为 solutions/ 在 xmake.lua 里被注释掉,实际校验零个目标。 +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +cd "$REPO_ROOT" + +PROVIDER=(mcpp run -q -p d2x/buildtools/mcpp --) + +# 任何退出路径都还原被改动的练习文件,绝不把仓库留在脏状态 +restore() { git checkout -- dslings/ 2>/dev/null || true; } +trap restore EXIT + +outcome_of() { # $1 = exercise id + "${PROVIDER[@]}" check "$1" 2>&1 \ + | grep -o '"outcome":"[a-z]*"' | head -1 | cut -d'"' -f4 +} + +echo "==> 枚举练习" +mapfile -t LINES < <("${PROVIDER[@]}" exercises 2>&1 | grep '"event":"exercise"') +if [ "${#LINES[@]}" -eq 0 ]; then + echo "FAIL: Provider 没有枚举出任何练习"; exit 1 +fi +echo " 共 ${#LINES[@]} 个" + +pass=0; fail=0; skipped=0 + +for line in "${LINES[@]}"; do + id=$(printf '%s' "$line" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + file=$(printf '%s' "$line" | grep -o '"files":\["[^"]*"' | head -1 | cut -d'"' -f4) + + rel="${file#"$REPO_ROOT"/}" # dslings/cpp11/xx.cpp + sol="solutions/${rel#dslings/}" # solutions/cpp11/xx.cpp + sol="${sol#en/}" # en 目录共用同一份参考答案 + + # 1) 未完成态必须不通过 + got=$(outcome_of "$id") + if [ "$got" = "pass" ]; then + echo "FAIL [$id] 练习未完成却判定通过"; fail=$((fail+1)); continue + fi + + # 2) 参考答案必须通过 + if [ ! -f "$sol" ]; then + echo "SKIP [$id] 无参考答案 ($sol)"; skipped=$((skipped+1)); continue + fi + cp "$sol" "$file" + got=$(outcome_of "$id") + git checkout -- "$file" + + if [ "$got" = "pass" ]; then + pass=$((pass+1)) + else + echo "FAIL [$id] 参考答案未通过 (outcome=$got)"; fail=$((fail+1)) + fi +done + +echo +echo "==> 参考答案通过 $pass · 失败 $fail · 跳过 $skipped" +[ "$fail" -eq 0 ] diff --git a/mcpp.toml b/mcpp.toml new file mode 100644 index 0000000..4edacd4 --- /dev/null +++ b/mcpp.toml @@ -0,0 +1,7 @@ +# d2mcpp 根 workspace。 +# +# 这里只登记「提交进仓库」的包。生成物(.d2x/build/ 下按 C++ 标准分的 +# member、以及只含当前一题的 _current)自成一个独立 workspace,由 +# Provider 生成——不放进这里,避免「根清单引用尚未生成的成员」的循环。 +[workspace] +members = ["d2x/buildtools/mcpp"] From fc2f3a645c4e1fe0a55f90c8e1ef005900877693 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 20 Jul 2026 00:01:20 +0800 Subject: [PATCH 2/7] =?UTF-8?q?chore:=20=E5=BD=BB=E5=BA=95=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20xmake=EF=BC=8C=E6=9E=84=E5=BB=BA=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E8=B5=B0=20mcpp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d2x 改为通用练习框架、构建由 mcpp Provider 承担后,xmake 已无调用方。 删除 11 个 xmake.lua(根 / dslings / dslings/en / solutions 各级)、 d2x/buildtools/xmake/ 插件、d2x/cpp/common.lua(xmake 侧的语言探测辅助, 早已被 main.lua 读 .d2x.json 取代)、以及只写着 TODO 的 cmake 适配器占位。 d2x/buildtools/README.md 一并删除——它描述的 list/build/run 契约已被 Provider 协议(describe/exercises/check)取代,留着会误导。 .xlings.json 去掉 xmake 3.0.7 的 pin(该版本已从 registry 移除,是 d2x CI 里那条 "用真 xmake 而非 xlings shim" workaround 的根因),同时去掉 gcc 与 mingw-w64 —— mcpp 自带工具链沙箱,Windows 侧由它的 MinGW/MSVC 覆盖。 CI 重写:旧流水线只挑 `-ref` 结尾的 xmake target,而 solutions/ 在 xmake.lua 里被注释掉,grep 返回空、循环全跳过、job 退出 0 —— 实际校验零个 目标,且第 60 行的 wc -l 对空串仍打印 "Found 1 reference targets" 把空转 掩盖了。新流水线跑 e2e.sh,断言每个参考答案通过、每个练习不通过,走的是 `d2x checker` 内部同一条 Provider 路径。 撰稿 skill 同步更新:练习不再需要注册,放进 dslings// 即可被目录约定 发现;per-exercise 编译选项改为练习文件头部的 // d2x:cxxflags: 指令。 复验:Provider 枚举 52 题、端到端 51/51 参考答案通过 0 失败、d2x checker 全链路正常。 --- .../2026-07-19-mcpp-replace-xmake-research.md | 416 ++++++++++++++++++ .agents/skills/d2mcpp-authoring/SKILL.md | 14 +- .../d2mcpp-authoring/assets/chapter.en.md | 2 +- .../d2mcpp-authoring/assets/chapter.zh.md | 2 +- .../d2mcpp-authoring/references/anatomy.md | 77 ++-- .github/workflows/dslings-ref-ci.yml | 101 ++--- .gitignore | 2 - .vscode/settings.json | 3 +- .xlings.json | 10 +- book/en/src/base/chapter_1.md | 2 +- book/src/base/chapter_1.md | 4 +- d2x/buildtools/README.md | 14 - d2x/buildtools/cmake/README.md | 1 - d2x/buildtools/xmake/main.lua | 67 --- d2x/buildtools/xmake/xmake.lua | 14 - d2x/cpp/common.lua | 29 -- dslings/cpp11/xmake.lua | 195 -------- dslings/cpp14/xmake.lua | 11 - dslings/en/cpp11/xmake.lua | 192 -------- dslings/en/cpp14/xmake.lua | 11 - dslings/en/xmake.lua | 6 - dslings/xmake.lua | 23 - solutions/cpp11/xmake.lua | 198 --------- solutions/cpp14/xmake.lua | 11 - solutions/xmake.lua | 11 - xmake.lua | 6 - 26 files changed, 505 insertions(+), 917 deletions(-) create mode 100644 .agents/docs/2026-07-19-mcpp-replace-xmake-research.md delete mode 100644 d2x/buildtools/README.md delete mode 100644 d2x/buildtools/cmake/README.md delete mode 100644 d2x/buildtools/xmake/main.lua delete mode 100644 d2x/buildtools/xmake/xmake.lua delete mode 100644 d2x/cpp/common.lua delete mode 100644 dslings/cpp11/xmake.lua delete mode 100644 dslings/cpp14/xmake.lua delete mode 100644 dslings/en/cpp11/xmake.lua delete mode 100644 dslings/en/cpp14/xmake.lua delete mode 100644 dslings/en/xmake.lua delete mode 100644 dslings/xmake.lua delete mode 100644 solutions/cpp11/xmake.lua delete mode 100644 solutions/cpp14/xmake.lua delete mode 100644 solutions/xmake.lua delete mode 100644 xmake.lua diff --git a/.agents/docs/2026-07-19-mcpp-replace-xmake-research.md b/.agents/docs/2026-07-19-mcpp-replace-xmake-research.md new file mode 100644 index 0000000..3438368 --- /dev/null +++ b/.agents/docs/2026-07-19-mcpp-replace-xmake-research.md @@ -0,0 +1,416 @@ +# 调研报告:用 mcpp 替代 xmake 支撑 d2mcpp 全项目 + +- 日期:2026-07-19 +- 范围:d2mcpp(课程仓库)、d2x(课程驱动 CLI)、mcpp 0.0.99(构建工具) +- 结论一句话:**当前不可整体替换;但经 §4 的 rustlings/cargo 对照修订后,真正的阻塞只剩 1 个——mcpp 的 `[package].standard` 不接受 c++11/14/17/20。推荐"按标准分 workspace member(5~10 个)+ 每 member 内多 target + ninja 逐 target 隔离"。** +- ⚠️ 阅读顺序:§1-§3 是初版结论(单包 155 target、3 个上游需求);**§4 基于 rustlings/cargo 的横向对照做了架构修订,以 §4.4 为准。** + +--- + +## 1. 现状:xmake 在 d2mcpp 里承担什么 + +### 1.1 集成缝在哪 + +d2mcpp 不直接暴露 xmake 给学习者。三个 README 里 `xmake` 出现 **0 次**,学习者的唯一入口是 `d2x checker`。xmake 通过一层薄适配器接入: + +``` +.d2x.json: "buildtools": "xmake d2x-buildtools" + ↓ +xmake.lua:1 add_plugindirs("d2x/buildtools") + ↓ +d2x/buildtools/xmake/{xmake.lua, main.lua} ← 实现 init / list / build / run +``` + +契约写在 `d2x/buildtools/README.md`,只有 4 个命令。d2x 侧的实现(`d2x/src/buildtools.cppm:28-42`)是裸字符串拼接 + `popen`: + +```cpp +auto init() const { return platform::run_command_capture(bin + " init"); } +auto list() const { return platform::run_command_capture(bin + " list"); } +auto build(const std::string& t) const { return platform::run_command_capture(bin + " build " + t); } +auto run (const std::string& t) const { return platform::run_command_capture(bin + " run " + t); } +``` + +`bin` 完全来自配置,**不限定是 xmake**——写成 `./d2x/buildtools/mcpp/adapter` 也能跑。这是好消息:适配器不需要 mcpp 提供插件机制。 + +### 1.2 契约的精确要求(迁移必须逐条满足) + +| 项 | 要求 | 出处 | +|---|---|---| +| `list` 输出 | 每行 `@ <绝对路径>, <绝对路径>...`,`@` 必须**恰好出现一次**,否则该行被**静默丢弃** | `d2x/src/buildtools.cppm:76-100` | +| 路径 | 事实上必须绝对(`utils::read_file_to_string` 会抛异常,编辑器打开、mtime 轮询都按 CWD 解析) | `d2x/src/utils.cppm:66` 的 TODO | +| 排序 | 存进 `std::map`,即**按 target 名字典序**决定练习顺序 | `buildtools.cppm:21` | +| 退出码 | `build`/`run` 必须 `exit == 0` 才算通过;不解析编译器错误文本 | `d2x/src/checker.cppm:18-26` | +| 通过判定 | build==0 **且** run==0 **且** 输出无 `❌` **且** 无 `D2X_WAIT` | `checker.cppm:80-127` | +| 环境 | d2x 调用前会**清空 `LD_LIBRARY_PATH`**,且从不 `chdir` | `d2x/src/platform.cppm:51-54` | +| 噪声容忍 | 不匹配的行被忽略;ANSI 转义会被 trim 掉 | `d2x/src/utils.cppm:32-56` | + +`init` 会在 `load_buildtools()` 里被**无条件调用一次且忽略返回值**(`buildtools.cppm:109`),mcpp 适配器可以让它做真正的初始化工作而不怕失败传播。 + +### 1.3 target 全景(迁移工作量的真实分母) + +11 个 `xmake.lua`,153 处 target 声明。实际参与构建的只有两套之一(zh / en 互斥,同名): + +| lang | target 数 | 组成 | +|---|---|---| +| `en`(`option` 默认,且是提交进仓库的 `.d2x.json` 值) | 51 | 1 hello + 48 en/cpp11 + 2 en/cpp14 | +| `zh` | 52 | 1 hello + 49 cpp11 + 2 cpp14 | + +- **`dslings/cpp17`、`cpp20`、`cpp23` 全是 6 字节的 README 占位符**,0 个 `.cpp`,没有任何 `includes()` 引用。课程实际只写到 cpp11 + 2 个 cpp14 练习。 +- **`solutions/` 51 个 `-ref` target 完全没被构建**——`xmake.lua:6` 的 `includes("solutions/xmake.lua")` 被注释掉了(commit `307dc8a`)。 +- **每个 target 都是"一个 `.cpp`、无依赖"**:`add_files` 带多文件/通配符 = 0 处,`add_deps`/`add_packages`/`add_defines`/`add_rules`/自定义 hook = 0 处。这是迁移最有利的事实。 + +需要特殊处理的只有 **32 个 target**(占 152 的 21%),且模式极少: + +| 特性 | 出现次数 | 涉及 target | +|---|---|---| +| `set_kind("binary")`(显式,语义上是默认值,可直接丢弃) | 23 | 各 dir 前几个 | +| `add_cxxflags("-fno-elide-constructors")` + `set_optimize("none")` | 3 | `04-rvalue-references` ×{zh,en,sol} | +| `add_cxxflags("-Wpedantic -Werror")` | 3 | `07-constexpr-0` ×{zh,en,sol} | +| 单 target `set_languages("c++17")` | 3 | `08-literal-type-0` ×{zh,en,sol} | +| 单 target `set_languages("cxx11")` | 2 | `00-0-hello-mcpp` ×{zh,en} | +| 文件级 `set_languages` | 7 个文件 | — | +| Windows 分支(`set_encodings` / `set_toolchains("gcc")` / `add_ldflags("-static")`) | 1 处 | `dslings/xmake.lua:1-5` | +| `add_includedirs(".")` | 1 处(根) | **155/155 个 `.cpp` 都靠它** `#include ` | + +--- + +## 2. mcpp 能力核对(含实测) + +以下带 ✅/❌ 的结论**均为本机 mcpp 0.0.99 实测**,非仅读文档。 + +### 2.1 能做到的 + +**✅ 单包多可执行 target。** `[targets.] kind="bin" main="..."`,每个 target 的 `main` 对其他 target 是排除的(`src/build/plan.cppm:882-895`),155 个各带 `main()` 的文件不会 `multiple definition`。实测 3 target 一次构建出 3 个二进制。 + +**✅ 扁平布局,零文件搬迁。** 不需要 `src/` 目录,`main` 可指向包根下任意相对路径: + +```toml +[build] +sources = ["*.cpp"] + +[targets.ex0] +kind = "bin" +main = "00-auto-and-decltype-0.cpp" +``` +实测通过。dslings 现有目录结构可以原地不动。 + +**✅ 每 target 编译选项。** `cxxflags` / `cflags` / `defines` 可用。文档警告"只作用于该 target 的入口 TU"——但对"一个练习 = 一个 `.cpp`"的场景,入口 TU **就是**整个程序,所以 `-fno-elide-constructors`、`-Wpedantic -Werror`、`-O0` 全部覆盖到位。这三个特殊 target 无损迁移。 + +**✅ 非模块的普通 C++ 能构建。** `import std` 的 BMI 预构建是条件触发的(`src/build/prepare.cppm:3176-3217`),155 个无 `import` 的普通 `.cpp` 走快速路径。 + +**✅ 退出码符合 d2x 契约。** 实测:正常构建 0 / 编译失败 1 / 成员不存在 2 / `run` 0。 + +**✅ 速度显著优于 xmake。** 同机实测: + +| 操作 | xmake | mcpp | +|---|---|---| +| 列出 target | **2.06 s** | — (无此命令) | +| 单练习构建(冷) | 0.63 s | **0.12 s** | +| 单练习构建(暖,无改动) | 0.61 s | **0.014 s** | + +xmake 每次都要付 Lua VM 启动成本,暖构建几乎不省。mcpp 暖路径 14 ms —— 对 `d2x checker` 这种"改一行、立刻重编"的循环,这是量级差异。 + +**✅ 自带工具链沙箱。** 这正是 d2x 自己迁到 mcpp 时解决 macOS SDK 问题的关键。同时能一并解掉 CI 里那条已知的坑:d2x 的 `checker-smoke` job 注释写着 *"d2mcpp 的 .xlings.json 钉了 xmake 3.0.7,它已从 registry 移除,所以 xlings 的 xmake shim 会解析失败"*(`d2x/.github/workflows/ci.yml:124-134`)。迁到 mcpp 后这个 workaround 可以整个删掉。 + +**✅ Windows 覆盖更好。** MinGW(`x86_64-windows-gnu`)与 MSVC(`msvc@system`,0.0.90 起)都支持,且默认 `static_stdlib = true`——正好对上 d2x 会清空 `LD_LIBRARY_PATH` 的行为,比现在手写 `add_ldflags("-static")` 更稳。 + +### 2.2 阻塞点 + +**🚨 阻塞 1:C++ 标准下限是 c++23。** + +``` +$ mcpp build # mcpp.toml 里 standard = "c++11" +error: unsupported C++ standard 'c++11'; expected c++23, c++26, c++2c, + gnu++23, gnu++26, c++latest, or c++fly +``` + +白名单硬编码在 `mcpp/src/manifest/types.cppm:539-586`。所有绕行通道都被显式堵死: + +``` +$ # [targets.X].cxxflags = ["-std=c++11"] +error: targets.ex-c.cxxflags contains '-std=c++11'; use [package].standard + to configure the C++ language standard +``` +(`[build].cxxflags` 同样有此守卫,见 `toml.cppm:830-836`。) + +对一个**以 C++ 标准分级为组织主轴**的课程仓库(`cpp11/` `cpp14/` `cpp17/` `cpp20/` `cpp23/`),这是根本性冲突。 + +**这有多严重?我做了量化实测**:把 51 个 `solutions/` 全部在"原标准"与 c++23 下编译并 diff 运行输出(地址已归一化): + +``` +identical output : 50 +DRIFTED : 1 ← 04-rvalue-references +``` + +好消息:**50/51 无差异**,短期风险比预想的低。坏消息:**漂移的那一个恰好是移动语义的核心课**。C++11 下: + +``` +----> 临时对像 - 右值2 +Object():0x...60c +Object(Object&&):0x...608 ← 移动构造,整节课的教学点 +~Object():0x...60c +``` + +C++23 下(同样带 `-fno-elide-constructors`): + +``` +----> 临时对像 - 右值2 +Object():0x...de8 ← 只剩这一行 +``` + +因为 C++17 的**保证复制省略**让 `-fno-elide-constructors` 无法再强制 prvalue 实质化。教学内容被标准升级**直接抹掉**,而不是"编译失败"这种能被 CI 发现的显式故障。随着课程往 cpp17/20 扩写,这类静默漂移只会变多。 + +顺带确认:`07-constexpr-0` 的 `-Wpedantic -Werror` 守卫(VLA 报错)在两个标准下**都成立**(各 3 个 error),这个练习无损。 + +**❌ 阻塞 2:`mcpp build ` 不能按 target 过滤。** + +`build` 子命令**没有位置参数**(`src/cli.cppm:217-241`),传进去的名字被静默忽略,永远全量构建。实测:`rm -rf target && mcpp build ex-a` → 三个二进制全出来了。(`-p/--package` 选的是 workspace 成员,不是 target;`--target` 是**平台三元组**,不是 target 名。`mcpp run ` 倒是支持。) + +这与 dslings 的语义直接冲突:**练习本来就该是编译不过的**(`D2X_YOUR_ANSWER` 宏展开为空,49 个练习带该标记)。实测单包场景: + +``` +$ mcpp build # 3 个 target,其中 1 个坏 +error: build failed +... +$ ls target/.../bin/ # 空 +``` + +一个坏兄弟拖垮整包、且**一个产物都不出**。 + +**❌ 阻塞 3:没有列 target 的命令,更没有机器可读输出。** + +全 CLI 22 个命令里没有 `list`/`targets`/`metadata`。唯一的 `--json` 是 `mcpp xpkg parse --json`。最接近的是 `target/.build_cache` 里的纯文本 `runTargets=` 块。d2x 的 `list` 契约无法直接满足。 + +### 2.3 阻塞 2 有一个可用的逃生舱 + +mcpp 后端是 ninja,且 **`build.ninja` 在构建失败时依然会生成**。实测(`ex-b` 故意写坏,先 `mcpp build` 失败退出 1,再清空产物只留 `build.ninja`): + +``` +1) mcpp build (all, b broken) exit=1 +2) 清空 bin/ 和 obj/,保留 build.ninja +3) ninja bin/ex-a exit=0 → bin/ 里出现 ex-a +4) ninja bin/ex-c exit=0 → bin/ 里出现 ex-c +5) ninja bin/ex-b exit=1 (坏的那个,正确失败) +6) 最终 bin/: ex-a ex-c +``` + +**逐 target 隔离 + 正确退出码,完全满足 d2x 契约。** `ninja -t targets` 还能直接枚举出 `bin/ex-a: cxx_link` 这样的列表,可以作为 `list` 命令的数据源之一。 + +代价:适配器要依赖 mcpp 的内部产物布局(`target///build.ninja`,路径含哈希,需 `find` 定位)和一个 ninja 可执行文件。**这是耦合内部实现,属于技术债,应该同时向上游提需求。** + +### 2.4 workspace 方案为什么不推荐 + +workspace 能天然隔离失败(实测 `mcpp build -p a` 在兄弟成员坏掉时正常返回 0),但代价过高: + +- **members 不支持 glob**——`docs/06-workspace.md:34` 写了 `members = ["libs/*"]`,**实测报错** `workspace member 'a' not found in [workspace].members`。解析器逐字符串比较(`src/project.cppm:40`)。155 个练习要在根清单里手写 155 条字面路径。**这是文档 bug,值得单独给 mcpp 提 issue。**(另:`workspace.exclude` 被解析后**从未被读取**,是死配置。) +- 每个成员要独立 `mcpp.toml` + 独立 `target/` 目录 → 155 个清单、155 个构建目录。 +- **workspace 根的 `[build]` 不被成员继承**——实测根上写 `include_dirs` 成员读不到,155 个成员每个都得重复写 `include_dirs = ["../../.."]`。 +- **成员构建是串行的**(`src/cli/cmd_build.cppm:65-74` 是普通 `for` 循环,每个成员完整走一遍 `prepare_build` + spawn ninja)。 + +--- + +## 3. 结论与建议 + +### 3.1 直接回答 + +**"除去 xmake、用 mcpp 支撑整个项目"——今天做不到。** 卡在三点:C++ 标准下限、无按 target 构建、无 target 枚举。其中第 2、3 点有 workaround(ninja 直连),**第 1 点没有 workaround**,必须改 mcpp。 + +但三点的修复成本都不高,而且第 1 点的修复位置非常明确、局部。 + +### 3.2 推荐路线:单包多 target + ninja 直连,分三阶段 + +> ⚠️ **本节已被 §4(rustlings/cargo 对照)修订。** 布局从"单包 155 target"改为"**按标准分 workspace member**",上游需求从 3 个降为 1 个必需 + 2 个可选。下面的阶段划分仍然成立,但请以 §4.4 的架构为准。 + +**阶段 0 — 先给 mcpp 提上游需求**(这是关键路径,其余都在等它) + +| # | 需求 | 位置 | 估计改动 | +|---|---|---|---| +| 1 | **【必需】** `[package].standard` 放宽到 `c++11/14/17/20`(含 `gnu++` 变体) | `mcpp/src/manifest/types.cppm:520-586` 的 `normalize_cpp_standard` | ~30 行,纯新增分支。已确认 `[language].modules must be true` 的守卫(`toml.cppm:146-149`)**只在出现废弃的 `[language]` 段时才触发**(`had_language_section`),对源码无硬约束,不构成架构冲突 | +| 2 | 【可选】`mcpp build ` 按 target 名过滤(对齐已有的 `mcpp run `) | `mcpp/src/cli.cppm:217-241` 加位置参数 + `src/build/execute.cppm` 过滤 link unit | 小。有 ninja 兜底 | +| 3 | 【可选】`mcpp list --json`(或 `mcpp targets`)输出 target 名 + 源文件列表 | 新增子命令,数据已在 plan 里 | 小。有 `ninja -t targets` 兜底 | + +附带值得报的两个 bug:workspace `members` glob 文档与实现不符(cargo 同款语法可用,mcpp 报错);`workspace.exclude` 是死代码。 + +> ❌ **不要提"per-target `standard`"这个需求。** 我一度打算这么提(对齐 cargo 的 per-`[[bin]]` `edition`),但实测发现 **cargo 正在废弃该字段**:`warning: 'edition' is set on binary 'ex-c' which is deprecated`。cargo 官方文档标注其"将在未来 Edition 移除"。语言版本的正确粒度是**包**,不是 target。详见 §4.3。 + +**阶段 1 — 在 d2mcpp 侧写 mcpp 适配器**(可与阶段 0 并行) + +- 新建 `d2x/buildtools/mcpp/`,与现有 `xmake/` 平级(`cmake/README.md` 早就占了位,内容只有 `TODO`——说明多适配器一直在规划里)。 +- 用生成器从 `dslings/**/*.cpp` 生成 `mcpp.toml`(155 个 `[targets.*]`,扁平布局,零文件搬迁)。生成器要处理的特殊情况只有 32 个 target、5 种模式,全部可枚举。 +- 适配器实现四个命令: + - `init` → `mcpp build`(忽略失败,目的是刷新 `build.ninja`) + - `list` → `ninja -t targets` 或直接读生成器的映射表,输出 `name@ /abs/path` + - `build ` → 上游需求 2 落地前用 `ninja -C bin/`,落地后换成 `mcpp build ` + - `run ` → `mcpp run ` +- 注意三个坑:**target 名要 shell 安全**(d2x 不做转义);**路径必须绝对**;**名字里不能有 `@`**(会让整行被静默丢弃)。 +- 命名要保持字典序 = 教学顺序(d2x 用 `std::map`)。 + +**阶段 2 — 切换与收尾** + +- `.d2x.json` 的 `buildtools` 改指向 mcpp 适配器;`.xlings.json` 去掉 `xmake` 与 `gcc`/`mingw-w64` 的 pin,换成 `mcpp`(mcpp 自带工具链)。 +- 删掉 `dslings/xmake.lua:1-5` 的 Windows 分支(mcpp 默认静态链接 + 自带 MinGW/MSVC 覆盖)。 +- d2x 的 `checker-smoke` CI job 可以删掉"用真 xmake 而非 xlings shim"的 workaround。 +- **`add_includedirs(".")` 对应 `[build] include_dirs = ["."]`** ——155/155 个文件全靠它,别漏。 + +### 3.3 阶段 0 完成前的过渡建议 + +- **不要现在就把 dslings 迁到 mcpp。** 强行迁移意味着把 cpp11 课程按 c++23 编译,`04-rvalue-references` 的教学内容会**静默失效**(不是编译报错,CI 抓不到)。 +- **可以现在就用 mcpp 的地方**:未来的 `dslings/cpp23/`。那个目录现在是空占位,而 C++23 模块教学本来就该用 mcpp(`d2x/mcpp/common.cppm` 已经放了一个 `export module d2x.project.common;` 的桩)。以它为试点验证适配器,零迁移风险。 + - ⚠️ 但那个桩现在是坏的:`#include` 在全局模块片段里且什么都没 `export`,而**宏本来就不能跨模块导出**——`import d2x.project.common;` 拿不到 `d2x_assert` 也拿不到 `D2X_WAIT`,即拿不到 d2x 的整个判定协议。cpp23 章节要么继续 `#include` 那个头,要么把断言重新设计成非宏形式。这需要先决策。 +- 注意 d2x 目前**只支持单一 `buildtools` 配置**。若要 cpp11-20 留 xmake、cpp23 用 mcpp,需要适配器内部按目录路由,或给 d2x 加多 buildtools 支持。**前者更简单,建议走前者。** + +### 3.4 顺带发现的既有问题(与迁移无关,但建议一并修) + +1. **`dslings-ref-ci.yml` 是个静默空转的 CI。** 它只挑 `-ref` 结尾的 target,而 `solutions/` 在 `xmake.lua:6` 被注释掉了 → grep 返回空 → 循环全跳过 → job 退出 0。第 60 行的 `wc -l` 对空串仍打印 "Found 1 reference targets",把空转掩盖了。**参考答案实际上没有任何 CI 校验。** +2. **`dslings/en/cpp11/xmake.lua` 少一个 target**:`00-auto-and-decltype-5.cpp` 文件在但没有 target(49 文件 / 48 target)。 +3. **命名不一致**:dslings 用 `cpp11-00-auto-and-decltype`(无 `-0`),solutions 用 `cpp11-00-auto-and-decltype-0-ref`。迁移生成器必须处理。 +4. **`solutions/xmake.lua` 没有 Windows 分支**,若重新启用会在 Windows 上缺 UTF-8/gcc/静态链接处理。 +5. **`d2x/src/checker.cppm:74-76` 无 `files.empty()` 保护**就索引 `files[0]`——某个 target 若列出零文件会崩。`d2x/docs/crash-analysis-d2x-in-d2mcpp.md:52` 声称加过保护,实际没有。 + +--- + +## 附:能力对照速查 + +| 需求 | xmake(现状) | mcpp 0.0.99 | 迁移后 | +|---|---|---|---| +| 155 个单文件可执行 | ✅ | ✅ `[targets.*]` | ✅ | +| 零文件搬迁(扁平布局) | ✅ | ✅ 实测 | ✅ | +| 每 target 编译选项 | ✅ | ✅ `cxxflags`/`defines` | ✅ | +| **每 target / 每目录 C++ 标准** | ✅ | 🚨 **下限 c++23,`-std=` 被硬拦** | 需上游 #1 | +| **只构建一个 target** | ✅ | ❌ 位置参数被忽略 | ninja 兜底 / 上游 #2 | +| **坏兄弟不拖垮好 target** | ✅ | ❌ 单包全挂且零产物 | ninja 兜底 | +| **枚举 target** | ✅ 插件 | ❌ 无命令、无 JSON | 生成器兜底 / 上游 #3 | +| 插件机制(`add_plugindirs`) | ✅ | ❌ 无 | 不需要,适配器可独立 | +| 单练习构建耗时(暖) | 0.61 s | **0.014 s** | 大幅改善 | +| 工具链自管理 | ❌ 依赖 xlings pin(3.0.7 已从 registry 移除) | ✅ 沙箱自带 | 大幅改善 | +| Windows | MinGW,需手写 `-static` | MinGW + MSVC,默认静态 | 改善 | + +--- + +## 4. 横向对照:rustlings 和 cargo 是怎么做的 + +dslings 与 rustlings 是同构问题——~155 个单文件练习、**默认就编译不过**、由 checker CLI 驱动。rustlings 恰好在 2024 年做过一次同方向的架构迁移,其取舍和踩到的坑对我们有直接参考价值。 + +### 4.1 rustlings 的两代架构 + +**v5 及以前:直调 `rustc`,不用 cargo。**(`src/exercise.rs` @ tag `5.6.1`) + +```rust +const RUSTC_EDITION_ARGS: &[&str] = &["--edition", "2021"]; +const CLIPPY_CARGO_TOML_PATH: &str = "./exercises/clippy/Cargo.toml"; + +Mode::Compile => Command::new("rustc") + .args([self.path.to_str().unwrap(), "-o", &temp_file()]) + .args(RUSTC_COLOR_ARGS).args(RUSTC_EDITION_ARGS).output(), +``` + +产物是 CWD 下 pid+线程 id 命名的临时文件,靠 `impl Drop for FileHandle` 回收。IDE 支持靠**另一套**手写生成的 `rust-project.json`(`src/project.rs`,需要手动跑 `rustlings lsp`)。clippy 则再套一层 hack:运行时往固定路径写一个临时 `Cargo.toml`、`cargo clean`、`cargo clippy`——每次全量重建,且练习被编译两遍。 + +**两套真相源直接酿成 bug。** PR #1355 记录得很清楚: + +> The edition args of `rustc` is set to 2018 by default. While rustling generate `rust-project.json` with edition setting to 2021. It may cause some problems. + +**v6(2024-03-31,commit `82b563f1`,`src/exercise.rs +56/−204`):生成 `Cargo.toml`,改用 cargo。** 动机在 issue #1935 里说得很直白,**三条理由里没有一条是速度**: + +> We can generate a `Cargo.toml` file containing a `[[bin]]` entry for every exercise. This should make things much smoother and less hacky, especially because we have many issues related to the **language server**. This should also enable us to use Cargo instead of rustc to run the exercises. That would especially make **Clippy** exercises less of a trouble. This approach also allows us to easily add **dependencies** to the exercises in case we want to do so in the future. + +即:**LSP、Clippy、未来的依赖管理**——外加消灭第二套真相源。 + +### 4.2 v6 的具体机制 + +| 项 | rustlings 做法 | +|---|---| +| 清单 | `dev/Cargo.toml` 是模板,**生成且提交进仓库**(不 gitignore)。只有 `bin = [...]` 那一段被机器重写(`src/cargo_toml.rs::bins_start_end_ind` 按字节偏移做字符串手术),其余手写内容原样保留,学习者可自行加 `[dependencies]` | +| 何时重新生成 | 只在显式 `rustlings dev update`,**从不在运行时隐式生成**。CI 用 `dev check` 重新生成并逐字节比对,不一致就报 "Cargo.toml is outdated" | +| 条目形式 | 不是 `[[bin]]` 段头,是等价的内联表数组,**只有 `name` 和 `path` 两个键**:`{ name = "intro1", path = "../exercises/00_intro/intro1.rs" }`。必须放在任何 table 头之前 | +| 规模 | 94 个练习 → **188 个 bin**(每个练习额外生成一个 `_sol` 指向参考答案) | +| 构建命令 | `cargo build -q --bin ` → 失败即止;`cargo test -q --bin `;`cargo clippy -q --bin --profile test`;最后**直接 exec `target/debug/`,不用 `cargo run`** | +| 诊断格式 | **纯文本**,不用 `--message-format=json`。`cargo metadata` 只用来取 `target_directory` | +| 练习清单来源 | `rustlings-macros/info.toml`,proc macro 在编译期 `include_bytes!` 进二进制。字段:`name` / `dir` / `test`(默认真) / `strict_clippy` / `hint` / `skip_check_unsolved` | +| 通过判定 | **纯退出码**,无输出匹配。特别地,为了避免练习里裸 `exit(1)` 让人一头雾水,会补一句 "The exercise didn't run successfully (nonzero exit code)" | +| 完成状态 | `.rustlings-state.txt`(gitignore)。格式极简:第 3 行是当前练习名,第 5 行起是已完成练习名。**只缓存"在哪一题"和"哪些做完了"**,不缓存编译输出。用名字不用索引,所以重排 `info.toml` 不会毁进度 | +| 文件监听 | `notify` v8 + **200ms 去抖**(脏位图,N 次快速保存塌缩成 1 次重建)+ `EXERCISE_RUNNING: AtomicBool` 自触发保护 + 激进事件过滤(只放行 `Modify::*` 和 `Access::Close(Write)`) | + +`// I AM NOT DONE` 标记**在 v6 已被删除**,改为按 `n` 显式进入下一题 + 持久化状态。 + +### 4.3 cargo 的能力(我在本机 cargo 1.89 实测) + +做了和 mcpp 完全对等的实验(3 个 bin,`ex-b` 故意写坏): + +| 能力 | 结果 | +|---|---| +| `cargo build --bin ex-a` | ✅ exit 0,只产出 `ex-a`,坏兄弟完全无关 | +| `cargo build --bin ex-b` | ✅ exit 101 | +| `cargo run --bin ex-a` | ✅ 且**透传程序退出码**(实测 `exit(3)` → 101 之外的 3) | +| `cargo metadata --format-version 1 --no-deps` | ✅ JSON 含 name + **绝对 `src_path`** + edition;**练习编译不过时照样 exit 0**(清单层查询,不编译) | +| workspace `members = ["ex/*"]` glob | ✅ 可用(mcpp 文档写了但实测报错) | +| `--message-format=json` | ✅ 给出 `file:line:col` 结构化诊断 | +| 逐 bin 构建耗时 | 暖 0.055s / 改动后 0.136s / `metadata` 0.038s | + +**🚨 但 per-`[[bin]]` `edition` 是个陷阱。** 它确实能工作(实测包级 2021 + 单 bin 覆盖 2015,2015-only 代码正确通过/拒绝),**但 cargo 会警告并正在移除**: + +``` +warning: `edition` is set on binary `ex-c` which is deprecated +``` + +cargo book 标注该字段 *"deprecated and will be removed in a future Edition"*。而 rustlings **从未使用过它**——全仓库 grep `edition` 只有一个值 `2024`,且从不出现在 bin 条目里;`append_bins()` 在代码层面就只能写 `name` 和 `path`;`ExerciseInfo` 没有任何 edition/flags 字段。188 个 target 全部继承唯一的包级 `edition = "2024"`。 + +> 而且即便只有一个包级 edition,rustlings 也已经踩到漂移风险——`Cargo.toml:17` 挂着注释 `# On Update: Update the edition of rustfmt in dev check and CARGO_TOML in dev new`,同一个值手工同步三处。 + +**结论:语言版本的正确粒度是「包」,不是「target」。** 这直接否定了我原先"给 mcpp 加 `[targets.X].standard`"的想法。 + +### 4.4 修订后的推荐架构:按标准分 workspace member + +既然粒度是包,而 **d2mcpp 的目录结构本来就是按标准组织的**,两者天然对齐: + +``` +mcpp.toml [workspace] members = ["dslings/cpp11", "dslings/cpp14", ...] +dslings/cpp11/mcpp.toml standard = "c++11" + 49 个 [targets.*] +dslings/cpp14/mcpp.toml standard = "c++14" + 2 个 +dslings/cpp17/mcpp.toml standard = "c++17" +dslings/cpp20/mcpp.toml standard = "c++20" +dslings/cpp23/mcpp.toml standard = "c++23" +``` + +**5~10 个 member,不是 155 个。** §2.4 里反对 workspace 的四条理由全部失效:members 不用 glob(10 条字面路径无所谓)、10 个 `mcpp.toml` 不是负担、`include_dirs` 重复 10 次不是负担、串行构建 10 个 member 也不是负担。 + +已实测验证(用 c++23/c++26 两个 member,因为 c++11 目前仍被拒): + +``` +cpp23 member -> ['-std=c++23'] ← 各自 compile_commands.json +cpp26 member -> ['-std=c++26'] +``` +**每 member 的 `standard` 正确落到编译行。** 且 `mcpp build -p cpp26` 在 `cpp23` member 有坏练习时正常返回 0。 + +剩余问题只有两个,都已有解: +- **member 内部**仍需逐 target 隔离(一个坏练习会拖垮同 member 的 49 个)→ ninja 兜底(§2.3 已实测)或上游需求 2。 +- `08-literal-type-0` 需要 c++17 但住在 cpp11 目录 → 单独开一个小 member,或挪到 cpp17。 + +**上游需求从 3 个降为 1 个必需:只剩 `[package].standard` 放宽。** 而且这个方向与 cargo 的包粒度哲学一致,不是在跟工具较劲。 + +### 4.5 rustlings 踩过的坑:哪些我们会中、哪些不会 + +**⚠️ 会中的:性能是这个模型引入的**回归**,不是收益。** issue #2071「Checking an exercise takes too long」(2024-08 提出,**至今未关**): + +> every time I update the code of an exercise, the `rust-analyzer` builds the whole `exercises` folder (it has like 156 exercises...). During this process, I cannot use any command. + +维护者承认 checker 和 IDE 争抢同一个 cargo lock 与 target 目录,且保存一次就全量失效重算。连带 #2113「Rustling is slow」、#2091「Too many open files (os error 24)」、#2072「LSP issues because of too many errors」。至今只有缓解措施(`rust-analyzer.cargo.targetDir` 隔离目录)。 + +**✅ 但我们大概率不会中最严重的那条**——因为这本质是 **rust-analyzer 特有的全 crate 检查模型**。C++ 侧是 clangd + `compile_commands.json`,**逐 TU 按需**,第 90 个练习编译不过不影响第 3 个的诊断。我实测确认 mcpp 的行为正好支持这点: + +- **构建失败时 `compile_commands.json` 照样生成**(104-target spike:`mcpp build` 退出 1,但 104 条 entry 齐全) +- 每条 entry 带自己的 `-std=`,配合 §4.4 的分 member 架构,clangd 能对每个练习显示**正确标准**下的诊断 + +**这反而是迁移的一个净收益。** d2mcpp 现在**没有** `compile_commands.json`(`.gitignore:49` 预留了位置但文件不存在),IDE 靠 `.vscode/settings.json` 里手写的 `C_Cpp.default.includePath`——它不编码任何 `-std=`,等于**全仓库一个标准**。这正是 rustlings PR #1355 那类"两套真相源"bug 的同款形态。 + +**✅ 独立于构建系统的、更便宜的一个win:done-state 缓存。** rustlings 的"每次重编所有练习"抱怨(#121/#132/#1843)**早于 v6,且是靠 `.rustlings-state.txt` 解决的,不是靠换 cargo**。d2x 目前没有任何完成状态持久化。这件事现在就能做,与 xmake/mcpp 之争无关。 + +**✅ 值得直接抄的 CI 门禁。** rustlings 的 `cargo dev check --require-solutions` 断言:**每个练习都必须失败,每个参考答案都必须通过**。对照 §3.4 第 1 条——d2mcpp 的 `dslings-ref-ci.yml` 目前是静默空转,参考答案零校验。这个门禁应该优先补上。 + +**✅ 值得抄的两个防御细节:** +- rustlings 全量检查时,**"报错"(而非"失败")的练习会退回串行重试**,注释写明 *"it could be because we exceeded the limit of open file descriptors"*。155 个 target 并发时同样会遇到。 +- 文件监听必须有**自触发保护**(构建自身的写入不能反过来触发重建)+ **去抖**。d2x 现在是 `wait_files_changed` 轮询 mtime,没有这两层。 + +### 4.6 一句话总结 + +rustlings 的证据**支持**"生成单一构建描述、一个练习一个 target"这条路线,但**理由是 IDE/lint 集成和消灭第二套真相源,不是速度**。语言版本用**包**粒度(cargo 正在废弃 target 粒度),这恰好让 d2mcpp 的"按标准分目录"结构变成天然的 workspace member 划分,把 mcpp 的上游需求压缩到只剩一条。 diff --git a/.agents/skills/d2mcpp-authoring/SKILL.md b/.agents/skills/d2mcpp-authoring/SKILL.md index 67abbfb..78162d3 100644 --- a/.agents/skills/d2mcpp-authoring/SKILL.md +++ b/.agents/skills/d2mcpp-authoring/SKILL.md @@ -4,7 +4,7 @@ description: >- Authoring conventions, design principles, and file formats for the d2mcpp (D2X) Modern C++ tutorial project. Use this whenever you add or edit a C++ language-feature lesson — a book chapter, a dslings exercise, a reference - solution, or when registering them in SUMMARY/xmake/changelog. Trigger it for + solution, or when registering them in SUMMARY/changelog. Trigger it for any request like "add a chapter for X", "write a dslings exercise for fold expressions", "add the constexpr lesson", "fill the cpp14 section", or anything touching book/src, book/en/src, dslings/, or solutions/. Even small @@ -19,7 +19,7 @@ quartet: **Book (mdBook) + Code (dslings exercises) + Solution + Auto-checker (`d2x checker`)**, all of it **bilingual (zh + en)**. A "lesson" is never one file — it is a coordinated set across several directories. The cardinal sin here is producing a half-set (a chapter with no exercise, a zh file with no en -counterpart, an exercise not registered in xmake). This skill exists so every +counterpart, an exercise missing from solutions/). This skill exists so every lesson lands complete and consistent. Read `references/anatomy.md` for the full directory map and the exact list of @@ -48,7 +48,8 @@ Honor that axis: remark, P0136 inheriting-constructor semantics fix. - **Compile each chapter at its introduction standard.** Do not globally pin a newer standard. If one exercise genuinely needs a later standard, make it an - **explicit, commented exception** in xmake — never a bare `-- TODO` hack. + **explicit, commented exception** via a `// d2x:cxxflags:` header directive + in the exercise file — never a bare `TODO` hack. Background and the full per-feature evolution analysis live in `.agents/docs/2026-06-08-cpp11-feature-evolution-and-cxx20-baseline.md`. Consult @@ -70,8 +71,7 @@ When adding a feature numbered `NN` with slug `topic` (e.g. `06-scoped-enums`): 3. `dslings//NN-topic-K.cpp` — one or more exercises (`K` = 0,1,2…), zh comments 4. `dslings/en//NN-topic-K.cpp` — en exercises (translate comments only) 5. `solutions//NN-topic-K.cpp` — reference solution per exercise -6. Register each exercise target in `dslings//xmake.lua` -7. Register each solution target in `solutions/xmake.lua` +6. (nothing to register — exercises are discovered by directory convention) 8. Add the chapter line to **both** `book/src/SUMMARY.md` and `book/en/src/SUMMARY.md` 9. Add a changelog entry to **both** `book/src/changelog.md` and `book/en/src/changelog.md` @@ -106,7 +106,7 @@ Use `assets/chapter.zh.md` / `assets/chapter.en.md`. Required structure, in orde 6. `## 二、真实案例` corroborates the feature with **real, verbatim STL code** quoted from the in-repo `msvc-stl/` basis — see "The 真实案例 section" below. 7. `## 四、练习代码` lists **every** exercise topic of the chapter (`-0`, `-1`, …, - matching the dslings/xmake count) with dslings links as an index, but the checker + matching the dslings file count) with dslings links as an index, but the checker block shows only the **single entry** command `d2x checker ` — `d2x` auto- advances through the remaining exercises, so do NOT list one command per exercise. Then a `### 练习交流讨论` link to the chapter's forum thread. Each exercise's own @@ -179,4 +179,4 @@ Use `assets/solution.cpp`. It is the corrected exercise with: Before claiming a lesson complete, verify the whole set exists and is wired up, and that the exercise/solution actually build & check. See `references/anatomy.md` for the verification commands (`d2x checker ` and -the xmake build) — run them; do not assert success without the output. +the mcpp build) — run them; do not assert success without the output. diff --git a/.agents/skills/d2mcpp-authoring/assets/chapter.en.md b/.agents/skills/d2mcpp-authoring/assets/chapter.en.md index 35ec161..f12c11a 100644 --- a/.agents/skills/d2mcpp-authoring/assets/chapter.en.md +++ b/.agents/skills/d2mcpp-authoring/assets/chapter.en.md @@ -60,7 +60,7 @@ - diff --git a/.agents/skills/d2mcpp-authoring/references/anatomy.md b/.agents/skills/d2mcpp-authoring/references/anatomy.md index cdd4dee..da7eabf 100644 --- a/.agents/skills/d2mcpp-authoring/references/anatomy.md +++ b/.agents/skills/d2mcpp-authoring/references/anatomy.md @@ -16,12 +16,10 @@ book/en/src/SUMMARY.md # en TOC (register here) book/src/changelog.md # zh changelog (add entry) book/en/src/changelog.md # en changelog (add entry) -dslings//NN-topic-K.cpp # zh exercise(s) -dslings/en//NN-topic-K.cpp # en exercise(s) -dslings//xmake.lua # register exercise target(s) +dslings//NN-topic-K.cpp # zh exercise(s) — no registration needed +dslings/en//NN-topic-K.cpp # en exercise(s) — no registration needed solutions//NN-topic-K.cpp # reference solution(s) -solutions/xmake.lua # register solution target(s) (includes ) ``` Repo-level shared basis (NOT per-lesson, created once, never edited by hand): @@ -37,54 +35,50 @@ The `## 二、真实案例` section of every chapter links into `msvc-stl/` for verbatim STL excerpt; you do not add files here per lesson — only refresh the snapshot via `msvc-stl/SOURCE.md` when needed. -A new `` section that does not exist yet also needs: an `includes("")` -line wired from `dslings/xmake.lua` and `solutions/xmake.lua`, a `dslings//` +A new `` section that does not exist yet needs only: a `dslings//` and `dslings/en//` directory, a `solutions//` directory, and the section heading in both `SUMMARY.md` files (e.g. `# C++14核心语言特性` / -`# C++14 Core Language Features`). +`# C++14 Core Language Features`). No build-file wiring — the Provider +discovers `dslings//` by directory convention. -## xmake registration — exercises (`dslings//xmake.lua`) +## Exercise registration — there is none -Append one target per exercise file. Binary kind is the default; only set it -where existing siblings do. Example for a two-exercise chapter: +Exercises are discovered by **directory convention**, not registered anywhere. +Dropping `dslings//NN-topic-K.cpp` into place is the whole job: the d2x +Provider (`d2x/buildtools/mcpp/`) derives the exercise id, order and chapter +from the path, and generates the mcpp manifests under `.d2x/build/`. -```lua --- target: -NN-topic +This is deliberate. rustlings' most expensive lesson (PR #1355) was the Rust +edition living in *two* places — the `rustc` args and `rust-project.json` — +which drifted and caused bugs. Any standalone registration file is a second +source of truth. Here the only truths are the directory layout and the exercise +file's own header comment. -target("-NN-topic-0") - add_files("NN-topic-0.cpp") +### Per-exercise compile flags -target("-NN-topic-1") - add_files("NN-topic-1.cpp") -``` - -Per-target options seen in the repo, use only when the lesson needs them: +When a lesson needs non-default flags, declare them in the exercise file's own +header — near the code they affect, impossible to drift: -```lua -target("cpp11-04-rvalue-references") - set_optimize("none") - add_cxxflags("-fno-elide-constructors") -- observe moves; note C++17 guaranteed elision can't be disabled - add_files("04-rvalue-references.cpp") +```cpp +// d2x:cxxflags: -O0 -fno-elide-constructors ``` -If an exercise needs a non-default standard, make it an **explicit, commented -exception** — never a bare TODO: +Real examples in the repo: `04-rvalue-references` (observe moves — but note +C++17 guaranteed elision means `-fno-elide-constructors` can no longer force +prvalue materialisation, so this lesson needs rewriting) and `07-constexpr-0` +(`-Wpedantic -Werror` to make the VLA an error). -```lua -target("cppNN-NN-topic-0") - set_languages("c++17") -- exception: - add_files("NN-topic-0.cpp") -``` +### C++ standard -The default standard is set at the top of `dslings//xmake.lua`. Per the -design principle, that should be the **introduction standard** of the section -(e.g. `set_languages("c++11")` for `cpp11`). Avoid per-host standard forks; if -one is unavoidable, comment why. +All exercises compile as **c++23**. mcpp currently rejects `c++11/14/17/20` +(`src/manifest/types.cppm` whitelist) and the project chose not to patch mcpp +upstream. The `cppNN/` directories therefore denote *when a feature was +introduced* — they no longer change compile flags. -## xmake registration — solutions (`solutions/xmake.lua` + `solutions//xmake.lua`) +## Solution registration — there is none either -Mirror the exercise targets so CI builds the reference solutions too. Follow the -existing pattern in `solutions//xmake.lua`. +Drop the file at `solutions//NN-topic-K.cpp`. CI swaps it over the exercise +and asserts it passes; see `d2x/buildtools/mcpp/tests/e2e.sh`. ## SUMMARY registration @@ -113,8 +107,11 @@ to an absolute `YYYY/MM/DD`. Run from the project root; report real output, do not assert success blind: ```bash -# build the exercise + solution targets (lang defaults come from xmake) -xmake f -c >/dev/null && xmake build cppNN-NN-topic-0 +# check a single exercise through the same path `d2x checker` uses +mcpp run -q -p d2x/buildtools/mcpp -- check cppNN-NN-topic-0 + +# or validate every exercise + solution at once +bash d2x/buildtools/mcpp/tests/e2e.sh # drive the auto-checker against the exercise (expects the unsolved exercise to fail, # the solution to pass) — name omits the NN- prefix diff --git a/.github/workflows/dslings-ref-ci.yml b/.github/workflows/dslings-ref-ci.yml index ab21712..20fcf35 100644 --- a/.github/workflows/dslings-ref-ci.yml +++ b/.github/workflows/dslings-ref-ci.yml @@ -1,4 +1,14 @@ -name: Validate dslings reference solutions +name: Validate dslings exercises and reference solutions + +# 断言两件事,缺一不可: +# 1. 每个练习「未完成时」不通过 —— 否则学员会被直接跳过,练习形同虚设 +# 2. 每个参考答案「放进去后」通过 —— 否则参考答案本身是错的 +# +# 走的是 `d2x checker` 内部使用的同一条 Provider 路径(d2x/buildtools/mcpp), +# 所以 CI 绿灯等价于学员本地能跑通,而不是另一条平行的构建路径。 +# +# 旧版本只挑 `-ref` 结尾的 xmake target,而 solutions/ 在 xmake.lua 里被注释掉, +# grep 返回空、循环全跳过、job 退出 0 —— 实际校验零个目标。 on: push: @@ -7,109 +17,64 @@ on: - "dslings/**" - "solutions/**" - "d2x/**" - - "xmake.lua" + - "mcpp.toml" + - ".d2x.json" - ".github/workflows/dslings-ref-ci.yml" pull_request: paths: - "dslings/**" - "solutions/**" - "d2x/**" - - "xmake.lua" + - "mcpp.toml" + - ".d2x.json" - ".github/workflows/dslings-ref-ci.yml" workflow_dispatch: jobs: - build-and-run-reference: + validate-exercises: runs-on: ubuntu-latest env: - XLINGS_VERSION: "0.4.14" + XLINGS_VERSION: "0.4.51" + XLINGS_NON_INTERACTIVE: "1" steps: - uses: actions/checkout@v4 - - name: Install Xlings ${{ env.XLINGS_VERSION }} + # 官方一键安装脚本自带镜像回退。手动 curl GitHub release 包会被 + # release CDN 的间歇性 504 打断。 + - name: Install xlings ${{ env.XLINGS_VERSION }} run: | set -eu - curl -fsSL "https://github.com/openxlings/xlings/releases/download/v${XLINGS_VERSION}/xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" \ - | tar -xzf - -C /tmp - /tmp/xlings-${XLINGS_VERSION}-linux-x86_64/bin/xlings self install + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh \ + | bash -s "v${XLINGS_VERSION}" echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - - name: Install xmake (via xlings) - run: xlings install xmake -y - - - name: xmake config - run: xmake f -y - - # Following steps go through the SAME `xmake d2x-buildtools` plugin - # path that `d2x checker` uses internally (see d2x/buildtools/xmake/main.lua). - # This way the CI exercises the real d2x integration code path, not a - # parallel xmake-only path. A passing CI here is what a learner would - # see if they ran `d2x checker cpp11-XX-feat-K-ref` locally. - - - name: List all *-ref targets via d2x-buildtools - id: list_ref + # 按 .xlings.json 安装 mcpp。mcpp 自带工具链沙箱,无需另装 gcc。 + - name: Install mcpp run: | set -eu - REF_TARGETS=$(xmake d2x-buildtools --command=list 2>/dev/null \ - | tr ' ' '\n' \ - | sed 's/@.*$//' \ - | grep -E '\-ref$' \ - | sort -u) - echo "Found $(echo "$REF_TARGETS" | wc -l) reference targets:" - echo "$REF_TARGETS" - echo "targets<> "$GITHUB_OUTPUT" - echo "$REF_TARGETS" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" + xlings update + xlings install -y - - name: Build all *-ref targets via d2x-buildtools - run: | - set -eu - while IFS= read -r tgt; do - [ -z "$tgt" ] && continue - echo "::group::build $tgt" - xmake d2x-buildtools --command=build --target="$tgt" - echo "::endgroup::" - done <<< "${{ steps.list_ref.outputs.targets }}" + - name: Build d2x Provider + run: mcpp build -p d2x/buildtools/mcpp - - name: Run all *-ref targets via d2x-buildtools and assert no ❌ - run: | - set -eu - fail=0 - while IFS= read -r tgt; do - [ -z "$tgt" ] && continue - echo "::group::run $tgt" - output=$(xmake d2x-buildtools --command=run --target="$tgt" 2>&1) - echo "$output" - if echo "$output" | grep -qE '❌|Compilation/Running failed'; then - echo "::error::reference target $tgt produced a ❌ — exercise solution does not pass its own asserts" - fail=1 - fi - if echo "$output" | grep -q 'Delete the D2X_WAIT to continue'; then - echo "::error::reference target $tgt still contains D2X_WAIT — remove it from the solution" - fail=1 - fi - echo "::endgroup::" - done <<< "${{ steps.list_ref.outputs.targets }}" - exit $fail + - name: Validate exercises and solutions + run: bash d2x/buildtools/mcpp/tests/e2e.sh - name: Verify dslings ↔ solutions filename parity run: | set -eu - # Every dslings/{cpp,hpp} file should have a matching solutions/ counterpart + # en/ 镜像跳过 —— 参考答案与语言无关,两边共用同一份 missing=0 while IFS= read -r f; do rel="${f#dslings/}" - # Skip the en/ mirror — solutions are language-neutral - case "$rel" in - en/*) continue ;; - esac if [ ! -f "solutions/$rel" ]; then echo "::warning::missing solutions/$rel (paired with dslings/$rel)" missing=$((missing+1)) fi done < <(find dslings -name '*.cpp' -not -path 'dslings/en/*') if [ "$missing" -gt 0 ]; then - echo "::warning::$missing dslings exercises do not yet have a reference solution. This is informational during the bring-up phase; tighten to error once cpp11 is fully covered." + echo "::warning::$missing 个练习尚无参考答案。铺开阶段仅作提示;cpp11 覆盖完整后收紧为错误。" fi diff --git a/.gitignore b/.gitignore index 19e8da7..3ec6cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -40,11 +40,9 @@ __pycache__ # d2x project files .xlings .vscode -.xmake .zed llm.config.xlings media -build .cache/ dslings/compile_commands.json # d2x Provider 生成物(mcpp workspace 清单)与学习进度 diff --git a/.vscode/settings.json b/.vscode/settings.json index a80607e..9638d4c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,8 +8,7 @@ "C_Cpp.files.exclude": { "**/.vscode": true, - "**/build": true, - "**/.xmake": true, + "**/target": true, "**/.xlings": true, "**/.DS_Store": true }, diff --git a/.xlings.json b/.xlings.json index 66d4e23..c66cdbb 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,10 +1,12 @@ { "workspace": { "d2x": "0.1.5", - "xmake": "3.0.7", "mdbook": "0.4.43", "code": "", - "gcc": { "linux": "15.1.0" }, - "mingw-w64": { "windows": "13.0.0" } + "mcpp": { + "linux": "0.0.99", + "macosx": "0.0.99", + "windows": "0.0.99" + } } -} \ No newline at end of file +} diff --git a/book/en/src/base/chapter_1.md b/book/en/src/base/chapter_1.md index 9baf009..802c819 100644 --- a/book/en/src/base/chapter_1.md +++ b/book/en/src/base/chapter_1.md @@ -158,7 +158,7 @@ Edit the `lang` attribute in the project configuration file `.d2x.json`. `zh` co ```bash { "version": "0.1.1", - "buildtools": "xmake d2x-buildtools", + "buildtools": "mcpp run -q -p d2x/buildtools/mcpp --", "lang": "en", ... } diff --git a/book/src/base/chapter_1.md b/book/src/base/chapter_1.md index fabfeb8..81920aa 100644 --- a/book/src/base/chapter_1.md +++ b/book/src/base/chapter_1.md @@ -156,7 +156,7 @@ Homepage: https://github.com/openxlings/xlings ```bash { "version": "0.1.1", - "buildtools": "xmake d2x-buildtools", + "buildtools": "mcpp run -q -p d2x/buildtools/mcpp --", "lang": "en", < -- 修改这里 ... } @@ -171,7 +171,7 @@ Homepage: https://github.com/openxlings/xlings ```bash { "version": "0.1.1", - "buildtools": "xmake d2x-buildtools", + "buildtools": "mcpp run -q -p d2x/buildtools/mcpp --", "lang": "en", < -- 修改这里 ... } diff --git a/d2x/buildtools/README.md b/d2x/buildtools/README.md deleted file mode 100644 index 9106a16..0000000 --- a/d2x/buildtools/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# d2x 支持的构建工具 - -通过对构建工具做最小化的接口规范来适配不同编程语言及其工具链 - -## 构建工具接口规范 - -``` -xxx d2x-buildtools [command] [target] - -Commands: - list List all targets - build Build specified target - run Run specified target -``` \ No newline at end of file diff --git a/d2x/buildtools/cmake/README.md b/d2x/buildtools/cmake/README.md deleted file mode 100644 index 30404ce..0000000 --- a/d2x/buildtools/cmake/README.md +++ /dev/null @@ -1 +0,0 @@ -TODO \ No newline at end of file diff --git a/d2x/buildtools/xmake/main.lua b/d2x/buildtools/xmake/main.lua deleted file mode 100644 index b757d22..0000000 --- a/d2x/buildtools/xmake/main.lua +++ /dev/null @@ -1,67 +0,0 @@ -import("core.base.json") -import("core.base.option") -import("core.project.project") -import("core.project.config") - -local function get_d2x_lang() - local d2x_json_file = path.join(os.projectdir(), ".d2x.json") - if os.isfile(d2x_json_file) then - local d2x_config = json.loadfile(d2x_json_file) or {} - return d2x_config["lang"] - end - return nil -end - -function list() - - config.load() - local lang = get_d2x_lang() - if lang and #lang > 0 then - config.set("lang", lang, {force = true}) - end - - local targets = project.targets() - for name, _ in pairs(targets) do - local files = targets[name]:sourcefiles() - local flag = true - - -- cprintf / print xxx - printf(name) -- target name - for _, file in ipairs(files) do - file = path.absolute(file) - if flag then - --printf(": " .. file) -- avoid C:\ issue on Windows - printf("@ " .. file) - flag = false - else - printf(", " .. file) - end - end - printf("\n") - end -end - -function main() - - local command = option.get("command") - local target = option.get("target") - - os.cd(os.projectdir()) - --print("project file: " .. project.rootfile()) - - if command == "init" then - local lang = get_d2x_lang() - if lang and #lang > 0 then - os.exec("xmake f --lang=" .. lang) - end - elseif command == "list" then - list() - elseif command == "build" then - os.exec("xmake build " .. (target or "")) - elseif command == "run" then - os.exec("xmake run " .. (target or "")) - else - print("Unknown command: " .. tostring(command)) - end - -end diff --git a/d2x/buildtools/xmake/xmake.lua b/d2x/buildtools/xmake/xmake.lua deleted file mode 100644 index ad4a113..0000000 --- a/d2x/buildtools/xmake/xmake.lua +++ /dev/null @@ -1,14 +0,0 @@ -task("d2x-buildtools") - - set_category("plugin") - - on_run("main") - - set_menu { - usage = "xmake d2x-buildtools [options]", - description = "d2x buildtools plugin", - options = { - {nil, "command", "v", nil, "d2x-buildtools's command"}, - {nil, "target", "v", nil, "the target name"} - } - } \ No newline at end of file diff --git a/d2x/cpp/common.lua b/d2x/cpp/common.lua deleted file mode 100644 index 4a14d17..0000000 --- a/d2x/cpp/common.lua +++ /dev/null @@ -1,29 +0,0 @@ -function get_local_lang() ---local config = platform.get_config_info() - local local_lang = "en" -- Default language - -- TODO: config language by xlings.json - if is_host("linux") then - local tmp_local_lang = os.getenv("LANG") or "en" - if tmp_local_lang:find("zh") then - local_lang = "zh" - end - elseif is_host("windows") then - local tmp_local_lang = nil - if find_tool("wmic") then -- Windows 10 and earlier - -- wmic is deprecated in Windows 11, but still available - tmp_local_lang = os.iorun([[wmic os get locale]]) - else -- win11+ - tmp_local_lang = os.iorun([[powershell -NoProfile -ExecutionPolicy Bypass -Command "'{0:X4}' -f (Get-Culture).LCID"]]) - end - if tmp_local_lang and tmp_local_lang:find("0804") then - local_lang = "zh" - end - elseif is_host("macosx") then - local tmp_local_lang = os.iorun([[defaults read -g AppleLocale]]) - if tmp_local_lang and tmp_local_lang:find("zh") then - local_lang = "zh" - end - end - - return local_lang -end diff --git a/dslings/cpp11/xmake.lua b/dslings/cpp11/xmake.lua deleted file mode 100644 index e7dbdae..0000000 --- a/dslings/cpp11/xmake.lua +++ /dev/null @@ -1,195 +0,0 @@ -if is_host("windows") then - set_languages("cxx14") -else - set_languages("cxx11") -end - --- target: cpp11-00-auto-and-decltype - -target("cpp11-00-auto-and-decltype") - set_kind("binary") - add_files("00-auto-and-decltype-0.cpp") -target("cpp11-00-auto-and-decltype-1") - set_kind("binary") - add_files("00-auto-and-decltype-1.cpp") -target("cpp11-00-auto-and-decltype-2") - set_kind("binary") - add_files("00-auto-and-decltype-2.cpp") -target("cpp11-00-auto-and-decltype-3") - set_kind("binary") - add_files("00-auto-and-decltype-3.cpp") -target("cpp11-00-auto-and-decltype-4") - set_kind("binary") - add_files("00-auto-and-decltype-4.cpp") -target("cpp11-00-auto-and-decltype-5") - set_kind("binary") - add_files("00-auto-and-decltype-5.cpp") - --- target: cpp11-01-default-and-delete - -target("cpp11-01-default-and-delete-0") - set_kind("binary") - add_files("01-default-and-delete-0.cpp") - -target("cpp11-01-default-and-delete-1") - set_kind("binary") - add_files("01-default-and-delete-1.cpp") - -target("cpp11-01-default-and-delete-2") - set_kind("binary") - add_files("01-default-and-delete-2.cpp") - --- target: cpp11-02-final-and-override - -target("cpp11-02-final-and-override-0") - add_files("02-final-and-override-0.cpp") - -target("cpp11-02-final-and-override-1") - add_files("02-final-and-override-1.cpp") - -target("cpp11-02-final-and-override-2") - add_files("02-final-and-override-2.cpp") - --- target: cpp11-03-trailing-return-type - -target("cpp11-03-trailing-return-type") - add_files("03-trailing-return-type.cpp") - --- target: cpp11-04-rvalue-references - -target("cpp11-04-rvalue-references") - set_optimize("none") - add_cxxflags("-fno-elide-constructors") - add_files("04-rvalue-references.cpp") - --- target: cpp11-05-move-semantics - -target("cpp11-05-move-semantics-0") - add_files("05-move-semantics-0.cpp") - -target("cpp11-05-move-semantics-1") - add_files("05-move-semantics-1.cpp") - -target("cpp11-05-move-semantics-2") - add_files("05-move-semantics-2.cpp") - --- target: cpp11-06-scoped-enums - -target("cpp11-06-scoped-enums-0") - add_files("06-scoped-enums-0.cpp") - -target("cpp11-06-scoped-enums-1") - add_files("06-scoped-enums-1.cpp") - --- target: cpp11-07-constexpr - -target("cpp11-07-constexpr-0") - add_cxxflags("-Wpedantic -Werror") - add_files("07-constexpr-0.cpp") - -target("cpp11-07-constexpr-1") - add_files("07-constexpr-1.cpp") - --- target: cpp11-08-literal-type - -target("cpp11-08-literal-type-0") - set_languages("c++17") -- TODO: optimize it - add_files("08-literal-type-0.cpp") - -target("cpp11-08-literal-type-1") - add_files("08-literal-type-1.cpp") - --- target: cpp11-09-list-initialization - -target("cpp11-09-list-initialization-0") - add_files("09-list-initialization-0.cpp") - -target("cpp11-09-list-initialization-1") - add_files("09-list-initialization-1.cpp") - -target("cpp11-09-list-initialization-2") - add_files("09-list-initialization-2.cpp") - -target("cpp11-09-list-initialization-3") - add_files("09-list-initialization-3.cpp") - --- target: cpp11-10-delegating-constructors - -target("cpp11-10-delegating-constructors-0") - add_files("10-delegating-constructors-0.cpp") - -target("cpp11-10-delegating-constructors-1") - add_files("10-delegating-constructors-1.cpp") - --- target: cpp11-11-inherited-constructors - -target("cpp11-11-inherited-constructors-0") - add_files("11-inherited-constructors-0.cpp") - -target("cpp11-11-inherited-constructors-1") - add_files("11-inherited-constructors-1.cpp") - -target("cpp11-11-inherited-constructors-2") - add_files("11-inherited-constructors-2.cpp") - --- target: cpp11-12-nullptr - -target("cpp11-12-nullptr-0") - add_files("12-nullptr-0.cpp") - -target("cpp11-12-nullptr-1") - add_files("12-nullptr-1.cpp") - -target("cpp11-12-nullptr-2") - add_files("12-nullptr-2.cpp") - --- target: cpp11-13-long-long -target("cpp11-13-long-long-0") - add_files("13-long-long-0.cpp") - -target("cpp11-13-long-long-1") - add_files("13-long-long-1.cpp") - --- target: cpp11-14-type-alias - -target("cpp11-14-type-alias-0") - add_files("14-type-alias-0.cpp") - -target("cpp11-14-type-alias-1") - add_files("14-type-alias-1.cpp") - -target("cpp11-14-type-alias-2") - add_files("14-type-alias-2.cpp") - -target("cpp11-14-type-alias-3") - add_files("14-type-alias-3.cpp") - --- target: cpp11-15-variadic-templates - -target("cpp11-15-variadic-templates-0") - add_files("15-variadic-templates-0.cpp") - -target("cpp11-15-variadic-templates-1") - add_files("15-variadic-templates-1.cpp") - --- target: cpp11-16-generalized-unions - -target("cpp11-16-generalized-unions-0") - add_files("16-generalized-unions-0.cpp") - -target("cpp11-16-generalized-unions-1") - add_files("16-generalized-unions-1.cpp") - -target("cpp11-16-generalized-unions-2") - add_files("16-generalized-unions-2.cpp") - --- target: cpp11-17-pod-type - -target("cpp11-17-pod-type-0") - add_files("17-pod-type-0.cpp") - -target("cpp11-17-pod-type-1") - add_files("17-pod-type-1.cpp") - -target("cpp11-17-pod-type-2") - add_files("17-pod-type-2.cpp") diff --git a/dslings/cpp14/xmake.lua b/dslings/cpp14/xmake.lua deleted file mode 100644 index 8a21cbf..0000000 --- a/dslings/cpp14/xmake.lua +++ /dev/null @@ -1,11 +0,0 @@ -set_languages("cxx14") - --- target: cpp14-00-generic-lambdas - -target("cpp14-00-generic-lambdas-0") - set_kind("binary") - add_files("00-generic-lambdas-0.cpp") - -target("cpp14-00-generic-lambdas-1") - set_kind("binary") - add_files("00-generic-lambdas-1.cpp") diff --git a/dslings/en/cpp11/xmake.lua b/dslings/en/cpp11/xmake.lua deleted file mode 100644 index 6daeb9e..0000000 --- a/dslings/en/cpp11/xmake.lua +++ /dev/null @@ -1,192 +0,0 @@ -if is_host("windows") then - set_languages("cxx14") -else - set_languages("cxx11") -end - --- target: cpp11-00-auto-and-decltype - -target("cpp11-00-auto-and-decltype") - set_kind("binary") - add_files("00-auto-and-decltype-0.cpp") -target("cpp11-00-auto-and-decltype-1") - set_kind("binary") - add_files("00-auto-and-decltype-1.cpp") -target("cpp11-00-auto-and-decltype-2") - set_kind("binary") - add_files("00-auto-and-decltype-2.cpp") -target("cpp11-00-auto-and-decltype-3") - set_kind("binary") - add_files("00-auto-and-decltype-3.cpp") -target("cpp11-00-auto-and-decltype-4") - set_kind("binary") - add_files("00-auto-and-decltype-4.cpp") - --- target: cpp11-01-default-and-delete - -target("cpp11-01-default-and-delete-0") - set_kind("binary") - add_files("01-default-and-delete-0.cpp") - -target("cpp11-01-default-and-delete-1") - set_kind("binary") - add_files("01-default-and-delete-1.cpp") - -target("cpp11-01-default-and-delete-2") - set_kind("binary") - add_files("01-default-and-delete-2.cpp") - --- target: cpp11-02-final-and-override - -target("cpp11-02-final-and-override-0") - add_files("02-final-and-override-0.cpp") - -target("cpp11-02-final-and-override-1") - add_files("02-final-and-override-1.cpp") - -target("cpp11-02-final-and-override-2") - add_files("02-final-and-override-2.cpp") - --- target: cpp11-03-trailing-return-type - -target("cpp11-03-trailing-return-type") - add_files("03-trailing-return-type.cpp") - --- target: cpp11-04-rvalue-references - -target("cpp11-04-rvalue-references") - set_optimize("none") - add_cxxflags("-fno-elide-constructors") - add_files("04-rvalue-references.cpp") - --- target: cpp11-05-move-semantics - -target("cpp11-05-move-semantics-0") - add_files("05-move-semantics-0.cpp") - -target("cpp11-05-move-semantics-1") - add_files("05-move-semantics-1.cpp") - -target("cpp11-05-move-semantics-2") - add_files("05-move-semantics-2.cpp") - --- target: cpp11-06-scoped-enums - -target("cpp11-06-scoped-enums-0") - add_files("06-scoped-enums-0.cpp") - -target("cpp11-06-scoped-enums-1") - add_files("06-scoped-enums-1.cpp") - --- target: cpp11-07-constexpr - -target("cpp11-07-constexpr-0") - add_cxxflags("-Wpedantic -Werror") - add_files("07-constexpr-0.cpp") - -target("cpp11-07-constexpr-1") - add_files("07-constexpr-1.cpp") - --- target: cpp11-08-literal-type - -target("cpp11-08-literal-type-0") - set_languages("c++17") -- TODO: optimize it - add_files("08-literal-type-0.cpp") - -target("cpp11-08-literal-type-1") - add_files("08-literal-type-1.cpp") - --- target: cpp11-09-list-initialization - -target("cpp11-09-list-initialization-0") - add_files("09-list-initialization-0.cpp") - -target("cpp11-09-list-initialization-1") - add_files("09-list-initialization-1.cpp") - -target("cpp11-09-list-initialization-2") - add_files("09-list-initialization-2.cpp") - -target("cpp11-09-list-initialization-3") - add_files("09-list-initialization-3.cpp") - --- target: cpp11-10-delegating-constructors - -target("cpp11-10-delegating-constructors-0") - add_files("10-delegating-constructors-0.cpp") - -target("cpp11-10-delegating-constructors-1") - add_files("10-delegating-constructors-1.cpp") - --- target: cpp11-11-inherited-constructors - -target("cpp11-11-inherited-constructors-0") - add_files("11-inherited-constructors-0.cpp") - -target("cpp11-11-inherited-constructors-1") - add_files("11-inherited-constructors-1.cpp") - -target("cpp11-11-inherited-constructors-2") - add_files("11-inherited-constructors-2.cpp") - --- target: cpp11-12-nullptr - -target("cpp11-12-nullptr-0") - add_files("12-nullptr-0.cpp") - -target("cpp11-12-nullptr-1") - add_files("12-nullptr-1.cpp") - -target("cpp11-12-nullptr-2") - add_files("12-nullptr-2.cpp") - --- target: cpp11-13-long-long -target("cpp11-13-long-long-0") - add_files("13-long-long-0.cpp") - -target("cpp11-13-long-long-1") - add_files("13-long-long-1.cpp") - --- target: cpp11-14-type-alias - -target("cpp11-14-type-alias-0") - add_files("14-type-alias-0.cpp") - -target("cpp11-14-type-alias-1") - add_files("14-type-alias-1.cpp") - -target("cpp11-14-type-alias-2") - add_files("14-type-alias-2.cpp") - -target("cpp11-14-type-alias-3") - add_files("14-type-alias-3.cpp") - --- target: cpp11-15-variadic-templates - -target("cpp11-15-variadic-templates-0") - add_files("15-variadic-templates-0.cpp") - -target("cpp11-15-variadic-templates-1") - add_files("15-variadic-templates-1.cpp") - --- target: cpp11-16-generalized-unions - -target("cpp11-16-generalized-unions-0") - add_files("16-generalized-unions-0.cpp") - -target("cpp11-16-generalized-unions-1") - add_files("16-generalized-unions-1.cpp") - -target("cpp11-16-generalized-unions-2") - add_files("16-generalized-unions-2.cpp") - --- target: cpp11-17-pod-type - -target("cpp11-17-pod-type-0") - add_files("17-pod-type-0.cpp") - -target("cpp11-17-pod-type-1") - add_files("17-pod-type-1.cpp") - -target("cpp11-17-pod-type-2") - add_files("17-pod-type-2.cpp") diff --git a/dslings/en/cpp14/xmake.lua b/dslings/en/cpp14/xmake.lua deleted file mode 100644 index 8a21cbf..0000000 --- a/dslings/en/cpp14/xmake.lua +++ /dev/null @@ -1,11 +0,0 @@ -set_languages("cxx14") - --- target: cpp14-00-generic-lambdas - -target("cpp14-00-generic-lambdas-0") - set_kind("binary") - add_files("00-generic-lambdas-0.cpp") - -target("cpp14-00-generic-lambdas-1") - set_kind("binary") - add_files("00-generic-lambdas-1.cpp") diff --git a/dslings/en/xmake.lua b/dslings/en/xmake.lua deleted file mode 100644 index 7678d9a..0000000 --- a/dslings/en/xmake.lua +++ /dev/null @@ -1,6 +0,0 @@ -target("00-0-hello-mcpp") - set_languages("cxx11") - add_files("hello-mcpp.cpp") - -includes("cpp11") -includes("cpp14") diff --git a/dslings/xmake.lua b/dslings/xmake.lua deleted file mode 100644 index 32c5a95..0000000 --- a/dslings/xmake.lua +++ /dev/null @@ -1,23 +0,0 @@ -if is_host("windows") then - set_encodings("source:utf-8", "target:utf-8") - set_toolchains("gcc") - add_ldflags("-static") -end - -option("lang") - set_default("en") - set_description("Language: en or zh") - -local lang = get_config("lang") - -if lang == "zh" then - - target("00-0-hello-mcpp") - set_languages("cxx11") - add_files("hello-mcpp.cpp") - - includes("cpp11") - includes("cpp14") -else - includes("en") -end diff --git a/solutions/cpp11/xmake.lua b/solutions/cpp11/xmake.lua deleted file mode 100644 index 31cdd51..0000000 --- a/solutions/cpp11/xmake.lua +++ /dev/null @@ -1,198 +0,0 @@ --- Reference solutions for cpp11 chapter exercises. --- Each target name mirrors dslings/cpp11/ with a "-ref" suffix. --- Used by CI to verify exercise infrastructure (xmake target / include paths / --- language standard) and that the canonical answer actually passes all --- d2x_assert / d2x_assert_eq checks. - -if is_host("windows") then - set_languages("cxx14") -else - set_languages("cxx11") -end - --- target: cpp11-00-auto-and-decltype - -target("cpp11-00-auto-and-decltype-0-ref") - add_files("00-auto-and-decltype-0.cpp") - -target("cpp11-00-auto-and-decltype-1-ref") - add_files("00-auto-and-decltype-1.cpp") - -target("cpp11-00-auto-and-decltype-2-ref") - add_files("00-auto-and-decltype-2.cpp") - -target("cpp11-00-auto-and-decltype-3-ref") - add_files("00-auto-and-decltype-3.cpp") - -target("cpp11-00-auto-and-decltype-4-ref") - add_files("00-auto-and-decltype-4.cpp") - -target("cpp11-00-auto-and-decltype-5-ref") - add_files("00-auto-and-decltype-5.cpp") - --- target: cpp11-01-default-and-delete - -target("cpp11-01-default-and-delete-0-ref") - add_files("01-default-and-delete-0.cpp") - -target("cpp11-01-default-and-delete-1-ref") - add_files("01-default-and-delete-1.cpp") - -target("cpp11-01-default-and-delete-2-ref") - add_files("01-default-and-delete-2.cpp") - --- target: cpp11-02-final-and-override - -target("cpp11-02-final-and-override-0-ref") - add_files("02-final-and-override-0.cpp") - -target("cpp11-02-final-and-override-1-ref") - add_files("02-final-and-override-1.cpp") - -target("cpp11-02-final-and-override-2-ref") - add_files("02-final-and-override-2.cpp") - --- target: cpp11-03-trailing-return-type - -target("cpp11-03-trailing-return-type-ref") - add_files("03-trailing-return-type.cpp") - --- target: cpp11-04-rvalue-references - -target("cpp11-04-rvalue-references-ref") - set_optimize("none") - add_cxxflags("-fno-elide-constructors") - add_files("04-rvalue-references.cpp") - --- target: cpp11-05-move-semantics - -target("cpp11-05-move-semantics-0-ref") - add_files("05-move-semantics-0.cpp") - -target("cpp11-05-move-semantics-1-ref") - add_files("05-move-semantics-1.cpp") - -target("cpp11-05-move-semantics-2-ref") - add_files("05-move-semantics-2.cpp") - --- target: cpp11-06-scoped-enums - -target("cpp11-06-scoped-enums-0-ref") - add_files("06-scoped-enums-0.cpp") - -target("cpp11-06-scoped-enums-1-ref") - add_files("06-scoped-enums-1.cpp") - --- target: cpp11-07-constexpr - -target("cpp11-07-constexpr-0-ref") - add_cxxflags("-Wpedantic -Werror") - add_files("07-constexpr-0.cpp") - -target("cpp11-07-constexpr-1-ref") - add_files("07-constexpr-1.cpp") - --- target: cpp11-08-literal-type - -target("cpp11-08-literal-type-0-ref") - set_languages("c++17") -- TODO: optimize it - add_files("08-literal-type-0.cpp") - -target("cpp11-08-literal-type-1-ref") - add_files("08-literal-type-1.cpp") - --- target: cpp11-09-list-initialization - -target("cpp11-09-list-initialization-0-ref") - add_files("09-list-initialization-0.cpp") - -target("cpp11-09-list-initialization-1-ref") - add_files("09-list-initialization-1.cpp") - -target("cpp11-09-list-initialization-2-ref") - add_files("09-list-initialization-2.cpp") - -target("cpp11-09-list-initialization-3-ref") - add_files("09-list-initialization-3.cpp") - --- target: cpp11-10-delegating-constructors - -target("cpp11-10-delegating-constructors-0-ref") - add_files("10-delegating-constructors-0.cpp") - -target("cpp11-10-delegating-constructors-1-ref") - add_files("10-delegating-constructors-1.cpp") - --- target: cpp11-11-inherited-constructors - -target("cpp11-11-inherited-constructors-0-ref") - add_files("11-inherited-constructors-0.cpp") - -target("cpp11-11-inherited-constructors-1-ref") - add_files("11-inherited-constructors-1.cpp") - -target("cpp11-11-inherited-constructors-2-ref") - add_files("11-inherited-constructors-2.cpp") - --- target: cpp11-12-nullptr - -target("cpp11-12-nullptr-0-ref") - add_files("12-nullptr-0.cpp") - -target("cpp11-12-nullptr-1-ref") - add_files("12-nullptr-1.cpp") - -target("cpp11-12-nullptr-2-ref") - add_files("12-nullptr-2.cpp") - --- target: cpp11-13-long-long - -target("cpp11-13-long-long-0-ref") - add_files("13-long-long-0.cpp") - -target("cpp11-13-long-long-1-ref") - add_files("13-long-long-1.cpp") - --- target: cpp11-14-type-alias - -target("cpp11-14-type-alias-0-ref") - add_files("14-type-alias-0.cpp") - -target("cpp11-14-type-alias-1-ref") - add_files("14-type-alias-1.cpp") - -target("cpp11-14-type-alias-2-ref") - add_files("14-type-alias-2.cpp") - -target("cpp11-14-type-alias-3-ref") - add_files("14-type-alias-3.cpp") - --- target: cpp11-15-variadic-templates - -target("cpp11-15-variadic-templates-0-ref") - add_files("15-variadic-templates-0.cpp") - -target("cpp11-15-variadic-templates-1-ref") - add_files("15-variadic-templates-1.cpp") - --- target: cpp11-16-generalized-unions - -target("cpp11-16-generalized-unions-0-ref") - add_files("16-generalized-unions-0.cpp") - -target("cpp11-16-generalized-unions-1-ref") - add_files("16-generalized-unions-1.cpp") - -target("cpp11-16-generalized-unions-2-ref") - add_files("16-generalized-unions-2.cpp") - --- target: cpp11-17-pod-type - -target("cpp11-17-pod-type-0-ref") - add_files("17-pod-type-0.cpp") - -target("cpp11-17-pod-type-1-ref") - add_files("17-pod-type-1.cpp") - -target("cpp11-17-pod-type-2-ref") - add_files("17-pod-type-2.cpp") diff --git a/solutions/cpp14/xmake.lua b/solutions/cpp14/xmake.lua deleted file mode 100644 index 8bbbb90..0000000 --- a/solutions/cpp14/xmake.lua +++ /dev/null @@ -1,11 +0,0 @@ -set_languages("cxx14") - --- target: cpp14-00-generic-lambdas - -target("cpp14-00-generic-lambdas-0-ref") - set_kind("binary") - add_files("00-generic-lambdas-0.cpp") - -target("cpp14-00-generic-lambdas-1-ref") - set_kind("binary") - add_files("00-generic-lambdas-1.cpp") diff --git a/solutions/xmake.lua b/solutions/xmake.lua deleted file mode 100644 index c1b845e..0000000 --- a/solutions/xmake.lua +++ /dev/null @@ -1,11 +0,0 @@ --- Reference solutions root. --- Mirrors dslings/ structure but only contains the canonical "filled-in" --- answers, used purely for build / run CI. Each target carries the "-ref" --- suffix to coexist with the dslings/ targets that share the same logical --- name (e.g. cpp11-13-long-long-0 vs cpp11-13-long-long-0-ref). --- --- Solutions are language-neutral by design: the comments matter only to --- contributors, not the compiler, so we keep a single zh-style copy. - -includes("cpp11/xmake.lua") -includes("cpp14/xmake.lua") diff --git a/xmake.lua b/xmake.lua deleted file mode 100644 index e37aca0..0000000 --- a/xmake.lua +++ /dev/null @@ -1,6 +0,0 @@ -add_plugindirs("d2x/buildtools") - -add_includedirs(".") - -includes("dslings/xmake.lua") ---includes("solutions/xmake.lua") \ No newline at end of file From 8b01ee8df1fa7f7700a99ae0d5d7fdb092b1245b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 20 Jul 2026 00:26:07 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(harness):=20=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E6=94=B9=E8=B5=B0=E4=BE=A7=E4=BF=A1=E9=81=93?= =?UTF-8?q?=EF=BC=8C=E7=BB=83=E4=B9=A0=E8=84=9A=E6=89=8B=E6=9E=B6=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=88=90=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 判定原先 100% 是带内信号:断言失败并不改变退出码(实测退出码仍为 0), 所以只能扫 stdout 找 ❌。这会误判——一个断言全过的正确解答,只要在说明 文字里打了个 ❌ 就被判失败(已复现)。反过来,输出截断或 Windows 控制台 代码页不对,判定同样失效。 改为:stdout 回归「给人看」,判定走侧信道 NDJSON。 harness 独立成 mcpp 库包 dslings/harness/,同时提供两条路径: - #include 宏路径。可见输出与旧版逐字节一致, ✅/❌ 的逐条对照是教学的一部分,不动。 - import d2x.harness; 模块路径。用 std::source_location 自动带 file/line,比 __LINE__ 更准,正好是 Verdict.diagnostics 需要的东西。 两条路径不等价,这是 C++ 的硬约束而非疏漏:宏无法跨模块导出,所以 D2X_YOUR_ANSWER 没有模块等价物——它必须展开为空才能制造编译错误。 模块化章节需要另一套填空约定。 include/ 下保持 d2x/cpp/common.hpp 这个既有路径,104 个练习和书本一行 不用改;路径改名可以以后单独做,不必和机制改造捆在一起。 生成的 member 清单改用 [dependencies] path 依赖,干掉了 include_dirs = ["../../.."] 那个把整个仓库根塞进搜索路径的 hack—— 原先练习能 #include 仓库里任何文件。 侧信道逐条追加而非退出时统一写:练习段错误时崩溃前的断言结果照样保留。 D2X_RESULT_FILE 未设置时 harness 只打印不写文件,学员直接跑二进制零摩擦。 Provider 判定顺序:有 ok:false → Fail 且每条失败转成一个 Diagnostic; 无失败但有 wait → Blocked;侧信道文件不存在 → 退回「编译通过 + 退出 0」。 最后一条让 harness 自动变成可选的——纯观察型练习(现有 18 个只用 D2X_WAIT)可以写成零依赖的纯 C++ 文件,学员能原样拷进 Compiler Explorer。 顺带删掉死代码 d2x_is_invocable(104 个练习中 0 处使用)和坏掉的模块桩 d2x/mcpp/common.cppm(宏不能跨模块导出,import 进去什么也拿不到)。 e2e.sh 的清理钩子收窄为逐个练习目录:harness 现在也住在 dslings/ 下, 原先的 `git checkout -- dslings/` 会把开发中的改动一起抹掉(已踩过)。 实测:51/51 参考答案通过;四态判定正确(未完成→fail、参考答案→pass、 答案对且输出含❌→pass、答案对留路障→blocked);真实断言失败产出带 行号与期望/实际值的 diagnostics;模块路径与「无 harness 退回退出码」均通过。 --- d2x/buildtools/mcpp/src/emit.cppm | 21 +++- d2x/buildtools/mcpp/src/main.cpp | 15 ++- d2x/buildtools/mcpp/src/manifest.cppm | 7 +- d2x/buildtools/mcpp/src/runner.cppm | 119 +++++++++++++++--- d2x/buildtools/mcpp/tests/e2e.sh | 12 +- d2x/cpp/common.hpp | 46 ------- d2x/mcpp/common.cppm | 5 - dslings/harness/include/d2x/cpp/common.hpp | 72 +++++++++++ .../harness/include/d2x}/cpp/honly_logger.hpp | 0 dslings/harness/include/d2x/cpp/report.hpp | 81 ++++++++++++ dslings/harness/mcpp.toml | 15 +++ dslings/harness/src/harness.cppm | 62 +++++++++ mcpp.toml | 2 +- 13 files changed, 379 insertions(+), 78 deletions(-) delete mode 100644 d2x/cpp/common.hpp delete mode 100644 d2x/mcpp/common.cppm create mode 100644 dslings/harness/include/d2x/cpp/common.hpp rename {d2x => dslings/harness/include/d2x}/cpp/honly_logger.hpp (100%) create mode 100644 dslings/harness/include/d2x/cpp/report.hpp create mode 100644 dslings/harness/mcpp.toml create mode 100644 dslings/harness/src/harness.cppm diff --git a/d2x/buildtools/mcpp/src/emit.cppm b/d2x/buildtools/mcpp/src/emit.cppm index 63a6bbb..4b516b1 100644 --- a/d2x/buildtools/mcpp/src/emit.cppm +++ b/d2x/buildtools/mcpp/src/emit.cppm @@ -83,9 +83,24 @@ export void output(std::string_view chunk) { line(std::format(R"({{"event":"output","chunk":{}}})", str(chunk))); } -export void verdict(std::string_view outcome, std::string_view stage_name, int exit_code) { - line(std::format(R"({{"event":"verdict","outcome":{},"stage":{},"exit_code":{},"diagnostics":[]}})", - str(outcome), str(stage_name), exit_code)); +export void verdict(std::string_view outcome, std::string_view stage_name, int exit_code, + const std::vector& diagnostics = {}) { + line(std::format(R"({{"event":"verdict","outcome":{},"stage":{},"exit_code":{},"diagnostics":{}}})", + str(outcome), str(stage_name), exit_code, array(diagnostics))); +} + +// 一条失败的断言 → 一个 Diagnostic。d2x 的前端据此做行内高亮和跳转, +// 学员不用在几十行输出里找是哪一条没过。 +export std::string diagnostic(std::string_view file, int line_no, + std::string_view expr, + std::string_view expected, std::string_view actual) { + auto message = expected.empty() && actual.empty() + ? std::format("断言未通过: {}", expr) + : std::format("断言未通过: {} —— 期望 {},实际 {}", expr, expected, actual); + + return std::format( + R"({{"file":{},"line":{},"col":0,"severity":"error","message":{}}})", + str(file), line_no, str(message)); } export void error(std::string_view message) { diff --git a/d2x/buildtools/mcpp/src/main.cpp b/d2x/buildtools/mcpp/src/main.cpp index 80be0f8..d2e710e 100644 --- a/d2x/buildtools/mcpp/src/main.cpp +++ b/d2x/buildtools/mcpp/src/main.cpp @@ -90,11 +90,20 @@ int cmd_check(const fs::path& root, std::string_view id) { } d2x::emit::stage("run"); - auto ran = d2x::runner::run_current(); + + // 侧信道放在构建目录里:与生成物同生共死,不污染仓库 + auto result_file = d2x::manifest::build_dir(root) / "_current" / "result.ndjson"; + auto ran = d2x::runner::run_current(result_file); d2x::emit::output(ran.output); - auto outcome = d2x::runner::judge_run(ran.exit_code, ran.output); - d2x::emit::verdict(d2x::runner::to_string(outcome), "run", ran.exit_code); + auto report = d2x::runner::judge_run(ran.exit_code, result_file); + + std::vector diags; + for (const auto& f : report.failures) { + diags.push_back(d2x::emit::diagnostic(f.file, f.line, f.expr, f.expected, f.actual)); + } + + d2x::emit::verdict(d2x::runner::to_string(report.outcome), "run", ran.exit_code, diags); return 0; } diff --git a/d2x/buildtools/mcpp/src/manifest.cppm b/d2x/buildtools/mcpp/src/manifest.cppm index bcf5843..9f4ef73 100644 --- a/d2x/buildtools/mcpp/src/manifest.cppm +++ b/d2x/buildtools/mcpp/src/manifest.cppm @@ -49,8 +49,11 @@ void write_package_header(std::ostream& out, std::string_view name) { std::println(out, "[build]"); // 显式清空源码 glob:练习不是这个包的「源码」,它们只是各自 target 的入口。 std::println(out, "sources = []"); - // 仓库根进搜索路径——155 个练习全部 #include - std::println(out, "include_dirs = [\"{}\"]", kToRoot); + std::println(out, ""); + // 脚手架走正经的库依赖,而不是把仓库根塞进 include 搜索路径。 + // 后者会让练习能 #include 仓库里任何文件,是个隐患。 + std::println(out, "[dependencies]"); + std::println(out, "harness = {{ path = \"{}/dslings/harness\" }}", kToRoot); } void write_target(std::ostream& out, const fs::path& repo_root, const Exercise& ex) { diff --git a/d2x/buildtools/mcpp/src/runner.cppm b/d2x/buildtools/mcpp/src/runner.cppm index 7bcb3ef..8f9b71a 100644 --- a/d2x/buildtools/mcpp/src/runner.cppm +++ b/d2x/buildtools/mcpp/src/runner.cppm @@ -62,15 +62,8 @@ export Captured capture(const std::string& cmd) { return result; } -// d2mcpp 的练习断言协议,定义在 d2x/cpp/common.hpp: -// d2x_assert / d2x_assert_eq 失败时打印 ❌ -// D2X_WAIT 宏打印一句提示,学员删掉它才算真正完成 -// // 这套判定是「课程约定」,不是「构建工具行为」——所以它属于 Provider, // 而不是 d2x 框架。换一门 Rust 课程,这里会完全不同。 -export constexpr std::string_view kFailMark = "\xE2\x9D\x8C"; // U+274C ❌ -export constexpr std::string_view kWaitMark = "D2X_WAIT"; - export enum class Outcome { Pass, Fail, Blocked }; export std::string_view to_string(Outcome o) { @@ -82,14 +75,99 @@ export std::string_view to_string(Outcome o) { return "fail"; } -// 运行阶段的输出判定。退出码为 0 只是必要条件: -// 出现 ❌ → 断言没过,Fail -// 出现 D2X_WAIT → 答案已对但路障还在,Blocked(既非失败也不该前进) -export Outcome judge_run(int exit_code, std::string_view output) { - if (exit_code != 0) return Outcome::Fail; - if (output.find(kFailMark) != std::string_view::npos) return Outcome::Fail; - if (output.find(kWaitMark) != std::string_view::npos) return Outcome::Blocked; - return Outcome::Pass; +export struct Failure { + std::string expr; + std::string expected; + std::string actual; + std::string file; + int line{}; +}; + +export struct RunReport { + Outcome outcome{Outcome::Pass}; + std::vector failures; +}; + +// 从侧信道 NDJSON 里取一个字段。这里不引入 JSON 库: +// 格式由我们自己的 harness 产出,字段固定、无嵌套、无数组。 +std::string field(std::string_view line, std::string_view key) { + auto pat = std::format("\"{}\":", key); + auto at = line.find(pat); + if (at == std::string_view::npos) return {}; + at += pat.size(); + if (at >= line.size()) return {}; + + if (line[at] == '"') { // 字符串值 + ++at; + std::string out; + while (at < line.size() && line[at] != '"') { + if (line[at] == '\\' && at + 1 < line.size()) { + ++at; + switch (line[at]) { + case 'n': out += '\n'; break; + case 't': out += '\t'; break; + case 'r': out += '\r'; break; + default: out += line[at]; + } + } else { + out += line[at]; + } + ++at; + } + return out; + } + auto end = line.find_first_of(",}", at); // 裸值(数字/布尔) + return std::string(line.substr(at, end == std::string_view::npos ? end : end - at)); +} + +// 判定顺序: +// 有 ok:false → Fail,每条失败都能转成一个 Diagnostic +// 无失败但有 wait → Blocked(答案已对,只差拆路障) +// 侧信道文件不存在 → 退回「退出码为 0 即通过」 +// +// 最后一条让 harness 自动变成可选的:纯观察型练习可以是零依赖的 +// 纯 C++ 文件,学员能原样拷进 Compiler Explorer。 +export RunReport judge_run(int exit_code, const fs::path& result_file) { + RunReport report; + + if (exit_code != 0) { + report.outcome = Outcome::Fail; + // 即使退出码非 0,已写入的断言仍然有价值(崩溃前过了几条) + } + + std::ifstream in(result_file); + if (!in) { + // 没有侧信道:练习没用 harness,退出码就是全部信息 + report.outcome = (exit_code == 0) ? Outcome::Pass : Outcome::Fail; + return report; + } + + bool saw_wait = false; + for (std::string line; std::getline(in, line); ) { + auto kind = field(line, "kind"); + if (kind == "wait") { saw_wait = true; continue; } + if (kind != "assert") continue; + if (field(line, "ok") == "true") continue; + + int line_no = 0; + auto raw = field(line, "line"); + std::from_chars(raw.data(), raw.data() + raw.size(), line_no); + + report.failures.push_back(Failure{ + .expr = field(line, "expr"), + .expected = field(line, "expected"), + .actual = field(line, "actual"), + .file = field(line, "file"), + .line = line_no, + }); + } + + if (!report.failures.empty()) report.outcome = Outcome::Fail; + else if (exit_code != 0) report.outcome = Outcome::Fail; + else if (saw_wait) report.outcome = Outcome::Blocked; + else report.outcome = Outcome::Pass; + + return report; } // mcpp 需要在 workspace 根(.d2x/build/)下执行。Provider 是独立进程, @@ -102,7 +180,16 @@ export Captured build_current() { return capture("mcpp build -q -p _current"); } -export Captured run_current() { +// 运行前把侧信道路径告诉 harness,并清掉上一轮的残留 —— +// harness 是追加写的,不清会把上次的失败算进这次。 +export Captured run_current(const fs::path& result_file) { + std::error_code ec; + fs::remove(result_file, ec); +#ifndef _WIN32 + ::setenv("D2X_RESULT_FILE", result_file.string().c_str(), 1); +#else + ::_putenv_s("D2X_RESULT_FILE", result_file.string().c_str()); +#endif return capture("mcpp run -q -p _current"); } diff --git a/d2x/buildtools/mcpp/tests/e2e.sh b/d2x/buildtools/mcpp/tests/e2e.sh index f0ef46d..c541817 100755 --- a/d2x/buildtools/mcpp/tests/e2e.sh +++ b/d2x/buildtools/mcpp/tests/e2e.sh @@ -14,8 +14,16 @@ cd "$REPO_ROOT" PROVIDER=(mcpp run -q -p d2x/buildtools/mcpp --) -# 任何退出路径都还原被改动的练习文件,绝不把仓库留在脏状态 -restore() { git checkout -- dslings/ 2>/dev/null || true; } +# 任何退出路径都还原被改动的练习文件,绝不把仓库留在脏状态。 +# +# 只还原练习源文件,绝不整目录还原 dslings/ —— 脚手架库 dslings/harness/ +# 也在这个目录下,整目录还原会把开发中的改动一起抹掉(踩过一次)。 +EXERCISE_DIRS=(dslings/cpp* dslings/en dslings/hello-mcpp.cpp) +restore() { + for d in "${EXERCISE_DIRS[@]}"; do + [ -e "$d" ] && git checkout -- "$d" 2>/dev/null || true + done +} trap restore EXIT outcome_of() { # $1 = exercise id diff --git a/d2x/cpp/common.hpp b/d2x/cpp/common.hpp deleted file mode 100644 index c67d9df..0000000 --- a/d2x/cpp/common.hpp +++ /dev/null @@ -1,46 +0,0 @@ -#ifndef COMMON_HPP_D2X -#define COMMON_HPP_D2X - -#include -#include - -#include - -#define d2x_assert(expr) \ -{ \ - if (!(expr)) { \ - HONLY_LOGW("❌(error) | %s", #expr); \ - } else { \ - HONLY_LOGI_P("✅ | %s", #expr); \ - } \ -} - -#define d2x_assert_eq(a, b) \ -{ \ - if (a != b) {\ - HONLY_LOGW("❌ | %s == %s (%s == %s)", \ - #a, #b, std::to_string(a).c_str(), std::to_string(b).c_str()); \ - } else {\ - HONLY_LOGI_P("✅ | %s == %s (%s == %s)", \ - #a, #b, std::to_string(a).c_str(), std::to_string(b).c_str()); \ - } \ -} - -#define D2X_WAIT HONLY_LOGW("🥳 Delete the D2X_WAIT to continue..."); -#define D2X_YOUR_ANSWER -#define D2X_DONT_DELETE_THIS(x) x - -template -class d2x_is_invocable { -private: - template - static auto test(U* f) -> decltype((*f)(std::declval()...), std::true_type()); - - template - static std::false_type test(...); - -public: - static constexpr bool value = decltype(test(nullptr))::value; -}; - -#endif \ No newline at end of file diff --git a/d2x/mcpp/common.cppm b/d2x/mcpp/common.cppm deleted file mode 100644 index c88709c..0000000 --- a/d2x/mcpp/common.cppm +++ /dev/null @@ -1,5 +0,0 @@ -module; - -#include - -export module d2x.project.common; \ No newline at end of file diff --git a/dslings/harness/include/d2x/cpp/common.hpp b/dslings/harness/include/d2x/cpp/common.hpp new file mode 100644 index 0000000..3b2a4bb --- /dev/null +++ b/dslings/harness/include/d2x/cpp/common.hpp @@ -0,0 +1,72 @@ +#ifndef COMMON_HPP_D2X +#define COMMON_HPP_D2X + +// d2mcpp 练习脚手架 —— 经典 #include 路径。 +// +// 这条路径提供宏,因为 D2X_YOUR_ANSWER 必须展开为空才能制造编译错误, +// 而宏无法跨模块导出。模块化的练习请改用 `import d2x.harness;`,它提供 +// d2x::check / check_eq / wait(靠 std::source_location 自动带 file/line), +// 但没有填空占位符的等价物。 +// +// 可见输出与旧版逐字节一致 —— ✅/❌ 的逐条对照是教学的一部分。新增的只是 +// 把同样的结果写进侧信道,让判定不再依赖扫 stdout 找 emoji(那会误判: +// 一个断言全过的正确解答,只要在说明文字里打了个 ❌ 就被判失败)。 + +#include +#include + +#include +#include + +namespace d2x::detail { + +// std::to_string 只接受算术类型,但断言里允许任意可比较类型。 +// 转得了的转,转不了的留空:侧信道少一点细节,好过整个 harness 编不过。 +template +inline std::string show(const T& v) { + if constexpr (requires { std::to_string(v); }) return std::to_string(v); + else if constexpr (requires { std::string(v); }) return std::string(v); + else return {}; +} + +} // namespace d2x::detail + +#define d2x_assert(expr) \ +{ \ + bool d2x_ok_ = static_cast(expr); \ + d2x::report::assertion(d2x_ok_, #expr, "true", d2x_ok_ ? "true" : "false", \ + __FILE__, __LINE__); \ + if (!d2x_ok_) { \ + HONLY_LOGW("❌(error) | %s", #expr); \ + } else { \ + HONLY_LOGI_P("✅ | %s", #expr); \ + } \ +} + +#define d2x_assert_eq(a, b) \ +{ \ + bool d2x_ok_ = ((a) == (b)); \ + d2x::report::assertion(d2x_ok_, #a " == " #b, \ + d2x::detail::show(b), d2x::detail::show(a), \ + __FILE__, __LINE__); \ + if (!d2x_ok_) {\ + HONLY_LOGW("❌ | %s == %s (%s == %s)", \ + #a, #b, std::to_string(a).c_str(), std::to_string(b).c_str()); \ + } else {\ + HONLY_LOGI_P("✅ | %s == %s (%s == %s)", \ + #a, #b, std::to_string(a).c_str(), std::to_string(b).c_str()); \ + } \ +} + +#define D2X_WAIT { \ + d2x::report::wait(__FILE__, __LINE__); \ + HONLY_LOGW("🥳 Delete the D2X_WAIT to continue..."); \ +} + +// 展开为空 —— 学员在这里填类型。留空会得到「未声明」错误, +// 比留一个占位标识符导致的语法错误更贴近意图。 +#define D2X_YOUR_ANSWER + +#define D2X_DONT_DELETE_THIS(x) x + +#endif // COMMON_HPP_D2X diff --git a/d2x/cpp/honly_logger.hpp b/dslings/harness/include/d2x/cpp/honly_logger.hpp similarity index 100% rename from d2x/cpp/honly_logger.hpp rename to dslings/harness/include/d2x/cpp/honly_logger.hpp diff --git a/dslings/harness/include/d2x/cpp/report.hpp b/dslings/harness/include/d2x/cpp/report.hpp new file mode 100644 index 0000000..81f6f37 --- /dev/null +++ b/dslings/harness/include/d2x/cpp/report.hpp @@ -0,0 +1,81 @@ +#ifndef REPORT_HPP_D2X +#define REPORT_HPP_D2X + +// 侧信道上报:把判定信号从 stdout 移到带外。 +// +// 为什么需要:断言失败并不改变退出码,所以判定只能靠扫 stdout 找 ❌ —— +// 这会误判。一个断言全过的正确解答,只要在说明文字里打了个 ❌ 就被判失败 +// (已复现)。反过来,输出被截断或 Windows 控制台代码页不对,判定同样失效。 +// +// 于是:stdout 回归「给人看」,判定走这个文件。 +// +// 逐条追加而非退出时统一写 —— 练习段错误时,崩溃之前的断言结果照样保留, +// 学员仍能看到前几条过了。 +// +// D2X_RESULT_FILE 未设置时这里什么都不做:学员直接跑二进制零摩擦。 + +#include +#include +#include + +namespace d2x::report { + +inline std::string escape(const std::string& s) { + std::string out; + out.reserve(s.size() + 8); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof buf, "\\u%04x", static_cast(c)); + out += buf; + } else { + out += static_cast(c); + } + } + } + return out; +} + +// 每次都重新打开并追加。比持有 FILE* 慢,但换来两个性质: +// 进程异常终止不丢已写入的行;无需依赖静态析构顺序。 +inline void emit(const std::string& json_line) { + const char* path = std::getenv("D2X_RESULT_FILE"); + if (!path || !*path) return; // 学员直接运行:什么都不做 + + if (std::FILE* f = std::fopen(path, "a")) { + std::fputs(json_line.c_str(), f); + std::fputc('\n', f); + std::fclose(f); + } +} + +inline void assertion(bool ok, const std::string& expr, + const std::string& expected, const std::string& actual, + const char* file, int line) { + std::string out = "{\"kind\":\"assert\",\"ok\":"; + out += ok ? "true" : "false"; + out += ",\"expr\":\"" + escape(expr) + "\""; + out += ",\"expected\":\"" + escape(expected) + "\""; + out += ",\"actual\":\"" + escape(actual) + "\""; + out += ",\"file\":\"" + escape(file ? file : "") + "\""; + out += ",\"line\":" + std::to_string(line) + "}"; + emit(out); +} + +inline void wait(const char* file, int line) { + std::string out = "{\"kind\":\"wait\",\"file\":\""; + out += escape(file ? file : ""); + out += "\",\"line\":" + std::to_string(line) + "}"; + emit(out); +} + +} // namespace d2x::report + +#endif // REPORT_HPP_D2X diff --git a/dslings/harness/mcpp.toml b/dslings/harness/mcpp.toml new file mode 100644 index 0000000..d1cea20 --- /dev/null +++ b/dslings/harness/mcpp.toml @@ -0,0 +1,15 @@ +[package] +name = "harness" +version = "0.1.0" +description = "d2mcpp 练习脚手架:断言、路障、填空占位符,以及判定用的侧信道上报" +license = "Apache-2.0" +standard = "c++23" + +# include/ 会传播给消费者,所以练习里 `#include ` +# 这个既有路径原样可用 —— 104 个练习和书本一行都不用改。 +# 路径命名以后可以单独重整,不必和这次机制改造捆在一起。 +[build] +include_dirs = ["include"] + +[targets.harness] +kind = "lib" diff --git a/dslings/harness/src/harness.cppm b/dslings/harness/src/harness.cppm new file mode 100644 index 0000000..9f3c882 --- /dev/null +++ b/dslings/harness/src/harness.cppm @@ -0,0 +1,62 @@ +module; + +// 上报核心以头文件形式共享给两条路径。放在全局模块片段里引入, +// 这样模块实现能用它,而它的宏(无)不会泄漏给消费者。 +#include +#include + +export module d2x.harness; + +import std; + +// 模块化练习的脚手架 —— import 路径。 +// +// 与 #include 路径的差异,写在这里免得踩坑: +// +// 宏无法跨模块导出。所以这里提供的是函数,不是 d2x_assert 那套宏。 +// 好处是 std::source_location 自动带上 file/line,比 __LINE__ 更准, +// 而且正好是 Provider 填 Verdict.diagnostics 需要的东西;代价是丢了 +// 表达式原文(宏版能打印 "a == b",函数版只能打印值)。 +// +// D2X_YOUR_ANSWER 没有模块等价物 —— 它必须展开为空才能制造编译错误, +// 本质就是宏。模块化章节需要另一套填空约定。 +export namespace d2x { + +// 与 #include 路径共用同一套可见输出格式,学员在两种练习里看到的是一致的。 +inline void check(bool ok, std::source_location loc = std::source_location::current()) { + d2x::report::assertion(ok, "check", "true", ok ? "true" : "false", + loc.file_name(), static_cast(loc.line())); + if (ok) std::print("\033[32m[HONLY LOGI]: - ✅ | check\033[0m\n"); + else std::print("\033[33m[HONLY LOGW]: {}:{} - ❌(error) | check\033[0m\n", + loc.file_name(), loc.line()); + std::fflush(stdout); +} + +template +inline void check_eq(const A& a, const B& b, + std::source_location loc = std::source_location::current()) { + const bool ok = (a == b); + + auto show = [](const auto& v) -> std::string { + if constexpr (requires { std::format("{}", v); }) return std::format("{}", v); + else return {}; + }; + + d2x::report::assertion(ok, "check_eq", show(b), show(a), + loc.file_name(), static_cast(loc.line())); + + if (ok) std::print("\033[32m[HONLY LOGI]: - ✅ | {} == {}\033[0m\n", show(a), show(b)); + else std::print("\033[33m[HONLY LOGW]: {}:{} - ❌ | {} == {}\033[0m\n", + loc.file_name(), loc.line(), show(a), show(b)); + std::fflush(stdout); +} + +// D2X_WAIT 的模块等价物:显式路障,学员删掉它才算真正完成这一题。 +inline void wait(std::source_location loc = std::source_location::current()) { + d2x::report::wait(loc.file_name(), static_cast(loc.line())); + std::print("\033[33m[HONLY LOGW]: {}:{} - 🥳 Delete the d2x::wait() to continue...\033[0m\n", + loc.file_name(), loc.line()); + std::fflush(stdout); +} + +} // namespace d2x diff --git a/mcpp.toml b/mcpp.toml index 4edacd4..963815c 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -4,4 +4,4 @@ # member、以及只含当前一题的 _current)自成一个独立 workspace,由 # Provider 生成——不放进这里,避免「根清单引用尚未生成的成员」的循环。 [workspace] -members = ["d2x/buildtools/mcpp"] +members = ["d2x/buildtools/mcpp", "dslings/harness"] From c9bb1eeed140c1c804c0185455647cce0949312d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 20 Jul 2026 00:52:58 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=E5=A0=B5=E4=BD=8F=20id=20=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E3=80=81=E4=BF=AE=E5=A4=8D=E8=8B=B1=E6=96=87=E7=AD=94?= =?UTF-8?q?=E6=A1=88=E7=A9=BA=E8=BD=AC=E3=80=81=E8=AE=A9=E6=95=99=E5=AD=A6?= =?UTF-8?q?=E6=BC=82=E7=A7=BB=E5=8F=AF=E8=A2=AB=20CI=20=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个真缺陷,都不是理论风险: 1. 练习 id 注入。id 直接取自文件名,有两个危险去向:d2x 把它拼进 shell 命令,我们把它写进生成的 TOML([targets.])。带反引号、`]` 或引号 的文件名在任一处都能越界 —— 对社区课程仓库来说,一个恶意 PR 文件名就 足以在任何跑 checker 的人机器上执行命令。 在 discovery 源头做白名单校验并拒绝,而不是在两个下游各自转义:这类 文件名本就是笔误或恶意,与其想办法安全地传递,不如让作者改名。 实测:`99-evil`touch pwned_marker`.cpp` 被拒绝,命令未执行。 2. e2e.sh 把所有英文参考答案静默 SKIP。前缀剥离顺序错了 —— `${sol#en/}` 执行时 sol 已经以 "solutions/" 开头,匹配不到任何东西,是个静默 no-op, 于是每道英文练习都因找不到 solutions/en/... 而跳过。 这正是本脚本头部注释里说要防的那种空转,和旧 CI 一模一样的毛病。 除了修顺序,另加一道防线:pass==0 时直接判失败,杜绝「0 失败」蒙混。 实测修复后 en 也是 51/51 真验证(此前是 0 通过 / 52 跳过)。 3. d2x_assert_eq 的日志分支仍用裸 std::to_string,而上报分支已改用 SFINAE 安全的 show()。std::to_string 没有 std::string / const char* / scoped enum 的重载,下一个比较字符串或强类型枚举的练习会直接编译失败 —— show() 存在的意义就是避免这个,却只用了一半。 教学漂移(04-rvalue-references): C++17 起 prvalue 直接初始化目标(保证复制省略),-fno-elide-constructors 再也无法让 `Object obj = Object();` 产生移动构造。全仓库改按 c++23 编译后, 这节课的核心观测点被静默抹掉。 改成从具名对象 std::move —— 这在任何标准下都必然调用移动构造。更重要的是 补了一条 d2x_assert(move_ctor_calls >= 1):漂移之所以能静默发生,正是因为 从前没有任何断言检查它,输出少一行没人发现。现在它是 CI 可检测的事实。 --- d2x/buildtools/mcpp/src/discovery.cppm | 28 ++++++++++++++++++++++ d2x/buildtools/mcpp/tests/e2e.sh | 20 +++++++++++++--- dslings/harness/include/d2x/cpp/common.hpp | 4 ++-- solutions/cpp11/04-rvalue-references.cpp | 15 +++++++++--- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/d2x/buildtools/mcpp/src/discovery.cppm b/d2x/buildtools/mcpp/src/discovery.cppm index a9e2d87..7a35184 100644 --- a/d2x/buildtools/mcpp/src/discovery.cppm +++ b/d2x/buildtools/mcpp/src/discovery.cppm @@ -4,6 +4,10 @@ // 是 PR #1355:edition 同时写在 rustc 参数和 rust-project.json 里,两边漂移 // 酿成 bug。任何独立声明文件都是第二套真相源。这里的真相只有两处,且都 // 无法漂移:目录结构,和练习文件自己的头部注释。 +module; + +#include // stderr + export module d2x.provider.discovery; import std; @@ -108,6 +112,21 @@ std::string humanize(std::string_view topic) { return out; } +// 练习 id 直接来自文件名,而它有两个危险去向: +// 1. d2x 把它拼进一条 shell 命令(` check `) +// 2. 我们把它写进生成的 TOML(`[targets.]`) +// 带反引号、`]`、引号或换行的文件名能在任一处越界。 +// +// 在源头挡住比在两个下游各自转义更可靠 —— 课程里本就不该出现这种文件名, +// 与其想办法安全地传递它,不如明确拒绝并让作者改名。 +export bool valid_id(std::string_view id) { + if (id.empty()) return false; + return std::ranges::all_of(id, [](unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'; + }); +} + // 扫描练习根目录。lang=zh 用 dslings/,lang=en 用 dslings/en/。 export std::vector scan(const fs::path& repo_root, std::string_view lang) { fs::path base = repo_root / "dslings"; @@ -139,6 +158,15 @@ export std::vector scan(const fs::path& repo_root, std::string_view la ex.title = humanize(stem); ex.order = std_rank(std_dir) * 100'000; } + + // 拒绝而不是转义:这类文件名是课程作者的笔误或恶意 PR, + // 静默接受只会把问题推到下游。 + if (!valid_id(ex.id)) { + std::println(stderr, + "d2x-buildtools-mcpp: 跳过 '{}' —— 练习 id 只允许 [A-Za-z0-9._-]", + ex.file.string()); + return; + } found.push_back(std::move(ex)); }; diff --git a/d2x/buildtools/mcpp/tests/e2e.sh b/d2x/buildtools/mcpp/tests/e2e.sh index c541817..4fa65a7 100755 --- a/d2x/buildtools/mcpp/tests/e2e.sh +++ b/d2x/buildtools/mcpp/tests/e2e.sh @@ -44,9 +44,14 @@ for line in "${LINES[@]}"; do id=$(printf '%s' "$line" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) file=$(printf '%s' "$line" | grep -o '"files":\["[^"]*"' | head -1 | cut -d'"' -f4) - rel="${file#"$REPO_ROOT"/}" # dslings/cpp11/xx.cpp - sol="solutions/${rel#dslings/}" # solutions/cpp11/xx.cpp - sol="${sol#en/}" # en 目录共用同一份参考答案 + # en/ 必须先剥,再拼 solutions/ —— 顺序反了的话 sol 已经以 "solutions/" + # 开头,`${sol#en/}` 匹配不到任何东西,是个静默 no-op,结果所有英文练习 + # 都因为找不到 solutions/en/... 而被 SKIP,测试全绿却一个都没验。 + # 这正是本脚本头部注释里说要防的那种「空转」。 + rel="${file#"$REPO_ROOT"/}" # dslings[/en]/cpp11/xx.cpp + rel="${rel#dslings/en/}" # en 镜像共用同一份参考答案 + rel="${rel#dslings/}" # cpp11/xx.cpp + sol="solutions/${rel}" # solutions/cpp11/xx.cpp # 1) 未完成态必须不通过 got=$(outcome_of "$id") @@ -71,4 +76,13 @@ done echo echo "==> 参考答案通过 $pass · 失败 $fail · 跳过 $skipped" + +# 防空转:一个参考答案都没验到时必须红,而不是「0 失败」蒙混过关。 +# 旧 CI 就是这么绿了很久的 —— 它只挑 -ref 目标,而 solutions/ 早被注释掉, +# 循环一次都没进,job 照样退出 0。 +if [ "$pass" -eq 0 ]; then + echo "FAIL: 没有验证到任何参考答案 —— 测试本身失效了,不是「全部通过」" + exit 1 +fi + [ "$fail" -eq 0 ] diff --git a/dslings/harness/include/d2x/cpp/common.hpp b/dslings/harness/include/d2x/cpp/common.hpp index 3b2a4bb..b420cbe 100644 --- a/dslings/harness/include/d2x/cpp/common.hpp +++ b/dslings/harness/include/d2x/cpp/common.hpp @@ -51,10 +51,10 @@ inline std::string show(const T& v) { __FILE__, __LINE__); \ if (!d2x_ok_) {\ HONLY_LOGW("❌ | %s == %s (%s == %s)", \ - #a, #b, std::to_string(a).c_str(), std::to_string(b).c_str()); \ + #a, #b, d2x::detail::show(a).c_str(), d2x::detail::show(b).c_str()); \ } else {\ HONLY_LOGI_P("✅ | %s == %s (%s == %s)", \ - #a, #b, std::to_string(a).c_str(), std::to_string(b).c_str()); \ + #a, #b, d2x::detail::show(a).c_str(), d2x::detail::show(b).c_str()); \ } \ } diff --git a/solutions/cpp11/04-rvalue-references.cpp b/solutions/cpp11/04-rvalue-references.cpp index 7686923..8becf04 100644 --- a/solutions/cpp11/04-rvalue-references.cpp +++ b/solutions/cpp11/04-rvalue-references.cpp @@ -13,6 +13,7 @@ struct Object; static Object * object_address = nullptr; +static int move_ctor_calls = 0; // 移动构造被调用的次数, 供断言检查 struct Object { int data = 0; @@ -21,7 +22,7 @@ struct Object { object_address = this; } Object(const Object&) { std::cout << "Object(const Object&):" << this << std::endl; } - Object(Object&&) { std::cout << "Object(Object&&):" << this << std::endl; } + Object(Object&&) { ++move_ctor_calls; std::cout << "Object(Object&&):" << this << std::endl; } ~Object() { std::cout << "~Object():" << this << std::endl; } }; @@ -29,8 +30,13 @@ int main() { // 关闭编译器优化 { std::cout << "----> 临时对像 - 右值1" << std::endl; Object(); - std::cout << "----> 临时对像 - 右值2" << std::endl; - Object obj = Object(); + std::cout << "----> 临时对像 - 右值2(具名对象 + std::move)" << std::endl; + // 注意: 不能写 `Object obj = Object();` 来观察移动构造 —— + // C++17 起 prvalue 直接初始化目标, 保证复制省略, 连 + // -fno-elide-constructors 也无法让那次移动发生。 + // 从具名对象 std::move 才是标准无关的观察方式。 + Object named; + Object obj = std::move(named); (void)obj; std::cout << "--------代码可修改区域-开始--------" << std::endl; @@ -43,6 +49,9 @@ int main() { // 关闭编译器优化 objRef.data = 1; // 修改被延长生命周期的临时对象的值(不要直接改动这行代码) std::cout << "objRef.data = " << objRef.data << " - " << &objRef << std::endl; d2x_assert((&objRef == object_address)); + // 钉住移动构造确实发生过。教学漂移之所以能静默发生, 正是因为 + // 从前没有任何断言检查它 —— 输出少了一行, 没人发现。 + d2x_assert((move_ctor_calls >= 1)); } return 0; From becc8e4f7c27470307ec78c5bf3483d9ef95ec23 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 20 Jul 2026 00:54:09 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(dslings):=2004-rvalue-references=20?= =?UTF-8?q?=E7=BB=83=E4=B9=A0=E4=BE=A7=E5=90=8C=E6=AD=A5=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E7=BB=99=20e2e=20=E5=8A=A0=E8=84=8F=E6=A0=91?= =?UTF-8?q?=E9=98=B2=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一次提交只带上了 solutions/ 侧 —— 练习侧的补丁被 e2e.sh 的清理钩子 还原掉了,我没复查就提交了。 清理钩子会把参考答案覆盖到练习上再还原,所以它天然会吃掉练习目录里未提交 的改动。这个陷阱已经咬过两次(一次丢了脚手架,一次丢了刚修好的练习), 所以加一道前置检查:练习目录不干净就拒绝运行,并列出是哪些文件。 宁可拒绝,也不能悄悄丢掉别人的工作。 --- d2x/buildtools/mcpp/tests/e2e.sh | 11 +++++++++++ dslings/cpp11/04-rvalue-references.cpp | 15 ++++++++++++--- dslings/en/cpp11/04-rvalue-references.cpp | 15 ++++++++++++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/d2x/buildtools/mcpp/tests/e2e.sh b/d2x/buildtools/mcpp/tests/e2e.sh index 4fa65a7..f889843 100755 --- a/d2x/buildtools/mcpp/tests/e2e.sh +++ b/d2x/buildtools/mcpp/tests/e2e.sh @@ -24,6 +24,17 @@ restore() { [ -e "$d" ] && git checkout -- "$d" 2>/dev/null || true done } + +# 本脚本会把参考答案覆盖到练习上再还原,所以运行前练习必须是干净的 —— +# 否则未提交的改动会被 restore 悄悄丢掉(作者踩过两次:一次丢了脚手架, +# 一次丢了刚修好的练习)。宁可拒绝运行,也不能吃掉别人的工作。 +if ! git diff --quiet -- "${EXERCISE_DIRS[@]}" 2>/dev/null; then + echo "拒绝运行:练习目录有未提交的改动,本测试会在结束时还原它们。" + echo "请先提交或 stash:" + git diff --stat -- "${EXERCISE_DIRS[@]}" | sed 's/^/ /' + exit 2 +fi + trap restore EXIT outcome_of() { # $1 = exercise id diff --git a/dslings/cpp11/04-rvalue-references.cpp b/dslings/cpp11/04-rvalue-references.cpp index 83660b2..b916830 100644 --- a/dslings/cpp11/04-rvalue-references.cpp +++ b/dslings/cpp11/04-rvalue-references.cpp @@ -21,6 +21,7 @@ struct Object; static Object * object_address = nullptr; +static int move_ctor_calls = 0; // 移动构造被调用的次数, 供断言检查 struct Object { int data = 0; @@ -29,7 +30,7 @@ struct Object { object_address = this; } Object(const Object&) { std::cout << "Object(const Object&):" << this << std::endl; } - Object(Object&&) { std::cout << "Object(Object&&):" << this << std::endl; } + Object(Object&&) { ++move_ctor_calls; std::cout << "Object(Object&&):" << this << std::endl; } ~Object() { std::cout << "~Object():" << this << std::endl; } }; @@ -37,8 +38,13 @@ int main() { // 关闭编译器优化 { std::cout << "----> 临时对像 - 右值1" << std::endl; Object(); - std::cout << "----> 临时对像 - 右值2" << std::endl; - Object obj = Object(); + std::cout << "----> 临时对像 - 右值2(具名对象 + std::move)" << std::endl; + // 注意: 不能写 `Object obj = Object();` 来观察移动构造 —— + // C++17 起 prvalue 直接初始化目标, 保证复制省略, 连 + // -fno-elide-constructors 也无法让那次移动发生。 + // 从具名对象 std::move 才是标准无关的观察方式。 + Object named; + Object obj = std::move(named); std::cout << "--------代码可修改区域-开始--------" << std::endl; @@ -51,6 +57,9 @@ int main() { // 关闭编译器优化 objRef.data = 1; // 修改被延长生命周期的临时对象的值(不要直接改动这行代码) std::cout << "objRef.data = " << objRef.data << " - " << &objRef << std::endl; d2x_assert((&objRef == object_address)); + // 钉住移动构造确实发生过。教学漂移之所以能静默发生, 正是因为 + // 从前没有任何断言检查它 —— 输出少了一行, 没人发现。 + d2x_assert((move_ctor_calls >= 1)); } D2X_WAIT diff --git a/dslings/en/cpp11/04-rvalue-references.cpp b/dslings/en/cpp11/04-rvalue-references.cpp index 82da5ff..b8ac8fd 100644 --- a/dslings/en/cpp11/04-rvalue-references.cpp +++ b/dslings/en/cpp11/04-rvalue-references.cpp @@ -21,6 +21,7 @@ struct Object; static Object * object_address = nullptr; +static int move_ctor_calls = 0; // 移动构造被调用的次数, 供断言检查 struct Object { int data = 0; @@ -29,7 +30,7 @@ struct Object { object_address = this; } Object(const Object&) { std::cout << "Object(const Object&):" << this << std::endl; } - Object(Object&&) { std::cout << "Object(Object&&):" << this << std::endl; } + Object(Object&&) { ++move_ctor_calls; std::cout << "Object(Object&&):" << this << std::endl; } ~Object() { std::cout << "~Object():" << this << std::endl; } }; @@ -37,8 +38,13 @@ int main() { // Disable compiler optimization { std::cout << "----> Temporary object - rvalue 1" << std::endl; Object(); - std::cout << "----> Temporary object - rvalue 2" << std::endl; - Object obj = Object(); + std::cout << "----> Temporary object - rvalue 2 (named object + std::move)" << std::endl; + // NOTE: `Object obj = Object();` will NOT show a move constructor. + // Since C++17 a prvalue initialises the target directly (guaranteed + // copy elision), and -fno-elide-constructors cannot bring that move + // back. Moving from a named object is the standard-independent way. + Object named; + Object obj = std::move(named); std::cout << "--------Code modifiable area - Start--------" << std::endl; @@ -51,6 +57,9 @@ int main() { // Disable compiler optimization objRef.data = 1; // Modify the value of the extended lifetime temporary object (do not directly modify this line) std::cout << "objRef.data = " << objRef.data << " - " << &objRef << std::endl; d2x_assert((&objRef == object_address)); + // 钉住移动构造确实发生过。教学漂移之所以能静默发生, 正是因为 + // 从前没有任何断言检查它 —— 输出少了一行, 没人发现。 + d2x_assert((move_ctor_calls >= 1)); } D2X_WAIT From 58d3645e1055045f7a8eee5a55f2f1be966b247f Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 20 Jul 2026 01:29:44 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(provider):=20=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=94=B9=E4=B8=BA=E7=9B=B8=E5=AF=B9=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E6=A0=B9=EF=BC=8C=E5=8D=8F=E8=AE=AE=E9=87=8C=E4=BB=8D?= =?UTF-8?q?=E7=BB=99=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学员看到的断言报错原先顶着一长串 /home/... 前缀 —— mcpp 传给编译器的是 绝对路径,__FILE__ 便是绝对的。生成的清单加上 -fmacro-prefix-map 让它 相对仓库根,噪声消失。 但协议要求绝对路径:d2x 靠 diagnostics.file 打开编辑器、监听文件变更。 所以在协议边界上还原成绝对。展示归展示,定位归定位。 --- d2x/buildtools/mcpp/src/main.cpp | 9 ++++++++- d2x/buildtools/mcpp/src/manifest.cppm | 14 ++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/d2x/buildtools/mcpp/src/main.cpp b/d2x/buildtools/mcpp/src/main.cpp index d2e710e..e099694 100644 --- a/d2x/buildtools/mcpp/src/main.cpp +++ b/d2x/buildtools/mcpp/src/main.cpp @@ -98,9 +98,16 @@ int cmd_check(const fs::path& root, std::string_view id) { auto report = d2x::runner::judge_run(ran.exit_code, result_file); + // 侧信道里的 file 来自 __FILE__,而生成的清单加了 -fmacro-prefix-map + // 让它相对仓库根(学员看到的报错才不会顶着一长串 /home/... 前缀)。 + // 但协议要求绝对路径 —— d2x 靠它开编辑器、靠它监听文件变更 —— 所以 + // 在协议边界上还原。展示归展示,定位归定位。 std::vector diags; for (const auto& f : report.failures) { - diags.push_back(d2x::emit::diagnostic(f.file, f.line, f.expr, f.expected, f.actual)); + auto abs = f.file.empty() || fs::path(f.file).is_absolute() + ? f.file + : (root / f.file).lexically_normal().string(); + diags.push_back(d2x::emit::diagnostic(abs, f.line, f.expr, f.expected, f.actual)); } d2x::emit::verdict(d2x::runner::to_string(report.outcome), "run", ran.exit_code, diags); diff --git a/d2x/buildtools/mcpp/src/manifest.cppm b/d2x/buildtools/mcpp/src/manifest.cppm index 9f4ef73..f34c7c8 100644 --- a/d2x/buildtools/mcpp/src/manifest.cppm +++ b/d2x/buildtools/mcpp/src/manifest.cppm @@ -39,7 +39,7 @@ std::string relative_to_member(const fs::path& repo_root, const fs::path& file) return std::format("{}/{}", kToRoot, rel); } -void write_package_header(std::ostream& out, std::string_view name) { +void write_package_header(std::ostream& out, std::string_view name, const fs::path& repo_root) { std::println(out, "# 由 d2x-buildtools-mcpp 生成,请勿手工编辑。"); std::println(out, "[package]"); std::println(out, "name = \"{}\"", name); @@ -49,6 +49,12 @@ void write_package_header(std::ostream& out, std::string_view name) { std::println(out, "[build]"); // 显式清空源码 glob:练习不是这个包的「源码」,它们只是各自 target 的入口。 std::println(out, "sources = []"); + // 让 __FILE__ 变成相对仓库根的路径。mcpp 传给编译器的是绝对路径, + // 于是 d2x_assert 的报错会印出一长串 /home/... 前缀,对学员是噪声。 + // 这只影响宏展开出来的字符串,不影响编译器自身诊断里的路径。 + // 前缀必须是编译器实际看到的绝对路径,不能用 kToRoot 那种相对形式。 + std::println(out, "cxxflags = [\"-fmacro-prefix-map={}/=\"]", + repo_root.generic_string()); std::println(out, ""); // 脚手架走正经的库依赖,而不是把仓库根塞进 include 搜索路径。 // 后者会让练习能 #include 仓库里任何文件,是个隐患。 @@ -96,7 +102,7 @@ export void write_full(const fs::path& repo_root, const std::vector& a std::vector members; for (const auto& [name, list] : by_member) { std::ostringstream buf; - write_package_header(buf, name); + write_package_header(buf, name, repo_root); for (const auto* ex : list) write_target(buf, repo_root, *ex); write_if_changed(root / name / "mcpp.toml", buf.str()); members.push_back(name); @@ -105,7 +111,7 @@ export void write_full(const fs::path& repo_root, const std::vector& a // _current 也是这个 workspace 的成员,占位清单先写空壳 members.push_back("_current"); write_if_changed(root / "_current" / "mcpp.toml", - [&] { std::ostringstream b; write_package_header(b, "_current"); return b.str(); }()); + [&] { std::ostringstream b; write_package_header(b, "_current", repo_root); return b.str(); }()); std::ostringstream ws; std::println(ws, "# 由 d2x-buildtools-mcpp 生成,请勿手工编辑。"); @@ -124,7 +130,7 @@ export void write_full(const fs::path& repo_root, const std::vector& a // 切题 0.118s、切回 0.018s。 export void write_current(const fs::path& repo_root, const Exercise& ex) { std::ostringstream buf; - write_package_header(buf, "_current"); + write_package_header(buf, "_current", repo_root); write_target(buf, repo_root, ex); write_if_changed(build_dir(repo_root) / "_current" / "mcpp.toml", buf.str()); } From e68706e5f07d9962ae31582d56c0b0a37e0bd67e Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 20 Jul 2026 01:35:04 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Provider=20?= =?UTF-8?q?=E4=B8=8E=E7=BB=83=E4=B9=A0=E8=84=9A=E6=89=8B=E6=9E=B6=E5=8F=82?= =?UTF-8?q?=E8=80=83=EF=BC=88=E7=9B=AE=E5=BD=95=E7=BA=A6=E5=AE=9A=E3=80=81?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E6=9C=BA=E5=88=B6=E3=80=81=E5=B7=B2=E7=9F=A5?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-20-mcpp-provider-reference.md | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 .agents/docs/2026-07-20-mcpp-provider-reference.md diff --git a/.agents/docs/2026-07-20-mcpp-provider-reference.md b/.agents/docs/2026-07-20-mcpp-provider-reference.md new file mode 100644 index 0000000..e41f029 --- /dev/null +++ b/.agents/docs/2026-07-20-mcpp-provider-reference.md @@ -0,0 +1,262 @@ +# d2mcpp Provider 与练习脚手架 参考 + +- 日期:2026-07-20 +- 分支:`feat/mcpp-provider` +- 配套调研:[`2026-07-19-mcpp-replace-xmake-research.md`](2026-07-19-mcpp-replace-xmake-research.md)(xmake → mcpp 的可行性论证与 rustlings/cargo 横向对照) +- d2x 侧协议规范:d2x 仓库 `.agents/docs/2026-07-20-d2x-architecture-reference.md` +- 本文定位:**当前实现的参考手册**——目录约定、生成物、判定机制、维护须知 + +--- + +## 1. 全景 + +xmake 已完全退役。构建链路是: + +``` +d2x(通用框架,零内置后端) + └─ Provider Protocol (NDJSON) ─→ d2x/buildtools/mcpp/ ← 本仓库自己的 Provider(C++26) + ├─ 目录约定发现练习 + ├─ 生成 mcpp workspace 清单 + ├─ 调 mcpp build/run + └─ 读侧信道判定通过 + ↓ + mcpp(未做任何改动) +``` + +### 提交进仓库的包 + +``` +mcpp.toml [workspace] members = ["d2x/buildtools/mcpp", "dslings/harness"] +d2x/buildtools/mcpp/ Provider,standard = "c++26" +dslings/harness/ 练习脚手架库,standard = "c++23" +``` + +### 生成物(gitignore) + +``` +.d2x/build/mcpp.toml 独立 workspace 根 +.d2x/build//mcpp.toml 该标准下全部 target —— 供 clangd +.d2x/build/_current/mcpp.toml 只含当前一题 —— 供 checker +.d2x/build/_current/result.ndjson 判定用侧信道 +.d2x/state.json 学员进度(d2x 写) +``` + +--- + +## 2. 引导:零脚本、Windows 安全 + +`.d2x.json`: + +```json +{ "buildtools": "mcpp run -q -p d2x/buildtools/mcpp --" } +``` + +d2x 拼接后得到 `mcpp run -q -p d2x/buildtools/mcpp -- check `。**从仓库根执行、无需 cd、首次自动构建 Provider**,暖开销约 26ms。 + +d2x 从不 `chdir`(它在静态初始化时捕获 CWD),所以免 `cd` 是硬要求。注意 `mcpp` 的 `-p` 匹配的是**目录 basename 或完整相对路径**,不是包名。 + +### ⚠️ 嵌套 mcpp 与 `LD_LIBRARY_PATH` + +`mcpp run` 会把 `LD_LIBRARY_PATH` 指向它私有的 glibc 并注入子进程。Provider 再去 spawn 嵌套的 `mcpp` 时被迫加载错配的 glibc,**在动态链接器里段错误**(冷启动稳定复现,输出只剩 `:\t__vdso_time` 这样的 trace 残片)。 + +`runner.cppm` 在每次 spawn 前 `unsetenv("LD_LIBRARY_PATH")` 绕过;mcpp 会为它自己的子进程重新设置正确的值。 + +d2x 侧对同一问题早有相同处理(`platform.cppm`),仓库里还留着 `workaround_ld_library_path_issue` 分支——**这是 mcpp 的既有问题,值得单独向上游报**。 + +--- + +## 3. 练习发现:目录约定 + 就近指令 + +**没有声明文件。** 放一个 `.cpp` 进去就是全部工作。 + +rustlings 最贵的一课是 PR #1355:Rust edition 同时写在 `rustc` 参数和 `rust-project.json` 里,两边漂移酿成 bug。**任何独立声明文件都是第二套真相源。** 这里的真相只有两处,且都无法漂移:目录结构,和练习文件自己的头部注释。 + +### 目录布局 + +``` +dslings/hello-mcpp.cpp 入门练习(chapter = intro) +dslings//NN-topic-K.cpp zh 练习 +dslings/en//NN-topic-K.cpp en 练习(lang=en 时启用,与 zh 互斥) +solutions//NN-topic-K.cpp 参考答案(zh/en 共用同一份) +``` + +### id / order / chapter 的推导 + +| 文件 | id | chapter | order | +|---|---|---|---| +| `dslings/cpp11/00-auto-and-decltype-0.cpp` | `cpp11-00-auto-and-decltype-0` | `cpp11/00-auto-and-decltype` | 1100000 | +| `dslings/cpp11/03-trailing-return-type.cpp` | `cpp11-03-trailing-return-type` | `cpp11/03-trailing-return-type` | 1100300 | +| `dslings/hello-mcpp.cpp` | `hello-mcpp` | `intro` | -100000 | + +`order = std_rank × 100000 + 章节号 × 100 + 练习序号`。尾部 `-<数字>` 是序号,没有就当 0(仓库里 03/04 两章确实缺 `-0` 后缀,规则把它们归一化)。 + +### 每练习编译选项 + +写在练习文件自己的头部注释里(只扫前 40 行): + +```cpp +// d2x:cxxflags: -O0 -fno-elide-constructors +``` + +仓库现存用例:`04-rvalue-references`(观察移动)、`07-constexpr-0`(`-Wpedantic -Werror` 让 VLA 成为错误)。 + +### id 白名单 + +id 只允许 `[A-Za-z0-9._-]`,违反者**被拒绝并打印到 stderr**,不进入枚举。 + +因为 id 有两个危险去向:d2x 拼进 shell 命令、我们写进生成的 TOML(`[targets.]`)。在源头挡住比在两个下游各自转义更可靠——这类文件名本就是笔误或恶意 PR,与其想办法安全传递,不如让作者改名。 + +--- + +## 4. 双 member 布局 + +**硬约束:dslings 的练习默认就编译不过**(49 个带 `D2X_YOUR_ANSWER`),而 mcpp 的 `build` 和 `run` 都会先全量构建整个包——一个坏兄弟拖垮全部且零产物。实测: + +``` +member 含全部 3 题(后两题未做完)→ mcpp build -p → exit=1 ← 当前这题被拖挂 +只生成当前这一题 → 0.071s,exit=0 +``` + +**member 粒度(标准/特性/练习)本身不解决逐题隔离。** 所以分两个: + +| 目录 | 内容 | 用途 | +|---|---|---| +| `.d2x/build//` | 该标准下全部 target | clangd —— 即使多数练习编译不过,mcpp 仍生成完整 `compile_commands.json`,IDE 对每题都有正确编译参数 | +| `.d2x/build/_current/` | 只含要验证的那一题 | checker | + +实测动态改写清单代价极低:切题 0.118s、切回 0.018s,**fingerprint 目录始终只有 1 个**(改写 target 集合不会让缓存爆炸)。 + +### 其他生成细节 + +- `main` 用 `../../../` 逃逸出包根,**练习源文件原地不动** +- 脚手架走 `[dependencies] path` 依赖,而非把仓库根塞进 include 搜索路径(后者会让练习能 `#include` 仓库里任何文件) +- `-fmacro-prefix-map=/=` 让 `__FILE__` 相对仓库根,学员看到的报错不再顶着一长串 `/home/...` +- 所有清单**内容比对后才落盘**,不推进 mtime,保住 mcpp 的快速路径 +- `check` 会先 `write_full` 再 `write_current`——全新仓库上学员直接跑 `d2x checker` 时根清单还不存在,否则 mcpp 报退出码 2 + +--- + +## 5. 判定机制 + +### 侧信道,不是扫 stdout + +判定原先 100% 是带内信号:断言失败**并不改变退出码**(实测 exit=0),所以只能扫 stdout 找 `❌`。这会误判——一个断言全过的正确解答,只要在说明文字里打了个 `❌` 就被判失败(已复现)。反过来,输出截断或 Windows 控制台代码页不对,判定同样失效。 + +现在 stdout 回归「给人看」,判定走侧信道: + +```jsonc +{"kind":"assert","ok":false,"expr":"a == b","expected":"1","actual":"2", + "file":"dslings/cpp11/xx.cpp","line":33} +{"kind":"wait","file":"dslings/cpp11/xx.cpp","line":40} +``` + +**逐条追加而非退出时统一写** —— 练习段错误时,崩溃之前的断言结果照样保留。 + +`D2X_RESULT_FILE` 环境变量未设置时 harness 只打印不写文件:**学员直接跑二进制零摩擦**。 + +### 判定顺序 + +| 侧信道内容 | Verdict | +|---|---| +| 有 `ok:false` | `fail` + 每条失败转一个 `Diagnostic` | +| 无失败但退出码非 0 | `fail` | +| 无失败、有 `wait` | `blocked` | +| **文件不存在**(练习没用 harness) | 退回「编译通过 + 退出 0 = `pass`」 | + +最后一条让 **harness 自动变成可选的**,不需要额外机制:纯观察型练习(现有 18 个只用 `D2X_WAIT`)可以写成零依赖的纯 C++ 文件,学员能原样拷进 Compiler Explorer。 + +### 路径:展示 vs 定位 + +侧信道里的 `file` 因 `-fmacro-prefix-map` 是相对路径(学员看着舒服),但协议要求绝对路径(d2x 靠它开编辑器、监听变更)。**在协议边界上还原成绝对。** + +--- + +## 6. 练习脚手架 `dslings/harness/` + +按库设计,目前住在本仓库,将来可整体抽成独立 mcpp 包给其他 C++ 课程复用。 + +### 两条路径不等价 + +| 设施 | `#include ` | `import d2x.harness;` | +|---|---|---| +| 断言 | `d2x_assert` / `d2x_assert_eq` 宏 | `d2x::check` / `check_eq`,`source_location` 自动带 file/line | +| 路障 | `D2X_WAIT` | `d2x::wait()` | +| 填空占位 | `D2X_YOUR_ANSWER` | **无等价物** | + +**宏无法跨模块导出**,这是 C++ 的硬约束而非疏漏。`D2X_YOUR_ANSWER` 必须展开为空才能制造编译错误,本质就是宏。**cpp20/cpp23 等模块化章节需要另设填空约定——这是个尚未决策的缺口。** + +`include/` 下保持 `d2x/cpp/common.hpp` 这个既有路径,所以 104 个练习和书本一行都不用改。路径改名可以以后单独做。 + +### 维护须知 + +`d2x_assert_eq` 的**所有分支**都必须走 `d2x::detail::show()`,不能用裸 `std::to_string`——后者没有 `std::string` / `const char*` / scoped enum 的重载,一旦有练习比较这些类型就会硬编译失败。(曾经上报分支用了 `show()` 而日志分支没用,是个潜伏的地雷。) + +--- + +## 7. 测试 + +### `d2x/buildtools/mcpp/tests/e2e.sh` + +断言两件事,缺一不可:**每个练习未完成时不通过**、**每个参考答案放进去后通过**。等价于 rustlings 的 `cargo dev check --require-solutions`。 + +走的是 `d2x checker` 内部同一条 Provider 路径,所以绿灯等价于学员本地能跑通,而非另一条平行路径。 + +**两道防线:** + +1. **防空转**——`pass == 0` 时直接判失败。旧 CI 就是这么绿了很久的:它只挑 `-ref` 目标,而 `solutions/` 早被注释掉,循环一次都没进,job 照样退出 0;第 60 行的 `wc -l` 对空串还打印 "Found 1 reference targets" 把空转掩盖了。同类问题在本脚本自己身上也犯过一次(`${sol#en/}` 剥离顺序错,导致所有英文答案静默 SKIP)。 + +2. **脏树检查**——练习目录有未提交改动时**拒绝运行**并列出文件。脚本会把参考答案覆盖到练习上再还原,天然会吃掉未提交的改动,这个陷阱咬过两次。 + +### 当前状态 + +| 项 | 结果 | +|---|---| +| Provider 枚举 | 52 题(zh:1 + 49 + 2;en 同样 52) | +| 端到端 zh | 51/51 参考答案通过 | +| 端到端 en | 51/51 参考答案通过 | +| d2x session 单测 | 28/28 | +| 事件流 | 纯 JSON,0 污染 | +| 注入防护 | 恶意文件名被拒绝,命令未执行 | + +--- + +## 8. C++ 标准与教学漂移 + +**所有练习按 `c++23` 编译。** mcpp 目前硬拒 `c++11/14/17/20`(`src/manifest/types.cppm` 的白名单),本方案选择不改 mcpp 上游。因此 `cppNN/` 目录现在表示**特性引入于哪个标准**,不再改变编译参数。 + +### 已知代价 + +`04-rvalue-references` 的移动构造教学点。C++17 起 prvalue 直接初始化目标(保证复制省略),`-fno-elide-constructors` 再也无法让 `Object obj = Object();` 产生移动构造。 + +**修法:** 改成从具名对象 `std::move`(任何标准下都必然调用移动构造),并补一条 `d2x_assert(move_ctor_calls >= 1)`。 + +**更重要的是那条断言。** 漂移之所以能静默发生,正是因为从前没有任何断言检查它——输出少一行没人发现。现在它是 CI 可检测的事实。 + +> **教训:教学点如果没有断言覆盖,它的消失就是不可见的。** 新增练习时应问:这一课的核心观测点,有断言在守着吗? + +量化过的风险面:51 个参考答案在「原标准 vs c++23」下逐一编译并 diff 运行输出,**50 个完全一致,仅此 1 个漂移**。但随着课程往 cpp17/20 扩写,这类静默漂移只会变多。 + +--- + +## 9. 撰稿流程的变化 + +`.agents/skills/d2mcpp-authoring/` 已同步更新: + +- **练习不再需要注册**——放进 `dslings//` 即被目录约定发现 +- 新增 `` 章节只需建目录 + 改两个 `SUMMARY.md`,无构建文件接线 +- 每练习编译选项改为文件头部 `// d2x:cxxflags:` 指令 +- 验证命令改为 `mcpp run -q -p d2x/buildtools/mcpp -- check ` 或整体 `bash d2x/buildtools/mcpp/tests/e2e.sh` + +--- + +## 10. 已知缺口 + +| 缺口 | 影响 | +|---|---| +| **模块化练习的填空占位符无约定** | `D2X_YOUR_ANSWER` 是宏,跨不了模块边界;cpp20/cpp23 章节需先决策 | +| **macOS / Windows 从未验证** | `_popen`、`_putenv_s` 的 `#ifdef` 分支全是纸面推断 | +| **新 CI 从未真跑过** | workflow 是手写的,`xlings install -y` 在 CI 环境能否装上 mcpp 未验证 | +| **`diagnostics` 只覆盖运行期断言** | 编译错误尚未解析成结构化诊断 | +| **Provider 侧无单测** | discovery 的 id/order/chapter 推导、manifest 生成都只被端到端间接覆盖 | +| **`hello-mcpp` 无参考答案** | e2e 只能 SKIP 它,入门练习实际未被验证 | +| **`solutions/` 无 en 镜像** | 设计如此(共用一份),但意味着 en 专有的措辞错误无法被答案校验发现 |