Skip to content

refactor: d2x 重设计为通用练习框架 + 双向协议边界#31

Draft
Sunrisepeak wants to merge 5 commits into
mainfrom
feat/exercise-framework-protocol
Draft

refactor: d2x 重设计为通用练习框架 + 双向协议边界#31
Sunrisepeak wants to merge 5 commits into
mainfrom
feat/exercise-framework-protocol

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

草稿,暂不合入。 保留信息供后续继续开发。

背景

d2x 原先把三件事缠在一起:枚举练习(课程知识)、构建运行(工具知识)、判定通过(教学逻辑)。所谓"抽象层"只是一个 shell 命令前缀加四次字符串拼接,于是 xmake 插件既得当构建器又得当课程目录。

本 PR 把 d2x 重设计为练习驱动学习的通用框架:它拥有学习循环和它的呈现,除此之外什么都不拥有。向下用协议对接课程,向上用协议对接前端,两侧都是 NDJSON 事件流。

配套的课程侧实现见 mcpp-community/d2mcpp#(Provider PR)。

主要变更

新增模块

文件 职责
src/domain.cppm Exercise / Outcome 三态 / Diagnostic / Verdict,纯数据零依赖
src/provider.cppm IExerciseProvider + ProcessProvider,唯一下行扩展点
src/session.cppm StateStore(按 id 持久化)+ Session(纯逻辑,可单测)
src/watch.cppm 文件监听:去抖 + 自触发保护
src/emit.cppm 上行 Frontend Protocol,--emit-events 启用
tests/session_test.cpp 28 个断言,无 IO 覆盖完整学习流程

删除 src/buildtools.cppmsrc/checker.cppm 从 100 行缠绕逻辑降为分层编排。

协议只有三个动词describe / exercises / check,刻意没有 build/run/test —— 那是编译型语言的形状,焊进通用框架就焊死了适用范围。

修复的缺陷

  • 练习 id 注入:id 源自课程仓库文件名,原先裸拼进 shell 命令交给 popen。对社区课程仓库而言,一个恶意 PR 文件名就足以在任何跑 checker 的人机器上执行命令。现做 shell 引用(Provider 侧另有白名单拒绝,纵深防御)。
  • DEFAULT_BUILDTOOLS 失效:仍指向已退役的 xmake,未配置的仓库会拿到必定失败的命令且报错误导。
  • 配置优先级倒置:命令行经环境变量传入,而配置加载是"值为空才读 env",导致 .d2x.json 反压住 --ui / --lang。改为 命令行 > 环境变量 > 本地 > 全局 > 默认
  • TUI 每 20 秒自刷:等待窗口到期后无条件重跑检查。改为纯文件驱动,实测静置 40 秒零输出。
  • files[0] 无保护崩溃读文件异常未捕获
  • --emit-events 下日志污染协议流:早退路径的错误曾直接打进 stdout。

设计取舍

编辑器改为可配置策略。 原先硬编码 code,等于假定所有人装了 VS Code。打开编辑器既不是结构也不是显示,是对学员机器的副作用 —— --emit-events 模式下 d2x 完全不碰它(外部前端已从事件流拿到所需信息)。通用 hook 机制不必单独造,事件流本身就是。

遗漏回收。 session 单测发现:起点优先"持久化 current"会让课程作者在学员当前位置之前插入的新练习被永久静默跳过。修法不是回退优先级(那会把主动跳级的学员硬拉回开头),而是推进到末尾时绕回去回收。

验证

session 单测 28/28 · 与 d2mcpp Provider 端到端联调 zh/en 各 51/51 · 事件流纯 JSON 0 污染 · 注入防护实测拒绝。

已知缺口(未在本 PR 解决)

  • macOS / Windows 从未验证,Windows 尤其存疑
  • provider / emit / watch 三层无单测
  • 前端仍是编译期插件,UiSink 是适配层而非真正的协议客户端重构
  • Provider 无超时上限
  • diagnostics 只覆盖运行期断言,编译错误未结构化

详见 .agents/docs/2026-07-20-d2x-architecture-reference.md(协议规范与模块参考)与 .agents/docs/2026-07-19-exercise-framework-protocol-design.md(决策过程)。

d2x 原先把三件事缠在一起:枚举练习(课程知识)、构建运行(工具知识)、
判定通过(教学逻辑)。所谓"抽象层"只是一个 shell 命令前缀加四次字符串
拼接,于是 xmake 插件既得当构建器又得当课程目录。

改为:d2x 只拥有学习循环和它的呈现,向下用 NDJSON 事件流对接 Provider。

- domain.cppm  领域模型。用词从 target 改为 exercise —— target 是构建工具
  的词汇,泄漏进领域层正是旧设计的问题。Outcome 增加 Blocked 第三态:
  学员代码已对、只差拆 D2X_WAIT 路障,既非失败也不该前进;旧实现把它塞进
  build_success=false 而 status 仍为 true,UI 显示成"成功但卡住"。
- provider.cppm  IExerciseProvider + ProcessProvider。只有 describe/
  exercises/check 三个动词,刻意没有 build/run/test —— 那是编译型语言的
  形状,焊进通用框架就焊死了适用范围。非 JSON 行静默丢弃,这不是宽容而是
  协议设计的一部分:Provider 常经由启动器间接执行,忽略噪声让哨兵前缀和
  "末行即 JSON"这类隐式约定都变得不必要。
- session.cppm  StateStore 按 id 而非下标持久化,重排或重命名练习不会毁掉
  学员进度;Session 的推进逻辑是纯逻辑,可用假 Provider 单测。
- platform  新增 run_command_lines 流式逐行读,学员在编译期就能看到输出;
  顺带修正 pclose 的 wait status 解码(exit 1 原本会变成 256)。
- checker.cppm  只剩编排。Provider 无响应时明确报"Provider 挂了",不再是
  "Failed to load targets" 紧跟 "No targets found" 的双重误导。
- 修复 files 为空时无保护索引 files[0] 的崩溃。

删除 buildtools.cppm。
功能补齐:

- emit.cppm 上行 Frontend Protocol。单向 NDJSON:d2x 保留控制权,前端纯
  显示。内置 TUI 走 UiSink(内存通道),外部客户端走 StdoutSink(管道),
  二者消费同一套事件类型 —— `d2x checker` 对学员仍是一条命令,而 VSCode
  插件/Web/CI 用 --emit-events 就能直接消费,不必链接 d2x。
  「攒页面状态」的逻辑从 checker 搬进 UiSink,编排层只管发事件。

- watch.cppm 文件监听。替换掉「把所有文件 mtime 相加比总和」的做法,它有
  三个问题:求和会抵消(两文件一增一减则漏检);没有去抖(编辑器多次写入
  会读到半截文件);去抖手写在调用方(连调两次 wait_files_changed)。
  新实现按文件记 mtime+size,内置安静期去抖,并提供 resync() 做自触发保护。

- tests/session_test.cpp 28 个断言,无 IO 覆盖完整学习流程。这是把
  checker::run() 从 100 行缠绕逻辑里拆出来的全部意义所在。

单测立刻抓到一个真设计缺陷:起点优先级是「持久化 current > 第一个未完成」,
于是课程作者在学员当前位置之前插入新练习时,那道题会被永久静默跳过。修法
不是回退优先级(那会把主动跳级的学员硬拉回开头),而是让推进逻辑走到末尾
时绕回去回收遗漏的练习 —— 学员不被打断,内容也不丢。

Bug 修复:

- ProcessProvider 对练习 id 做 shell 引用。id 源自课程仓库的文件名,原先
  裸拼进命令串交给 popen,带反引号或分号的文件名可以注入。(根因侧的校验
  在 d2mcpp 的 discovery 里,两端都堵。)

- DEFAULT_BUILDTOOLS 从 "xmake d2x-buildtools" 改为空。xmake 已随上一轮
  退役,留着会让未配置的仓库拿到一个必定失败的命令,报错还指向 xmake。

- read_source 包住读文件异常。原先无保护,练习文件读不到就整个会话崩。

- --emit-events 模式下日志改道 stderr,stdout 只剩协议事件。实测修复前
  有 5 行日志混进事件流。
四处来自真实试用的反馈:

1. TUI 每 20 秒自己刷一屏。原实现在等待窗口到期后无条件重跑一次检查 ——
   我当时的理由是「外部因素变化时不至于卡死」,但代价是学员什么都没做却
   看到界面在动,还白白重编一遍。检查必须由文件变更驱动:窗口到期只是
   继续等,绝不重建。实测静置 40 秒零输出。

2. --ui / --lang 等命令行参数被配置文件压住。命令行是通过写环境变量传进来
   的,而配置加载是「值为空才读 env」,于是 .d2x.json 反过来赢了。改为
   环境变量覆盖文件,优先级恢复为:命令行 > 环境变量 > 本地配置 > 全局
   配置 > 默认值。

3. 自动打开编辑器原先硬编码 `code`,等于假定所有人都装了 VS Code。这是
   「策略」不是「机制」:打开编辑器既不是结构也不是显示,而是对学员机器
   的副作用。改为可配置(.d2x.json 的 "editor" 或 D2X_EDITOR),未配置时
   按 $VISUAL → $EDITOR → code 回退,显式配空串即关闭;支持 {file} 占位符。

   --emit-events 模式下完全不碰编辑器 —— 外部前端已从事件流拿到 exercise
   和 verdict,开不开、怎么开是它的决定,两边都动只会打架。通用 hook 机制
   不必单独造,事件流本身就是。

4. log::to_stderr 的调用挪到最前。原先它在 buildtools 检查之后,于是配置
   缺失时两行错误直接打进 stdout,外部客户端拿到的第一样东西就是非 JSON。
   现在早退路径上的错误也不会落进协议流(实测 stdout 0 字节)。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant