diff --git a/.agents/docs/2026-07-19-exercise-framework-protocol-design.md b/.agents/docs/2026-07-19-exercise-framework-protocol-design.md new file mode 100644 index 0000000..946b283 --- /dev/null +++ b/.agents/docs/2026-07-19-exercise-framework-protocol-design.md @@ -0,0 +1,428 @@ +# d2x 架构重设计:通用练习框架 + 双向协议边界 + +- 日期:2026-07-19 +- 状态:设计已确认,进入实现 +- 涉及仓库:`d2learn/d2x`(分支 `feat/exercise-framework-protocol`)、`mcpp-community/d2mcpp`(分支 `feat/mcpp-provider`) +- 相关调研:`d2mcpp/.agents/docs/2026-07-19-mcpp-replace-xmake-research.md` + +--- + +## 1. 定位 + +**d2x 是练习驱动学习的通用框架:它拥有学习循环和它的呈现,除此之外什么都不拥有。** + +d2x 不知道 C++,不知道 mcpp,不知道怎么编译任何东西。它向下定义一套协议对接课程,向上定义一套协议对接前端,两侧都是 NDJSON 事件流。 + +``` + 前端:内置 TUI / print / VSCode 插件 / Web / CI + ↑ Frontend Protocol (NDJSON,单向) + ┌────────────────────────────────────────────┐ + │ d2x core —— 学习循环 · 会话状态 · 文件监听 · 编排 │ + └────────────────────────────────────────────┘ + ↕ Provider Protocol (NDJSON) + 课程侧:d2mcpp Provider(C++26 + mcpp) +``` + +### d2x 明确不拥有 + +| 不拥有 | 归属 | +|---|---| +| 构建工具、编译命令、清单生成 | Provider | +| 通过判定规则(`❌` / `D2X_WAIT` 是 d2mcpp 的 C++ 断言约定) | Provider | +| 练习内容、顺序、章节归属 | Provider | +| 具体渲染方式 | 前端 | + +--- + +## 2. 领域模型 + +```cpp +struct Exercise { + std::string id; // 稳定标识,状态持久化用它 + int order; // 显式顺序,不再靠字典序 + std::string title; + std::string chapter; + std::vector files; // 学员编辑的文件,绝对路径 + std::optional hint; + std::optional solution; +}; + +enum class Outcome { Pass, Fail, Blocked }; + +struct Diagnostic { + std::string file; int line; int col; + std::string severity; // "error" | "warning" | "note" + std::string message; +}; + +struct Verdict { + Outcome outcome; + std::string stage; // Provider 自定义:"compile" / "run" / "lint" + std::string output; // 给学员看的原始输出,保留 ANSI + std::vector diagnostics; // 可选,Provider 给不出就是空 +}; +``` + +### 三个关键决定 + +**`Blocked` 是独立的第三态。** 学员代码已经正确,但还有一个显式路障没拆(d2mcpp 里是 `D2X_WAIT` 宏)。它既不是失败也不该前进。当前实现把它塞进 `build_success=false`,同时 `status` 仍为 true,UI 显示成"成功但卡住",语义是错的(`checker.cppm:88-97`)。 + +**`order` 显式化。** 当前顺序是 `get_targets()` 从 `std::map` 取 key 的字典序(`buildtools.cppm:45-51`),教学顺序成了命名的副作用——重命名一个练习会悄悄改变课程顺序。 + +**`diagnostics` 进核心模型但可选。** 框架的价值在呈现;能给出结构化诊断的 Provider,前端就能做行内高亮和跳转,给不出的退化成纯文本。 + +--- + +## 3. 下行 · Provider Protocol + +### 进程模型 + +一次调用一个进程,参数走 argv,事件走 stdout 逐行 NDJSON: + +``` + describe + exercises + check +``` + +Provider 命令来自课程配置(`.d2x.json` 的 `buildtools` 字段,语义扩展为"Provider 命令行前缀")。进程间无状态,Provider 可自行在磁盘缓存。 + +实测启动开销 26ms(经 `mcpp run` 转发),不值得上常驻进程。事件流的形状使得将来换成常驻 JSON-RPC 只是换传输,领域模型不动。 + +### 命令与响应 + +``` +describe → {"event":"describe","protocol":1,"name":"mcpp"} + +exercises → {"event":"exercise","id":"...","order":0,"title":"...", + "chapter":"cpp11/00-auto-and-decltype","files":["/abs/..."]} + × N + +check → {"event":"stage","name":"compile"} + {"event":"output","chunk":"...保留 ANSI..."} + {"event":"stage","name":"run"} + {"event":"output","chunk":"..."} + {"event":"verdict","outcome":"pass|fail|blocked", + "stage":"run","diagnostics":[...]} +``` + +### 为什么只有三个动词,没有 build / run / test + +`build` + `run` 两段式是编译型语言的形状。一次 `check` 内部要编译几次、跑不跑测试、判定看退出码还是看输出里的 `❌`,全是课程的事。把两段式焊进通用框架就等于焊死了适用范围。 + +### 为什么是事件流而不是请求/响应 + +这一个决定同时解掉四个问题: + +1. **不需要哨兵或"末行 JSON"约定** —— 每一行都是 JSON,解析不了的行直接忽略,正好吞掉启动器噪声。实测 `mcpp run -q` 会吐一个前导空行;若 Provider 需要重新编译,mcpp 的编译输出也会混进来。 +2. **不需要 `prepare` 命令** —— 耗时准备就是 verdict 之前的一串 `stage` 事件。 +3. **编译输出实时可见** —— 当前 d2x 用 `popen` 读到 EOF 才显示,全程黑屏。 +4. **`2>&1` 混流不再是问题** —— `platform::run_command_capture` 硬编码了 `cmd + " 2>&1"`(三个平台实现都是),噪声天然被过滤。 + +### `output` 为什么走 JSON 字段 + +`check` 内部跑的编译器输出和练习程序输出是**给学员看的**,必须原样保留 ANSI 颜色。由 Provider 自己捕获后塞进事件,d2x 拿到的就是干净的结构化结果。这同时消除了当前"在混了 mcpp 横幅的文本里做 `❌` 子串搜索"的脆弱性。 + +### 暂不引入 + +`describe` 只返回 `protocol` + `name`。`capabilities` 协商等真有第二个 Provider 实现时再加。 + +--- + +## 4. 上行 · Frontend Protocol + +**单向。** d2x 保留全部控制权:通过即自动前进,失败即等文件变更。前端纯显示。 + +``` +{"event":"session","total":51,"completed":12,"current":"cpp11-04-rvalue-references"} +{"event":"exercise","id":"...","title":"...","chapter":"...","files":[...]} +{"event":"stage","name":"compile"} +{"event":"output","chunk":"..."} +{"event":"verdict","outcome":"blocked","diagnostics":[...]} +{"event":"waiting","reason":"file-change"} +{"event":"hint","text":"...AI 助手产出..."} +{"event":"done"} +``` + +### 两侧的关系 + +`stage` / `output` / `verdict` 三类事件**两侧同构**,d2x 对它们基本是转发 + 补上会话上下文。只属于上行的是 `session` / `exercise` / `waiting` / `hint` / `done`。 + +**下行是上行的子集**,不是两套无关的协议。 + +### 内置前端必须是同进程 + +`d2x checker` 对学员必须仍然是一条命令,不能让人先起引擎再起前端。所以内置 TUI/print 是**同进程客户端走内存通道**,与外部客户端走管道用同一套事件类型,只是换传输。现有的 `IUIBackend` + `UILoader`(`src/ui/ui_interface.cppm`、`src/ui/loader.cppm`)从"编译期插件"改造成"协议客户端"。 + +--- + +## 5. d2x 内部分层 + +``` +src/domain.cppm Exercise · Verdict · Diagnostic · SessionState 纯数据,零依赖 +src/provider.cppm IExerciseProvider +src/provider/process.cppm ProcessProvider —— 起子进程,逐行读 NDJSON +src/session.cppm Session —— 遍历/推进,纯逻辑 +src/session/state.cppm StateStore —— .d2x/state.json +src/watch.cppm FileWatcher —— 去抖 + 自触发保护 +src/emit.cppm EventSink —— 上行事件发射(内存通道 或 stdout) +src/ui/** 前端客户端,只消费 domain 类型 +src/assistant.cppm 已有,产出 hint 事件 +src/app.cppm 装配 +``` + +**关键性质:`session/` 是纯逻辑。** 给它一个假 Provider 和内存状态,就能测完整学习流程,不碰文件系统、不碰构建工具、不碰终端。当前 `checker::run()` 是一个 100 行函数,把构建、判定、开编辑器、问 AI、刷 UI、等文件全缠在一起(`checker.cppm:28-131`),一行都测不了。 + +### 状态持久化 + +``` +.d2x/state.json { "current": "", "completed": ["", ...] } +``` + +**用 id 不用下标**——重排或重命名练习不会毁掉学员进度。这是 rustlings 的教训:它的 `.rustlings-state.txt` 同样按名字存,且"每次重编所有练习"的性能投诉(#121/#132/#1843)正是靠这个状态缓存解决的,与换构建工具无关。 + +--- + +## 6. 一次 check 的数据流 + +``` +Session 取当前 Exercise + └→ ProcessProvider 起 ` check ` + └→ Provider 写 .d2x/build/_current/mcpp.toml(只含这一题) + mcpp build -p _current → stage:compile + output 事件 + 运行产物 → stage:run + output 事件 + 扫 ❌ / D2X_WAIT → verdict 事件 + └→ d2x 逐行转发 stage/output,补 session 上下文,发给前端 + └→ pass → StateStore 记完成 → 下一题 + fail/blocked → 开编辑器(仅首次)→ 问 AI → hint 事件 + → FileWatcher 等变更 → 重试 +``` + +--- + +## 7. 错误处理 + +三类分开,各有明确行为: + +| 类别 | 行为 | +|---|---| +| Provider 起不来 / `describe` 失败 | 致命,清晰报错退出 | +| 输出畸形(非 JSON 行) | 忽略该行并记 debug 日志 | +| 一次 check 完全没有 verdict 事件 | 当作 Fail,把原始输出原样呈现给学员 | +| 练习本身没通过 | 正常业务路径,不是错误 | + +当前实现的两层误导要消除:`Failed to load targets with exit code: N`(`buildtools.cppm:83`)后面紧跟 `No targets found for checking.`(`checker.cppm:40`),学员看到的是"没有练习"而不是"Provider 挂了"。 + +### 两条现在缺失的防御 + +- **`files` 为空的练习**由 Session 层拒绝并跳过。当前 `checker.cppm:76` 无保护直接 `files[0]`,某个 target 列出零文件就崩。`d2x/docs/crash-analysis-d2x-in-d2mcpp.md:52` 声称加过保护,实际没有。 +- **Provider 超时上限**,不能让 checker 无限挂住。 + +--- + +## 8. d2mcpp 侧:Provider 实现 + +### 形态 + +C++26 + mcpp 构建,位于 `d2x/buildtools/mcpp/`,是 d2mcpp 根 workspace 的一个成员。 + +``` +d2mcpp/mcpp.toml [workspace] members = ["d2x/buildtools/mcpp"] ← 提交 +d2mcpp/d2x/buildtools/mcpp/ Provider 包,standard = "c++26" ← 提交 +d2mcpp/.d2x/build/ 生成物 ← gitignore + mcpp.toml 独立 workspace 根 + cpp11/mcpp.toml 全量 target,供 clangd + _current/mcpp.toml 只含当前一题,供 checker +``` + +Provider 包**不属于**生成的那个 workspace,避免循环依赖。 + +### 引导(零脚本、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`(`platform.cppm:12` 在命名空间静态初始化时捕获 CWD),所以必须免 `cd`。 + +注意 `-p` 匹配的是**目录 basename 或完整相对路径**,不是包名。 + +### 内部模块 + +``` +discovery/ 目录约定扫描 dslings/**/<章节>-<序号>.cpp → id / order / chapter / files +manifest/ 生成 .d2x/build/{cppNN, _current}/mcpp.toml +runner/ 调 mcpp build -p / run -p,捕获输出 +verdict/ ❌ 与 D2X_WAIT 判定 +emit/ NDJSON 事件输出 +``` + +### 为什么是"双 member" + +**逐题隔离是硬需求**:dslings 的练习默认就编译不过(49 个带 `D2X_YOUR_ANSWER`),而 mcpp 的 `build` 和 `run` 都会先全量构建整个包,一个坏兄弟拖垮全部且零产物。实测: + +``` +member 含全部 3 题(后两题未做完)→ mcpp build -p → exit=1 ← 当前这题被拖挂 +适配器只生成当前这一题 → 0.071s,exit=0 +``` + +member 粒度(标准/特性/练习)本身**不解决**逐题隔离。所以分两个: + +- `cpp11/` 等按标准分的 member 持全量 target,供 clangd 拿到完整 `compile_commands.json` +- `_current/` 每次只写当前一题,checker 只构建它 + +实测动态改写清单代价极低:切到下一题 0.118s、切回上一题 0.018s,**fingerprint 目录始终只有 1 个**(改写 target 集合不会让缓存爆炸)。 + +### 零文件搬迁 + +`main` 可以用 `../` 逃逸出包根(已实测),所以练习源文件原地不动,生成的清单放 `.d2x/build/` 即可。 + +### C++ 标准 + +全部按 `c++23` 编译(决策:不改 mcpp 上游)。已知代价:`04-rvalue-references` 的移动构造教学点会被 C++17 保证复制省略静默抹掉(实测复现,51 个参考答案里只有这 1 个漂移)。该练习需要重写以在 C++17+ 下仍可观测移动,或改写书本章节说明。**这是本方案唯一的教学内容损失,必须单独跟进。** + +--- + +## 9. 测试策略 + +| 层 | 方式 | +|---|---| +| `session/` | FakeProvider + 内存 StateStore,全流程无 IO 单测 —— **本次重构最大收益**,当前 0 覆盖 | +| `provider/` | 喂预录 NDJSON 流,专测容错:畸形行、缺 verdict、输出截断 | +| Provider 侧 | 假练习树,验证 id/order/chapter 推导与生成的清单 | +| 端到端 | 真 d2mcpp + 真 mcpp:**断言每个参考答案通过、每个练习不通过** | + +端到端这条抄 rustlings 的 `cargo dev check --require-solutions`。它顺带补上 d2mcpp 那个静默空转的 CI:`dslings-ref-ci.yml` 只挑 `-ref` 结尾的 target,而 `solutions/` 在 `xmake.lua:6` 被注释掉,grep 返回空、循环全跳过、job 退出 0,实际校验零个 target。 + +--- + +## 10. 迁移与兼容 + +**不做 v1 回落。** 本设计是干净重做,不受现有 xmake 插件布局约束。现有 `xmake d2x-buildtools` 插件在新协议下不再工作;d2mcpp 切到新 Provider 后 xmake 路径整体退役。 + +其他仍在用 v1 的课程仓库需要各自实现 Provider——这正是"具体工具由具体项目实现"的定位所要求的。 + +--- + +## 11. 实现与验证结果(2026-07-19) + +分支:d2x `feat/exercise-framework-protocol` · d2mcpp `feat/mcpp-provider` + +### 已实现 + +**d2x**(新增 3 个模块,删除 `buildtools.cppm`,重写 `checker.cppm`) + +| 文件 | 内容 | +|---|---| +| `src/domain.cppm` | Exercise / Outcome 三态 / Diagnostic / Verdict | +| `src/provider.cppm` | `IExerciseProvider` + `ProcessProvider`(NDJSON 解析、非 JSON 行静默丢弃) | +| `src/session.cppm` | `StateStore`(`.d2x/state.json`,按 id)+ `Session`(定位起点、推进) | +| `src/platform*.cppm` | 新增 `run_command_lines` 流式逐行读,并修正 `pclose` 的 wait status 解码 | +| `src/checker.cppm` | 只剩编排,从 100 行缠绕逻辑降为分层调用 | + +**d2mcpp Provider**(C++26 + mcpp,`d2x/buildtools/mcpp/`) + +`emit.cppm`(NDJSON + JSON 转义)· `discovery.cppm`(目录约定 + `// d2x:cxxflags:` 就近指令)· `manifest.cppm`(双 member 生成,内容比对后才落盘)· `runner.cppm`(调 mcpp、三态判定)· `tests/e2e.sh` + +### 实测结果 + +| 验证项 | 结果 | +|---|---| +| Provider 枚举 | 52 个练习(1 hello + 49 cpp11 + 2 cpp14,与调研数一致) | +| 三态判定 | 未完成 → `fail@compile`;参考答案 → `pass@run`;答案对但留 `D2X_WAIT` → `blocked@run` | +| **端到端断言** | **51/51 参考答案通过,0 失败**;每个未完成练习都正确不通过 | +| d2x 全链路 | Provider 加载 → 52 题枚举 → `[compile]` 阶段**实时显示** → 编译错误呈现 → 等待文件变更(退出 124 为健康) | +| 推进与持久化 | 放入 6 份参考答案后自动连推 6 题,`current` 前进到下一道未完成练习 | +| 断点续做 | 重启后进度条 `6/52`,直接从 `cpp11-01-default-and-delete-0` 开始 | + +### 实现中发现的两个真实缺陷 + +**1. `mcpp run` 向子进程泄漏 `LD_LIBRARY_PATH`,导致嵌套 mcpp 段错误。** + +Provider 由 `mcpp run` 启动时,mcpp 会把 `LD_LIBRARY_PATH` 指向它私有的 glibc +(`~/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib64`)并注入子进程。Provider 接着 +spawn 嵌套的 `mcpp`(另一个二进制)时被迫加载错配的 glibc,在动态链接器里段错误, +输出里只留下 `:\t__vdso_time` 这样的 trace 残片。 + +冷启动稳定复现 3/3;直接执行 Provider 二进制则 3/3 通过——这是决定性对照。 + +修复:`runner.cppm` 在每次 spawn 前 `unsetenv("LD_LIBRARY_PATH")`,mcpp 会为它自己的 +子进程重新设置正确的值。d2x 侧对同一问题早有相同处理(`platform.cppm` 的 +`run_command_capture`,仓库里还留着 `workaround_ld_library_path_issue` 分支)—— +**说明这是 mcpp 的既有问题,值得单独向上游报。** + +**2. 冷启动时 `check` 找不到 workspace。** + +`check` 原本只写 `_current/mcpp.toml`,而根清单由 `exercises` 写。全新仓库上学员 +直接跑 `d2x checker` 时根清单尚不存在,mcpp 以退出码 2 报 `workspace member not found`。 +修复:`check` 先 `write_full` 再 `write_current`;两者都做内容比对后才落盘, +重复调用不会推进 mtime、不会让 mcpp 的快速路径失效。 + +### 未完成 + +- **`session/` 的单测尚未编写。**「纯逻辑可单测」是本次重构的最大收益,但目前只有端到端验证,FakeProvider 单测还没落地。 +- **`diagnostics` 只走通了协议管道,Provider 还没真正产出。** 需要解析编译器输出(或改用 `-fdiagnostics-format=json`)才能填充,前端的行内高亮也就还没兑现。 +- **前端仍是编译期插件。** 上行 Frontend Protocol 已在设计中定稿,但内置 TUI/print 尚未改造成协议客户端,目前仍直接调 `ui::update_checker_page`。 +- **文件监听未改造。** 仍是 `utils::wait_files_changed` 轮询 mtime,去抖与自触发保护还没做。 +- **`04-rvalue-references` 的教学漂移未处理。** 见第 8 节。 + +--- + +## 12. 第二轮:功能补齐与缺陷修复(2026-07-20) + +### 补齐的功能 + +| 功能 | 位置 | 说明 | +|---|---|---| +| 上行 Frontend Protocol | `d2x/src/emit.cppm` | 单向 NDJSON。内置 TUI 走 `UiSink`(内存通道),外部客户端走 `StdoutSink`(管道),同一套事件类型。`--emit-events` 启用 | +| 文件监听 | `d2x/src/watch.cppm` | 按文件记 mtime+size、内置安静期去抖、`resync()` 自触发保护 | +| session 单测 | `d2x/tests/session_test.cpp` | 28 个断言,无 IO 覆盖完整学习流程 | +| 教学漂移修复 | `dslings/**/04-rvalue-references.cpp` | 改用具名对象 `std::move`,并加断言钉住 | + +原先的监听实现「把所有文件 mtime 相加再比总和」有三个问题:求和会抵消(两文件一增一减则漏检);无去抖(编辑器多次写入会读到半截文件);去抖手写在调用方。 + +### 单测立刻抓到的设计缺陷 + +起点优先级是「显式指定 > 持久化 current > 第一个未完成」。这条本身是对的——学员主动跳级后重启不该被硬拉回开头。但副作用是:**课程作者在学员当前位置之前插入新练习,那道题会被永久静默跳过**。 + +修法不是回退优先级,而是让推进逻辑走到末尾时绕回去回收遗漏的练习(`Session::advance_to_next_incomplete`)。学员不被打断,内容也不丢。 + +### 对抗性审查发现的三个真缺陷 + +**1. 练习 id 注入(严重)。** id 直接取自文件名,有两个危险去向:d2x 把它拼进 shell 命令交给 `popen`,Provider 把它写进生成的 TOML(`[targets.]`)。带反引号、`]` 或引号的文件名在任一处都能越界——对社区课程仓库而言,一个恶意 PR 文件名就足以在任何跑 checker 的人机器上执行命令。 + +在 `discovery.cppm` 源头做白名单校验并**拒绝**,而不是在两个下游各自转义;d2x 侧同时加 shell 引用做纵深防御。实测 `` 99-evil`touch pwned_marker`.cpp `` 被拒绝、命令未执行。 + +**2. `e2e.sh` 把所有英文参考答案静默 SKIP。** 前缀剥离顺序错了——`${sol#en/}` 执行时 `sol` 已经以 `solutions/` 开头,匹配不到任何东西,是个静默 no-op。 + +**这正是本脚本存在的理由所要防的那种空转,和旧 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()` 存在的意义就是避免这个,却只用了一半。 + +### 其他修复 + +- `DEFAULT_BUILDTOOLS` 从 `"xmake d2x-buildtools"` 改为空。xmake 已退役,留着会让未配置的仓库拿到必定失败的命令,报错还指向 xmake。 +- `read_source` 包住读文件异常。原先无保护,练习文件读不到就整个会话崩。 +- `--emit-events` 模式下日志改道 stderr。实测修复前有 5 行日志混进事件流。 +- `e2e.sh` 增加脏树前置检查。该脚本会把参考答案覆盖到练习上再还原,天然会吃掉练习目录里未提交的改动——**这个陷阱咬过两次**(一次丢了脚手架,一次丢了刚修好的练习)。现在不干净就拒绝运行并列出文件。 + +### 当前验证状态 + +| 项 | 结果 | +|---|---| +| d2x session 单测 | 28/28 | +| Provider 端到端(zh) | 51/51 参考答案通过 | +| Provider 端到端(en) | 51/51 参考答案通过 | +| TUI 全链路 | 正常,进度 0/52,健康挂起 | +| 事件流全链路 | 12 行 JSON,**0 行污染** | +| 注入防护 | 恶意文件名被拒绝,命令未执行 | + +### 仍然欠着的 + +- **macOS / Windows 从未验证。** Windows 尤其存疑:`_popen`、`_putenv_s`、`unsetenv` 的 `#ifdef` 分支、`shell_quote` 的 cmd.exe 分支,全是纸面推断。 +- **新 CI 从未真跑过。** workflow 是手写的,`xlings install -y` 在 CI 环境能否装上 mcpp 未验证。 +- **`--ui print` 参数不生效**——`.d2x.json` 的 `ui_backend` 覆盖了 CLI 参数(d2x 既有问题)。因此 print 后端路径未被真正验证。 +- **`diagnostics` 只在断言失败时产出**,编译错误尚未解析成结构化诊断(需要 `-fdiagnostics-format=json` 或解析编译器输出)。 +- **模块化练习的填空占位符没有约定。** `D2X_YOUR_ANSWER` 是宏,无法跨模块导出;cpp20/cpp23 章节需要另设方案。 diff --git a/.agents/docs/2026-07-20-d2x-architecture-reference.md b/.agents/docs/2026-07-20-d2x-architecture-reference.md new file mode 100644 index 0000000..d9f80a7 --- /dev/null +++ b/.agents/docs/2026-07-20-d2x-architecture-reference.md @@ -0,0 +1,251 @@ +# d2x 架构参考 + +- 日期:2026-07-20 +- 分支:`feat/exercise-framework-protocol` +- 配套设计文档:[`2026-07-19-exercise-framework-protocol-design.md`](2026-07-19-exercise-framework-protocol-design.md)(决策过程与理由) +- 本文定位:**当前实现的参考手册**——协议规范、模块职责、扩展方式、已知缺口 + +--- + +## 1. d2x 是什么 + +**练习驱动学习的通用框架:它拥有学习循环和它的呈现,除此之外什么都不拥有。** + +d2x 不知道 C++、不知道 mcpp、不知道怎么编译任何东西。它向下用协议对接课程,向上用协议对接前端。 + +``` + 前端:内置 TUI / print / VSCode 插件 / Web / CI + ↑ Frontend Protocol (NDJSON,单向) + ┌────────────────────────────────────────────┐ + │ d2x core —— 学习循环 · 会话状态 · 文件监听 · 编排 │ + └────────────────────────────────────────────┘ + ↕ Provider Protocol (NDJSON) + 课程侧:由具体课程实现(如 d2mcpp 的 C++26 Provider) +``` + +| d2x 拥有 | 归属他方 | +|---|---| +| 学习循环的编排 | 构建工具、编译命令 | +| 会话状态与断点续做 | 通过判定规则 | +| 文件监听与去抖 | 练习内容、顺序、章节 | +| 事件的产生与分发 | 具体渲染方式 | + +--- + +## 2. 下行 · Provider Protocol + +课程侧实现的唯一扩展点。命令来自 `.d2x.json` 的 `buildtools`(语义是「Provider 命令行前缀」)。 + +### 三个动词 + +``` + describe + exercises + check +``` + +**刻意没有 build / run / test。** 那是编译型语言的形状,焊进通用框架就焊死了适用范围。一次 `check` 内部编译几次、跑不跑测试、判定看退出码还是看输出,全是课程的事。 + +### 事件格式 + +每行一个 JSON 对象,写到 stdout。**解析不了的行由 d2x 静默忽略**——这不是宽容,是协议设计的一部分:Provider 常经由启动器间接执行(如 `mcpp run`),启动器会往 stdout 混入空行乃至编译输出。忽略非 JSON 行让噪声天然失效,于是不需要哨兵前缀或「末行即 JSON」这类隐式约定。 + +```jsonc +// describe +{"event":"describe","protocol":1,"name":"mcpp"} + +// exercises —— 每题一行 +{"event":"exercise","id":"cpp11-00-auto-and-decltype-0","order":1100000, + "title":"auto and decltype (0)","chapter":"cpp11/00-auto-and-decltype", + "files":["/abs/path/to/exercise.cpp"]} + +// check —— 按发生顺序流式输出 +{"event":"stage","name":"compile"} +{"event":"output","chunk":"...保留 ANSI 的原始输出..."} +{"event":"stage","name":"run"} +{"event":"output","chunk":"..."} +{"event":"verdict","outcome":"pass|fail|blocked","stage":"run","exit_code":0, + "diagnostics":[{"file":"/abs/path","line":33,"col":0, + "severity":"error","message":"断言未通过: ..."}]} +``` + +### 字段约定 + +| 字段 | 要求 | +|---|---| +| `id` | 稳定标识,完成状态按它持久化。**必须 shell 安全**——d2x 会做引用,但 Provider 应在源头校验并拒绝异常字符 | +| `order` | 显式顺序。d2x 按它排序,不依赖 id 的字典序 | +| `files` | **绝对路径**。d2x 用它打开编辑器、监听变更 | +| `diagnostics[].file` | **绝对路径**,同上。即使展示用相对路径,协议边界上也要还原 | +| `outcome` | 三态,见下 | + +### 三态 outcome + +| 值 | 含义 | d2x 行为 | +|---|---|---| +| `pass` | 通过 | 标记完成,推进下一题 | +| `fail` | 未通过 | 等文件变更后重试 | +| `blocked` | **代码已正确,但还有显式路障未拆**(如 d2mcpp 的 `D2X_WAIT`) | 同 `fail`,但前端可区分呈现 | + +`blocked` 是独立态而非布尔的一部分——旧实现把它塞进 `build_success=false` 而 `status` 仍为 true,UI 显示成「成功但卡住」,语义是错的。 + +### Provider 实现要点 + +- **进程无状态**,可自行在磁盘缓存。实测启动开销约 26ms(经 `mcpp run` 转发),不值得上常驻进程。 +- **`check` 没有 verdict 事件** = Provider 中途死了或输出被截断。d2x 一律当作 `fail` 并把原始输出原样呈现,绝不当成通过。 +- **`describe` 失败是致命错误**,d2x 明确报「Provider 挂了」而非「没有练习」。 +- 事件流的形状使得将来换成常驻 JSON-RPC 只是换传输,领域模型不动。 + +--- + +## 3. 上行 · Frontend Protocol + +**单向。** d2x 保留全部控制权(通过即自动前进,失败即等文件变更),前端纯显示。 + +`--emit-events` 启用;stdout 是纯 NDJSON 协议流,**日志全部改道 stderr**。 + +```jsonc +{"event":"session","total":52,"completed":12,"current":"cpp11-04-rvalue-references"} +{"event":"exercise","id":"...","order":0,"title":"...","chapter":"...","files":[...]} +{"event":"stage","name":"compile"} +{"event":"output","chunk":"..."} +{"event":"verdict","outcome":"blocked","stage":"run","diagnostics":[...]} +{"event":"waiting","reason":"file-change"} +{"event":"hint","text":"...AI 助手产出..."} +{"event":"done"} +``` + +### 两侧的关系 + +`stage` / `output` / `verdict` **两侧同构**,d2x 对它们基本是转发 + 补会话上下文。只属于上行的是 `session` / `exercise` / `waiting` / `hint` / `done`。 + +**下行是上行的子集**,不是两套无关的协议。 + +### 内置前端是同进程客户端 + +`d2x checker` 对学员必须仍是一条命令,不能让人先起引擎再起前端。所以内置 TUI/print 走内存通道(`UiSink`),外部客户端走管道(`StdoutSink`),二者消费同一套事件类型,只是换传输。 + +**`--emit-events` 模式下 d2x 不打开编辑器**——外部前端已从事件流拿到 `exercise` 和 `verdict`,开不开、怎么开是它的决定,两边都动只会打架。 + +--- + +## 4. 模块职责 + +``` +src/domain.cppm Exercise · Outcome · Diagnostic · Verdict 纯数据,零依赖 +src/provider.cppm IExerciseProvider + ProcessProvider 唯一下行扩展点 +src/session.cppm StateStore + Session 纯逻辑,可单测 +src/watch.cppm FileWatcher 去抖 + 自触发保护 +src/emit.cppm IEventSink + StdoutSink + UiSink 上行协议 +src/checker.cppm 编排 只发事件,不拼页面 +src/editor.cppm 可配置的编辑器策略 +src/config.cppm 配置加载与优先级 +``` + +**关键性质:`session/` 是纯逻辑。** 给它假 Provider 和内存状态就能测完整学习流程,不碰文件系统、不碰构建工具、不碰终端。`tests/session_test.cpp` 有 28 个断言。 + +### 用词 + +领域对象叫 **exercise**,不叫 target。`target` 是构建工具的词汇,让它泄漏进领域层正是旧设计的问题所在——d2x 是课程工具。 + +--- + +## 5. 会话状态 + +``` +.d2x/state.json { "current": "", "completed": ["", ...] } +``` + +**按 id 存,不按下标。** 重排或重命名练习不会毁掉学员进度。这是 rustlings 的经验(其 `.rustlings-state.txt` 同样按名字存)。 + +### 起点优先级 + +``` +显式指定(d2x checker ) > 持久化 current > 第一个未完成 > 开头 +``` + +### 遗漏回收 + +推进到末尾时会**绕回去找未完成的练习**(`Session::advance_to_next_incomplete`)。 + +原因:起点优先「持久化 current」是对的(学员主动跳级后重启不该被硬拉回开头),但副作用是课程作者在学员当前位置**之前**插入新练习时,那道题会被永久静默跳过。绕一圈保证「学员不被打断,内容也不丢」。这个缺陷是 session 单测发现的。 + +--- + +## 6. 文件监听 + +`src/watch.cppm`。按文件记 `mtime + size`,内置安静期去抖,提供 `resync()` 做自触发保护。 + +**检查完全由文件变更驱动。** 等待窗口到期只是继续等,绝不重新构建——早先的实现在窗口到期后无条件重跑,结果 TUI 每 20 秒自己刷一屏、白白重编一遍,学员什么都没做却看到界面在动。实测修复后静置 40 秒零输出。 + +替换掉的旧实现有三个问题:把所有文件 mtime **相加比总和**(两文件一增一减则互相抵消,改动被漏掉);无去抖(编辑器多次写入会读到半截文件);去抖手写在调用方。 + +--- + +## 7. 配置 + +`.d2x.json`(本地)与 `~/.d2x.json`(全局)。 + +| 键 | 说明 | +|---|---| +| `buildtools` | **Provider 命令行前缀。无默认值**——Provider 是课程特有的,必须由课程仓库声明 | +| `ui_backend` | `tui` / `print` | +| `lang` | 课程语言,透传给 Provider | +| `editor` | 编辑器命令。支持 `{file}` 占位符;**显式配空串 = 关闭**;未配置时按 `$VISUAL` → `$EDITOR` → `code` 回退 | +| `llm` | AI 助手配置 | + +### 优先级 + +``` +命令行 > 环境变量 > 本地配置 > 全局配置 > 默认值 +``` + +命令行参数是通过写环境变量传进来的(`cmdprocessor::apply_global_options`),所以环境变量必须**覆盖**配置文件而非「只填空缺」——原先是后者,导致 `.d2x.json` 反过来压住了 `--ui` / `--lang`。 + +--- + +## 8. 安全约定 + +**练习 id 来自课程仓库的文件名,是不可信输入。** 它有两个危险去向:d2x 拼进 shell 命令交给 `popen`;Provider 可能写进生成的构建清单。带反引号、`]`、引号或换行的文件名在任一处都能越界——对社区课程仓库而言,一个恶意 PR 文件名就足以在任何跑 checker 的人机器上执行命令。 + +**纵深防御:** +- d2x 侧:`ProcessProvider` 对 id 做 shell 引用(POSIX 单引号包裹;Windows 拒绝含引号的 id) +- Provider 侧:应在发现阶段用白名单校验并**拒绝**,而不是想办法安全地传递 + +--- + +## 9. 已知缺口 + +| 缺口 | 影响 | +|---|---| +| **macOS / Windows 从未验证** | Windows 尤其存疑:`_popen`、`_putenv_s`、`shell_quote` 的 cmd.exe 分支全是纸面推断 | +| ~~`provider/` 层无单测~~ | **已覆盖(2026-07-24)**:fake-provider conformance e2e(`tests/e2e.sh`)覆盖畸形行/缺 verdict/describe 失败/超时/锁/flush;协议能力已析出 `d2x.protocol`(protocol/ workspace 成员) | +| **`emit` / `watch` 层无单测** | 同上 | +| **`diagnostics` 只覆盖运行期断言** | 编译错误尚未解析成结构化诊断,需要 `-fdiagnostics-format=json` 或解析编译器输出 | +| **前端仍是编译期插件** | `IUIBackend` + `UILoader` 尚未真正改造成协议客户端,`UiSink` 是适配层而非重构 | +| ~~Provider 无超时上限~~ | **已修(2026-07-24)**:活性超时住 `d2x.protocol.transport`,默认 120s 无输出即终止,可配 `provider_idle_timeout` | +| **多文件练习支持不完整** | `Exercise.files` 是数组,但 AI 助手只读 `files.front()` | + +--- + +## 10. 相关记录 + +- 决策过程与替代方案对比:`2026-07-19-exercise-framework-protocol-design.md` +- xmake → mcpp 的调研(含 rustlings/cargo 横向对照):d2mcpp 仓库 `.agents/docs/2026-07-19-mcpp-replace-xmake-research.md` +- Provider 实现范例:d2mcpp 仓库 `.agents/docs/2026-07-20-mcpp-provider-reference.md` + + +--- + +## 11. 2026-07-24 批次更新摘要 + +- **协议层分库**:types/codec/transport 析出为 `protocol/` 成员(模块 `d2x.protocol.*`), + core 经兼容壳(`d2x.domain`/`d2x.provider`)零改动消费;规范性载体仍是本文协议章节, + conformance 判据是 `tests/fake_provider.sh` + `tests/e2e.sh`。 +- 新增:活性超时、checker 单实例锁(`.d2x/checker.lock`)、进度文件原子写入+损坏备份、 + `d2x status` 只读总览、消息目录 i18n(zh/en)。 +- xlings 依赖策略反转:缺失即报错+分平台安装命令,删除代装链路。 +- UI 词汇修正:`UIState` 与页面全面改用 exercise(旧 target 字段随 UI 接口退役); + print 页面固定分区,诊断置顶,长输出截断+`.d2x/last-output.log` 全量落盘。 +- 版本切换日期制:`2026.07.24.1` 起。 +设计与决策:`2026-07-24-d2x-stability-usability-design.md`。 diff --git a/.agents/docs/2026-07-24-d2x-stability-usability-design.md b/.agents/docs/2026-07-24-d2x-stability-usability-design.md new file mode 100644 index 0000000..4a42062 --- /dev/null +++ b/.agents/docs/2026-07-24-d2x-stability-usability-design.md @@ -0,0 +1,202 @@ +# d2x 稳定性与可用度优化设计 + +- 日期:2026-07-24 +- 分支:`feat/exercise-framework-protocol` +- 关联:[`2026-07-20-d2x-architecture-reference.md`](2026-07-20-d2x-architecture-reference.md)(架构参考,其 §9 缺口本文逐条消化) +- 方法:**实测证据驱动**——问题清单全部来自本周 d2mcpp「练习即测试」端到端接入期间的真实观察,不做臆测式加固 +- 本文定位:设计方案(未实现),核心点供 review 后拆计划 + +--- + +## 1. 现状盘点 + +| 模块 | 健康度 | 依据 | +|---|---|---| +| session/ | ✅ 有 28 断言单测;按 id 持久化、遗漏回收 | 架构参考 §5 | +| watch/ | ⚠️ 无测;去抖/自触发保护逻辑正确(实测静置 40s 零输出) | §6 | +| provider/ | ⚠️ 无测;**无任何超时**;NDJSON 容错未被自动化覆盖 | §9 | +| emit/ | ⚠️ 无测;StdoutSink 逐行 flush ✓ | 代码 | +| ui/print | ⚠️ 渲染尾 flush 刚修(13863df);词汇/布局遗留 | 本周实测 | +| ui/tui | ⚠️ ftxui 帧渲染;未审计中断恢复 | 代码 | +| xlings 集成(install/new/book/list) | ❌ 多处脆弱点(§2 S3/S5/S10) | 代码审读 | + +## 2. 问题清单(编号 = 后文方案引用;★ = 本周真实咬过人) + +**稳定性** + +- **S1 Provider 无超时**:describe/exercises/check 任一挂死 → checker 永久挂住。课程 Provider 冷启动(如 `mcpp run` 首次要构建 Provider)可达分钟级,**固定总时长超时是错的设计**。 +- **S2 并发实例互踩 ★**:两个 checker(或一个泄漏实例)同时监听同一仓库——本周一个泄漏的 `d2x checker` 在 e2e 覆盖答案时并发触发重建,与外部构建竞争产物目录,测试二进制被替换瞬间报 exit 127。无实例锁。 +- **S3 `d2x install` 链路五个脆弱点**:① `xlings install d2x:` 不带 `-y`(非交互环境卡在确认提示);② `ensure_xlings_installed()` 返回值被忽略(用户拒绝安装后照样往下执行);③ `` 未做白名单校验直接拼 shell 命令;④ 失败只打退出码,无原因分类与指引(网络?索引过期?建议 `xlings update`/`--mirror CN`?);⑤ 成功后不校验结果(目标目录 + `.d2x.json` 存在)、无下一步提示。 +- **S5 `has_xlings()` 误判**:`regex_match` 要求输出**整体**恰为 `xlings x.y.z`——xlings 将来多打一行 banner 就误判未安装,触发重装流程。 +- **S6 state.json 损坏 = 静默清零进度**:解析失败走 `is_discarded → return`,不崩溃但**进度丢失且无告警**。应为:损坏文件改名备份 + 明确告知 + 从空白继续。 +- **S12 写入原子性**:state.json 的保存若非「临时文件 + rename」,断电/并发可产生半写文件(正是 S6 的来源)。 + +**显示 / UX** + +- **S4 stdout 契约不完整 ★**:print 页面缓冲滞留(已修 13863df),但「TTY=人读 / 非 TTY=必须逐行或逐页 flush」尚未成为全 UI 层的显式契约与测试项。 +- **S7 词汇漂移**:领域层早已改名 exercise,但 `ICheckerPageUI::UIState` 仍是 `target/built_targets/total_targets`,页面打 `Target: hello-mcpp`、`---------E-Files---------` 等旧格式——接口把旧词汇固化了。 +- **S8 长输出淹没**:编译错误全量塞进页面,几百行时关键信息(结构化 diagnostics、首个错误)被推出屏幕。 +- **S9 i18n 混杂**:UI/日志中英夹杂(`Provider: mcpp` + 中文错误提示);`lang` 只切练习集不切界面文案。 +- **S10 长操作静默 ★**:`d2x book` 内部 `xlings install mdbook -y` 走 `run_command_capture`——下载几十秒期间用户面对完全静止的终端。 +- **S11 可见性缺口**:没有不进入监听循环的进度总览(哪些完成/当前在哪/一共多少),学习者只能进 checker 才知道。 + +**质量基建** + +- **S13 无自有端到端测试 ★**:d2x 的协议行为(describe 失败、缺 verdict、畸形行、blocked 三态、文件变更推进)只被 d2mcpp 的 CI 间接覆盖——d2x 自身回归要靠下游发现。 + +## 3. 设计方案 + +### D0 协议层分库:`d2x.protocol`(架构主线,按 review 意见确立) + +把「与真实练习项目对接」的协议能力从 d2x core 中析出为独立库(先做 +d2x 仓库内的 mcpp workspace 成员 `protocol/`,模块名 `d2x.protocol`, +成熟后可独立发布),d2x core 只消费**标准化的数据与状态**: + +``` +┌ d2x core 学习循环 · 会话进度 · 文件监听 · 前端编排(它独有的东西) +├ d2x.protocol ── 本次析出 ── +│ types Exercise / Verdict / Outcome / Diagnostic / 事件类型(上行+下行) +│ codec NDJSON 编码(emit 端)与解码(parse 端),转义、容错(畸形行忽略) +│ transport ProcessProvider:spawn / shell-quote / **活性超时(D1 落在这里)** +│ conformance 假 Provider + 协议一致性测试套件(**D7 落在这里**) +└ 课程侧 d2mcpp buildtools(可复用 codec 的 emit 端) / 任意语言自行实现 +``` + +三条边界纪律: + +1. **规范性载体仍是协议文档(schema),库只是参考实现**。Provider 可以用 + 任何语言实现协议,绝不允许出现"想接入必须链接这个 C++ 库"的事实标准—— + conformance 测试套件(可执行的假 Provider + 校验器)才是跨语言的合规判据。 +2. **状态的归属划清**:pass/fail/blocked 三态、诊断、事件流是**协议数据**, + 进 `d2x.protocol`;学习进度(state.json、completed/current、推进规则) + 是**学习循环状态**,留在 d2x core 的 session——协议层无进度概念。 +3. 双向协议**字段零改动**:这是纯结构重组 + 单一真相源化。当前 d2x 的 + parse 端与 d2mcpp 的 emit 端是两份手写实现,靠文档同步——正是 rustlings + PR #1355 式的双真相源风险;d2mcpp 侧接入 codec emit 端后归一。 + +收益:D1/D7 有了正确的安家处;未来 VSCode/Web 前端消费上行事件时直接 +复用 types+codec;协议版本演进(describe.protocol)有了唯一执行点。 + +### D1 Provider 活性超时(治 S1,落位 `d2x.protocol.transport`) + +固定总时长会误杀冷启动,正确模型是**活性(liveness)**: + +``` +自上次收到任何输出行起 > idle_timeout 秒 → 判定挂死,SIGKILL, +verdict 缺失走既有「无 verdict = fail + 原样呈现已收输出」路径。 +``` + +- 平台层 `run_command_lines` 增加带 idle-deadline 的变体(POSIX:poll + 读循环 + WNOHANG,对照 mcpp `capture_exec_deadline` 的实现经验;Windows 暂记录为尽力而为)。 +- 默认 `idle_timeout = 120s`(覆盖最慢的单文件编译间隙),`.d2x.json` 可配 `provider_idle_timeout`;describe/exercises 与 check 共用同一机制。 +- 杀死后 UI 明示「Provider 无响应已终止(120s 无输出)」而非无限转圈。 + +### D2 checker 单实例锁(治 S2) + +- `.d2x/checker.lock` 写入 pid + 启动时间;启动时读锁:进程存活(`kill(pid,0)`/Windows OpenProcess)→ 拒绝启动并提示「另一 checker (pid N) 正在运行」;进程已死 → 视为陈旧锁自动接管。 +- 正常退出与信号路径(SIGINT/SIGTERM handler)删除锁;TUI 退出路径一并审计终端状态恢复。 + +### D3 xlings 依赖简化 + `d2x install` 健壮化(治 S3/S5,按 review 意见收窄) + +**依赖策略反转:d2x 不再保证/代装 xlings。** 默认直接使用;缺失即报错退出, +输出分平台安装命令后由用户自行安装——删除 `ensure_xlings_installed()` 的 +交互问询与 `platform::xlings_install()` 整条代装链路: + +``` +error: 未检测到 xlings(d2x 的包管理依赖) +安装后重试本命令: + Linux/macOS: curl -fsSL https://d2learn.org/xlings-install.sh | bash + Windows: irm https://d2learn.org/xlings-install.ps1.txt | iex +``` + +理由:代装是嵌套的安装器(安装器里再跑安装器),失败面大、责任边界混乱; +一条明确的报错 + 官方命令比"帮你装"更可预期。 + +保留的健壮化项: +1. 包名白名单 `[A-Za-z0-9._-]`,非法即拒绝(与练习 id 同一纪律)。 +2. `has_xlings`:`regex_match` → `regex_search`(多一行 banner 不误判); + 以 `get_xlings_bin()` 存在性为先导判据。 +3. 命令加 `-y`。 +4. 失败分类:按退出码/输出给**下一步指令**(`xlings update` / + `xlings config --mirror CN`),而不是裸状态码。 +5. 成功后校验 `/.d2x.json`,打印「cd && d2x checker」引导; + 目标目录已存在时明确报错(不静默覆盖)。 +6. `d2x new`/`d2x book` 同标准对齐(缺 xlings 同样报错+命令;模板 rename + 失败清理残留)。 + +### D4 stdout 契约成文 + 测试(治 S4/S10) + +- 契约:**stdout 三种角色互斥**——协议流(--emit-events,逐行 flush)|人读页面(每次渲染整页后 flush)|透传子进程输出(不捕获)。log 一律 stderr。 +- `d2x book`/install 的长操作从 `run_command_capture` 改为透传(用户看得见 xlings 自己的进度条)。 +- 回归项进 D7 的 e2e:`checker --ui print > file` 断言文件含页面内容(防 13863df 复发)。 + +### D5 呈现层重构(治 S7/S8/S9) + +- `ICheckerPageUI::UIState` 字段改名:`exercise / files / completed / total`(编译期接口,print/tui 两个内置后端同步改;插件协议客户端化仍是架构参考 §9 的独立缺口,本次不动协议)。 +- 页面固定分区(print 与 tui 同构): + +``` +Progress [====>----] 12/52 ← 单一进度行 +Exercise: cpp11-04-rvalue-references (cpp11/04-rvalue-references) +Status: ❌ 编译失败 | 🚧 已通过,待拆路障 | ✅ 通过 +Checks: (结构化 diagnostics 置顶,最多 5 条,file:line + message) +Output: 头 20 行 + «… 省略 N 行,完整输出见 .d2x/last-output.log» + 尾 30 行 +Hint: AI 提示(未启用则单行说明) +``` + +- 全量输出落 `.d2x/last-output.log`(每次 check 覆写)——截断不丢信息。 +- 文案走消息目录(zh/en 两份,键控),随 `lang`;默认 en,`lang=zh` 全中文。 + +### D6 `d2x status`(治 S11) + +只读命令:Provider `exercises` + state.json → 输出总览(完成数/当前/各章节完成度),不构建、不监听。`--emit-events` 下输出对应 JSON。列表长时按章节聚合。 + +### D7 fake-provider 测试基建(治 S13,兼 §9 三个无测模块) + +- `tests/fake-provider.sh`:≤50 行 bash,按 argv 输出可配置的协议脚本(正常流/describe 失败/无 verdict/畸形行/挂死 sleep/blocked)。 +- e2e(不依赖 mcpp/d2mcpp): + 1. 正常闯关:fake 先 fail 后 pass,改文件触发推进,断言 state.json; + 2. 协议容错:畸形行忽略、缺 verdict=fail、describe 失败=明确报错; + 3. D1 超时:挂死 provider 在 idle_timeout 后被杀且 UI 有明示; + 4. D2 锁:双实例第二个被拒; + 5. D4 flush:重定向下页面内容存在。 +- provider/emit/watch 的纯逻辑部分补单测(解析、转义、去抖计时可注入时钟)。 + +### D8 状态写入原子化(治 S6/S12) + +- StateStore 保存:写 `state.json.tmp` + `rename`;加载失败:原文件改名 `state.json.corrupt-` + stderr 告警 + 空状态继续。 + +## 4. 路线图 + +| # | 项 | 治 | 验收 | +|---|---|---|---| +| P0 | D0 协议层析出(types/codec/transport 骨架,行为等价迁移) | 架构 | 既有 session 单测 + d2mcpp e2e 全绿(纯重组不改行为) | +| P1 | D1 活性超时 + D2 单实例锁 | S1/S2 | fake-provider 挂死 e2e;双实例 e2e | +| P2 | D3 xlings 依赖简化 + install 健壮化 | S3/S5 | 缺 xlings 报错含分平台命令;非交互全流程;失败注入出指引 | +| P3 | D8 状态原子化 | S6/S12 | 损坏注入 e2e:进度备份+告警+可继续 | +| P4 | D7 conformance 套件(住 d2x.protocol) | S13 | e2e 独立于 mcpp 全绿,进 CI;d2mcpp emit 端切换到 codec | +| P5 | D4 stdout 契约 + 透传 | S4/S10 | 重定向断言;book 安装可见进度 | +| P6 | D5 呈现重构 | S7/S8/S9 | 新布局截图对照;书中示例同步 | +| P7 | D6 status 子命令 | S11 | 只读、亚秒返回 | + +依赖:P0 是 P1/P4 的结构前置(先安家再添能力);P1 的 deadline 变体是 P4 超时用例的前置;P6 会改动 d2mcpp 书中的控制台示例(跨仓库联动,放最后与 d2x 发版一起做)。 + +## 5. 兼容性与不动项 + +- **双向协议一个字段不动**(Provider Protocol / Frontend Protocol);D5 仅改编译期内置接口与文案。 +- `platform.cppm` 的 `LD_LIBRARY_PATH` 清理**保留**(mcpp 0.0.104 虽已根治 mcpp 链路,但它还保护 editor 等非 mcpp 子进程)。 +- 「前端插件协议客户端化」「多文件练习 files.front()」「编译错误结构化」仍挂在架构参考 §9,不并入本批(范围控制)。 + +## 6. 决策记录(2026-07-24 review 定案) + +| 决策点 | 定案 | 原因 | +|---|---|---| +| D1 idle_timeout 默认值 | **120s,`.d2x.json` 可配** | 活性模型下 120s 度量的是「单次输出间隙」而非总时长——最慢场景(单个大 TU 编译、Provider 冷启动的静默段)实测远低于此;误杀面已被「有输出即续命 + 可配置 + 杀前 UI 明示」三重缓解覆盖,不值得再加复杂度(如自适应阈值) | +| D5 UIState 编译期改名 | **本批直接改** | 当前 print/tui 均为同仓内置后端,无外部插件消费者——改名成本是一次性的同仓同步;若等「前端协议客户端化」一起做,词汇漂移会继续被新代码引用而扩散,未来清理成本单调上升 | +| 三缺口(多文件练习/编译错误结构化/前端协议化)| **继续挂起,不并入** | 各自是独立工作流且无本批依赖;并入只会稀释本批「稳定性/可用度」的验收焦点。挂在架构参考 §9 持续追踪 | +| 版本制式 | **自本批起改为日期版本 `YYYY.MM.DD.N`**(本批 = 2026.07.24.1) | d2x 是面向学习者的应用而非被依赖的库,semver 的兼容性语义用不上;日期版本对「装的是多新的 d2x」这个唯一真实问题表达力更强。N 为同日序号 | + +## 7. 风险 + +- D1 的 idle 判定依赖 Provider 有持续输出——一个合法但完全静默 3 分钟的 Provider 会被误杀;120s 默认 + 可配置 + 杀前 UI 倒计时提示作为缓解。 +- D5 改 UIState 是编译期破坏性改动——print/tui 同仓库同步改,无外部插件消费者(现状),成本可控。 +- Windows:D1/D2 的进程探活与杀死需要 `_WIN32` 分支,延续「纸面推断」风险——与架构参考 §9 的 Windows 验证缺口一起在发版前过一轮。 diff --git a/.agents/docs/2026-07-24-stability-batch-plan.md b/.agents/docs/2026-07-24-stability-batch-plan.md new file mode 100644 index 0000000..9ae77a6 --- /dev/null +++ b/.agents/docs/2026-07-24-stability-batch-plan.md @@ -0,0 +1,30 @@ +# d2x 稳定性批次 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 落实 `2026-07-24-d2x-stability-usability-design.md` 全部方案(P0–P7),版本切换为日期制 **2026.07.24.1**,单 PR → CI 全绿 → bypass squash 合入 → 发布 + xlings 生态验证(含本地 d2mcpp 联动)。 + +**Architecture:** 先 P0 把协议能力析出为 rooted-workspace 成员 `protocol/`(模块 `d2x.protocol.*`:types/codec/transport),d2x core 经 path 依赖消费;活性超时与 conformance 套件随后落位其中。其余任务按设计文档 D1–D8 逐项实现,每项带 fake-provider e2e 或单测。 + +## Global Constraints + +- 双向协议字段零改动;d2mcpp 联动 e2e 全程作为回归闸门。 +- 版本双源同步:`mcpp.toml` + `src/config.cppm Info::VERSION` = `2026.07.24.1`;release.yml 的版本比对逻辑兼容日期制。 +- 每任务一提交;PR 单个;合入用 `gh pr merge --squash --admin`(目标已授权)。 +- 生态验证:本地 d2mcpp 全链路(checker 推进/e2e)+ 发布产物 + xlings 安装链路(镜像/索引按记忆中 d2x 发布流程)。 + +## Tasks + +- [x] **T0 版本日期制**:mcpp.toml + Info::VERSION → 2026.07.24.1;release.yml 版本核对与 tag 命名兼容(v2026.07.24.1)。 +- [x] **T1 (P0) 协议层析出**:`protocol/` 成员(types.cppm=domain 类型迁移+`export import` 兼容壳,codec.cppm=parse_event/escape/downlink serialize,transport.cppm=ProcessProvider+shell_quote);root mcpp.toml 变 rooted workspace + path 依赖;行为等价,session 单测+d2mcpp 冒烟不变绿。 +- [x] **T2 (P1a) 活性超时**:platform `run_command_lines_idle`(POSIX poll+WNOHANG+idle 判定,Windows 回退无超时);transport 接入,默认 120s,`.d2x.json provider_idle_timeout`/env 可配;杀死后 log 明示。 +- [x] **T3 (P1b) 单实例锁**:`.d2x/checker.lock`(pid);启动探活拒绝/陈旧接管;SIGINT/SIGTERM 清锁。 +- [x] **T4 (P2) xlings 简化+install 健壮化**:require_xlings(缺失→分平台命令报错退出,删代装链路);包名白名单;`-y`;失败指引;成功校验 .d2x.json+引导;new/book/list 对齐;has_xlings regex_search。 +- [x] **T5 (P3) 状态原子化**:StateStore 保存 tmp+rename;损坏→改名备份+stderr 告警+空态继续。 +- [x] **T6 (P4) conformance 套件**:tests/fake_provider.sh(场景:ok/describe-fail/no-verdict/garbage/hang/blocked)+tests/e2e.sh(闯关推进/容错/超时/锁/flush 五组)+protocol 单测(tests/protocol_test.cpp)。 +- [x] **T7 (P5) stdout 契约**:book 的 mdbook 安装改透传;契约注释成文;flush 断言在 T6。 +- [x] **T8 (P6) 呈现重构**:UIState 改名(exercise/files/completed/total);print+tui 新分区布局;输出截断(头20尾30)+`.d2x/last-output.log`;消息目录 zh/en 随 lang。 +- [x] **T9 (P7) `d2x status`**:只读总览(按章节聚合),--emit-events 出 JSON。 +- [x] **T10 文档**:架构参考 §9 缺口勾销更新+新增条目;README 命令表;设计文档路线图勾选。 +- [ ] **T11 PR+CI+合入**:push → PR(标题带 2026.07.24.1) → CI 绿 → squash 合入。 +- [ ] **T12 发布+生态验证**:release.yml(version=2026.07.24.1) → 产物→ xlings-res/d2x 双源镜像+sha256 → xim-pkgindex d2x.lua bump PR → 合并;`xlings install d2x@2026.07.24.1`;本地 d2mcpp 联动(checker 闯关+e2e all)复验;结果回记。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e30fd3..a182dcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: env: XLINGS_VERSION: 0.4.51 - MCPP_VERSION: 0.0.52 + MCPP_VERSION: 0.0.104 XLINGS_NON_INTERACTIVE: '1' jobs: @@ -44,6 +44,13 @@ jobs: chmod +x "$binary" "$binary" --version + # 自有 e2e:fake-provider 驱动,零 mcpp/课程依赖——协议容错/推进/ + # 活性超时/单实例锁/flush 五组(tests/e2e.sh)。 + - name: Protocol conformance e2e (fake provider) + run: | + D2X="$PWD/$(find target -name d2x -type f | head -1)" + D2X="$D2X" bash tests/e2e.sh + build-macos: name: build (${{ matrix.os }}, mcpp) runs-on: ${{ matrix.os }} @@ -121,23 +128,29 @@ jobs: xlings install -y mcpp build - # Use a real xmake (not the xlings `xmake` shim): d2mcpp's .xlings.json - # pins xmake 3.0.7, which is gone from the registry, so the shim would - # fail to resolve. A standalone xmake ignores .xlings.json. Put it ahead - # of the xlings bin on PATH. - - name: Install xmake - run: | - curl -fsSL https://xmake.io/shget.text | bash - source ~/.xmake/profile 2>/dev/null || true - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - "$HOME/.local/bin/xmake" --version - + # d2mcpp 已迁移「练习即测试」(mcpp Provider);需要 mcpp >= 0.0.104。 + # TODO: d2mcpp PR #84 合入 main 后把 ref 切回默认分支。 - name: Checkout d2mcpp course uses: actions/checkout@v4 with: repository: mcpp-community/d2mcpp + ref: feat/mcpp-provider path: d2mcpp + # 按课程自己的 .xlings.json 安装 mcpp——xlings 的 workspace-pin shim + # 解析只认经该路径安装的版本(全局 `xlings install mcpp@X` 不满足, + # CI 实测 "version not found";已知 xlings 侧待改进项)。 + - name: Install course toolchain (per d2mcpp .xlings.json) + run: cd d2mcpp && xlings install -y + + # 预热 + 可见诊断:不带 -q 构建 Provider——冷启动的工具链下载/构建 + # 全程可见;任何失败在这里直接暴露,而不是被 checker 的协议流吞掉。 + - name: Warm course provider (visible diagnostics) + run: | + cd d2mcpp + mcpp build -p d2x/buildtools + mcpp run -q -p d2x/buildtools -- describe + - name: Run d2x checker (must reach first exercise, not hang on load) run: | D2X="$PWD/$(find target -name d2x -type f | head -1)" @@ -145,7 +158,6 @@ jobs: cd d2mcpp # Force the print UI so output is plain text in a non-TTY runner. sed -i 's/"ui_backend": *"tui"/"ui_backend": "print"/' .d2x.json || true - xmake f -y set +e timeout 120 "$D2X" checker --ui print --lang en > checker.out 2>&1 code=$? diff --git a/.xlings.json b/.xlings.json index e88cf7b..2689ffa 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": { "linux": "0.0.52", "macosx": "0.0.52", "windows": "0.0.52" } + "mcpp": { "linux": "0.0.104", "macosx": "0.0.104", "windows": "0.0.104" } } } diff --git a/mcpp.toml b/mcpp.toml index b3adb9e..51fa7a5 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "d2x" -version = "0.1.5" +version = "2026.07.24.1" description = "AI-powered development assistant for C++ projects" license = "Apache-2.0" repo = "https://github.com/d2learn/d2x" @@ -9,6 +9,12 @@ repo = "https://github.com/d2learn/d2x" kind = "bin" main = "src/main.cpp" +[workspace] +members = ["protocol"] + +[dependencies] +d2x-protocol = { path = "protocol" } + [dependencies.compat] ftxui = "6.1.9" diff --git a/protocol/mcpp.toml b/protocol/mcpp.toml new file mode 100644 index 0000000..f39d39c --- /dev/null +++ b/protocol/mcpp.toml @@ -0,0 +1,11 @@ +[package] +name = "d2x-protocol" +version = "2026.07.24.1" +description = "d2x Provider/Frontend 协议层:类型、编解码、传输(参考实现;规范性载体是协议文档,跨语言合规判据是 conformance 套件)" +license = "Apache-2.0" + +[build] +include_dirs = ["src/json"] + +[targets.d2x-protocol] +kind = "lib" diff --git a/protocol/src/codec.cppm b/protocol/src/codec.cppm new file mode 100644 index 0000000..6a5d847 --- /dev/null +++ b/protocol/src/codec.cppm @@ -0,0 +1,65 @@ +// d2x.protocol.codec — 双向协议的 NDJSON 编解码。 +// +// 解码端(d2x core 消费):每行一个 JSON 对象,解析不了的行忽略——这不是 +// 宽容,是协议设计的一部分:Provider 常经由启动器(如 `mcpp run`)间接执行, +// 启动器会往 stdout 混入空行乃至编译输出,忽略非 JSON 行让噪声天然失效。 +// +// 编码端(课程侧 Provider 可选复用):下行事件的标准序列化。d2x 的 parse 端 +// 与课程侧的 emit 端此前是两份手写实现,靠文档同步——双真相源。接入本模块 +// 后归一;不接入的 Provider(任意语言)以协议文档 + conformance 套件为准。 +export module d2x.protocol.codec; + +import std; +import d2x.json; +import d2x.protocol.types; + +namespace d2x::protocol::codec { + +// ── 解码 ──────────────────────────────────────────────────────────── +export std::optional parse_event(std::string_view line) { + auto begin = line.find('{'); + if (begin == std::string_view::npos) return std::nullopt; + auto candidate = line.substr(begin); + auto parsed = nlohmann::json::parse(candidate, nullptr, /*allow_exceptions=*/false); + if (parsed.is_discarded() || !parsed.is_object()) return std::nullopt; + return parsed; +} + +// ── 编码(下行事件,一行一个,调用方负责逐行输出并 flush)─────────────── +export std::string describe_event(std::string_view name, int protocol_version) { + nlohmann::json ev{{"event", "describe"}, {"protocol", protocol_version}, + {"name", name}}; + return ev.dump(); +} + +export std::string exercise_event(const Exercise& ex) { + nlohmann::json ev{{"event", "exercise"}, {"id", ex.id}, {"order", ex.order}, + {"title", ex.title}, {"chapter", ex.chapter}, + {"files", ex.files}}; + return ev.dump(); +} + +export std::string stage_event(std::string_view name) { + return nlohmann::json{{"event", "stage"}, {"name", name}}.dump(); +} + +export std::string output_event(std::string_view chunk) { + return nlohmann::json{{"event", "output"}, {"chunk", chunk}}.dump(); +} + +export std::string error_event(std::string_view message) { + return nlohmann::json{{"event", "error"}, {"message", message}}.dump(); +} + +export std::string verdict_event(const Verdict& v, int exit_code) { + nlohmann::json diags = nlohmann::json::array(); + for (const auto& d : v.diagnostics) { + diags.push_back({{"file", d.file}, {"line", d.line}, {"col", d.col}, + {"severity", d.severity}, {"message", d.message}}); + } + return nlohmann::json{{"event", "verdict"}, {"outcome", to_string(v.outcome)}, + {"stage", v.stage}, {"exit_code", exit_code}, + {"diagnostics", diags}}.dump(); +} + +} // namespace d2x::protocol::codec diff --git a/src/json.cppm b/protocol/src/json.cppm similarity index 100% rename from src/json.cppm rename to protocol/src/json.cppm diff --git a/src/json/LICENSE b/protocol/src/json/LICENSE similarity index 100% rename from src/json/LICENSE rename to protocol/src/json/LICENSE diff --git a/src/json/json.hpp b/protocol/src/json/json.hpp similarity index 100% rename from src/json/json.hpp rename to protocol/src/json/json.hpp diff --git a/protocol/src/process.cppm b/protocol/src/process.cppm new file mode 100644 index 0000000..629c157 --- /dev/null +++ b/protocol/src/process.cppm @@ -0,0 +1,158 @@ +// d2x.protocol.process — 协议传输的进程运行原语。 +// +// 协议层自带最小的「逐行读子进程输出」能力,不依赖 d2x core 的 platform +// 模块——依赖方向必须是 core → protocol,不能反过来。 +module; + +#include +#include +#ifndef _WIN32 +# include +# include +# include +# include +# include +#endif + +export module d2x.protocol.process; + +import std; + +namespace d2x::protocol::process { + +// 运行结果:exit_code 为平台归一化退出码(信号终止 → 128+sig); +// idle_killed 表示因活性超时被终止。 +export struct RunStatus { + int exit_code = 0; + bool idle_killed = false; +}; + +#ifndef _WIN32 +int normalize(int status) { + if (status == -1) return 127; + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); + return status; +} +#endif + +// 逐行运行(无超时)。stderr 并入 stdout;行尾 \n/\r\n 归一剥除; +// 结尾不带换行的最后一行同样回调。 +// +// spawn 前清空 LD_LIBRARY_PATH:Provider 链路曾因继承的私有 glibc loader +// 路径在嵌套进程里段错误(mcpp >= 0.0.104 已在其侧根治,这里保留是因为 +// 该保护对任意 Provider 启动器成立,不只 mcpp)。 +export RunStatus run_lines(const std::string& cmd, + const std::function& on_line) { +#ifndef _WIN32 + ::setenv("LD_LIBRARY_PATH", "", 1); +#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, false}; + + std::string line; + std::array buffer{}; + while (std::fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + line += buffer.data(); + if (line.ends_with('\n')) { + line.pop_back(); + if (line.ends_with('\r')) line.pop_back(); + on_line(line); + line.clear(); + } + } + if (!line.empty()) on_line(line); + +#ifdef _WIN32 + int status = ::_pclose(pipe); + return {status == -1 ? 127 : status, false}; +#else + return {normalize(::pclose(pipe)), false}; +#endif +} + +// 逐行运行 + 活性超时:自上次收到任何输出起超过 idle 时长即判定挂死, +// SIGKILL 进程组并返回 idle_killed=true。固定总时长会误杀 Provider 的 +// 冷启动构建(可达分钟级),活性模型只要求「持续有产出」。 +// +// Windows:暂无安全的按句柄终止路径,回退为无超时运行(与 mcpp 的 +// --timeout 同样的 documented best-effort 语义)。 +export RunStatus run_lines_idle(const std::string& cmd, + std::chrono::milliseconds idle, + const std::function& on_line) { + if (idle.count() <= 0) return run_lines(cmd, on_line); +#ifdef _WIN32 + return run_lines(cmd, on_line); +#else + ::setenv("LD_LIBRARY_PATH", "", 1); + // popen 无法拿到 pid,这里手工 fork + exec sh -c,子进程自成进程组, + // 超时可以杀掉整组(Provider 往往还有孙进程,只杀直接子进程会留孤儿)。 + int fds[2]; + if (::pipe(fds) != 0) return {127, false}; + + pid_t pid = ::fork(); + if (pid < 0) { ::close(fds[0]); ::close(fds[1]); return {127, false}; } + if (pid == 0) { + ::setpgid(0, 0); + ::dup2(fds[1], 1); + ::dup2(fds[1], 2); + ::close(fds[0]); + ::close(fds[1]); + ::execl("/bin/sh", "sh", "-c", cmd.c_str(), static_cast(nullptr)); + ::_exit(127); + } + ::close(fds[1]); + ::fcntl(fds[0], F_SETFL, ::fcntl(fds[0], F_GETFL) | O_NONBLOCK); + + std::string line; + std::array buffer{}; + auto last_output = std::chrono::steady_clock::now(); + bool killed = false; + + auto drain = [&]() -> bool { // 返回是否读到了任何数据 + bool got = false; + for (;;) { + ssize_t n = ::read(fds[0], buffer.data(), buffer.size()); + if (n <= 0) break; + got = true; + for (ssize_t i = 0; i < n; ++i) { + char c = buffer[static_cast(i)]; + if (c == '\n') { + if (line.ends_with('\r')) line.pop_back(); + on_line(line); + line.clear(); + } else { + line += c; + } + } + } + return got; + }; + + int status = 0; + for (;;) { + struct pollfd pfd{fds[0], POLLIN, 0}; + ::poll(&pfd, 1, 200); + if (drain()) last_output = std::chrono::steady_clock::now(); + + pid_t r = ::waitpid(pid, &status, WNOHANG); + if (r == pid) { + drain(); + if (!line.empty()) on_line(line); + ::close(fds[0]); + return {normalize(status), killed}; + } + if (!killed && std::chrono::steady_clock::now() - last_output > idle) { + ::kill(-pid, SIGKILL); // 整个进程组 + killed = true; + } + } +#endif +} + +} // namespace d2x::protocol::process diff --git a/protocol/src/transport.cppm b/protocol/src/transport.cppm new file mode 100644 index 0000000..e8405c0 --- /dev/null +++ b/protocol/src/transport.cppm @@ -0,0 +1,216 @@ +// d2x.protocol.transport — Provider 传输:以子进程形式驱动课程 Provider。 +// +// d2x 唯一的向下扩展点。只有三个动词(describe / exercises / check),刻意 +// 没有 build/run/test——那是编译型语言的形状,焊进通用框架就焊死了适用范围。 +// +// 活性超时住在这里:自上次输出起超过 idle 时长即判定 Provider 挂死并终止, +// verdict 缺失走「无 verdict = fail + 原样呈现已收输出」的既有路径。 +export module d2x.protocol.transport; + +import std; +import d2x.json; +import d2x.protocol.types; +import d2x.protocol.codec; +import d2x.protocol.process; + +namespace d2x::protocol { + +// 一次 check 过程中的实时进度。宿主把它转发给前端,让学习者在编译时 +// 就能看到输出,而不是等到全部结束才黑屏变白屏。 +export struct Progress { + std::function on_stage; + std::function on_output; +}; + +export class IExerciseProvider { +public: + virtual ~IExerciseProvider() = default; + + // 自我描述。失败即致命——没有 Provider 就没有课程。 + virtual bool describe(std::string& name_out) = 0; + + virtual std::vector exercises() = 0; + + virtual Verdict check(const Exercise& ex, const Progress& progress) = 0; +}; + +// 练习 id 来自课程仓库的文件名,最终会拼进一条 shell 命令。带空格、引号 +// 或 `;` 的文件名会让命令断开甚至注入。这里做单引号包裹(POSIX 语义: +// 单引号内除自身外一切字面化),内部单引号用 '\'' 转义。 +// +// Windows 的 cmd.exe 不认单引号;那里用双引号包裹,且拒绝含引号的 id—— +// 这类 id 本就不该出现在课程里。 +export std::string shell_quote(std::string_view s) { +#ifdef _WIN32 + if (s.find('"') != std::string_view::npos) return {}; // 调用方视作非法 + return std::format("\"{}\"", s); +#else + std::string out = "'"; + for (char c : s) { + if (c == '\'') out += "'\\''"; + else out += c; + } + out += "'"; + return out; +#endif +} + +export struct TransportOptions { + // 活性超时:自上次输出起的最大静默时长;<=0 关闭。 + std::chrono::milliseconds idle_timeout{std::chrono::seconds(120)}; + // 协议层不依赖宿主日志——异常路径经回调上报(跳过的练习、超时终止等)。 + std::function on_warning; +}; + +// 以子进程形式驱动 Provider。命令来自宿主配置,参数走 argv。 +export class ProcessProvider final : public IExerciseProvider { + std::string mCommand; + TransportOptions mOpts; + + void warn(std::string msg) const { + if (mOpts.on_warning) mOpts.on_warning(std::move(msg)); + } + + // 单次调用;逐行回调。返回运行状态(退出码 + 是否被活性超时终止)。 + process::RunStatus invoke(std::string_view args, + const std::function& on_event) const { + auto status = process::run_lines_idle( + std::format("{} {}", mCommand, args), mOpts.idle_timeout, + [&](std::string_view line) { + // 非 JSON 行静默丢弃:启动器噪声是预期内的常态,不是异常。 + if (auto ev = codec::parse_event(line)) on_event(*ev); + }); + if (status.idle_killed) { + warn(std::format( + "provider idle-timeout: no output for {}s while running '{} {}' — killed", + std::chrono::duration_cast(mOpts.idle_timeout).count(), + mCommand, args)); + } + return status; + } + +public: + explicit ProcessProvider(std::string command, TransportOptions opts = {}) + : mCommand(std::move(command)), mOpts(std::move(opts)) {} + + bool describe(std::string& name_out) override { + bool seen = false; + auto status = invoke("describe", [&](const nlohmann::json& ev) { + if (ev.value("event", "") != "describe") return; + name_out = ev.value("name", "unknown"); + seen = true; + }); + return seen && status.exit_code == 0; + } + + std::vector exercises() override { + std::vector found; + invoke("exercises", [&](const nlohmann::json& ev) { + if (ev.value("event", "") != "exercise") return; + + Exercise ex; + ex.id = ev.value("id", ""); + ex.order = ev.value("order", 0); + ex.title = ev.value("title", ex.id); + ex.chapter = ev.value("chapter", ""); + if (ev.contains("files") && ev["files"].is_array()) { + for (const auto& f : ev["files"]) { + if (f.is_string()) ex.files.push_back(f.get()); + } + } + + // 没有 id 的练习无法持久化状态;没有文件的练习会让「打开编辑器」 + // 和「监听变更」都失去目标。 + if (ex.id.empty()) { + warn("provider returned an exercise without id, skipped"); + return; + } + if (ex.files.empty()) { + warn(std::format("exercise '{}' has no files, skipped", ex.id)); + return; + } + found.push_back(std::move(ex)); + }); + + // 比较器而非 ranges 投影:clang 20(MSVC 目标)对跨模块类型的 + // ranges 投影在 PCM 代码生成阶段 ICE(Windows CI 实测)。 + std::sort(found.begin(), found.end(), + [](const Exercise& a, const Exercise& b) { return a.order < b.order; }); + return found; + } + + Verdict check(const Exercise& ex, const Progress& progress) override { + Verdict verdict; + bool got_verdict = false; + std::string collected; + + auto quoted = shell_quote(ex.id); + if (quoted.empty()) { + verdict.outcome = Outcome::Fail; + verdict.output = std::format( + "exercise id '{}' contains characters that cannot be passed safely to the provider", + ex.id); + return verdict; + } + + auto status = invoke(std::format("check {}", quoted), + [&](const nlohmann::json& ev) { + auto kind = ev.value("event", ""); + + if (kind == "stage") { + auto name = ev.value("name", ""); + verdict.stage = name; + if (progress.on_stage) progress.on_stage(name); + + } else if (kind == "output") { + auto chunk = ev.value("chunk", ""); + collected += chunk; + if (progress.on_output) progress.on_output(chunk); + + } else if (kind == "verdict") { + if (auto o = outcome_from(ev.value("outcome", ""))) { + verdict.outcome = *o; + got_verdict = true; + } + verdict.stage = ev.value("stage", verdict.stage); + if (ev.contains("diagnostics") && ev["diagnostics"].is_array()) { + for (const auto& d : ev["diagnostics"]) { + verdict.diagnostics.push_back(Diagnostic{ + .file = d.value("file", ""), + .line = d.value("line", 0), + .col = d.value("col", 0), + .severity = d.value("severity", "error"), + .message = d.value("message", ""), + }); + } + } + + } else if (kind == "error") { + collected += ev.value("message", ""); + collected += '\n'; + } + }); + + verdict.output = std::move(collected); + + // 没收到 verdict 事件 = Provider 中途死了、输出被截断、或被活性 + // 超时终止。绝不能当成通过——原始输出原样交给学习者,让他看见真相。 + if (!got_verdict) { + verdict.outcome = Outcome::Fail; + if (status.idle_killed) { + verdict.output += std::format( + "\n[d2x] provider produced no output for {}s and was terminated", + std::chrono::duration_cast(mOpts.idle_timeout).count()); + } else { + // 无论是否已有部分输出都要附上原因——只给「真相的前半段」 + // 会让 fail 看起来毫无来由。 + if (!verdict.output.empty()) verdict.output += '\n'; + verdict.output += std::format( + "[d2x] provider did not report a verdict for '{}'", ex.id); + } + } + return verdict; + } +}; + +} // namespace d2x::protocol diff --git a/protocol/src/types.cppm b/protocol/src/types.cppm new file mode 100644 index 0000000..3d12d18 --- /dev/null +++ b/protocol/src/types.cppm @@ -0,0 +1,58 @@ +// d2x 协议层的数据类型。纯数据,零依赖——不认识构建工具,不认识 UI。 +// 这些类型是双向协议的载荷形状,由 d2x core 与(可选地)课程侧 Provider 共用; +// 学习进度(session 状态)不在这里——协议层无进度概念。 +// +// 注意用词:这里是 Exercise(练习),不是 target。target 是构建工具的词汇, +// 让它泄漏进领域层正是旧设计的问题所在。d2x 是课程工具,它的领域对象是练习。 +export module d2x.protocol.types; + +import std; + +namespace d2x::protocol { + +export struct Exercise { + std::string id; // 稳定标识,完成状态按它持久化 + int order{}; // 显式顺序 + std::string title; + std::string chapter; + std::vector files; // 学习者编辑的文件,绝对路径 +}; + +// 三态。Blocked 表示「学习者代码已经正确,但还有一个显式路障没拆」 +// (d2mcpp 里是 D2X_WAIT 宏)——它既不是失败,也不该前进。 +// 旧实现把它塞进 build_success=false 而 status 仍为 true, +// UI 上显示成「成功但卡住」,语义是错的。 +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"; +} + +export std::optional outcome_from(std::string_view s) { + if (s == "pass") return Outcome::Pass; + if (s == "fail") return Outcome::Fail; + if (s == "blocked") return Outcome::Blocked; + return std::nullopt; +} + +export struct Diagnostic { + std::string file; + int line{}; + int col{}; + std::string severity; // "error" | "warning" | "note" + std::string message; +}; + +export struct Verdict { + Outcome outcome{Outcome::Fail}; + std::string stage; // Provider 自定义:"compile" / "run" / "lint" + std::string output; // 给学习者看的原始输出,保留 ANSI + std::vector diagnostics; +}; + +} // namespace d2x::protocol diff --git a/src/buildtools.cppm b/src/buildtools.cppm deleted file mode 100644 index 2c19d0d..0000000 --- a/src/buildtools.cppm +++ /dev/null @@ -1,113 +0,0 @@ -export module d2x.buildtools; - -import std; - -import d2x.config; -import d2x.platform; -import d2x.utils; -import d2x.log; - -namespace d2x { -/* -xxx d2x-buildtools [command] [target] - -Commands: - list List all targets - build Build specified target - run Run specified target -*/ - -class BuildTools { - std::map> targets; - std::string bin = "d2x-buildtools"; -public: - BuildTools(std::string bin) : bin(std::move(bin)) {} - ~BuildTools() {} - -public: // commands - 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& target) const { - return platform::run_command_capture(bin + " build " + target); - } - - auto run(const std::string& target) const { - return platform::run_command_capture(bin + " run " + target); - } - -public: // get/set - std::vector get_targets() const { - std::vector keys; - for (const auto& [key, _] : targets) { - keys.push_back(key); - } - return keys; - } - - std::vector get_files_for(const std::string& target) const { - if (targets.contains(target)) { - return targets.at(target); - } - return {}; - } - -public: - - void print_targets() { - if (targets.empty()) { - log::warning("No targets found."); - return; - } - for (const auto& [target, files] : targets) { - std::println("Target: {}", target); - for (const auto& file : files) { - std::println(" - {}", file); - } - } - } - -public: - void load_targets() { - auto [exit_code, output] = list(); - // Parse output to populate targets map - // { "target1": ["src/main.cpp", "src/util.cpp"], "target2": ["src/app.cpp"] - // output format assumed to be: - // target: src/main.cpp, src/util.cpp - if (exit_code != 0) { - std::println("Failed to load targets with exit code: {}", exit_code); - return; - } - - //std::println("Buildtools output:\n{}", output); - - auto lines = d2x::utils::split_string(output, '\n'); - for (const auto& line : lines) { - auto parts = d2x::utils::split_string(line, '@'); - if (parts.size() != 2) continue; - auto target_name = d2x::utils::trim_string(parts[0]); - auto files_str = d2x::utils::trim_string(parts[1]); - auto file_list = d2x::utils::split_string(files_str, ','); - for (const auto& file : file_list) { - targets[target_name].push_back(d2x::utils::trim_string(file)); - } - } - } -}; // class BuildTools - -export BuildTools load_buildtools() { - std::string bin = Config::buildtools(); - if (bin.empty()) { - bin = "xmake d2x-buildtools"; - } - BuildTools bt(bin); - bt.init(); - return bt; -} - -} // namespace d2x diff --git a/src/checker.cppm b/src/checker.cppm index d4e03c4..4d2caf1 100644 --- a/src/checker.cppm +++ b/src/checker.cppm @@ -1,3 +1,10 @@ +// 学习循环的编排。 +// +// 这里只做编排:从 Provider 拿练习、驱动 Session 推进、往 EventSink 发事件、 +// 失败时等文件变更再重试。 +// +// 它不知道怎么编译、不知道怎么判定通过(那是 Provider 的事), +// 也不知道页面长什么样(那是 EventSink 背后的前端的事)。 export module d2x.checker; import std; @@ -5,129 +12,228 @@ import std; import d2x.log; import d2x.utils; import d2x.ui; -import d2x.buildtools; +import d2x.config; +import d2x.domain; +import d2x.provider; +import d2x.session; +import d2x.instance_lock; +import d2x.emit; import d2x.assistant; import d2x.editor; +import d2x.platform; +import d2x.watch; namespace d2x { namespace checker { -// detect flag -constexpr std::string D2X_WAIT = "D2X_WAIT"; +using domain::Outcome; -std::pair build_with_error_handling(const auto& btools, const std::string &target) { - auto [exit_code, output] = btools.build(target); - return std::make_pair(exit_code == 0, output); +// 等待学员编辑时的单次轮询窗口(毫秒)。 +// +// 注意这不是「重试间隔」:窗口到期后只是再等一轮,绝不会重新构建。 +// 检查必须由文件变更驱动 —— 早先的实现在窗口到期后无条件重跑一次, +// 结果 TUI 每 20 秒自己刷一屏、白白重编一遍,学员什么都没做却看到 +// 界面在动,误以为是自己触发的。 +constexpr int kWaitWindowMs = 20 * 1000; + +std::filesystem::path state_path() { + return std::filesystem::path(platform::get_rundir()) / ".d2x" / "state.json"; } -std::pair run_with_error_handling(const auto& btools, const std::string &target) { - auto [exit_code, output] = btools.run(target); - return std::make_pair(exit_code == 0, output); +// 读取学员正在编辑的源码。练习可能挂多个文件,AI 助手看第一个即可 —— +// 但读不到不该让整个会话崩掉(旧实现在这里无保护地抛异常)。 +std::string read_source(const std::vector& files) { + if (files.empty()) return {}; + try { + return utils::read_file_to_string(files.front()); + } catch (const std::exception& e) { + log::warning("读取练习文件失败: {}", e.what()); + return {}; + } } -export void run(const std::string& start_target = "") { +// 只读进度总览:Provider exercises + state.json,不构建、不监听、亚秒返回。 +// --emit-events 时输出 machine-readable JSON 一行。 +export void status(bool emit_events = false) { + if (emit_events) log::to_stderr(true); - auto btools = d2x::load_buildtools(); + auto command = Config::buildtools(); + if (command.empty()) { + log::error("未配置 buildtools —— 请在 .d2x.json 里指定 Provider 命令"); + return; + } + auto provider = provider::ProcessProvider(command, provider::TransportOptions{ + .idle_timeout = std::chrono::seconds(Config::provider_idle_timeout()), + .on_warning = [](std::string msg) { log::warning("{}", msg); }, + }); + auto exercises = provider.exercises(); + if (exercises.empty()) { + log::error("Provider 没有返回任何练习"); + return; + } + auto state = session::StateStore(state_path()); + + // 按章节聚合 + struct Chap { int total{}; int done{}; }; + std::vector> chapters; + int done_total = 0; + for (const auto& ex : exercises) { + auto it = chapters.begin(); + for (; it != chapters.end(); ++it) + if (it->first == ex.chapter) break; + if (it == chapters.end()) { + chapters.emplace_back(ex.chapter, Chap{}); + it = std::prev(chapters.end()); + } + it->second.total++; + if (state.is_completed(ex.id)) { it->second.done++; done_total++; } + } - btools.load_targets(); + if (emit_events) { + std::string chaps; + for (const auto& [name, c] : chapters) { + if (!chaps.empty()) chaps += ","; + chaps += std::format(R"({{"chapter":"{}","completed":{},"total":{}}})", + name, c.done, c.total); + } + std::println(R"({{"event":"status","completed":{},"total":{},"current":"{}","chapters":[{}]}})", + done_total, exercises.size(), state.current(), chaps); + return; + } - auto targets = btools.get_targets(); + std::println("Progress: {}/{}{}", done_total, exercises.size(), + state.current().empty() ? "" : std::format(" current: {}", state.current())); + std::println(""); + for (const auto& [name, c] : chapters) { + std::println(" {} {}/{} {}", + c.done == c.total ? "✅" : (c.done > 0 ? "🔶" : "⬜"), + c.done, c.total, name); + } +} + +export void run(const std::string& start_target = "", bool emit_events = false) { - int total_targets = targets.size(); - int built_targets = 0; + // 先改道再做任何事:早退路径上的错误日志同样不能落进协议流。 + // (曾经这行在 buildtools 检查之后,于是配置缺失时两行报错直接 + // 打进了 stdout,外部客户端拿到的第一样东西就是非 JSON。) + if (emit_events) log::to_stderr(true); - if (total_targets == 0) { - log::warning("No targets found for checking."); + // 单实例锁:两个监听循环互踩的破坏是静默的(见 instance_lock 模块头注)。 + auto lock_path = std::filesystem::path(platform::get_rundir()) / ".d2x" / "checker.lock"; + if (!instance_lock::acquire(lock_path, [&](long pid) { + log::error("另一个 d2x checker (pid {}) 正在此仓库运行;如确认其已退出,删除 {} 后重试", + pid, lock_path.string()); + })) { return; } + struct LockGuard { ~LockGuard() { instance_lock::release(); } } lock_guard; - // 如果指定了起始target,找到第一个匹配的位置 - std::size_t start_idx = 0; - if (!start_target.empty()) { - bool found = false; - for (std::size_t i = 0; i < targets.size(); ++i) { - if (targets[i].find(start_target) != std::string::npos) { - start_idx = i; - found = true; - log::info("Starting from target: {}", targets[i]); - break; - } - built_targets += 1; // skip targets before the matched one - } - if (!found) { - built_targets = 0; // reset if not found - log::warning("Target '{}' not found. Starting from beginning.", start_target); - } + auto command = Config::buildtools(); + if (command.empty()) { + log::error("未配置 buildtools —— 请在 .d2x.json 里指定 Provider 命令"); + log::error("例如: \"buildtools\": \"mcpp run -q -p d2x/buildtools/mcpp --\""); + return; } - auto assistant = d2x::Assistant(); - - for (std::size_t idx = start_idx; idx < targets.size(); ++idx) { - const auto& target = targets[idx]; - //log::info("Checking target: {}", target); - - bool build_success { false }; - bool status { false }; - auto output = std::string { }; - bool open_target_file { false }; - - auto files = btools.get_files_for(target); - // read original code from files[0] - auto original_code = utils::read_file_to_string(files[0]); + // 内置前端走内存通道,外部客户端走管道,二者消费同一套事件类型。 + std::unique_ptr sink; + if (emit_events) sink = std::make_unique(); + else sink = std::make_unique(); + + // 活性超时与告警回调经此接入协议层——core 负责配置与日志,机制在 transport。 + auto provider = provider::ProcessProvider(command, provider::TransportOptions{ + .idle_timeout = std::chrono::seconds(Config::provider_idle_timeout()), + .on_warning = [](std::string msg) { log::warning("{}", msg); }, + }); + + // Provider 起不来是致命错误,且必须说清楚是「Provider 挂了」而不是 + // 「没有练习」。旧实现先打 "Failed to load targets with exit code" + // 再打 "No targets found for checking.",两层都在误导学员。 + std::string provider_name; + if (!provider.describe(provider_name)) { + log::error("Provider 无响应: {}", command); + log::error("请确认该命令可执行,且实现了 d2x Provider 协议(describe/exercises/check)"); + return; + } + log::info("Provider: {}", provider_name); - assistant.set_original_code(original_code); + auto exercises = provider.exercises(); + if (exercises.empty()) { + log::warning("Provider '{}' 没有返回任何练习", provider_name); + return; + } - while (!build_success) { + auto state = session::StateStore(state_path()); + auto session = session::Session(exercises, &state); + session.seek_start(start_target); + session.enter_current(); - std::tie(build_success, output) = build_with_error_handling(btools, target); + auto assistant = d2x::Assistant(); - if (build_success) { - std::tie(build_success, output) = run_with_error_handling(btools, target); + while (!session.done()) { + const auto& exercise = session.current(); + + sink->session(static_cast(session.total()), + static_cast(session.completed_count()), + exercise.id); + sink->exercise(exercise); + + bool opened_editor = false; + auto watcher = watch::FileWatcher(exercise.files); + + for (;;) { + // 每次重试都重读源码:学员刚改过,AI 助手要看到最新版本 + auto source = read_source(exercise.files); + assistant.set_original_code(source); + + // 边跑边发。旧实现读到 EOF 才刷新,编译期间全程黑屏。 + std::size_t streamed = 0; + auto progress = provider::Progress{ + .on_stage = [&](std::string_view s) { sink->stage(s); }, + .on_output = [&](std::string_view c) { streamed += c.size(); sink->output(c); }, + }; + + auto verdict = provider.check(exercise, progress); + // verdict.output 里可能有传输层事后补充的说明(缺 verdict 的原因、 + // 活性超时的终止说明)——它们没走过流式回调,不补发就永远到不了 + // 前端/协议消费者。 + if (verdict.output.size() > streamed) { + sink->output(std::string_view(verdict.output).substr(streamed)); } + sink->verdict(verdict); - status = build_success; + if (verdict.outcome == Outcome::Pass) break; - if (!output.empty()) { - if (output.find("❌") != std::string::npos) { - status = false; - build_success = false; - } else if (output.find(D2X_WAIT) != std::string::npos) { - build_success = false; - } + // Blocked 与 Fail 都不推进、都等学员改文件;区别只在前端怎么呈现, + // 而那已经由 verdict 事件里的 outcome 表达了。 + // --emit-events 模式下不碰编辑器:外部前端已经从事件流里拿到 + // 了 exercise 和 verdict,开不开、怎么开是它的决定。 + if (!opened_editor && !emit_events) { + for (const auto& file : exercise.files) editor::open(file); + opened_editor = true; } - if (build_success) { - built_targets += 1; - } else if (!open_target_file) { - // Open file in editor on first failure - for (const auto& file : files) { - editor::open(file); - } - open_target_file = true; - } + sink->hint(assistant.ask(source, verdict.output)); + sink->waiting("file-change"); + + // 构建刚跑完,先把当前文件状态当作基线 —— 否则构建期间的任何 + // 落盘都会被当成学员的编辑(自触发保护)。去抖在 FileWatcher + // 内部完成,调用方不再需要连着调两次。 + watcher.resync(); - // ask ai assistant for tips - // ask ai assistant for tips - auto ecode = utils::read_file_to_string(files[0]); - //auto ai_tips = assistant.ask(ecode, output); - auto ai_tips = assistant.ask(ecode, output); - - ui::update_checker_page( - target, files, - built_targets, total_targets, - output, status, - ai_tips - ); - - if (!build_success) { - utils::wait_files_changed(files, 20 * 1000); - // wait user action to change files to avoid shaking - while (utils::wait_files_changed(files, 1 * 1000)); + // 一直等到文件真的变了才重跑。学员没动手时界面必须是静止的。 + while (!watcher.wait_for_change(std::chrono::milliseconds{kWaitWindowMs})) { + // 窗口到期只是继续等,不重新构建、不刷新界面 } } + + session.complete_current(); } - log::info("Checker finished."); + sink->session(static_cast(session.total()), + static_cast(session.completed_count()), ""); + sink->done(); + log::info("全部练习已完成 🎉"); } } // namespace checker diff --git a/src/cmdprocessor.cppm b/src/cmdprocessor.cppm index ee8fbc9..bf2c10b 100644 --- a/src/cmdprocessor.cppm +++ b/src/cmdprocessor.cppm @@ -36,7 +36,7 @@ void new_project(const cmdline::ParsedArgs& args) { return; } - xlings::ensure_xlings_installed(); + if (!xlings::require_xlings()) return; std::string cmd = "xlings install d2x:project-template -y"; std::println("加载项目模板..."); @@ -72,6 +72,9 @@ export int run(int argc, char* argv[]) { .option("llm-prompt").takes_value().global().help("set LLM system prompt") .option("llm-api-key").takes_value().global().help("set LLM API key") .option("llm-api-url").takes_value().global().help("set LLM API URL") + // 让外部前端(VSCode 插件 / Web / CI)直接消费上行事件流, + // 不必链接 d2x,也不必解析 TUI 的转义序列。 + .option("emit-events").global().help("emit the frontend protocol as NDJSON on stdout") .subcommand("new") .description("create new d2x project from template") .arg("project-name").help("project name") @@ -101,17 +104,26 @@ export int run(int argc, char* argv[]) { } std::println("Opening book: {}", bookdir.string()); if (std::filesystem::exists(bookdir)) { - platform::run_command_capture("xlings install mdbook -y"); + if (!xlings::require_xlings()) return; + // 透传而非捕获:mdbook 下载可达几十秒,使用者应看到 + // xlings 自己的进度,而不是面对静止的终端(D4/S10)。 + platform::exec("xlings install mdbook -y"); platform::exec(("mdbook serve --open " + bookdir.string()).c_str()); } else std::println("Error: No book found"); }) .subcommand("checker") .description("run checker for d2x project's exercises") - .arg("target").help("target name") + .arg("target").help("exercise name (substring match)") .action([](const cmdline::ParsedArgs& a) { apply_global_options(a); - checker::run(std::string(a.positional_or(0, ""))); + checker::run(std::string(a.positional_or(0, "")), a.is_flag_set("emit-events")); + }) + .subcommand("status") + .description("show exercise progress overview (read-only)") + .action([](const cmdline::ParsedArgs& a) { + apply_global_options(a); + checker::status(a.is_flag_set("emit-events")); }) .subcommand("config") .description("configure d2x (.d2x.json)") diff --git a/src/config.cppm b/src/config.cppm index 8e44234..03344b2 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -17,7 +17,7 @@ export struct EnvVars; export class Config; struct Info { - static constexpr std::string_view VERSION = "0.1.5"; + static constexpr std::string_view VERSION = "2026.07.24.1"; // 日期版本制 YYYY.MM.DD.N(见 2026-07-24 设计文档决策记录) static constexpr std::string_view REPO = "https://github.com/d2learn/d2x"; }; @@ -31,6 +31,9 @@ struct EnvVars { // BuildTools static constexpr std::string_view D2X_BUILDTOOLS = "D2X_BUILDTOOLS"; + // 打开练习文件用的编辑器命令。空字符串 = 不自动打开。 + static constexpr std::string_view D2X_EDITOR = "D2X_EDITOR"; + // LLM static constexpr std::string_view D2X_LLM_API_KEY = "D2X_LLM_API_KEY"; static constexpr std::string_view D2X_LLM_API_URL = "D2X_LLM_API_URL"; @@ -61,13 +64,19 @@ public: std::string lang; std::string ui_backend; std::string buildtools; + std::string editor; + bool editor_is_set{false}; // 区分「未配置」与「显式配成空串(=关闭)」 + int provider_idle_timeout{120}; // 秒;<=0 关闭活性超时 LLMConfig llm; // Defaults static constexpr std::string_view DEFAULT_UI_BACKEND = "tui"; static constexpr std::string_view DEFAULT_LANG = "en"; static constexpr std::string_view DEFAULT_MODEL = "deepseek-chat"; - static constexpr std::string_view DEFAULT_BUILDTOOLS = "xmake d2x-buildtools"; + // 没有通用默认值:Provider 是课程特有的,由课程仓库在 .d2x.json 里 + // 声明。旧的 "xmake d2x-buildtools" 默认值已随 xmake 退役失效 —— + // 留着会让未配置的仓库拿到一个必定失败的命令,而报错还指向 xmake。 + static constexpr std::string_view DEFAULT_BUILDTOOLS = ""; }; private: @@ -92,14 +101,36 @@ private: } } - // Fill missing values from environment variables - if (mData.lang.empty()) mData.lang = utils::get_env_or_default(EnvVars::D2X_LANG); - if (mData.ui_backend.empty()) mData.ui_backend = utils::get_env_or_default(EnvVars::D2X_UI_BACKEND); - if (mData.buildtools.empty()) mData.buildtools = utils::get_env_or_default(EnvVars::D2X_BUILDTOOLS); - if (mData.llm.api_key.empty()) mData.llm.api_key = utils::get_env_or_default(EnvVars::D2X_LLM_API_KEY); - if (mData.llm.api_url.empty()) mData.llm.api_url = utils::get_env_or_default(EnvVars::D2X_LLM_API_URL); - if (mData.llm.model.empty()) mData.llm.model = utils::get_env_or_default(EnvVars::D2X_LLM_API_MODEL, "deepseek-chat"); - if (mData.llm.system_prompt.empty()) mData.llm.system_prompt = utils::get_env_or_default(EnvVars::D2X_LLM_SYSTEM_PROMPT); + // 环境变量覆盖配置文件,而不是「只填空缺」。 + // + // 命令行参数是通过写环境变量传进来的(见 cmdprocessor 的 + // apply_global_options),所以这一步的顺序直接决定了 + // `--ui print` 能不能压过 .d2x.json 里的 "ui_backend"。 + // 原先是 `if (empty()) 才读 env`,于是配置文件反过来压住了命令行, + // --ui / --lang 这些参数看起来「没生效」。 + // + // 现在的优先级:命令行 > 环境变量 > 本地配置 > 全局配置 > 默认值。 + auto override_from_env = [](std::string& field, std::string_view name) { + if (auto v = utils::get_env_or_default(name); !v.empty()) field = v; + }; + override_from_env(mData.lang, EnvVars::D2X_LANG); + override_from_env(mData.ui_backend, EnvVars::D2X_UI_BACKEND); + override_from_env(mData.buildtools, EnvVars::D2X_BUILDTOOLS); + if (auto v = utils::get_env_or_default("D2X_PROVIDER_IDLE_TIMEOUT"); !v.empty()) { + int secs{}; + auto [_, ec] = std::from_chars(v.data(), v.data() + v.size(), secs); + if (ec == std::errc{}) mData.provider_idle_timeout = secs; + } + if (auto v = utils::get_env_or_default(EnvVars::D2X_EDITOR); !v.empty()) { + mData.editor = v; + mData.editor_is_set = true; + } + override_from_env(mData.llm.api_key, EnvVars::D2X_LLM_API_KEY); + override_from_env(mData.llm.api_url, EnvVars::D2X_LLM_API_URL); + override_from_env(mData.llm.model, EnvVars::D2X_LLM_API_MODEL); + override_from_env(mData.llm.system_prompt, EnvVars::D2X_LLM_SYSTEM_PROMPT); + + if (mData.llm.model.empty()) mData.llm.model = std::string{ConfigData::DEFAULT_MODEL}; } void load_from_file(const std::string& path) { @@ -108,6 +139,11 @@ private: mData.lang = json.value("lang", ""); mData.ui_backend = json.value("ui_backend", ""); mData.buildtools = json.value("buildtools", ""); + mData.provider_idle_timeout = json.value("provider_idle_timeout", 120); + if (json.contains("editor")) { + mData.editor = json.value("editor", ""); + mData.editor_is_set = true; + } // Load LLM config from nested "llm" object (new format) or flat keys (legacy) if (json.contains("llm") && json["llm"].is_object()) { @@ -134,6 +170,10 @@ private: if (mData.lang.empty()) mData.lang = json.value("lang", ""); if (mData.ui_backend.empty()) mData.ui_backend = json.value("ui_backend", ""); if (mData.buildtools.empty()) mData.buildtools = json.value("buildtools", ""); + if (!mData.editor_is_set && json.contains("editor")) { + mData.editor = json.value("editor", ""); + mData.editor_is_set = true; + } if (json.contains("llm") && json["llm"].is_object()) { auto& llm = json["llm"]; @@ -167,6 +207,9 @@ public: // BuildTools getter [[nodiscard]] static const std::string& buildtools() { return instance().mData.buildtools; } + [[nodiscard]] static int provider_idle_timeout() { return instance().mData.provider_idle_timeout; } + [[nodiscard]] static const std::string& editor() { return instance().mData.editor; } + [[nodiscard]] static bool editor_is_set() { return instance().mData.editor_is_set; } // LLM getters [[nodiscard]] static const std::string& api_key() { return instance().mData.llm.api_key; } diff --git a/src/domain.cppm b/src/domain.cppm new file mode 100644 index 0000000..dacd1e4 --- /dev/null +++ b/src/domain.cppm @@ -0,0 +1,17 @@ +// 兼容壳:领域类型已析出至协议层(d2x.protocol.types)——它们是双向协议的 +// 载荷形状,由 core 与课程侧共用。本模块保留 d2x::domain 命名空间,core 内 +// 既有代码零改动。学习进度(session 状态)不属于协议层,仍归 core。 +export module d2x.domain; + +export import d2x.protocol.types; + +export namespace d2x::domain { + +using d2x::protocol::Exercise; +using d2x::protocol::Outcome; +using d2x::protocol::Diagnostic; +using d2x::protocol::Verdict; +using d2x::protocol::to_string; +using d2x::protocol::outcome_from; + +} // namespace d2x::domain diff --git a/src/editor.cppm b/src/editor.cppm index b6440f0..7db646d 100644 --- a/src/editor.cppm +++ b/src/editor.cppm @@ -1,22 +1,65 @@ -// TODO: add more editor support +// 打开练习文件。 +// +// 这是「策略」而非「机制」:打开编辑器既不是结构也不是显示,而是对学员 +// 机器的副作用。d2x 作为通用框架不该替人决定用哪个编辑器,甚至不该决定 +// 要不要开 —— 原先硬编码 `code`,等于假定所有人都装了 VS Code。 +// +// 所以: +// - 命令可配置(.d2x.json 的 "editor",或 D2X_EDITOR 环境变量) +// - 未配置时按 $VISUAL → $EDITOR → code 依次回退 +// - 显式配成空字符串 = 关闭,一行都不动学员的环境 +// - --emit-events 模式下由调用方跳过:外部前端从事件流里已经拿到 +// exercise 和 verdict,开不开、怎么开是它自己的事,两边都动只会打架 +// +// 通用的 hook 机制不必单独造 —— 事件流本身就是。这里只留一个旋钮, +// 对应人们真正想调的那一件事。 export module d2x.editor; import std; import d2x.log; +import d2x.config; import d2x.platform; namespace d2x { namespace editor { +// 是否显式关闭(配置里写了空串)。未配置不算关闭,走回退链。 +export bool disabled() { + return Config::editor_is_set() && Config::editor().empty(); +} + +std::string resolve_command() { + if (const auto& configured = Config::editor(); !configured.empty()) return configured; + + for (const char* name : {"VISUAL", "EDITOR"}) { + if (const char* v = std::getenv(name); v && *v) return v; + } + return "code"; // 保持既有默认行为 +} + export void open(const std::string& file_path) { + if (disabled()) return; + + auto command_template = resolve_command(); auto absolute_path = std::filesystem::absolute(file_path).string(); - auto command = std::format("code \"{}\"", absolute_path); - auto result = platform::exec(command); - if (result != 0) { - log::warning("Failed to open file '{}' in VS Code.", file_path); + + // 支持 {file} 占位符,方便 `emacsclient -n {file}` 这类需要把路径放 + // 中间的命令;没有占位符就按惯例追加到末尾。 + std::string command; + if (auto at = command_template.find("{file}"); at != std::string::npos) { + command = command_template; + command.replace(at, std::string_view{"{file}"}.size(), + std::format("\"{}\"", absolute_path)); + } else { + command = std::format("{} \"{}\"", command_template, absolute_path); + } + + if (platform::exec(command) != 0) { + log::warning("打开编辑器失败: {}", command); + log::warning("可在 .d2x.json 设置 \"editor\"(留空则不自动打开)"); } } } // namespace editor -} // namespace d2x \ No newline at end of file +} // namespace d2x diff --git a/src/emit.cppm b/src/emit.cppm new file mode 100644 index 0000000..de7e729 --- /dev/null +++ b/src/emit.cppm @@ -0,0 +1,186 @@ +// 上行 Frontend Protocol:d2x → 前端。 +// +// 单向。d2x 保留全部控制权(通过即自动前进,失败即等文件变更),前端纯显示。 +// +// 与下行 Provider Protocol 同一种机制(NDJSON 事件流),不同词汇表。 +// stage / output / verdict 三类事件两侧同构,d2x 对它们基本是转发 + 补上 +// 会话上下文;只属于上行的是 session / exercise / waiting / hint / done。 +// 所以不是两套无关的协议,而是「下行是上行的子集」。 +// +// 内置前端走内存通道(UiSink),外部客户端走管道(StdoutSink), +// 二者消费同一套事件类型 —— `d2x checker` 对学员仍然是一条命令。 +module; + +// stdout / fflush 是 C 运行时的宏与符号,import std 不提供 +#include + +export module d2x.emit; + +import std; + +import d2x.domain; +import d2x.ui; +import d2x.ui.interface; +import d2x.json; + +namespace d2x::emit { + +using domain::Exercise; +using domain::Outcome; +using domain::Verdict; + +export class IEventSink { +public: + virtual ~IEventSink() = default; + + virtual void session(int total, int completed, std::string_view current) = 0; + virtual void exercise(const Exercise& ex) = 0; + virtual void stage(std::string_view name) = 0; + virtual void output(std::string_view chunk) = 0; + virtual void verdict(const Verdict& v) = 0; + virtual void waiting(std::string_view reason) = 0; + virtual void hint(std::string_view text) = 0; + virtual void done() = 0; +}; + +// ── 外部客户端:NDJSON over stdout ───────────────────────────────── +// +// VSCode 插件、Web 前端、CI 都只是这条流的消费者,不需要链接 d2x。 +export class StdoutSink final : public IEventSink { + static void line(const nlohmann::json& ev) { + std::println("{}", ev.dump()); + std::fflush(stdout); // 前端是逐行读的,缓冲会让实时性失效 + } + +public: + void session(int total, int completed, std::string_view current) override { + line({{"event", "session"}, {"total", total}, + {"completed", completed}, {"current", current}}); + } + + void exercise(const Exercise& ex) override { + line({{"event", "exercise"}, {"id", ex.id}, {"order", ex.order}, + {"title", ex.title}, {"chapter", ex.chapter}, {"files", ex.files}}); + } + + void stage(std::string_view name) override { + line({{"event", "stage"}, {"name", name}}); + } + + void output(std::string_view chunk) override { + line({{"event", "output"}, {"chunk", chunk}}); + } + + void verdict(const Verdict& v) override { + nlohmann::json diags = nlohmann::json::array(); + for (const auto& d : v.diagnostics) { + diags.push_back({{"file", d.file}, {"line", d.line}, {"col", d.col}, + {"severity", d.severity}, {"message", d.message}}); + } + line({{"event", "verdict"}, {"outcome", domain::to_string(v.outcome)}, + {"stage", v.stage}, {"diagnostics", diags}}); + } + + void waiting(std::string_view reason) override { + line({{"event", "waiting"}, {"reason", reason}}); + } + + void hint(std::string_view text) override { + line({{"event", "hint"}, {"text", text}}); + } + + void done() override { line({{"event", "done"}}); } +}; + +// ── 内置前端:内存通道 ───────────────────────────────────────────── +// +// 现有的 TUI/print 后端一次性接收整页状态,而协议是增量事件。 +// 这里承担二者之间的适配:累积事件,在合适的时机刷新页面。 +// +// 这段「攒状态」的逻辑原先散在 checker 的循环里,搬到这里之后编排层 +// 只管发事件,不再关心页面怎么拼。 +export class UiSink final : public IEventSink { + Exercise mExercise; + int mTotal{}; + int mCompleted{}; + std::string mStage; + std::string mOutput; + std::string mHint; + std::string mOutcome; // "" 检测中 | pass | fail | blocked + std::vector mChecks; + + static std::filesystem::path output_log_path() { + return std::filesystem::path(".d2x") / "last-output.log"; + } + + void refresh() { + ICheckerPageUI::UIState st; + st.exercise = mExercise.id; + st.chapter = mExercise.chapter; + st.files = mExercise.files; + st.completed = mCompleted; + st.total = mTotal; + st.outcome = mOutcome; + st.checks = mChecks; + st.output = mStage.empty() ? mOutput + : std::format("[{}]\n{}", mStage, mOutput); + st.output_log_path = output_log_path().string(); + st.hint = mHint; + ui::update_checker_page(st); + } + +public: + void session(int total, int completed, std::string_view current) override { + mTotal = total; + mCompleted = completed; + (void)current; + } + + void exercise(const Exercise& ex) override { + mExercise = ex; + mStage.clear(); + mOutput.clear(); + mHint.clear(); + mOutcome.clear(); + mChecks.clear(); + } + + void stage(std::string_view name) override { + mStage.assign(name); + mOutput.clear(); // 新阶段开始,上一阶段的输出已经看过了 + refresh(); + } + + void output(std::string_view chunk) override { + mOutput.append(chunk); + refresh(); + } + + void verdict(const Verdict& v) override { + mOutcome = std::string(domain::to_string(v.outcome)); + mChecks.clear(); + for (const auto& d : v.diagnostics) { + mChecks.push_back(std::format("{}:{} {}", d.file, d.line, d.message)); + } + mStage.clear(); + + // 全量输出落盘:页面可以放心截断,信息不丢。 + std::error_code ec; + std::filesystem::create_directories(output_log_path().parent_path(), ec); + std::ofstream log(output_log_path(), std::ios::trunc); + if (log) log << v.output; + + refresh(); + } + + void waiting(std::string_view) override { /* TUI 用进度条表达,无需额外动作 */ } + + void hint(std::string_view text) override { + mHint.assign(text); + refresh(); + } + + void done() override {} +}; + +} // namespace d2x::emit diff --git a/src/instance_lock.cppm b/src/instance_lock.cppm new file mode 100644 index 0000000..12de0a0 --- /dev/null +++ b/src/instance_lock.cppm @@ -0,0 +1,97 @@ +// 单实例锁:同一仓库同时只允许一个 checker。 +// +// 依据(2026-07-24 设计文档 S2):一个泄漏的 checker 实例在下游 e2e 覆盖 +// 答案时并发触发重建,与外部构建竞争产物目录,测试二进制被替换瞬间报 +// exit 127——两个监听循环互踩的破坏是静默且难归因的。 +// +// 机制:.d2x/checker.lock 写入本进程 pid。启动时若锁存在: +// pid 仍存活 → 拒绝启动(明确报出对方 pid); +// pid 已死 → 陈旧锁(上一个实例被 kill -9/断电),自动接管。 +// 正常退出走 RAII;SIGINT/SIGTERM 走信号处理器删锁后退出。 +module; + +#include +#include +#ifndef _WIN32 +# include +# include +#else +# include +#endif + +export module d2x.instance_lock; + +import std; + +namespace d2x::instance_lock { + +namespace { + // 信号处理器只能访问无锁的全局状态;路径在 acquire 时固化。 + std::filesystem::path g_lock_path; + + bool pid_alive(long pid) { +#ifndef _WIN32 + return ::kill(static_cast(pid), 0) == 0; +#else + HANDLE h = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, + static_cast(pid)); + if (!h) return false; + DWORD code = 0; + bool alive = ::GetExitCodeProcess(h, &code) && code == STILL_ACTIVE; + ::CloseHandle(h); + return alive; +#endif + } + + extern "C" void on_signal(int sig) { + std::error_code ec; + if (!g_lock_path.empty()) std::filesystem::remove(g_lock_path, ec); + std::_Exit(128 + sig); + } +} + +// 尝试获取锁。失败时经 on_conflict 报出持有者 pid,返回 false。 +export bool acquire(const std::filesystem::path& lock_path, + const std::function& on_conflict) { + std::error_code ec; + std::filesystem::create_directories(lock_path.parent_path(), ec); + + if (std::filesystem::exists(lock_path)) { + long holder = 0; + { + std::ifstream in(lock_path); + in >> holder; + } + if (holder > 0 && pid_alive(holder)) { + if (on_conflict) on_conflict(holder); + return false; + } + // 陈旧锁:持有者已死,接管。 + std::filesystem::remove(lock_path, ec); + } + + { + std::ofstream out(lock_path, std::ios::trunc); +#ifndef _WIN32 + out << ::getpid() << '\n'; +#else + out << ::GetCurrentProcessId() << '\n'; +#endif + } + g_lock_path = lock_path; + + std::signal(SIGINT, on_signal); + std::signal(SIGTERM, on_signal); + return true; +} + +// 正常退出路径的释放(信号路径由 on_signal 兜底)。 +export void release() { + std::error_code ec; + if (!g_lock_path.empty()) { + std::filesystem::remove(g_lock_path, ec); + g_lock_path.clear(); + } +} + +} // namespace d2x::instance_lock diff --git a/src/log.cppm b/src/log.cppm index b83ef59..6d34a03 100644 --- a/src/log.cppm +++ b/src/log.cppm @@ -1,8 +1,19 @@ +module; + +#include // stderr + export module d2x.log; import std; import d2x.platform; +// 日志是否改走 stderr。 +// +// --emit-events 模式下 stdout 是协议流,任何非 JSON 行都是污染。前端虽然 +// 会忽略解析不了的行,但让协议流保持纯净才是对的:外部客户端可以直接 +// 逐行 JSON.parse,不必先过滤噪声。 +bool g_log_to_stderr = false; + template void log_print(const std::string& level, const std::string& color, std::format_string fmt, Args&&... args) { auto now = std::chrono::system_clock::now(); @@ -20,12 +31,16 @@ void log_print(const std::string& level, const std::string& color, std::format_s level, message); - d2x::platform::println(log_line); + if (g_log_to_stderr) std::println(stderr, "{}", log_line); + else d2x::platform::println(log_line); } namespace d2x { export namespace log { +// 把日志改道到 stderr,让 stdout 只剩协议事件 +inline void to_stderr(bool on) { ::g_log_to_stderr = on; } + // C++23 std::println + 变参模板 template diff --git a/src/msg.cppm b/src/msg.cppm new file mode 100644 index 0000000..de661e0 --- /dev/null +++ b/src/msg.cppm @@ -0,0 +1,48 @@ +// UI 消息目录:界面文案随 lang 切换(zh/en),终结中英混杂。 +// +// 只收录面向学习者的句子;日志(log::*)与协议流不经过这里。 +// 键控查表而非散落的三元表达式——新增语言 = 加一列,不是改一片。 +export module d2x.msg; + +import std; +import d2x.config; + +namespace d2x::msg { + +export enum class Key { + StatusChecking, + StatusPass, + StatusFail, + StatusBlocked, + OutputOmitted, // {0}=省略行数 {1}=完整输出路径 + WaitingForChange, + AiDisabled, + AllDone, +}; + +struct Entry { std::string_view zh; std::string_view en; }; + +constexpr Entry table(Key k) { + switch (k) { + case Key::StatusChecking: return {"⏳ 检测中...", "⏳ checking..."}; + case Key::StatusPass: return {"✅ 通过", "✅ passed"}; + case Key::StatusFail: return {"❌ 未通过(按下方提示修复后保存即可自动重测)", + "❌ failed (fix per hints below; saving re-checks automatically)"}; + case Key::StatusBlocked: return {"🚧 检查已全部通过——删除 d2x::wait() 路障即可完成本题", + "🚧 all checks passed — remove the d2x::wait() barrier to finish"}; + case Key::OutputOmitted: return {"… 中间省略 {} 行,完整输出: {}", + "… {} lines omitted, full output: {}"}; + case Key::WaitingForChange:return {"等待文件修改…", "waiting for file changes…"}; + case Key::AiDisabled: return {"AI 助手未启用(配置 llm.api_key 后开启)", + "AI assistant disabled (set llm.api_key to enable)"}; + case Key::AllDone: return {"全部练习已完成 🎉", "all exercises completed 🎉"}; + } + return {"", ""}; +} + +export std::string_view text(Key k) { + auto e = table(k); + return Config::lang() == "zh" ? e.zh : e.en; +} + +} // namespace d2x::msg diff --git a/src/platform.cppm b/src/platform.cppm index 79c37aa..7aeae40 100644 --- a/src/platform.cppm +++ b/src/platform.cppm @@ -53,6 +53,15 @@ namespace platform { return platform_impl::run_command_capture(cmd); } + // 流式版本:每读到一行就回调,用于 Provider 的 NDJSON 事件流。 + // 同样先清 LD_LIBRARY_PATH —— 子进程可能再去 spawn 别的动态链接程序, + // 继承下去会加载错配的运行时(mcpp 私有 glibc 就会这样段错误)。 + export int run_command_lines(const std::string& cmd, + const std::function& on_line) { + set_env_variable("LD_LIBRARY_PATH", ""); + return platform_impl::run_command_lines(cmd, on_line); + } + export int exec(const std::string& cmd) { // TODO: fix return 139 issue (on linux) // workaround by clear LD_LIBRARY_PATH @@ -60,21 +69,5 @@ namespace platform { return std::system(cmd.c_str()); } - export bool xlings_install() { - std::println("正在安装 xlings..."); - int status = platform::exec(std::string(XLINGS_INSTALL_CMD)); - if (status == 0) { - std::println("xlings 安装成功!"); - std::string xlings_path { std::filesystem::path(get_xlings_bin()).parent_path().string() }; - char* path_env = std::getenv("PATH"); - if (path_env) { - std::string new_path = std::string(path_env) + ";" + xlings_path; - set_env_variable("PATH", new_path.c_str()); - } - return true; - } - std::println("xlings 安装失败"); - return false; - } } // namespace platform } // namespace d2x diff --git a/src/platform/linux.cppm b/src/platform/linux.cppm index bec2721..6c5fcf2 100644 --- a/src/platform/linux.cppm +++ b/src/platform/linux.cppm @@ -2,6 +2,9 @@ module; #include #include +#if defined(__linux__) +#include +#endif export module d2x.platform:linux; import std; @@ -31,6 +34,35 @@ namespace platform_impl { return {status, output}; } + // 流式逐行读。Provider 协议是 NDJSON 事件流,必须边读边处理: + // 学员在等编译结果,读到 EOF 才显示等于全程黑屏。 + // 返回真实退出码(pclose 给的是 wait status,exit 1 会变成 256)。 + export int run_command_lines(const std::string& cmd, + const std::function& on_line) { + std::string full = cmd + " 2>&1"; + FILE* pipe = ::popen(full.c_str(), "r"); + if (!pipe) return -1; + + std::string line; + std::array buffer{}; + while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + line += buffer.data(); + if (line.ends_with('\n')) { + line.pop_back(); + if (line.ends_with('\r')) line.pop_back(); + on_line(line); + line.clear(); + } + } + if (!line.empty()) on_line(line); // 最后一行可能没有换行符 + + int status = ::pclose(pipe); + if (status == -1) return 127; + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); + return status; + } + export void clear_console() { std::system("clear"); } diff --git a/src/platform/macos.cppm b/src/platform/macos.cppm index 371ff8c..2792045 100644 --- a/src/platform/macos.cppm +++ b/src/platform/macos.cppm @@ -1,6 +1,9 @@ module; #include +#if defined(__APPLE__) +#include +#endif #include export module d2x.platform:macos; @@ -32,6 +35,33 @@ namespace platform_impl { return {status, output}; } + // 流式逐行读,见 linux 分区的说明。 + export int run_command_lines(const std::string& cmd, + const std::function& on_line) { + std::string full = cmd + " 2>&1"; + FILE* pipe = ::popen(full.c_str(), "r"); + if (!pipe) return -1; + + std::string line; + std::array buffer{}; + while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + line += buffer.data(); + if (line.ends_with('\n')) { + line.pop_back(); + if (line.ends_with('\r')) line.pop_back(); + on_line(line); + line.clear(); + } + } + if (!line.empty()) on_line(line); + + int status = ::pclose(pipe); + if (status == -1) return 127; + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); + return status; + } + export void clear_console() { std::system("clear"); } diff --git a/src/platform/windows.cppm b/src/platform/windows.cppm index e17f124..55a7668 100644 --- a/src/platform/windows.cppm +++ b/src/platform/windows.cppm @@ -29,6 +29,29 @@ namespace platform_impl { return {code, output}; } + // 流式逐行读,见 linux 分区的说明。_pclose 直接给退出码,无需 wait status 解码。 + export int run_command_lines(const std::string& cmd, + const std::function& on_line) { + std::string full = cmd + " 2>&1"; + FILE* pipe = _popen(full.c_str(), "r"); + if (!pipe) return -1; + + std::string line; + std::array buffer{}; + while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + line += buffer.data(); + if (line.ends_with('\n')) { + line.pop_back(); + if (line.ends_with('\r')) line.pop_back(); + on_line(line); + line.clear(); + } + } + if (!line.empty()) on_line(line); + + return _pclose(pipe); + } + export void clear_console() { // run by cmd std::system("cls"); diff --git a/src/provider.cppm b/src/provider.cppm new file mode 100644 index 0000000..8b6eb1c --- /dev/null +++ b/src/provider.cppm @@ -0,0 +1,16 @@ +// 兼容壳:Provider 传输已析出至协议层(d2x.protocol.transport)。 +// 活性超时、shell 安全引用、NDJSON 容错都住在协议层;core 侧只负责把 +// 配置(超时时长)与日志(告警回调)接进去——见 checker 的构造点。 +export module d2x.provider; + +export import d2x.protocol.transport; + +export namespace d2x::provider { + +using d2x::protocol::Progress; +using d2x::protocol::IExerciseProvider; +using d2x::protocol::ProcessProvider; +using d2x::protocol::TransportOptions; +using d2x::protocol::shell_quote; + +} // namespace d2x::provider diff --git a/src/session.cppm b/src/session.cppm new file mode 100644 index 0000000..e17b540 --- /dev/null +++ b/src/session.cppm @@ -0,0 +1,178 @@ +// 学习会话:遍历、推进、完成状态。 +// +// 这一层是纯逻辑——不碰构建工具、不碰终端、不碰文件监听。给它一个假 +// Provider 和一份内存状态就能测完整流程。旧实现把这些逻辑埋在 +// checker::run() 的 100 行循环里,一行都测不了。 +module; + +// stderr 是宏,import std 不提供 +#include + +export module d2x.session; + +import std; + +import d2x.domain; +import d2x.json; + +namespace d2x::session { + +using domain::Exercise; + +// 完成状态持久化。 +// +// 按 id 存,不按下标——重排或重命名练习不会毁掉学员进度。这是 rustlings +// 的经验:它的 .rustlings-state.txt 同样按名字存,而「每次重编所有练习」 +// 的性能投诉正是靠这个缓存解决的,与换构建工具无关。 +export class StateStore { + std::filesystem::path mPath; + std::string mCurrent; + std::set mCompleted; + +public: + explicit StateStore(std::filesystem::path path) : mPath(std::move(path)) { load(); } + + void load() { + std::ifstream in(mPath); + if (!in) return; + auto parsed = nlohmann::json::parse(in, nullptr, /*allow_exceptions=*/false); + if (parsed.is_discarded() || !parsed.is_object()) { + // 损坏的进度文件不能静默清零(学习者会无感知地丢档):改名备份、 + // 明确告知,再从空白继续。备份带时间戳,反复损坏也不互相覆盖。 + in.close(); + auto ts = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + auto backup = mPath; + backup += std::format(".corrupt-{}", ts); + std::error_code ec; + std::filesystem::rename(mPath, backup, ec); + std::println(stderr, + "warning: 进度文件损坏,已备份为 {} 并从空白进度继续", + backup.string()); + return; + } + + mCurrent = parsed.value("current", ""); + if (parsed.contains("completed") && parsed["completed"].is_array()) { + for (const auto& id : parsed["completed"]) + if (id.is_string()) mCompleted.insert(id.get()); + } + } + + void save() const { + nlohmann::json out; + out["current"] = mCurrent; + out["completed"] = std::vector(mCompleted.begin(), mCompleted.end()); + + std::error_code ec; + std::filesystem::create_directories(mPath.parent_path(), ec); + std::ofstream file(mPath); + if (file) file << out.dump(2) << '\n'; + } + + bool is_completed(const std::string& id) const { return mCompleted.contains(id); } + const std::string& current() const { return mCurrent; } + + void mark_completed(const std::string& id) { mCompleted.insert(id); save(); } + void set_current(const std::string& id) { mCurrent = id; save(); } + + // 学员改坏了已完成的练习时用得上 + void unmark(const std::string& id) { mCompleted.erase(id); save(); } +}; + +// 会话推进逻辑。纯函数式的部分抽在这里,便于单测。 +export class Session { + std::vector mExercises; + StateStore* mState; + std::size_t mIndex{0}; + +public: + Session(std::vector exercises, StateStore* state) + : mExercises(std::move(exercises)), mState(state) {} + + bool empty() const { return mExercises.empty(); } + std::size_t total() const { return mExercises.size(); } + + std::size_t completed_count() const { + if (!mState) return 0; + // 平铺循环,同 seek_start 的 ICE 规避说明 + std::size_t n = 0; + for (const auto& e : mExercises) + if (mState->is_completed(e.id)) ++n; + return n; + } + + // 定位起点。优先级:显式指定 > 持久化的 current > 第一个未完成 > 开头。 + // 匹配用子串,方便学员只敲 `d2x checker rvalue`。 + // 平铺循环而非 ranges 投影:clang 20(MSVC 目标)对跨模块类型 + // (Exercise 现居 d2x.protocol.types)的 ranges 投影在 PCM 代码生成 + // 阶段 ICE(Windows CI 实测,Stack dump 指向本函数)。行为等价。 + void seek_start(std::string_view wanted) { + if (!wanted.empty()) { + for (std::size_t i = 0; i < mExercises.size(); ++i) { + if (mExercises[i].id.find(wanted) != std::string::npos) { + mIndex = i; + return; + } + } + } + + if (mState && !mState->current().empty()) { + for (std::size_t i = 0; i < mExercises.size(); ++i) { + if (mExercises[i].id == mState->current()) { + mIndex = i; + return; + } + } + } + + for (std::size_t i = 0; i < mExercises.size(); ++i) { + if (!mState || !mState->is_completed(mExercises[i].id)) { + mIndex = i; + return; + } + } + mIndex = 0; + } + + bool done() const { return mIndex >= mExercises.size(); } + + const Exercise& current() const { return mExercises[mIndex]; } + + void enter_current() { if (mState && !done()) mState->set_current(current().id); } + + void complete_current() { + if (done()) return; + if (mState) mState->mark_completed(current().id); + advance_to_next_incomplete(); + enter_current(); + } + +private: + bool incomplete(const Exercise& e) const { + return !mState || !mState->is_completed(e.id); + } + + // 推进到下一道未完成的练习:先向后找,找不到再从头绕一圈。 + // + // 为什么要绕回去:起点优先级是「持久化 current > 第一个未完成」, + // 这是对的 —— 学员主动跳级后重启不该被硬拉回开头。但副作用是, + // 课程作者在学员当前位置之前插入新练习时,那道题会被静默跳过。 + // 绕一圈保证「学员不会被打断,也不会丢内容」。 + // + // 向后找时跳过已完成的:学员重玩时不必把做过的题再验一遍, + // 这与 seek_start 的行为一致。 + void advance_to_next_incomplete() { + for (std::size_t i = mIndex + 1; i < mExercises.size(); ++i) { + if (incomplete(mExercises[i])) { mIndex = i; return; } + } + for (std::size_t i = 0; i <= mIndex && i < mExercises.size(); ++i) { + if (incomplete(mExercises[i])) { mIndex = i; return; } + } + mIndex = mExercises.size(); // 全部完成 + } + +public: +}; + +} // namespace d2x::session diff --git a/src/ui.cppm b/src/ui.cppm index 7f78c69..2a62213 100644 --- a/src/ui.cppm +++ b/src/ui.cppm @@ -50,26 +50,14 @@ void switch_backend(std::string_view name) { internal::g_ui_backend->start(); } -void update_checker_page( - std::string target, std::vector target_files, - int built_targets, int total_targets, - std::string output, bool status, std::string ai_tips = "" -) { - auto state = ICheckerPageUI::UIState{}; - state.target = std::move(target); - state.target_files = std::move(target_files); - state.built_targets = built_targets; - state.total_targets = total_targets; - state.output = std::move(output); - state.status = status; - state.ai_tips = std::move(ai_tips); +void update_checker_page(const ICheckerPageUI::UIState& state) { internal::backend()->update_page(PageID::Checker, state); } -void update_ai_tips(std::string ai_tips) { +void update_ai_tips(std::string hint) { auto state = ICheckerPageUI::UIState{}; - state.ai_tips = std::move(ai_tips); - state.only_update_ai_tips = true; + state.hint = std::move(hint); + state.only_update_hint = true; internal::backend()->update_page(PageID::Checker, state); } diff --git a/src/ui/plugin/print_backend/checker_page.cppm b/src/ui/plugin/print_backend/checker_page.cppm index 9f01f8f..287ceaf 100644 --- a/src/ui/plugin/print_backend/checker_page.cppm +++ b/src/ui/plugin/print_backend/checker_page.cppm @@ -1,73 +1,132 @@ +module; + +// stdout 是宏,import std 不提供,flush 需要它走全局模块片段 +#include + export module d2x.ui.plugin.print:checker_page; import std; import d2x.utils; import d2x.platform; +import d2x.msg; import d2x.ui.interface; namespace d2x { -// print-based checker page implementation +// print 页面:固定分区(Progress / Exercise / Status / Checks / Output / Hint)。 +// 结构化诊断置顶(最多 5 条);长输出头 20 + 尾 30 行截断,全量在 +// .d2x/last-output.log——截断不丢信息(2026-07-24 设计文档 D5)。 class PrintCheckerPage : public ICheckerPageUI { UIState mState__; std::mutex mConsoleMutex__; + static constexpr int kHeadLines = 20; + static constexpr int kTailLines = 30; + static constexpr int kMaxChecks = 5; + void render() { std::lock_guard lock(mConsoleMutex__); d2x::platform::clear_console(); - // Progress bar - std::string progress_bar = ""; - if (mState__.total_targets > mState__.built_targets) - progress_bar += ">" + std::string(mState__.total_targets - mState__.built_targets - 1, '-'); - if (mState__.built_targets > 0) - progress_bar = std::string(mState__.built_targets, '=') + progress_bar; - - progress_bar = "\033[32m" + progress_bar + "\033[0m"; - - // Status - auto status_str = mState__.status - ? "OK: Compilation/Running succeeded" - : "Error: Compilation/Running failed"; - - std::string target_file = utils::normalize_path( - mState__.target_files.empty() ? std::string{} : mState__.target_files[0] - ); - - std::println("Progress: [{}] {}/{}", progress_bar, mState__.built_targets, mState__.total_targets); + // Progress + std::string bar; + if (mState__.total > mState__.completed) + bar += ">" + std::string(static_cast(mState__.total - mState__.completed - 1), '-'); + if (mState__.completed > 0) + bar = std::string(static_cast(mState__.completed), '=') + bar; + std::println("Progress: [\033[32m{}\033[0m] {}/{}", bar, mState__.completed, mState__.total); std::println(""); - std::println("Target: {}", mState__.target); - std::println("{} for {}", status_str, target_file); + + // Exercise + auto file = utils::normalize_path( + mState__.files.empty() ? std::string{} : mState__.files.front()); + if (mState__.chapter.empty()) + std::println("Exercise: {}", mState__.exercise); + else + std::println("Exercise: {} ({})", mState__.exercise, mState__.chapter); + std::println("File: {}", file); std::println(""); - if (mState__.status) { - std::println(" The code is compiling!"); - } else { - std::println(" The code has some errors!"); + // Status(三态;空 = 检测中) + std::string_view status_line = + mState__.outcome == "pass" ? msg::text(msg::Key::StatusPass) + : mState__.outcome == "blocked" ? msg::text(msg::Key::StatusBlocked) + : mState__.outcome == "fail" ? msg::text(msg::Key::StatusFail) + : msg::text(msg::Key::StatusChecking); + std::println("Status: {}", status_line); + + // Checks:结构化诊断置顶——学习者第一眼看到「哪一行没过」 + if (!mState__.checks.empty()) { + std::println(""); + int shown = 0; + for (const auto& c : mState__.checks) { + if (shown++ == kMaxChecks) { + std::println(" … ({} more)", mState__.checks.size() - kMaxChecks); + break; + } + std::println(" • {}", c); + } } + // Output:头尾截断,全量另存 std::println("\n---\n"); - std::println("{}", mState__.output); + auto lines = split_lines(mState__.output); + if (std::cmp_less_equal(lines.size(), kHeadLines + kTailLines)) { + for (auto& l : lines) std::println("{}", l); + } else { + for (std::size_t i = 0; i < kHeadLines; ++i) std::println("{}", lines[i]); + std::println(""); + std::size_t omitted = lines.size() - kHeadLines - kTailLines; + std::println("\033[33m{}\033[0m", + std::vformat(msg::text(msg::Key::OutputOmitted), + std::make_format_args(omitted, mState__.output_log_path))); + std::println(""); + for (std::size_t i = lines.size() - kTailLines; i < lines.size(); ++i) + std::println("{}", lines[i]); + } std::println("\n---"); - std::println("🤖: {}", mState__.ai_tips); + + // Hint + std::println("🤖: {}", mState__.hint.empty() + ? std::string(msg::text(msg::Key::AiDisabled)) + : mState__.hint); + + // 页面渲染完整体 flush 一次。stdout 重定向到管道/文件时是全缓冲, + // 而 clear_console 经子进程直写 fd 绕过了缓冲——不 flush 的话, + // 非 TTY 消费者只能看到清屏序列,页面内容永远滞留在缓冲区。 + std::fflush(stdout); + } + + static std::vector split_lines(const std::string& s) { + std::vector lines; + std::string cur; + for (char c : s) { + if (c == '\n') { lines.push_back(std::move(cur)); cur.clear(); } + else { cur += c; } + } + if (!cur.empty()) lines.push_back(std::move(cur)); + return lines; } public: void update(const UIState& state) override { - // Only update ai_tips, preserve other fields - if (state.only_update_ai_tips) { - mState__.ai_tips = state.ai_tips; + if (state.only_update_hint) { + mState__.hint = state.hint; } else { - std::string old_ai_tips = std::move(mState__.ai_tips); + std::string old_hint = std::move(mState__.hint); mState__ = state; - if (state.ai_tips.empty() && !old_ai_tips.empty()) { - mState__.ai_tips = std::move(old_ai_tips); + if (state.hint.empty() && !old_hint.empty()) { + mState__.hint = std::move(old_hint); } } render(); } }; +export std::unique_ptr make_print_checker_page() { + return std::make_unique(); +} + } // namespace d2x diff --git a/src/ui/plugin/tui_backend/checker_page.cppm b/src/ui/plugin/tui_backend/checker_page.cppm index 74619cd..d6ddae8 100644 --- a/src/ui/plugin/tui_backend/checker_page.cppm +++ b/src/ui/plugin/tui_backend/checker_page.cppm @@ -20,15 +20,15 @@ public: std::lock_guard lock(mMutex__); // Only update ai_tips, preserve other fields - if (state.only_update_ai_tips) { - mState__.ai_tips = state.ai_tips; + if (state.only_update_hint) { + mState__.hint = state.hint; } else { // Full state update, preserve old ai_tips if new one is empty - std::string old_ai_tips = std::move(mState__.ai_tips); + std::string old_ai_tips = std::move(mState__.hint); mState__ = state; - if (state.ai_tips.empty() && !old_ai_tips.empty()) { - mState__.ai_tips = std::move(old_ai_tips); + if (state.hint.empty() && !old_ai_tips.empty()) { + mState__.hint = std::move(old_ai_tips); } } @@ -66,8 +66,8 @@ private: const int term_height = terminal_size.dimy > 0 ? terminal_size.dimy : 24; const int bar_width = 40; - const int total = mState__.total_targets; - const int built = mState__.built_targets; + const int total = mState__.total; + const int built = mState__.completed; const float ratio = total > 0 ? static_cast(built) / total : 0.0f; const int filled = static_cast(ratio * bar_width); @@ -90,32 +90,47 @@ private: text(std::format(" {}/{} ", built, total)) | bold | color(Color::White) }); - const char* status_icon = mState__.status ? "✓" : "✗"; - auto status_color = mState__.status ? Color::Green : Color::Red; + const char* status_icon = mState__.outcome == "pass" ? "✓" + : mState__.outcome == "blocked" ? "🚧" + : mState__.outcome == "fail" ? "✗" : "…"; + auto status_color = mState__.outcome == "pass" ? Color::Green + : mState__.outcome == "blocked" ? Color::Yellow + : mState__.outcome == "fail" ? Color::Red : Color::GrayDark; - auto target_display = hbox({ + auto exercise_display = hbox({ text(" "), text(status_icon) | bold | color(status_color), text(" "), - text(mState__.target) | color(Color::Magenta) + text(mState__.exercise) | color(Color::Magenta) }); - const auto target_file = utils::normalize_path( - mState__.target_files.empty() ? std::string{} : mState__.target_files.front() + const auto exercise_file = utils::normalize_path( + mState__.files.empty() ? std::string{} : mState__.files.front() ); Elements status_elements; status_elements.push_back(progress_display); status_elements.push_back(text("")); - status_elements.push_back(target_display); - if (!target_file.empty()) { + status_elements.push_back(exercise_display); + if (!exercise_file.empty()) { status_elements.push_back(hbox({ text(" +") | color(Color::Yellow), text(" → ") | color(Color::Blue), - text(target_file) | color(Color::GrayDark) + text(exercise_file) | color(Color::GrayDark) })); } status_elements.push_back(text("")); + // 结构化诊断置顶(最多 5 条)——学习者第一眼看到「哪一行没过」 + int shown = 0; + for (const auto& c : mState__.checks) { + if (shown++ == 5) { + status_elements.push_back(text(std::format(" … ({} more)", mState__.checks.size() - 5)) | color(Color::GrayDark)); + break; + } + status_elements.push_back(hbox({ text(" • ") | color(Color::Red), + text(c) | color(Color::GrayLight) })); + } + if (!mState__.checks.empty()) status_elements.push_back(text("")); auto status_section = vbox(std::move(status_elements)); auto status_screen = Screen::Create(Dimension::Full(), Dimension::Fit(status_section)); @@ -125,8 +140,8 @@ private: std::println(""); // AI Area Height Calculation - auto ai_lines = split_lines(mState__.ai_tips); - int ai_height = mState__.ai_tips.empty() ? 0 : static_cast(ai_lines.size()) + 2; + auto ai_lines = split_lines(mState__.hint); + int ai_height = mState__.hint.empty() ? 0 : static_cast(ai_lines.size()) + 2; // Output Area Height Calculation int available_for_output = term_height - STATUS_LINES - std::max(ai_height, AI_MIN_LINES); @@ -146,7 +161,7 @@ private: std::println(""); // AI Area - if (!mState__.ai_tips.empty()) { + if (!mState__.hint.empty()) { // Blinking animation icons const char* ai_icons[] = { "🤖", "✨", "👾", "🧠", "🎮" }; const char* ai_icon = ai_icons[(mAnimation_frame__ / 2) % 5]; diff --git a/src/ui/ui_interface.cppm b/src/ui/ui_interface.cppm index d798a44..1a7d6dc 100644 --- a/src/ui/ui_interface.cppm +++ b/src/ui/ui_interface.cppm @@ -59,15 +59,20 @@ public: // Main UI interface with multi-page support export class ICheckerPageUI : public IPageUI { public: + // 词汇与领域层一致:exercise,不是 target(旧字段名把构建工具词汇 + // 固化进了接口,页面因此一直打 "Target:")。 struct UIState : IUIState { - std::string target; - std::vector target_files; - int built_targets = 0; - int total_targets = 0; - std::string output; - bool status = false; - std::string ai_tips; - bool only_update_ai_tips = false; + std::string exercise; // 练习 id + std::string chapter; + std::vector files; + int completed = 0; + int total = 0; + std::string outcome; // "" 检测中 | pass | fail | blocked + std::vector checks; // 结构化诊断行(file:line message),置顶展示 + std::string output; // 原始输出(页面自行截断) + std::string output_log_path; // 全量输出文件(截断提示引用) + std::string hint; // AI 提示 + bool only_update_hint = false; }; public: virtual ~ICheckerPageUI() = default; diff --git a/src/watch.cppm b/src/watch.cppm new file mode 100644 index 0000000..a834226 --- /dev/null +++ b/src/watch.cppm @@ -0,0 +1,100 @@ +// 文件监听:去抖 + 自触发保护。 +// +// 替换掉原先「把所有文件 mtime 相加再比总和」的做法 —— 那有三个问题: +// +// 1. 求和会抵消。两个文件的 mtime 一增一减,总和不变,改动被漏掉。 +// 2. 没有去抖。编辑器保存常常是多次写入(或写临时文件再 rename), +// 第一次写入就触发重建,可能读到半截文件。 +// 3. 去抖逻辑手写在调用方(连续调两次 wait_files_changed), +// 语义藏在调用点,没法单独验证。 +export module d2x.watch; + +import std; + +namespace d2x::watch { + +// 每个文件单独记 mtime 与大小。大小是廉价的第二维度:某些文件系统 +// mtime 粒度是秒级,一秒内的改动只靠 mtime 看不出来。 +struct Stamp { + std::int64_t mtime{}; + std::uintmax_t size{}; + bool exists{}; + + bool operator==(const Stamp&) const = default; +}; + +Stamp stamp_of(const std::filesystem::path& p) { + std::error_code ec; + if (!std::filesystem::exists(p, ec)) return {}; + + Stamp s; + s.exists = true; + auto t = std::filesystem::last_write_time(p, ec); + if (!ec) s.mtime = t.time_since_epoch().count(); + s.size = std::filesystem::file_size(p, ec); + if (ec) s.size = 0; + return s; +} + +export class FileWatcher { + std::vector mFiles; + std::vector mSnapshot; + + std::vector sample() const { + std::vector out; + out.reserve(mFiles.size()); + for (const auto& f : mFiles) out.push_back(stamp_of(f)); + return out; + } + +public: + explicit FileWatcher(std::vector files) + : mFiles(std::move(files)) { resync(); } + + // 重新采样,把当前状态当作基线。 + // + // 这是自触发保护:构建过程本身可能碰到被监听的文件(生成、格式化、 + // 或仅仅是被读取时更新 atime 的边缘情况)。在开始等待之前重新采样, + // 就不会把自己造成的变化误当作学员的编辑。 + void resync() { mSnapshot = sample(); } + + // 等待学员改文件。 + // + // 检测到第一处改动后不立即返回,而是进入「安静期」:持续采样直到 + // settle 时长内没有新变化,才认为这一轮编辑结束。这样编辑器的多次 + // 写入只会触发一次重建,也避免读到写了一半的文件。 + // + // 返回 true 表示发生了改动,false 表示超时(学员没动)。 + bool wait_for_change(std::chrono::milliseconds timeout, + std::chrono::milliseconds settle = std::chrono::milliseconds{300}, + std::chrono::milliseconds poll = std::chrono::milliseconds{150}) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + + // 阶段一:等第一处改动 + bool changed = false; + while (std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(poll); + auto now = sample(); + if (now != mSnapshot) { + mSnapshot = std::move(now); + changed = true; + break; + } + } + if (!changed) return false; // 整段时间都没动静 + + // 阶段二:安静期。有新变化就重新计时,直到 settle 内无变化。 + auto quiet_until = std::chrono::steady_clock::now() + settle; + while (std::chrono::steady_clock::now() < quiet_until) { + std::this_thread::sleep_for(poll); + auto now = sample(); + if (now != mSnapshot) { + mSnapshot = std::move(now); + quiet_until = std::chrono::steady_clock::now() + settle; + } + } + return true; + } +}; + +} // namespace d2x::watch diff --git a/src/xlings.cppm b/src/xlings.cppm index e482eb3..711bce7 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -1,3 +1,13 @@ +// xlings 集成:依赖策略是「直接用,缺失即报错」。 +// +// d2x 不代装 xlings(2026-07-24 设计文档 D3 决策):代装是安装器里再跑 +// 安装器,失败面大、责任边界混乱。缺失时输出分平台官方安装命令后退出, +// 由使用者自行安装——一条明确的报错比「帮你装」更可预期。 +module; + +// stderr 是宏,import std 不提供 +#include + export module d2x.xlings; import std; @@ -9,72 +19,89 @@ namespace d2x { namespace xlings { [[nodiscard]] bool has_xlings() { - - // check by xlings binary path + // 先看二进制路径,再退化到 --version 探测。 if (std::filesystem::exists(platform::get_xlings_bin())) { return true; } auto [status, output] = d2x::platform::run_command_capture("xlings --version"); auto clean_output = d2x::utils::strip_ansi(output); - // match xlings x.x.x format to check if xlings is installed - return status == 0 && std::regex_match(clean_output, std::regex("xlings (\\d+\\.\\d+\\.\\d+)")); + // regex_search 而非 regex_match:输出里将来多一行 banner 不该被误判为 + // 「未安装」(误判会把使用者引向不必要的重装)。 + return status == 0 + && std::regex_search(clean_output, std::regex("xlings (\\d+\\.\\d+\\.\\d+)")); } -export bool ensure_xlings_installed() { - if (!has_xlings()) { - if (!d2x::utils::ask_yes_no("xlings 未安装,是否现在安装?", true)) { - std::println("已取消安装"); - return false; - } - - if (!d2x::platform::xlings_install()) { - std::println("xlings 安装失败"); - return false; - } - } - return true; +// 缺失即报错并给出安装命令;返回是否可用。所有依赖 xlings 的子命令 +// (install/new/book/list)共用这一个入口。 +export bool require_xlings() { + if (has_xlings()) return true; + + std::println(stderr, "error: 未检测到 xlings(d2x 的包管理依赖)"); + std::println(stderr, "安装后重试本命令:"); + std::println(stderr, " Linux/macOS: curl -fsSL https://d2learn.org/xlings-install.sh | bash"); + std::println(stderr, " Windows: irm https://d2learn.org/xlings-install.ps1.txt | iex"); + return false; } -export bool install(const std::string& pkgname) { +// 包名最终拼进 shell 命令——与练习 id 同一纪律:白名单校验,拒绝而非转义。 +[[nodiscard]] bool valid_pkgname(std::string_view name) { + if (name.empty()) return false; + return std::ranges::all_of(name, [](unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'; + }); +} - std::println("开始安装 -> {}", pkgname); +export bool install(const std::string& pkgname) { + if (!valid_pkgname(pkgname)) { + std::println(stderr, "error: 非法包名 '{}'(仅允许 [A-Za-z0-9._-])", pkgname); + return false; + } + if (!require_xlings()) return false; - ensure_xlings_installed(); + if (std::filesystem::exists(pkgname)) { + std::println(stderr, "error: 目录 '{}' 已存在——不做静默覆盖。", pkgname); + std::println(stderr, "如需更新,进入该目录使用课程自带的更新方式(如 d2x update / git pull)。"); + return false; + } - // Install the package - std::string command = "xlings install d2x:" + pkgname; + std::string command = "xlings install d2x:" + pkgname + " -y"; std::println("正在执行: {}", command); - int status = platform::exec(command.c_str()); + int status = platform::exec(command); + + if (status != 0) { + std::println(stderr, "error: 安装失败(退出码 {})。排查建议:", status); + std::println(stderr, " 1. 刷新索引: xlings update"); + std::println(stderr, " 2. 网络受限: xlings config --mirror CN"); + std::println(stderr, " 3. 确认包名: d2x list {}", pkgname); + return false; + } - if (status == 0) { + // 校验结果:课程项目的标志是 .d2x.json。 + auto marker = std::filesystem::path(pkgname) / ".d2x.json"; + if (!std::filesystem::exists(marker)) { + std::println(stderr, "warning: 安装命令成功但未找到 {}——课程可能安装到了其他目录,以 xlings 输出为准。", + marker.string()); return true; } - std::println("安装失败,命令返回状态码: {}", status); - return false; + std::println(""); + std::println("安装完成。开始练习:"); + std::println(" cd {} && d2x checker", pkgname); + return true; } export void list(const std::string& query = "") { + if (!require_xlings()) return; - ensure_xlings_installed(); - - std::string command = "xim -s d2x:" + query; + std::string command = "xim -s d2x:" + (valid_pkgname(query) || query.empty() ? query : ""); auto [status, output] = d2x::platform::run_command_capture(command); if (status != 0) { - std::println("查询失败: {}", output); + std::println(stderr, "查询失败: {}", output); return; } - - // Strip ANSI escape codes and print from first '{' - //auto clean_output = d2x::utils::strip_ansi(output); - //auto pos = clean_output.find('{'); - //if (pos != std::string::npos) { - // clean_output = clean_output.substr(pos); - //} - //std::println("{}", clean_output); - std::println("{}", output); } diff --git a/tests/e2e.sh b/tests/e2e.sh new file mode 100755 index 0000000..22ebc01 --- /dev/null +++ b/tests/e2e.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# d2x 自有端到端测试:以假 Provider 驱动,不依赖 mcpp/d2mcpp。 +# +# 覆盖(2026-07-24 设计文档 D7): +# 1 协议容错 垃圾行忽略、缺 verdict=fail、describe 失败=明确报错 +# 2 闯关推进 fail → 改文件 → pass → 推进,state.json 持久化 +# 3 活性超时 挂死 Provider 被终止并明示 +# 4 单实例锁 第二实例被拒 +# 5 stdout 契约 print 页面在重定向下仍可见(防 13863df 回归) +# +# 用法: D2X=/path/to/d2x bash tests/e2e.sh +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +D2X="${D2X:?D2X=/path/to/d2x required}" +FAKE="$HERE/fake_provider.sh" + +rc=0 +fail() { echo "E2E FAIL: $*"; rc=1; } + +# 每个场景独立的临时"课程仓库" +setup() { # $1 = mode + local dir + dir=$(mktemp -d) + printf 'unsolved\n' > "$dir/ex1.txt" + printf 'unsolved\n' > "$dir/ex2.txt" + cat > "$dir/.d2x.json" </dev/null ) +} + +# ── 1a 垃圾行忽略:混入噪声仍能给出正确 verdict ────────────────────── +dir=$(setup garbage) +out=$(run_events "$dir" 15) +echo "$out" | grep -q '"event":"verdict"' && echo "$out" | grep -q '"outcome":"fail"' \ + || fail "garbage: 未在噪声中给出 fail verdict: $out" +echo "$out" | grep -q '"event":"session"' || fail "garbage: 缺 session 事件" +rm -rf "$dir" + +# ── 1b 缺 verdict = fail(绝不能当通过)──────────────────────────────── +dir=$(setup no-verdict) +out=$(run_events "$dir" 15) +echo "$out" | grep -q '"outcome":"fail"' || fail "no-verdict: 未判 fail: $out" +echo "$out" | grep -qi "did not report a verdict" || fail "no-verdict: 缺原因说明" +rm -rf "$dir" + +# ── 1c describe 失败 = 明确报「Provider 挂了」,而非「没有练习」────────── +dir=$(setup describe-fail) +err=$( cd "$dir" && timeout 15 "$D2X" checker --emit-events 2>&1 >/dev/null ) +echo "$err" | grep -q "Provider 无响应" || fail "describe-fail: 缺明确报错: $err" +rm -rf "$dir" + +# ── 2 闯关推进:fail → 写入 SOLVED → pass → 推进到 ex-2,状态落盘 ────── +dir=$(setup ok) +( cd "$dir" && timeout 30 "$D2X" checker --emit-events > events.ndjson 2>/dev/null ) & +CHK=$! +sleep 3 +printf 'SOLVED\n' > "$dir/ex1.txt" +sleep 6 +kill $CHK 2>/dev/null; wait $CHK 2>/dev/null +grep -q '"outcome":"pass"' "$dir/events.ndjson" || fail "advance: 未见 pass verdict" +grep -q '"current":"ex-2"' "$dir/events.ndjson" || fail "advance: 未推进到 ex-2" +grep -q '"ex-1"' "$dir/.d2x/state.json" 2>/dev/null || fail "advance: state.json 未记录 ex-1 完成" +rm -rf "$dir" + +# ── 3 活性超时:挂死 Provider 在 idle 秒后被终止并明示 ──────────────── +# 注意:checker 判 fail 后会驻留等待文件变更(设计行为),所以不量总时长; +# 判据是「20s 窗口内出现 verdict」——若 idle 超时未生效,hang(300s)不可能 +# 在窗口内给出任何 verdict。 +dir=$(setup hang) +out=$(run_events "$dir" 20 D2X_PROVIDER_IDLE_TIMEOUT=2) +echo "$out" | grep -q '"outcome":"fail"' || fail "idle-timeout: 20s 内未出现 fail verdict(超时未生效)" +echo "$out" | grep -qi "terminated\|no output" || fail "idle-timeout: 缺终止说明: $out" +rm -rf "$dir" + +# ── 4 单实例锁 ─────────────────────────────────────────────────────── +dir=$(setup ok) +( cd "$dir" && timeout 20 "$D2X" checker --emit-events > /dev/null 2>&1 ) & +CHK=$! +sleep 3 +second=$( cd "$dir" && timeout 8 "$D2X" checker --emit-events 2>&1 >/dev/null ) +echo "$second" | grep -q "正在此仓库运行" || fail "lock: 第二实例未被拒: $second" +kill $CHK 2>/dev/null; wait $CHK 2>/dev/null +rm -rf "$dir" + +# ── 5 stdout 契约:print 页面重定向下可见 ───────────────────────────── +dir=$(setup ok) +( cd "$dir" && timeout 12 "$D2X" checker --ui print > page.out 2>/dev/null ) +grep -q "ex-1" "$dir/page.out" || fail "flush: 重定向下页面不可见(13863df 回归)" +rm -rf "$dir" + +if [[ $rc -eq 0 ]]; then echo "E2E: ALL GREEN"; else echo "E2E: FAILED"; fi +exit $rc diff --git a/tests/fake_provider.sh b/tests/fake_provider.sh new file mode 100755 index 0000000..d9acf62 --- /dev/null +++ b/tests/fake_provider.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# 协议一致性测试用假 Provider。 +# +# 用法(作为 .d2x.json 的 buildtools): +# bash tests/fake_provider.sh # d2x 会追加 describe/exercises/check +# +# mode: +# ok 正常课程:两道练习;check 语义:练习文件含 SOLVED → pass, +# 含 WAIT → blocked,否则 fail(带一条结构化诊断) +# describe-fail describe 直接失败(退出 1、无输出) +# no-verdict check 只发 stage/output 就正常退出——缺 verdict 必须判 fail +# garbage 事件流里混入非 JSON 噪声行——必须被忽略,验证仍正常工作 +# hang check 陷入无输出的沉睡——活性超时必须终止它 +# +# 练习文件路径经 FAKE_DIR 环境变量传入(e2e 准备的临时目录)。 +set -u + +MODE="${1:?mode required}"; shift +VERB="${1:?verb required}"; shift || true + +DIR="${FAKE_DIR:?FAKE_DIR required}" + +emit() { printf '%s\n' "$1"; } + +case "$VERB" in +describe) + [[ "$MODE" == describe-fail ]] && exit 1 + emit '{"event":"describe","protocol":1,"name":"fake"}' + ;; +exercises) + emit "{\"event\":\"exercise\",\"id\":\"ex-1\",\"order\":1,\"title\":\"one\",\"chapter\":\"ch\",\"files\":[\"$DIR/ex1.txt\"]}" + emit "{\"event\":\"exercise\",\"id\":\"ex-2\",\"order\":2,\"title\":\"two\",\"chapter\":\"ch\",\"files\":[\"$DIR/ex2.txt\"]}" + ;; +check) + ID="${1:?id required}" + case "$MODE" in + hang) + sleep 300 + ;; + no-verdict) + emit '{"event":"stage","name":"compile"}' + emit '{"event":"output","chunk":"compiling...\n"}' + exit 0 + ;; + garbage|ok) + [[ "$MODE" == garbage ]] && { echo "launcher noise: not json"; echo ""; echo "{broken json"; } + emit '{"event":"stage","name":"check"}' + FILE="$DIR/${ID/ex-/ex}.txt" + CONTENT="$(cat "$FILE" 2>/dev/null || true)" + if [[ "$CONTENT" == *SOLVED* ]]; then + emit '{"event":"verdict","outcome":"pass","stage":"check","exit_code":0,"diagnostics":[]}' + elif [[ "$CONTENT" == *WAIT* ]]; then + emit '{"event":"verdict","outcome":"blocked","stage":"check","exit_code":1,"diagnostics":[]}' + else + emit "{\"event\":\"output\",\"chunk\":\"not solved yet\\n\"}" + emit "{\"event\":\"verdict\",\"outcome\":\"fail\",\"stage\":\"check\",\"exit_code\":1,\"diagnostics\":[{\"file\":\"$FILE\",\"line\":1,\"col\":0,\"severity\":\"error\",\"message\":\"write SOLVED into the file\"}]}" + fi + ;; + esac + ;; +*) + exit 2 + ;; +esac diff --git a/tests/session_test.cpp b/tests/session_test.cpp new file mode 100644 index 0000000..32cb628 --- /dev/null +++ b/tests/session_test.cpp @@ -0,0 +1,243 @@ +// session 层单测:不碰文件系统、不碰构建工具、不碰终端。 +// +// 这是把 checker::run() 从 100 行缠绕逻辑里拆出来的全部意义所在 —— +// 学习流程的推进规则现在可以脱离一切外部依赖来验证。 + +#include // stderr —— import std 不提供 C 运行时的宏 + +import std; +import d2x.domain; +import d2x.session; + +using d2x::domain::Exercise; +using d2x::session::Session; +using d2x::session::StateStore; + +namespace { + +int g_failed = 0; +int g_total = 0; + +void check(bool ok, std::string_view what, std::source_location loc = std::source_location::current()) { + ++g_total; + if (ok) return; + ++g_failed; + std::println(stderr, "FAIL [{}:{}] {}", loc.file_name(), loc.line(), what); +} + +// 每个用例一个临时目录,互不干扰 +std::filesystem::path temp_state(std::string_view name) { + auto dir = std::filesystem::temp_directory_path() / std::format("d2x-session-test-{}", name); + std::filesystem::remove_all(dir); + return dir / "state.json"; +} + +std::vector make_exercises(int n) { + std::vector out; + for (int i = 0; i < n; ++i) { + out.push_back(Exercise{ + .id = std::format("ex-{}", i), + .order = i, + .title = std::format("Exercise {}", i), + .chapter = "test", + .files = {std::format("/tmp/ex-{}.cpp", i)}, + }); + } + return out; +} + +// ── 用例 ──────────────────────────────────────────────────────────── + +void empty_session_is_done() { + auto path = temp_state("empty"); + StateStore state(path); + Session s({}, &state); + check(s.empty(), "空会话 empty()"); + check(s.total() == 0, "空会话 total()==0"); + s.seek_start(""); + check(s.done(), "空会话立即 done()"); +} + +void advances_in_order() { + auto path = temp_state("advance"); + StateStore state(path); + Session s(make_exercises(3), &state); + s.seek_start(""); + + check(s.current().id == "ex-0", "从第一题开始"); + s.complete_current(); + check(s.current().id == "ex-1", "推进到第二题"); + s.complete_current(); + check(s.current().id == "ex-2", "推进到第三题"); + s.complete_current(); + check(s.done(), "全部完成后 done()"); +} + +void completed_count_tracks_progress() { + auto path = temp_state("count"); + StateStore state(path); + Session s(make_exercises(4), &state); + s.seek_start(""); + + check(s.completed_count() == 0, "起始完成数为 0"); + s.complete_current(); + check(s.completed_count() == 1, "完成一题后计数为 1"); + s.complete_current(); + check(s.completed_count() == 2, "完成两题后计数为 2"); +} + +// 断点续做:这是 rustlings 的教训 —— 按 id 存而非下标, +// 重排或重命名练习不会毁掉学员进度。 +void resumes_from_persisted_state() { + auto path = temp_state("resume"); + { + StateStore state(path); + Session s(make_exercises(5), &state); + s.seek_start(""); + s.complete_current(); // ex-0 + s.complete_current(); // ex-1 + } + { + StateStore fresh(path); // 从磁盘重新加载 + Session s(make_exercises(5), &fresh); + s.seek_start(""); + check(s.current().id == "ex-2", "重启后从 ex-2 继续"); + check(s.completed_count() == 2, "重启后完成数仍为 2"); + } +} + +// 顺序变了,进度不该丢 —— 用下标存就会在这里错位 +void reordering_preserves_progress() { + auto path = temp_state("reorder"); + { + StateStore state(path); + Session s(make_exercises(4), &state); + s.seek_start(""); + s.complete_current(); // ex-0 完成 + } + { + // 课程作者在开头插入一道新练习,原有 id 全部后移 + auto shifted = make_exercises(4); + shifted.insert(shifted.begin(), Exercise{ + .id = "ex-new", .order = -1, .title = "新增", .chapter = "test", + .files = {"/tmp/ex-new.cpp"}, + }); + + StateStore fresh(path); + Session s(shifted, &fresh); + s.seek_start(""); + check(s.completed_count() == 1, "重排后 ex-0 仍算已完成"); + + // 持久化的 current 优先 —— 学员回到离开时的位置,不被硬拉回开头 + check(s.current().id == "ex-1", "从离开时的位置继续,而不是新插入的那道"); + + // 但新插入的那道不能被永久跳过:做完后面所有题后应回收它 + s.complete_current(); // ex-1 + s.complete_current(); // ex-2 + s.complete_current(); // ex-3 + check(!s.done(), "还有漏掉的练习,不算全部完成"); + check(s.current().id == "ex-new", "走到末尾时回收被跳过的 ex-new"); + s.complete_current(); + check(s.done(), "回收完才真正结束"); + } +} + +void explicit_start_wins_over_state() { + auto path = temp_state("explicit"); + StateStore state(path); + Session s(make_exercises(5), &state); + s.seek_start(""); + s.complete_current(); // current 变成 ex-1 + + Session again(make_exercises(5), &state); + again.seek_start("ex-3"); + check(again.current().id == "ex-3", "显式指定优先于持久化状态"); +} + +void start_matches_substring() { + auto path = temp_state("substr"); + StateStore state(path); + std::vector list{ + {.id = "cpp11-04-rvalue-references", .order = 0, .title = "t", .chapter = "c", .files = {"/tmp/a.cpp"}}, + {.id = "cpp11-05-move-semantics-0", .order = 1, .title = "t", .chapter = "c", .files = {"/tmp/b.cpp"}}, + }; + Session s(list, &state); + s.seek_start("move"); + check(s.current().id == "cpp11-05-move-semantics-0", "子串匹配定位(学员只敲关键词)"); +} + +void unknown_start_falls_back_to_first_incomplete() { + auto path = temp_state("unknown"); + StateStore state(path); + Session s(make_exercises(3), &state); + s.seek_start("no-such-exercise"); + check(s.current().id == "ex-0", "未匹配到时退回第一道未完成练习"); +} + +// 已完成的练习不该被重复要求做 +void skips_completed_on_fresh_seek() { + auto path = temp_state("skip"); + StateStore state(path); + state.mark_completed("ex-0"); + state.mark_completed("ex-1"); + + Session s(make_exercises(4), &state); + s.seek_start(""); + check(s.current().id == "ex-2", "跳过已完成的 ex-0/ex-1"); +} + +// 学员把做完的题改坏了,应该能退回重做 +void unmark_allows_redo() { + auto path = temp_state("unmark"); + StateStore state(path); + state.mark_completed("ex-0"); + state.unmark("ex-0"); + + Session s(make_exercises(2), &state); + s.seek_start(""); + check(s.completed_count() == 0, "撤销完成标记后计数归零"); + check(s.current().id == "ex-0", "撤销后回到该题"); +} + +// 状态文件损坏不该让学员的会话崩掉 +void corrupt_state_file_is_tolerated() { + auto path = temp_state("corrupt"); + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path) << "{ this is not valid json"; + + StateStore state(path); // 不应抛异常 + Session s(make_exercises(2), &state); + s.seek_start(""); + check(s.completed_count() == 0, "损坏的状态文件当作空状态"); + check(s.current().id == "ex-0", "损坏状态下仍从头开始"); +} + +// 没有 StateStore 也要能工作(一次性检查、CI 场景) +void works_without_state_store() { + Session s(make_exercises(3), nullptr); + s.seek_start(""); + check(s.completed_count() == 0, "无状态存储时完成数为 0"); + check(s.current().id == "ex-0", "无状态存储时从头开始"); + s.complete_current(); + check(s.current().id == "ex-1", "无状态存储时仍能推进"); +} + +} // namespace + +int main() { + empty_session_is_done(); + advances_in_order(); + completed_count_tracks_progress(); + resumes_from_persisted_state(); + reordering_preserves_progress(); + explicit_start_wins_over_state(); + start_matches_substring(); + unknown_start_falls_back_to_first_incomplete(); + skips_completed_on_fresh_seek(); + unmark_allows_redo(); + corrupt_state_file_is_tolerated(); + works_without_state_store(); + + std::println("session: {}/{} 通过", g_total - g_failed, g_total); + return g_failed == 0 ? 0 : 1; +}