From bafb1d24fb6ff426a5b47d2ee21b04d47ca8cbbb Mon Sep 17 00:00:00 2001 From: Nanam1 <16244616+nanam112@user.noreply.gitee.com> Date: Wed, 15 Jul 2026 19:16:18 +0800 Subject: [PATCH 1/7] docs: add DSL error beautifier design guides --- ...00\345\217\221\346\226\207\346\241\243.md" | 691 ++++++++++++++++++ ...76\350\256\241\346\226\207\346\241\243.md" | 588 +++++++++++++++ 2 files changed, 1279 insertions(+) create mode 100644 "docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" create mode 100644 "docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" diff --git "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" new file mode 100644 index 0000000..dd5e243 --- /dev/null +++ "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -0,0 +1,691 @@ +# 课题9:DSL错误提示美化器开发文档 + +> **文档类型**:开发指南 | **状态**:草案 | **版本**:v0.1 +> **调研基线**:`main@997d2aa` | **建议周期**:12 周 +> **重要说明**:本文是后续开发指导,不表示文中功能已经在当前仓库实现 + +--- + +## 1. 课题目标 + +本课题在现有 `scratchv/frontend/dsl_errors.py` 基础上,把结构化错误诊断真正接入: + +- `DSLParser`; +- `ExtendedDSLParser`; +- `CompilerDriver` 的 DSL 编译路径; +- ScratchV CLI 的错误输出。 + +最终效果是:用户提交错误 DSL 时,能够看到准确位置、源码高亮、稳定错误码和可信的修复建议;需要检查整个文件时,可以一次报告多个独立错误。 + +详细架构和接口约束见 [设计文档](09-DSL错误提示美化器-设计文档.md)。 + +--- + +## 2. 当前起点 + +### 2.1 已经存在的代码 + +| 文件 | 需要先理解的内容 | +|------|------------------| +| `scratchv/frontend/dsl_errors.py` | `DSLSyntaxError`、`format_error()`、`ErrorCollector`、建议表 | +| `scratchv/frontend/dsl_parser.py` | 基础 DSL 的逐行正则解析与 IRBuilder 调用 | +| `scratchv/frontend/dsl_extended.py` | `if/while` 块解析、块索引和嵌套逻辑 | +| `scratchv/compiler.py` | DSL/ONNX 分流、扩展解析器回退、`CompileResult.errors` | +| `scratchv/main.py` | CLI 参数、编译结果和 stderr 输出 | +| `tests/test_dsl_errors.py` | 现有错误模块的单元测试风格 | +| `tests/test_parser.py` | 基础 DSL 成功路径 | +| `tests/test_dsl_extended.py` | 扩展语法及 `DSLParseError` 兼容要求 | + +### 2.2 当前能力边界 + +当前错误美化模块可以独立构造和打印错误: + +```python +from scratchv.frontend.dsl_errors import make_error, format_error + +error = make_error( + line=2, + col=10, + message="unsupported operation 'ad'", + source_line="result = ad(a, b)", + filename="bad.dsl", + error_code="E200", +) +print(format_error(error, use_color=False)) +``` + +但是解析器并不会自动产生这个错误对象。`ErrorCollector` 也只是容器,当前解析流程没有错误恢复机制。 + +### 2.3 开发前必须复现的问题 + +开始改代码前,至少记录以下三类基线输出: + +```text +1. 无法识别的普通语句 +2. 不支持的算子 +3. 缺失 endif/endwhile 的扩展 DSL +``` + +基线记录应包含:输入 DSL、调用入口、异常类型、异常文本和退出码。后续用相同输入证明错误质量确实改善。 + +--- + +## 3. 前置知识 + +开始课题前,建议掌握: + +1. Python 异常继承、`dataclass`、`enum` 和类型注解; +2. 正则表达式的 `match`、`fullmatch` 和捕获组; +3. 编译器前端中的源码位置、错误恢复和级联错误; +4. ScratchV 的 `IRBuilder` 与 `Program`; +5. pytest 参数化、异常断言和文本快照; +6. ANSI 转义码、TTY 和 stderr; +7. Git 小步提交和回归测试。 + +不要求先实现完整 tokenizer、AST 或 LSP。 + +--- + +## 4. 推荐阅读顺序 + +### 第一步:跑通正确 DSL + +阅读并运行: + +```bash +python -m pytest tests/test_parser.py tests/test_dsl_extended.py -q +``` + +目标:理解正确程序如何从 DSL 进入 `IRBuilder`,不要先看错误模块就直接修改异常类型。 + +### 第二步:单独理解错误模块 + +```bash +python -m pytest tests/test_dsl_errors.py -q +``` + +重点回答: + +- `DSLSyntaxError` 的字段哪些是 1-based? +- `format_error()` 如何计算 caret 长度? +- `ErrorCollector` 达到上限后怎样计数? +- 自动建议来自显式 `fix_hint` 还是启发式规则? + +### 第三步:追踪 CLI 数据流 + +```text +scratchv/main.py + → CompilerDriver.compile() + → CompilerDriver._parse() + → ExtendedDSLParser 或 DSLParser + → CompileResult.diagnostics / diagnostic_limit_reached / errors + → stderr +``` + +特别关注 `_parse()` 中捕获所有异常再回退的逻辑。它是错误信息被覆盖的主要风险点。 + +### 第四步:阅读设计约束 + +阅读 [设计文档](09-DSL错误提示美化器-设计文档.md) 的第 3、5、7、9、12 和 16 节,再开始编码。 + +--- + +## 5. 建议目录与改动范围 + +后续实现建议只修改与课题直接相关的文件: + +```text +scratchv/frontend/ +├── dsl_errors.py # 扩展位置、渲染和收集语义 +├── dsl_grammar.py # 拟新增:Parser/Validator 共享语法与算子签名 +├── dsl_validator.py # 拟新增:无 IR 副作用的验证器 +├── dsl_parser.py # 接入 SourceBuffer/validator +├── dsl_extended.py # 接入控制流块验证 +└── __init__.py # 必要时导出稳定公共接口 + +scratchv/ +├── compiler.py # 保留结构化诊断,收窄回退条件 +└── main.py # 终端颜色与 stderr 输出 + +tests/ +├── test_dsl_errors.py +├── test_dsl_validator.py # 拟新增 +├── test_parser.py +├── test_dsl_extended.py +└── test_dsl_diagnostics_cli.py # 拟新增端到端测试 +``` + +不要在本课题中顺便重构 IR、优化器或后端。 + +--- + +## 6. 开发策略 + +采用测试先行和小步集成。每一步都应保持正确 DSL 可编译,不能等到最后一次性跑测试。 + +建议顺序: + +```text +锁定输出契约 + → SourceBuffer + → 错误继承兼容 + → 基础行级验证 + → 算子签名验证 + → 扩展块验证 + → Parser 集成 + → CompilerDriver/CLI 集成 + → 多错误与颜色策略 + → 全量回归 +``` + +--- + +## 7. 阶段一:锁定诊断输出契约 + +### 7.1 先写失败测试 + +为以下输出建立无颜色精确测试: + +```text +bad.dsl:5:1: error[E100]: cannot parse statement + 5 | retrun result + | ^~~~~~ +note: did you mean 'return'? +``` + +至少断言: + +- 文件名; +- 1-based 行列; +- 错误码; +- 源码行; +- caret 起点和长度; +- 无 ANSI 转义码。 + +### 7.2 保持旧接口 + +已有调用仍应工作: + +```python +DSLSyntaxError(1, 1, "message") +format_error(error, use_color=False) +ErrorCollector(filename="test.dsl", max_errors=20) +``` + +不要为了新设计删除现有参数或更改已有字段顺序。 + +### 7.3 完成标准 + +- 新输出契约测试失败的原因是功能尚未实现,而不是测试写错; +- 现有 `tests/test_dsl_errors.py` 仍通过; +- 文档中的示例与测试期望完全一致。 + +--- + +## 8. 阶段二:实现 SourceBuffer + +### 8.1 目的 + +解析器当前过早执行 `strip()`,导致缩进和原始列信息丢失。`SourceBuffer` 应成为唯一的源码位置来源。 + +建议接口: + +```python +@dataclass(frozen=True) +class SourceBuffer: + text: str + filename: str = "" + + def line_text(self, line: int) -> str: + ... +``` + +### 8.2 测试矩阵 + +| 输入 | 预期 | +|------|------| +| `a\nb` | 第 1 行 `a`,第 2 行 `b` | +| `a\r\nb` | 与 LF 行号一致 | +| 空字符串 | 不越界,不虚构源码 | +| 末尾换行 | 不产生错误的额外语句 | +| 中文标识符或注释 | 列号按 Python 字符索引计算 | +| 制表符 | 原始列稳定,渲染时 caret 对齐 | + +### 8.3 常见错误 + +- 在保存原始行之前调用 `strip()`; +- 混用 0-based 内部索引和 1-based 用户位置; +- 用字节偏移计算 Unicode 列号; +- 只在扩展解析器保留空行,基础解析器仍丢失行号。 + +--- + +## 9. 阶段三:统一异常兼容关系 + +当前 `DSLParseError` 定义在 `dsl_parser.py`,`DSLSyntaxError` 独立继承 `Exception`。直接改为抛出新异常可能破坏捕获 `DSLParseError` 的调用方和测试。 + +建议目标: + +```python +try: + DSLParser().parse(bad_source) +except DSLParseError as error: + assert isinstance(error, DSLSyntaxError) +``` + +实现时应避免 `dsl_parser.py` 与 `dsl_errors.py` 的循环导入。可选择: + +1. 把兼容基类移到 `dsl_errors.py`,再从旧路径重新导出; +2. 把共享异常基类放进一个小型无依赖模块。 + +不建议通过捕获任意异常再用字符串包装的方式伪造兼容,因为它会丢失原始错误类别和位置。 + +--- + +## 10. 阶段四:基础 DSL 验证器 + +### 10.1 为什么不能在错误后继续构建 IR + +一条语句可能已经调用了部分 `IRBuilder` 方法才失败。继续解析会让 builder 状态不可预测。因此验证器只检查源码,不创建 `Value`、Block 或 Program。 + +### 10.2 建议验证顺序 + +对每个物理行: + +1. 跳过空行和注释; +2. 判断语句类别; +3. 检查外层结构; +4. 检查关键字和块栈; +5. 检查算子是否存在; +6. 检查位置参数与关键字参数; +7. 记录诊断或进入下一行。 + +### 10.3 使用 `fullmatch` + +验证语法时优先使用 `fullmatch`,避免只匹配行首后忽略尾部垃圾。例如: + +```text +x = add(a, b) unexpected +``` + +不能因为前半段匹配成功而被当成合法语句。 + +### 10.4 算子签名表 + +不要在多个 `if/elif` 中重复参数规则。建议集中描述: + +```python +OP_SIGNATURES = { + "add": OpSignature(positional=2), + "relu": OpSignature(positional=1), + "softmax": OpSignature(positional=1, optional_kwargs={"axis"}), + "matmul": OpSignature( + positional=2, + optional_kwargs={"rows", "cols", "inner", "m", "n", "k"}, + ), +} +``` + +`OP_SIGNATURES`、语句正则和关键字集合必须放在 Parser 与 Validator 都导入的共享模块中,不能各自维护副本。Parser 的执行 handler 可以独立存在,但必须增加自动测试:`set(OP_SIGNATURES) == set(OP_HANDLERS)`。这样新增算子时,只要漏改一侧,测试会立即失败。 + +### 10.5 完成标准 + +- 不支持算子不再泄漏普通 `DSLParseError` 文本; +- 参数不足不再泄漏 `IndexError`; +- 一行结构错误不会产生多个级联错误; +- 正确基础 DSL 的 Program 与基线一致。 + +--- + +## 11. 阶段五:扩展 DSL 块验证 + +### 11.1 块栈 + +使用独立的验证栈,不复用 IR builder 的 `_loop_stack`: + +```python +@dataclass +class BlockFrame: + kind: Literal["if", "while", "for"] + line: int + col: int + saw_else: bool = False +``` + +### 11.2 必测规则 + +- `endif` 只关闭 `if`; +- `endwhile` 只关闭 `while`; +- `endfor` 只关闭 `for`; +- `else` 必须位于 `if` 内; +- 同一个 `if` 只能有一个 `else`; +- 文件结束时每个未关闭块都报告开始位置; +- 嵌套块错误不能破坏后续独立语句的位置。 + +结束符类型不匹配统一使用 `E110`。例如 `while ... endif` 应在 `endif` 处报告“期望 `endwhile`”,随后把该 `endif` 作为 `while` 的恢复性结束符并弹栈,EOF 不重复报告 `E111`。对于 `if ... while ... endif`,报告一个 `E110` 后弹出内层 `while` 和匹配的外层 `if`。只有真正留到 EOF 的块才报告 `E111`。 + +### 11.3 不要静默接受文件结尾 + +当前扩展解析流程在找不到结束关键字时可能走到文件尾。验证器必须把这种情况转为 `E111`,并指向块开始处,而不是最后一行。 + +--- + +## 12. 阶段六:接入 Parser + +### 12.1 成功路径 + +成功调用保持不变: + +```python +program = DSLParser().parse(source) +program = ExtendedDSLParser().parse(source) +``` + +建议增加 keyword-only 文件名: + +```python +program = DSLParser().parse(source, filename="model.dsl") +``` + +### 12.2 失败路径 + +默认 `parse()` 在验证失败时抛出第一个 `DSLSyntaxError`。它不返回 Program,也不进入 IR 生成阶段。 + +多错误调用使用显式入口: + +```python +collector = ExtendedDSLParser().validate( + source, + filename="bad.dsl", + max_errors=20, +) + +if collector.has_errors: + print(collector.report(), file=sys.stderr) +``` + +### 12.3 Parser 状态重置 + +每次 `parse()` 前确认 builder、变量表、循环栈和标签计数器处于干净状态。诊断集成不能让同一个 parser 实例第二次解析时继承上次失败状态。 + +--- + +## 13. 阶段七:接入 CompilerDriver 与 CLI + +### 13.1 收窄解析器回退 + +当前 `_parse()` 会捕获扩展解析器的任意异常后尝试基础解析器。修改时应遵循: + +- 用户语法错误直接返回,不回退; +- 由于扩展解析器继承基础语法,编译器驱动优先统一使用 `ExtendedDSLParser`;如需保留两种模式,使用显式配置选择,不扫描关键字猜测; +- 只有明确的“解析器不适用”状态允许回退; +- `_parse()` 不捕获 `IndexError`、`AssertionError` 等实现错误。 + +还要修改外层 `CompilerDriver.compile()`:只把 `DSLSyntaxError` 等已知用户错误转换为失败结果,意外异常继续抛出,让库测试直接失败。CLI 顶层负责在正常模式输出 `internal compiler error` 并返回 2;只有显式调试模式打印 traceback。 + +### 13.2 CompileResult + +建议给 `CompileResult` 增加 `diagnostics: list[DSLSyntaxError]`、`diagnostic_limit_reached: bool` 和 `diagnostic_limit: int`。结构化诊断供 CLI 最终渲染,后两个字段把错误抑制状态从 collector 传到 renderer;`errors` 继续保存完整的无颜色文本,兼容现有调用方。`diagnostics` 与 `errors` 同时存在时,CLI 只渲染 `diagnostics`,避免重复输出,但仍根据 `diagnostic_limit_reached` 输出 footer。 + +不要再次添加模糊前缀: + +```text +不建议:Parse error: bad.dsl:5:1: error... +建议: bad.dsl:5:1: error[E100]: ... +``` + +避免出现 `Error: Parse error: ...` 的重复层级。 + +### 13.3 CLI + +CLI 负责: + +- 把诊断写到 stderr; +- 失败返回 1; +- 把 `sys.stderr` 传给 renderer,终端交互时允许颜色; +- 输出重定向或 `NO_COLOR` 存在时关闭颜色; +- 不打印 traceback,除非用户显式启用调试模式。 + +建议的手工验收命令: + +```bash +scratchv bad.dsl -o output.s +scratchv bad.dsl -o output.s 2> error.txt +``` + +检查 `error.txt` 中没有 `\x1b[` ANSI 序列。 + +--- + +## 14. 阶段八:多错误收集 + +### 14.1 只恢复到可信边界 + +基础 DSL 的可信边界是下一物理行;扩展 DSL 还包括 `else`、`endif`、`endwhile` 和 `endfor`。 + +不要在参数列表中盲目寻找下一个逗号后继续,因为当前解析器不是 token 流,容易把后续字符误认为新语句。 + +### 14.2 错误上限 + +建议默认最多报告 20 个真实错误。达到上限后: + +- 停止继续验证; +- 设置 `collector.limit_reached = True`; +- renderer 输出一条无源码位置的 `note: error limit ...` footer; +- 转换为 `CompileResult` 时同步复制 `diagnostic_limit_reached` 和 `diagnostic_limit`; +- 标题中的错误数量仍为 20; +- 不把抑制消息放进 `errors`,也不当作第 21 个源码错误。 + +### 14.3 排序和去重 + +报告按 `(line, col, error_code)` 排序。同一位置、同一错误码、同一消息只保留一次。 + +--- + +## 15. 阶段九:修复建议 + +### 15.1 建议来源 + +```text +显式上下文提示 > 精确拼写表 > 错误码固定提示 > 无提示 +``` + +示例: + +| 输入 | 错误 | 建议 | +|------|------|------| +| `retrun x` | 未识别关键字 | `did you mean 'return'?` | +| `x = ad(a, b)` | 不支持算子 | `did you mean 'add'?` | +| `if (a > b` | 缺少右括号 | `add the missing ')'` | +| 随机未知单词 | 未识别语句 | 不猜测 | + +### 15.2 误报测试 + +不仅要测试“应该出现建议”,也要测试“这里不应出现建议”。例如变量名与关键字相似时,不应建议把变量改成关键字。 + +--- + +## 16. 测试计划 + +### 16.1 快速测试 + +开发过程中每个小步骤运行: + +```bash +python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py -q +``` + +### 16.2 解析器回归 + +```bash +python -m pytest \ + tests/test_parser.py \ + tests/test_dsl_extended.py \ + tests/test_dsl_errors.py \ + tests/test_dsl_validator.py \ + tests/test_dsl_diagnostics_cli.py -q +``` + +Windows PowerShell 可把路径放在同一行执行。 + +```powershell +python -m pytest tests/test_parser.py tests/test_dsl_extended.py tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_dsl_diagnostics_cli.py -q +``` + +### 16.3 项目验证 + +```bash +python .Codex/harness/verify/run.py --level L1 +python .Codex/harness/verify/run.py --level L2 +``` + +如果本地专属 harness 不存在,应明确记录环境缺失,并运行仓库可用的等价检查: + +```bash +make test +python scripts/build_docs_html.py --output-dir benchmark_reports/docs +``` + +不能因为 harness 缺失就声称 L2 已通过。 + +### 16.4 测试用例清单 + +| 类别 | 最少用例 | +|------|----------| +| 正确基础 DSL | 算术、一元算子、matmul、for | +| 正确扩展 DSL | if/else、while、嵌套块 | +| 行列定位 | 首行、中间行、缩进、CRLF、Unicode、tab | +| 语句结构 | 缺赋值号、括号、逗号、冒号、尾部垃圾 | +| 块结构 | 多余结束符、错误类型结束符、缺失结束符、重复 else | +| 算子签名 | 未知算子、参数不足、参数过多、未知 kwarg、非法值 | +| 多错误 | 3 个独立错误、级联抑制、达到上限 | +| 输出 | ANSI 开关、`NO_COLOR`、stderr、退出码 | +| 兼容性 | `DSLParseError` 捕获、原 parse 调用、正确 IR 不变 | + +--- + +## 17. 调试指南 + +### 17.1 caret 偏移一列 + +检查: + +- 内部索引是否 0-based; +- 对外 `col` 是否 1-based; +- 行号前缀宽度是否计入了源码列; +- 原始行是否被 `strip()`; +- tab 是否经过显示宽度映射。 + +不要通过随意加减常数修复单个样例,应先写多个不同列位置的参数化测试。 + +### 17.2 错误行号总是 1 + +确认基础解析器没有使用 `text.strip().split("\n")` 后丢弃原始索引。应对原始物理行执行 `enumerate(..., start=1)`。 + +### 17.3 扩展错误变成普通解析错误 + +检查 `CompilerDriver._parse()` 是否仍然捕获所有异常并回退。结构化用户错误不应触发回退。 + +### 17.4 一处错误产生很多错误 + +验证器可能在外层结构失败后继续执行参数检查。为每行定义“主要结构错误后停止本行”的规则。 + +### 17.5 正确 DSL 的 IR 改变 + +错误诊断改动不应改变成功语义。比较改动前后的 IRPrinter 输出和指令结构,定位验证阶段是否误改了 parser 或 builder 状态。 + +--- + +## 18. 代码评审清单 + +### 正确性 + +- [ ] 所有行列均为 1-based,并有边界测试。 +- [ ] 原始源码行在位置计算前未被破坏。 +- [ ] 有错误时不生成或返回部分 IR。 +- [ ] 扩展语法错误不会被回退逻辑覆盖。 +- [ ] 用户错误不会泄漏 Python 内部异常。 +- [ ] 错误码与设计文档一致。 + +### 兼容性 + +- [ ] `DSLParser().parse(text)` 成功调用保持不变。 +- [ ] `DSLParseError` 旧捕获方式仍有效。 +- [ ] `format_error()` 现有参数仍可使用。 +- [ ] 正确 DSL 的 IR 与基线一致。 + +### 用户体验 + +- [ ] 消息说明问题而不是描述实现细节。 +- [ ] caret 指向真正错误 token。 +- [ ] 修复建议可信且没有明显误报。 +- [ ] 非 TTY 输出不含 ANSI。 +- [ ] 多错误报告没有明显级联噪音。 + +### 测试 + +- [ ] 单元、解析器集成、CompilerDriver 和 CLI 均有覆盖。 +- [ ] LF、CRLF、Unicode、tab 和空文件有覆盖。 +- [ ] 错误上限和去重有覆盖。 +- [ ] L1、L2 或明确记录的等价验证已执行。 + +--- + +## 19. 12 周开发计划 + +| 周次 | 目标 | 可验收产物 | +|------|------|------------| +| W1 | 熟悉 DSL、IRBuilder 和当前错误路径 | 调研笔记、3 个基线错误样例 | +| W2 | 锁定纯文本输出和错误码 | 失败测试、输出规范 | +| W3 | 实现并测试 SourceBuffer | LF/CRLF/Unicode/tab 单元测试 | +| W4 | 统一异常继承和兼容导出 | `DSLParseError` 兼容测试 | +| W5 | 实现基础语句结构验证 | `E100`–`E103` 测试 | +| W6 | 实现算子签名验证 | `E200`–`E203` 测试 | +| W7 | 实现 if/while/for 块栈验证 | `E110`–`E112` 测试 | +| W8 | 接入基础和扩展解析器 | 正确 IR 回归、结构化首错 | +| W9 | 实现多错误恢复、去重和上限 | 3 错误样例、`limit_reached` footer 测试 | +| W10 | 接入 CompilerDriver 和 CLI | stderr、退出码、无 traceback 测试 | +| W11 | 完善颜色、建议和边界用例 | TTY/NO_COLOR、误报测试 | +| W12 | 全量验证、性能测量和文档收尾 | L2 结果、评审报告、演示样例 | + +--- + +## 20. 交付产物 + +建议最终提交包含: + +- 结构化错误与渲染改进; +- `SourceBuffer` 和无副作用 DSL validator; +- 基础、扩展解析器集成; +- CompilerDriver 和 CLI 集成; +- 单元、集成、端到端测试; +- 至少 10 个错误 DSL 示例及预期输出; +- 错误码参考表; +- L1/L2 或等价验证记录; +- 性能测量结果; +- 最终 self-review 报告。 + +--- + +## 21. 最终验收演示 + +准备一个包含三个独立错误的 `bad.dsl`: + +```text +x = ad(a, b) +y = relu(x, 1) +endwhile +``` + +期望演示: + +1. `validate()` 一次报告 3 个错误; +2. 每个错误的位置和错误码正确; +3. `ad` 获得可信的 `add` 拼写建议; +4. `relu` 参数数量错误不泄漏 `IndexError`; +5. `endwhile` 报告无匹配开始块; +6. 编译流程失败且不产生输出汇编; +7. 重定向到文件时没有 ANSI; +8. 修正三处错误后,同一程序正常生成 IR 和目标代码。 + +这组演示同时覆盖位置、建议、签名检查、块检查、多错误、CLI 和成功回归,是本课题最小但完整的验收闭环。 diff --git "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" new file mode 100644 index 0000000..c21faef --- /dev/null +++ "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -0,0 +1,588 @@ +# 课题9:DSL错误提示美化器设计文档 + +> **文档类型**:技术设计 | **状态**:草案 | **版本**:v0.1 +> **调研基线**:`main@997d2aa` | **调研日期**:2026-07-15 +> **关联模块**:`scratchv/frontend/dsl_errors.py`、`dsl_parser.py`、`dsl_extended.py`、`scratchv/compiler.py` + +--- + +## 1. 文档定位 + +本文描述 ScratchV DSL 错误提示美化器的**拟议迭代方案**,用于指导后续开发和评审。 + +本文不是已完成实现的说明。当前仓库已经具备独立的错误对象、文本格式化器和错误收集器,但尚未把这些能力完整接入基础 DSL 解析器、扩展 DSL 解析器和编译器驱动。文中标为“建议”“拟新增”的接口均属于设计提案。 + +配套文档: + +- [课题9:DSL错误提示美化器](09-DSL错误提示美化器.md):现有课程概览 +- [课题9:DSL错误提示美化器开发文档](09-DSL错误提示美化器-开发文档.md):建议开发顺序、测试方法和交付标准 +- [课题1:DSL前端增强器](01-DSL前端增强器.md):扩展 DSL 的控制流语法 + +--- + +## 2. 背景与问题 + +ScratchV 提供两套 DSL 解析器: + +- `DSLParser`:解析逐行表达式、`return` 和 `for/endfor`。 +- `ExtendedDSLParser`:在基础语法上增加 `if/else/endif` 和 `while/endwhile`。 + +当前解析失败时,用户通常只能得到类似下面的消息: + +```text +Error: Parse error: Cannot parse line: retrun result +``` + +这条消息没有文件名、行号、列号和修复建议。更重要的是,编译器驱动会先尝试扩展解析器,并用宽泛的 `except Exception` 回退到基础解析器。扩展解析器产生的原始错误可能被覆盖,最终报告与真正失败位置不一致。 + +目标体验如下: + +```text +bad.dsl:5:1: error[E100]: cannot parse statement + 5 | retrun result + | ^~~~~~ +note: did you mean 'return'? +``` + +好的诊断应回答四个问题: + +1. 哪个文件、哪一行、哪一列出错? +2. 编译器实际发现了什么问题? +3. 哪段源码与问题直接相关? +4. 用户下一步应该怎样修复? + +--- + +## 3. 当前实现审计 + +### 3.1 已有能力 + +| 位置 | 当前能力 | 结论 | +|------|----------|------| +| `dsl_errors.py` | `DSLSyntaxError` 保存行、列、消息、源码行、文件名、提示和错误码 | 可复用 | +| `dsl_errors.py` | `format_error()` 输出 gcc/clang 风格文本并支持 ANSI 颜色 | 可复用,需补充自动颜色策略 | +| `dsl_errors.py` | `ErrorCollector` 支持收集、上限、报告和清空 | 可复用,需明确恢复与计数语义 | +| `dsl_errors.py` | `_SUGGESTIONS` 和 `_COMMON_FIXES` 提供少量启发式建议 | 可复用,需避免误报 | +| `frontend/__init__.py` | 导出 `DSLSyntaxError`、`format_error` 和 `ErrorCollector` | 已形成部分公共接口 | +| `tests/test_dsl_errors.py` | 覆盖错误对象、颜色、格式化、收集器和工厂函数 | 单模块测试基础较好 | + +### 3.2 主要差距 + +| 差距 | 当前表现 | 用户影响 | +|------|----------|----------| +| 解析器未集成 | `DSLParser` 和 `ExtendedDSLParser` 仍抛出 `DSLParseError` 或底层异常 | 美化器只可手工调用 | +| 原始位置丢失 | 基础解析器对每行执行 `strip()`,没有保存原始行号和缩进 | 无法可靠计算列号 | +| 扩展错误被吞掉 | `CompilerDriver._parse()` 捕获扩展解析器的所有异常后回退 | 真正错误原因可能被替换 | +| 参数错误泄漏 | 参数数量不足可能触发 `IndexError` | 用户看到 Python 内部异常 | +| 块结构不完整 | 缺少 `endif` 或 `endwhile` 时可能静默到文件结尾 | 错误程序可能产生不完整 IR | +| 多错误仅有容器 | `ErrorCollector` 能保存多个错误,但解析器没有同步和恢复机制 | 遇到首错仍无法继续 | +| 上下文不完整 | `context_lines` 没有完整源文件,只能输出空的上下文行号 | 无法显示真实前后文 | +| 颜色默认开启 | `use_color=True` 不检查 TTY 或 `NO_COLOR` | 重定向到文件时出现转义码 | +| 测试缺少端到端覆盖 | 没有“错误 DSL → 解析器 → CompileResult/CLI”测试 | 集成回归无法被发现 | + +### 3.3 语义约束 + +基础解析器的 `_resolve()` 会把首次出现的名字创建为输入值,因此当前 DSL 没有严格的“变量未定义”错误语义。错误美化器不应在未改变语言规则的前提下报告“未定义变量”。如需该能力,应另立语言语义课题。 + +--- + +## 4. 设计目标与非目标 + +### 4.1 设计目标 + +1. 基础和扩展 DSL 的解析错误均携带稳定、准确的位置。 +2. 默认库调用保持失败即停止,避免返回被错误污染的 IR。 +3. 显式验证模式可以一次报告多个相互独立的错误。 +4. 所有用户输入错误转换为结构化诊断,不暴露 `IndexError`、`KeyError` 等实现异常。 +5. 纯文本输出稳定,适合单元测试、CI、日志和文件重定向。 +6. 现有 `DSLParser().parse(text)` 的成功路径和 IR 结果保持兼容。 +7. 错误码、位置规则和恢复规则可测试、可扩展。 + +### 4.2 非目标 + +本课题不包含: + +- 重写完整词法器或引入第三方解析框架; +- 修改 DSL 的变量定义、类型检查或算子语义; +- 自动修改用户源码; +- LSP、编辑器插件或 IDE 实时诊断; +- JSON/SARIF 报告协议; +- 修改 ONNX 解析器的错误体系; +- 在语法错误存在时生成或执行部分 IR。 + +--- + +## 5. 设计原则 + +### 5.1 先验证,后生成 IR + +当前解析器在读取源码的同时调用 `IRBuilder`。如果在错误后强行继续,builder 中可能残留半个循环或基本块,后续错误也容易成为连锁误报。 + +因此多错误模式采用“两阶段”策略: + +```text +源文件 ──▶ 轻量语法验证 ──▶ 0 个错误? ──是──▶ 现有 IR 生成流程 + │ + └────────否──▶ ErrorCollector ──▶ 格式化报告 +``` + +验证阶段只检查可从源码直接确定的规则,不创建 IR。只有验证通过后才进入现有解析和 IR 构建流程。 + +### 5.2 精确错误优先于大量错误 + +多错误收集不是越多越好。一个缺失的右括号可能导致同一行出现多个派生错误。验证器应在确认一条语句的外层结构错误后停止分析该语句,只在下一条独立语句继续。 + +### 5.3 公共接口渐进兼容 + +现有 `dsl_errors.py` 的公开字段和常用函数继续保留。新增字段必须提供默认值;成功解析的调用方式不变;旧的 `DSLParseError` 导入路径继续有效。 + +--- + +## 6. 总体架构 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ DSL source / filename │ +└─────────────────────────────┬────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ SourceBuffer │ +│ 保留原始文本、物理行、文件名;负责 offset/line/column 映射 │ +└─────────────────────────────┬────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ DSLValidator │ +│ 行级语法、算子签名、控制流块栈;不调用 IRBuilder │ +└───────────────┬───────────────────────────────┬──────────────┘ + │ 诊断 │ 无诊断 + ▼ ▼ +┌────────────────────────────┐ ┌───────────────────────────┐ +│ ErrorCollector │ │ DSLParser / Extended... │ +│ 去重、排序、上限、抑制提示 │ │ 复用现有 IR 生成流程 │ +└───────────────┬────────────┘ └─────────────┬─────────────┘ + ▼ ▼ +┌────────────────────────────┐ ┌───────────────────────────┐ +│ DiagnosticRenderer │ │ Program │ +│ ANSI / plain text │ │ 后续优化与代码生成 │ +└────────────────────────────┘ └───────────────────────────┘ +``` + +### 6.1 组件职责 + +| 组件 | 职责 | 不负责 | +|------|------|--------| +| `SourceBuffer` | 保存原始源码、获取行文本、映射位置 | 判断语法是否正确 | +| `DSLValidator` | 产生结构化错误并执行有限同步 | 创建 IR、修复源码 | +| `DSLSyntaxError` | 表示一个诊断 | 读取文件、打印到终端 | +| `ErrorCollector` | 收集、去重、排序和限制诊断 | 决定解析恢复点 | +| `format_error()` | 把单个诊断渲染为文本 | 推断语言语义 | +| `DSLParser` | 验证通过后生成 IR | 在错误状态下返回部分 Program | +| `CompilerDriver` | 选择解析器并把诊断交给 `CompileResult` | 吞掉异常后盲目回退 | + +--- + +## 7. 数据模型设计 + +### 7.1 SourceBuffer(拟新增内部类型) + +```python +@dataclass(frozen=True) +class SourceBuffer: + text: str + filename: str = "" + + def line_text(self, line: int) -> str: ... + def line_count(self) -> int: ... +``` + +约束: + +- 行号和列号均从 1 开始。 +- 列号以 Python 字符索引为基础,即按 Unicode code point 计数。 +- 保留原始行,不在位置计算前执行 `strip()`。 +- 渲染器把制表符按 4 列展开,但错误对象中的列号仍指向原始字符位置。caret 的显示列必须逐字符换算:普通字符增加 1 列,tab 增加到下一个 4 列制表位,不能直接把 raw column 当作显示空格数。 +- Windows `\r\n` 与 Unix `\n` 均映射到相同的物理行。 + +### 7.2 DSLSyntaxError(兼容扩展) + +建议保留现有必需字段,并增加可选的结束列: + +```python +@dataclass +class DSLSyntaxError(DSLParseError): + line: int + col: int + message: str + source_line: str = "" + filename: Optional[str] = None + fix_hint: Optional[str] = None + error_code: Optional[str] = None + end_col: Optional[int] = None # 1-based, exclusive +``` + +兼容策略: + +- `DSLSyntaxError` 继承或等价兼容 `DSLParseError`,保留旧代码的捕获行为。 +- 新字段放在已有字段之后并提供默认值。 +- `end_col is None` 时继续使用当前 token 长度估算。 +- `__str__()` 始终输出无颜色文本,避免异常字符串携带终端控制码。 + +### 7.3 验证结果 + +多错误收集使用显式验证入口,避免改变 `parse()` 的返回类型: + +```python +def validate( + self, + text: str, + *, + filename: Optional[str] = None, + max_errors: int = 20, +) -> ErrorCollector: ... +``` + +调用约定: + +- `validate()` 不生成 IR。 +- `collector.has_errors` 为真时,调用者不得继续编译。 +- `parse()` 可先调用同一验证逻辑;发现错误时抛出第一个 `DSLSyntaxError`。 +- CLI 或教学工具需要一次展示多个错误时,先调用 `validate()`,再调用 `report()`。 + +### 7.4 编译结果中的结构化诊断 + +CLI 需要根据实际 stderr 是否为 TTY 决定颜色,因此 `CompilerDriver` 不能只返回已经格式化的字符串。建议为 `CompileResult` 增加兼容字段: + +```python +@dataclass +class CompileResult: + # 已有字段保持不变 + errors: list[str] + diagnostics: list[DSLSyntaxError] = field(default_factory=list) + diagnostic_limit_reached: bool = False + diagnostic_limit: int = 20 +``` + +数据流约定: + +- `diagnostics` 保存结构化错误,是 CLI 渲染的首选数据源。 +- `diagnostic_limit_reached` 和 `diagnostic_limit` 把 collector 的抑制状态传到 CLI,保证 renderer 能输出 footer。 +- `errors` 保留无 ANSI 文本,兼容现有库调用方和序列化逻辑。 +- 两个字段同时存在时,CLI 只渲染 `diagnostics`,不得重复打印 `errors`。 +- 只有无法表示为 DSL 诊断的预期业务错误才仅写入 `errors`。 +- 错误上限属于报告元数据,不伪造为带 `line=0, col=0` 的 `DSLSyntaxError`。 + +--- + +## 8. 错误分类与错误码 + +错误码用于测试、文档检索和未来扩展。消息文字可以改进,错误码语义必须保持稳定。 + +| 错误码 | 分类 | 触发条件 | 建议高亮 | +|--------|------|----------|----------| +| `E100` | 未识别语句 | 整行不符合任何 DSL 语句 | 第一个非空 token | +| `E101` | 括号不配对 | 调用或条件缺少左右括号 | 缺失点或多余括号 | +| `E102` | 缺少分隔符 | 赋值号、逗号或控制流冒号缺失 | 邻近 token | +| `E103` | 非法标识符 | 目标名或循环变量不符合规则 | 完整标识符 | +| `E110` | 块结束符不匹配 | `endif`、`endwhile`、`endfor` 无匹配开始,或与当前栈顶块类型不一致 | 结束关键字 | +| `E111` | 块未闭合 | 到文件结尾仍存在打开的块 | 对应开始关键字 | +| `E112` | `else` 位置错误 | 没有对应 `if` 或同一块重复 `else` | `else` token | +| `E200` | 不支持的算子 | 算子名不在注册表 | 算子名 | +| `E201` | 参数数量错误 | 位置参数数量不符合算子签名 | 调用参数区 | +| `E202` | 关键字参数错误 | 未知、重复或缺失的 kwarg | 参数名 | +| `E203` | 参数值错误 | `rows`、`axis` 等需要数值但格式非法 | 参数值 | + +不在本课题中使用的错误: + +- “变量未定义”:现有语言把首次出现的名字视为输入值。 +- 类型不匹配:当前 DSL 前端没有完整类型检查阶段。 +- IR 验证错误:应由 `IRVerifier` 报告,而不是 DSL 语法层报告。 + +--- + +## 9. 验证与错误恢复 + +### 9.1 基础 DSL + +基础 DSL 以物理行为自然同步边界。单行验证流程建议如下: + +```text +跳过空行/注释 + ├─ for 语句 → 检查变量、范围和 block stack + ├─ endfor → 检查栈顶是否为 for + ├─ return → 检查关键字后是否具有非空操作数 + └─ assignment → 检查 lhs、op、括号、参数与 kwargs +``` + +如果一行外层结构已经错误,验证器记录一条主要错误后直接进入下一物理行,不继续检查该行的算子参数。 + +### 9.2 扩展 DSL + +扩展语法使用块栈: + +```python +BlockFrame(kind="if", line=4, saw_else=False) +BlockFrame(kind="while", line=9) +BlockFrame(kind="for", line=12) +``` + +同步规则: + +1. 普通语句失败后跳到下一物理行。 +2. `else` 只与最近且尚未出现 `else` 的 `if` 匹配。 +3. 结束关键字与栈顶匹配时正常弹栈;栈为空时报告 `E110` 后继续。 +4. 栈非空但类型不匹配时,在当前结束符报告一个 `E110`,消息同时写明“发现什么”和“期望什么”。为避免 EOF 级联:若该结束符能匹配更外层块,则弹出到该外层块(含它);若栈中没有可匹配块,则把它作为栈顶块的恢复性结束符并弹出栈顶。被恢复弹出的块不再报告 `E111`。 +5. 到达文件尾时,只为仍留在栈中的每个未闭合块报告 `E111`。 +6. 达到错误上限后停止验证,并把 `collector.limit_reached` 设为真;抑制提示作为报告 footer 输出,不加入 `errors` 列表。 + +恢复示例: + +| 源码结构 | 诊断序列 | 恢复后栈 | +|----------|----------|----------| +| `while ... endif` | 当前 `endif` 报 1 个 `E110`:期望 `endwhile` | 弹出 `while`,EOF 不再报 `E111` | +| `if ... while ... endif` | 当前 `endif` 报 1 个 `E110`:应先出现 `endwhile` | 弹出 `while` 和匹配的 `if` | +| `if ... while ... EOF` | `while`、`if` 的开始位置各报 1 个 `E111` | 文件结束 | + +### 9.3 去重和级联抑制 + +建议用以下键去重: + +```text +(filename, line, col, error_code, message) +``` + +同一行最多报告一个结构错误和一个独立的算子签名错误。由结构错误直接导致的后续错误不再报告。 + +--- + +## 10. 修复建议策略 + +修复建议应当保守。错误建议错误时,比没有建议更影响用户判断。 + +优先级从高到低: + +1. 解析器在明确上下文中提供的 `fix_hint`; +2. 精确拼写表,例如 `retrun → return`; +3. 基于错误码的固定建议,例如 `E101 → add the missing ')'`; +4. 没有足够信息时不输出建议。 + +建议规则: + +- 拼写建议仅比较同类关键字或算子名。 +- 不对任意源代码单词做全局替换建议。 +- 不建议会改变程序语义的操作。 +- 提示文字不参与控制流判断;逻辑只依赖错误码和结构化字段。 + +--- + +## 11. 渲染设计 + +### 11.1 纯文本格式 + +```text +{filename}:{line}:{col}: error[{code}]: {message} + {line_width} | {source_line} + {padding} | {caret_and_tildes} +note: {fix_hint} +``` + +约束: + +- 文件名未知时显示 ``,不输出空位置前缀。 +- 行号宽度按本次报告的最大行号计算。 +- `end_col` 存在时按跨度绘制 `^~~~`;不存在时才估算 token 长度。 +- `source_line` 为空时不显示源码和 caret。 +- 测试和 `str(error)` 强制无颜色。 + +### 11.2 ANSI 颜色 + +保留 `format_error(err, use_color: bool)` 作为兼容的纯格式化函数;另提供面向输出流的 renderer: + +```python +def render_error( + err: DSLSyntaxError, + *, + stream: TextIO, + use_color: Optional[bool] = None, +) -> str: ... +``` + +`render_error()` 的颜色规则: + +- `True`:强制开启; +- `False`:强制关闭; +- `None`:仅当传入的 `stream.isatty()` 为真且未设置 `NO_COLOR` 时开启。 + +颜色只改变显示,不得改变可见字符内容、错误码或换行数量。 + +### 11.3 多错误报告 + +`ErrorCollector.report()` 应区分真实错误和抑制消息: + +```text +--- 3 error(s) found --- +... +note: error limit (3) reached; further errors suppressed +``` + +标题中的数量只统计真实源码错误。错误上限提示在验证阶段来自 `ErrorCollector.limit_reached`,进入编译驱动后复制到 `CompileResult.diagnostic_limit_reached`;它不占用错误位置,也不计入 `error_count`。 + +--- + +## 12. 解析器与编译器驱动集成 + +### 12.1 基础解析器 + +建议的成功路径保持不变: + +```python +program = DSLParser().parse(source) +``` + +拟议扩展: + +```python +program = DSLParser().parse(source, filename="model.dsl") +collector = DSLParser().validate(source, filename="model.dsl") +``` + +`_parse_line()` 应接收带原始位置的行上下文,而不是只接收 `strip()` 后的字符串。 + +### 12.2 扩展解析器 + +`ExtendedDSLParser` 与基础解析器共享 `SourceBuffer`、算子签名验证和错误工厂,只增加控制流块规则。不要复制一套错误消息和错误码。 + +Validator 与 Parser 还必须共享语法事实源,例如 `STATEMENT_PATTERNS`、`OP_SIGNATURES` 和关键字集合。Parser 的执行分派可以保留独立 handler,但其算子名集合必须由测试断言与 `OP_SIGNATURES` 完全一致,避免“验证通过、执行失败”或“Parser 接受、Validator 拒绝”。 + +### 12.3 CompilerDriver + +当前“扩展解析失败后捕获所有异常并尝试基础解析器”的策略需要移除或收窄: + +- 由于 `ExtendedDSLParser` 已继承基础语法,编译器驱动可统一使用扩展解析器;如果未来保留两种模式,则必须由显式配置选择,不能扫描关键字猜测,也不能通过异常回退选择; +- `DSLSyntaxError` 属于用户输入错误,必须原样保留,不得触发回退; +- 只有明确表示“该解析器不适用”的内部信号才允许回退; +- `CompileResult.diagnostics` 保存结构化诊断,`diagnostic_limit_reached`/`diagnostic_limit` 保存抑制状态,`errors` 保存兼容的无 ANSI 文本; +- CLI 把 `sys.stderr` 传给 renderer,由 renderer 根据该流的 TTY 状态决定颜色。 + +`CompilerDriver.compile()` 的异常边界也必须同步调整:它只捕获 `DSLSyntaxError` 等已知用户输入错误并生成失败的 `CompileResult`。`IndexError`、`AssertionError` 等意外异常不应被包装成普通 Parse error;库调用时让它们抛出以便测试发现,CLI 顶层再把它们转换为明确的 `internal compiler error` 和退出码 2。正常模式不打印 traceback,显式调试模式才打印。 + +这样可以避免扩展语法错误被基础解析器的次生错误覆盖。 + +--- + +## 13. 测试设计 + +### 13.1 单元测试 + +| 测试对象 | 重点 | +|----------|------| +| `SourceBuffer` | LF/CRLF、空文件、末尾换行、Unicode、制表符 | +| `DSLSyntaxError` | 兼容构造、继承关系、`end_col`、无颜色 `str()` | +| `format_error()` / renderer | 对齐、跨度、无文件名、无源码、指定 stream 的颜色自动策略 | +| `ErrorCollector` | 去重、排序、错误上限、真实错误计数、清空 | +| 建议规则 | 精确命中、大小写、无误报、显式提示优先 | + +### 13.2 解析器集成测试 + +至少覆盖: + +- 无法识别的语句; +- `retrun` 等拼写错误; +- 缺少左右括号; +- 不支持的算子; +- 一元、二元和带 kwarg 算子的参数数量错误; +- 多余 `endfor`、`endif`、`endwhile`; +- 缺失块结束符; +- `else` 无匹配或重复; +- 嵌套 `if/while/for` 的恢复; +- 同一文件多个独立错误; +- 正确 DSL 的 IR 与改动前一致。 + +### 13.3 端到端测试 + +```text +bad.dsl → CompilerDriver.compile() → CompileResult(success=False) +``` + +断言: + +- 包含文件名、行、列、错误码和源码行; +- 不包含 Python traceback、`IndexError` 或 `KeyError`; +- 扩展语法错误没有被基础解析器错误覆盖; +- CLI 失败退出码为 1; +- 重定向输出时没有 ANSI 转义码。 + +### 13.4 快照测试原则 + +只对纯文本格式使用快照。每个快照应尽量只包含一个概念,避免所有错误共用一个巨大 golden 文件。错误码和位置做精确断言,建议文本可以单独断言。 + +--- + +## 14. 兼容性与迁移 + +| 风险 | 兼容措施 | +|------|----------| +| 调用方捕获 `DSLParseError` | 让 `DSLSyntaxError` 保持其子类或兼容别名关系 | +| 调用方只传 `text` | `filename` 使用 keyword-only 可选参数 | +| 测试依赖无颜色 `str(e)` | `__str__()` 固定调用 `use_color=False` | +| 正确程序 IR 发生变化 | 增加改动前后 IR 快照或结构对比测试 | +| 旧格式没有错误码 | 错误码作为新增内容,文档标明输出格式版本变化 | +| 外部调用直接使用 `format_error()` | 保留现有参数,新增行为通过可选参数提供 | + +--- + +## 15. 性能要求 + +错误诊断不是编译热点,但验证阶段不能明显拖慢批量基准: + +- 源码扫描时间复杂度为 `O(n)`,`n` 为源码字符数。 +- 每行最多进行常数次正则匹配和算子表查询。 +- 拼写建议优先使用字典精确匹配,不对所有词执行无界编辑距离搜索。 +- 对 `benchmarks/cases/*.dsl` 执行 5 次预热,再执行 10 组测量;每组依次解析全部用例 100 次,使用组耗时中位数。相同机器、Python 版本和进程配置下,“验证 + IR 生成”的中位数目标不超过原解析流程的 1.5 倍。 +- 错误数量达到上限后立即停止进一步分析。 + +报告必须同时给出基线、改动后中位数、倍率和测试环境;不能仅凭主观判断声明达成。 + +--- + +## 16. 验收标准 + +满足以下条件时,课题可判定完成: + +1. 基础和扩展解析器的用户输入错误均转换为 `DSLSyntaxError`。 +2. 每个错误至少包含文件名、1-based 行列、稳定错误码和可读消息。 +3. 至少覆盖第 8 节列出的 `E100`、`E101`、`E110`、`E111`、`E200`、`E201`。 +4. 显式验证模式能从一个文件报告至少 3 个独立错误。 +5. 有语法错误时不返回可执行的部分 IR。 +6. `CompilerDriver` 不再用基础解析器错误覆盖扩展解析器错误。 +7. 纯文本输出不含 ANSI,终端自动颜色遵守 TTY 和 `NO_COLOR`。 +8. 现有正确 DSL 示例、解析器测试和 L2 验证全部通过。 +9. 新增单元、集成和端到端测试均通过。 +10. 文档说明如何增加新的错误码、验证规则和修复建议。 + +--- + +## 17. 风险与权衡 + +| 选择 | 收益 | 代价 | +|------|------|------| +| 验证与 IR 生成分离 | 多错误收集安全,不污染 builder | 部分语法规则会被检查两次 | +| 保留正则解析 | 改动小,适合教学课题 | 复杂语法扩展能力有限 | +| 稳定错误码 | 测试与文档可长期引用 | 新错误分类需要谨慎评审 | +| 保守修复建议 | 降低误导用户的概率 | 可提供的建议数量较少 | +| 不做部分 IR | 保证下游阶段输入可信 | 无法展示错误后的局部编译结果 | + +如果未来 DSL 语法明显增长,应把本方案视为迁移到真正 tokenizer/parser 之前的过渡层,而不是无限扩展行级正则验证器。 + +--- + +## 18. 待后续课题讨论 + +以下方向保留为未来工作,不作为本课题验收项: + +- 统一 ONNX、DSL、IR 验证器的 `Diagnostic` 协议; +- JSON、SARIF 和机器可读错误输出; +- LSP 诊断与编辑器下划线; +- 跨行 SourceSpan 和相关位置(related locations); +- 基于算子注册表自动生成参数诊断; +- 国际化错误消息; +- 将 DSL 正式迁移到 token 流和语法树。 From c9dd5328d82b4ea6e2cd8e7b303617285319bbbf Mon Sep 17 00:00:00 2001 From: mahiru114514 <243091814+mahiru114514@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:39:48 +0800 Subject: [PATCH 2/7] feat: integrate structured DSL diagnostics --- ...26-08-12-dsl-diagnostics-implementation.md | 231 ++++++++++++++ scratchv/compiler.py | 48 ++- scratchv/frontend/__init__.py | 14 +- scratchv/frontend/dsl_errors.py | 105 ++++-- scratchv/frontend/dsl_extended.py | 17 +- scratchv/frontend/dsl_parser.py | 31 +- scratchv/frontend/dsl_validator.py | 300 ++++++++++++++++++ scratchv/main.py | 36 ++- tests/test_dsl_diagnostics_cli.py | 96 ++++++ tests/test_dsl_errors.py | 88 ++++- tests/test_dsl_validator.py | 168 ++++++++++ 11 files changed, 1076 insertions(+), 58 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-12-dsl-diagnostics-implementation.md create mode 100644 scratchv/frontend/dsl_validator.py create mode 100644 tests/test_dsl_diagnostics_cli.py create mode 100644 tests/test_dsl_validator.py diff --git a/docs/superpowers/plans/2026-08-12-dsl-diagnostics-implementation.md b/docs/superpowers/plans/2026-08-12-dsl-diagnostics-implementation.md new file mode 100644 index 0000000..0b2e1e9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-dsl-diagnostics-implementation.md @@ -0,0 +1,231 @@ +# DSL Diagnostics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Integrate structured, location-aware diagnostics into ScratchV's base and extended DSL paths while preserving successful DSL IR output and leaving all non-DSL behavior unchanged. + +**Architecture:** Add a DSL-only source/validation layer shared by both parsers. Validation runs before IR generation, reports stable diagnostic codes through `DSLSyntaxError` and `ErrorCollector`, and passes structured diagnostics through only the DSL branches of `CompilerDriver` and the CLI. + +**Tech Stack:** Python 3.12-compatible type hints, dataclasses, regular expressions, argparse, pytest. + +--- + +## File map and scope boundary + +- Create `scratchv/frontend/dsl_validator.py`: source preservation, shared operator signatures, line validation, block-stack recovery, multi-error collection. +- Modify `scratchv/frontend/dsl_errors.py`: compatible diagnostic spans, plain/automatic rendering, deterministic collector semantics. +- Modify `scratchv/frontend/dsl_parser.py`: pre-IR validation and filename-aware errors; preserve successful IR generation. +- Modify `scratchv/frontend/dsl_extended.py`: use shared validation before extended IR generation; preserve successful control-flow IR. +- Modify `scratchv/frontend/__init__.py`: export new DSL-only public diagnostic interfaces. +- Modify `scratchv/compiler.py`: only DSL parsing and diagnostic result fields. +- Modify `scratchv/main.py`: only structured DSL diagnostic rendering and top-level internal-error exit handling. +- Create `tests/test_dsl_validator.py`: validator and parser integration tests. +- Create `tests/test_dsl_diagnostics_cli.py`: CompilerDriver/CLI DSL diagnostics tests. +- Modify `tests/test_dsl_errors.py`: error model, renderer and collector regression tests. +- Do not modify ONNX parsing, IR definitions, optimizer passes, backends, simulators, benchmarks, or their tests. + +### Task 1: Diagnostic model and rendering + +**Files:** +- Modify: `scratchv/frontend/dsl_errors.py` +- Test: `tests/test_dsl_errors.py` + +- [ ] **Step 1: Write failing tests for compatibility, spans, tabs and automatic color** + +Add tests asserting that `DSLSyntaxError` remains catchable as `DSLParseError`, accepts exclusive `end_col`, renders `error[E100]`, expands tabs at four-column stops, keeps `str(error)` ANSI-free, and `render_error(..., use_color=None)` honors `isatty()` and `NO_COLOR`. + +- [ ] **Step 2: Verify the new renderer tests fail for missing behavior** + +Run: `python -m pytest tests/test_dsl_errors.py -q` + +Expected: failures for `end_col`, `render_error`, header placement, tab alignment, and collector limit metadata. + +- [ ] **Step 3: Implement the minimal compatible diagnostic API** + +Implement these interfaces without changing existing positional constructor compatibility: + +```python +@dataclass +class DSLSyntaxError(DSLParseError): + line: int + col: int + message: str + source_line: str = "" + filename: Optional[str] = None + fix_hint: Optional[str] = None + error_code: Optional[str] = None + end_col: Optional[int] = None + +def render_error( + err: DSLSyntaxError, + *, + stream: TextIO, + use_color: Optional[bool] = None, +) -> str: ... +``` + +Keep `format_error()` available, default unknown filenames to ``, render codes as `error[E100]:`, and calculate display columns by expanding tabs to four-column stops. + +- [ ] **Step 4: Make collector semantics deterministic** + +Add `limit_reached`; stop at exactly `max_errors`; do not append a fake line-zero error; deduplicate by `(filename, line, col, error_code, message)`; sort reports by `(line, col, error_code or "")`; reset all state in `clear()`. + +- [ ] **Step 5: Verify Task 1 is green** + +Run: `python -m pytest tests/test_dsl_errors.py -q` + +Expected: all tests in the file pass with no warnings. + +### Task 2: Shared source buffer and base DSL validation + +**Files:** +- Create: `scratchv/frontend/dsl_validator.py` +- Modify: `scratchv/frontend/dsl_parser.py` +- Test: `tests/test_dsl_validator.py` +- Test: `tests/test_parser.py` + +- [ ] **Step 1: Write failing source-buffer and E100/E101/E200/E201 tests** + +Cover LF/CRLF, blank input, trailing newline, Unicode and tabs. Add parser-facing cases for `retrun x`, missing call parenthesis, unsupported `ad`, and incorrect arity for unary/binary/keyword operators. Assert exact 1-based line/column, source line, filename and stable code. + +- [ ] **Step 2: Verify base-validator RED behavior** + +Run: `python -m pytest tests/test_dsl_validator.py tests/test_parser.py -q` + +Expected: new tests fail because `SourceBuffer`, `DSLValidator.validate()` and filename-aware `parse()` do not exist. + +- [ ] **Step 3: Add the shared syntax facts** + +Define immutable `SourceBuffer`, `OpSignature`, base statement patterns, keyword sets, and `OP_SIGNATURES` for every operator dispatched by `DSLParser`. Validate assignment structure with `fullmatch`, split arguments without creating IR, and stop validation of a line after its first structural error. + +- [ ] **Step 4: Add base block and signature validation** + +Validate `for/endfor`, `return`, identifiers, parentheses, supported operations, positional counts, known/duplicate kwargs and numeric kwargs. Preserve the language rule that first-use variable names are valid inputs. + +- [ ] **Step 5: Gate base parsing before IR generation** + +Expose: + +```python +def validate( + self, + text: str, + *, + filename: Optional[str] = None, + max_errors: int = 20, +) -> ErrorCollector: ... + +def parse( + self, + text: str, + *, + filename: Optional[str] = None, +) -> Program: ... +``` + +`parse()` raises the first `DSLSyntaxError` before resetting and using `IRBuilder`; successful programs retain their previous `Program.dump()` structure. + +- [ ] **Step 6: Verify base validation and successful IR are green** + +Run: `python -m pytest tests/test_dsl_validator.py tests/test_parser.py tests/test_dsl_errors.py -q` + +Expected: all selected tests pass. + +### Task 3: Extended block validation and recovery + +**Files:** +- Modify: `scratchv/frontend/dsl_validator.py` +- Modify: `scratchv/frontend/dsl_extended.py` +- Test: `tests/test_dsl_validator.py` +- Test: `tests/test_dsl_extended.py` + +- [ ] **Step 1: Write failing E110/E111/E112 recovery tests** + +Cover stray closers, missing closers, `else` without `if`, duplicate `else`, `while ... endif`, `if ... while ... endif`, nested blocks at EOF, and three independent errors in one file. Assert mismatched closers do not also create recovered-block E111 errors. + +- [ ] **Step 2: Verify extended-validator RED behavior** + +Run: `python -m pytest tests/test_dsl_validator.py tests/test_dsl_extended.py -q` + +Expected: new block-validation cases fail under the current silent-EOF behavior. + +- [ ] **Step 3: Implement one shared block stack** + +Use `BlockFrame(kind, line, col, saw_else)` in the validator. Match `endif/endwhile/endfor` against the stack; on mismatch, emit one E110 and recover by popping through an outer matching block or treating the closer as recovery for the top frame. Emit E111 only for frames left at EOF. + +- [ ] **Step 4: Gate extended parsing with the shared validator** + +Override validation only to enable extended rules, then validate before any labels, blocks or instructions are created. Keep the existing successful `if/else/while/for` IR path and reset parser state on every call. + +- [ ] **Step 5: Verify extended validation and IR regression** + +Run: `python -m pytest tests/test_dsl_validator.py tests/test_dsl_extended.py tests/test_parser.py -q` + +Expected: all selected tests pass. + +### Task 4: DSL-only CompilerDriver and CLI integration + +**Files:** +- Modify: `scratchv/compiler.py` +- Modify: `scratchv/main.py` +- Modify: `scratchv/frontend/__init__.py` +- Create: `tests/test_dsl_diagnostics_cli.py` + +- [ ] **Step 1: Write failing CompilerDriver and CLI tests** + +Test a temporary bad `.dsl` through `CompilerDriver.compile()` and `main(argv)`. Assert `success is False`, `diagnostics` contains the original `DSLSyntaxError`, `errors` contains the complete plain rendering without `Parse error:` duplication, CLI exits 1, redirected stderr has no ANSI, and missing `endif` is not overwritten by a base-parser error. + +- [ ] **Step 2: Verify integration RED behavior** + +Run: `python -m pytest tests/test_dsl_diagnostics_cli.py -q` + +Expected: failures for missing structured result fields, broad parser fallback and CLI rendering. + +- [ ] **Step 3: Add compatible result metadata** + +Append defaulted `diagnostics`, `diagnostic_limit_reached`, and `diagnostic_limit` fields to `CompileResult`. In the DSL branch only, use `ExtendedDSLParser.parse(text, filename=input_path)` and preserve `DSLSyntaxError` without broad fallback. Do not alter ONNX parsing or successful result semantics. + +- [ ] **Step 4: Render diagnostics once at the CLI boundary** + +When `result.diagnostics` is non-empty, render them to `sys.stderr` with automatic color and do not print `result.errors` again. Preserve exit 1 for input diagnostics. Convert unexpected top-level exceptions to `internal compiler error` and exit 2 without traceback in normal mode. + +- [ ] **Step 5: Verify end-to-end DSL diagnostics** + +Run: `python -m pytest tests/test_dsl_diagnostics_cli.py tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_parser.py tests/test_dsl_extended.py -q` + +Expected: all selected tests pass with no ANSI in captured output. + +### Task 5: Scope, regression and project verification + +**Files:** +- Modify only files listed in the file map if verification exposes a DSL-specific defect. + +- [ ] **Step 1: Run every DSL-related test** + +Run: `python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_dsl_diagnostics_cli.py tests/test_parser.py tests/test_dsl_extended.py -q` + +Expected: all pass. + +- [ ] **Step 2: Run L1/L2 Harness if present** + +Run: `python .Codex/harness/verify/run.py --level L1` and `python .Codex/harness/verify/run.py --level L2`. + +If the Harness is absent, record that fact and run the repository-equivalent full command `python -m pytest tests -q` in the project environment containing declared dependencies. + +- [ ] **Step 3: Prove the scope boundary** + +Run: `git diff --name-only` and `git diff --check`. + +Expected: only the DSL files, DSL sections of `compiler.py`/`main.py`, DSL tests, and this plan appear; no whitespace errors. + +- [ ] **Step 4: Self-review diagnostic requirements** + +Confirm E100, E101, E110, E111, E200 and E201; three-error collection; no partial IR; no parser fallback masking; plain redirected output; successful DSL IR regression; and no non-DSL behavior changes. + +- [ ] **Step 5: Update shared memory only if a novel reusable lesson exists** + +If `memory/memory.md` exists, merge one non-duplicate entry in `[2026-08-12] ...` format. If it does not exist, record the missing Harness facility and do not create unrelated infrastructure in this DSL patch. + +- [ ] **Step 6: Stage, commit and push after all verification is green** + +Run `git add` with the explicit reviewed file list, commit with an English message such as `feat: integrate structured DSL diagnostics`, then push the current branch without force. diff --git a/scratchv/compiler.py b/scratchv/compiler.py index 7e61036..c8973bb 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -190,6 +190,9 @@ class CompileResult: stats: dict[str, Any] = field(default_factory=dict) errors: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) + diagnostics: list[Any] = field(default_factory=list) + diagnostic_limit_reached: bool = False + diagnostic_limit: int = 20 def summary(self) -> str: """Return a one-line summary.""" @@ -240,10 +243,44 @@ def compile(self, input_path: str, output_path: str | None = None, if output_path is None: output_path = "output.ll" if self.config.backend == "llvm" else "output.s" + use_dsl = ( + dsl_source is not None + or (input_path and input_path.endswith(".dsl")) + ) + + if use_dsl: + source = dsl_source + if source is None and input_path: + with open(input_path) as source_file: + source = source_file.read() + from scratchv.frontend.dsl_extended import ExtendedDSLParser + parser = ExtendedDSLParser() + collector = parser.validate( + source or "", filename=input_path or "", + ) + if collector.has_errors: + diagnostics = collector.errors + return CompileResult( + success=False, + errors=[str(error) for error in diagnostics], + diagnostics=diagnostics, + diagnostic_limit_reached=collector.limit_reached, + diagnostic_limit=collector.max_errors, + ) + # --- 1. Parse --- try: program = self._parse(input_path, dsl_source) except Exception as e: + if use_dsl: + from scratchv.frontend.dsl_errors import DSLSyntaxError + if isinstance(e, DSLSyntaxError): + return CompileResult( + success=False, + errors=[str(e)], + diagnostics=[e], + ) + raise return CompileResult( success=False, errors=[f"Parse error: {e}"], ) @@ -334,13 +371,10 @@ def _parse(self, input_path: str, dsl_source: str | None = None): if source is None and input_path: with open(input_path) as f: source = f.read() - # Try extended DSL first - try: - from scratchv.frontend.dsl_extended import ExtendedDSLParser - return ExtendedDSLParser().parse(source) - except Exception: - from scratchv.frontend.dsl_parser import DSLParser - return DSLParser().parse(source) + from scratchv.frontend.dsl_extended import ExtendedDSLParser + return ExtendedDSLParser().parse( + source or "", filename=input_path or "", + ) else: from scratchv.frontend.onnx_parser import ONNXParser return ONNXParser().parse(input_path) diff --git a/scratchv/frontend/__init__.py b/scratchv/frontend/__init__.py index e65ba35..d5e9f8a 100644 --- a/scratchv/frontend/__init__.py +++ b/scratchv/frontend/__init__.py @@ -1,13 +1,25 @@ from .onnx_parser import ONNXParser from .dsl_parser import DSLParser from .dsl_extended import ExtendedDSLParser -from .dsl_errors import DSLSyntaxError, format_error, ErrorCollector +from .dsl_errors import ( + DSLParseError, + DSLSyntaxError, + ErrorCollector, + format_error, + render_error, +) +from .dsl_validator import DSLValidator, OP_SIGNATURES, SourceBuffer __all__ = [ "ONNXParser", "DSLParser", "ExtendedDSLParser", "DSLSyntaxError", + "DSLParseError", "format_error", + "render_error", "ErrorCollector", + "DSLValidator", + "OP_SIGNATURES", + "SourceBuffer", ] diff --git a/scratchv/frontend/dsl_errors.py b/scratchv/frontend/dsl_errors.py index d42b0f3..0de67af 100644 --- a/scratchv/frontend/dsl_errors.py +++ b/scratchv/frontend/dsl_errors.py @@ -17,9 +17,10 @@ from __future__ import annotations import enum +import os import sys from dataclasses import dataclass -from typing import Optional +from typing import Optional, TextIO # --------------------------------------------------------------------------- @@ -90,8 +91,12 @@ def _color(text: str, color: Color) -> str: # DSLSyntaxError # --------------------------------------------------------------------------- +class DSLParseError(Exception): + """Base exception retained for compatibility with existing callers.""" + + @dataclass -class DSLSyntaxError(Exception): +class DSLSyntaxError(DSLParseError): """Enriched syntax error with precise location information. Attributes: @@ -111,6 +116,7 @@ class DSLSyntaxError(Exception): filename: Optional[str] = None fix_hint: Optional[str] = None error_code: Optional[str] = None + end_col: Optional[int] = None def __str__(self) -> str: return format_error(self, use_color=False) @@ -182,23 +188,18 @@ def format_error( parts: list[str] = [] # Build location prefix - location = "" - if err.filename: - location += err.filename - location += f":{err.line}:{err.col}: " + location = f"{err.filename or ''}:{err.line}:{err.col}: " # Error header - error_tag = "error" + error_label = ( + f"error[{err.error_code}]" if err.error_code else "error" + ) if use_color: location = _color(location, Color.BOLD) - error_tag = _color("error", Color.RED) + error_tag = _color(error_label, Color.RED) parts.append(f"{location}{error_tag}: {err.message}") else: - parts.append(f"{location}error: {err.message}") - - # Error code - if err.error_code: - parts[-1] += f" [{err.error_code}]" + parts.append(f"{location}{error_label}: {err.message}") # Source line display if err.source_line: @@ -215,19 +216,28 @@ def format_error( # Error line if use_color: line_prefix = _color(f" {err.line} |", Color.GRAY) - parts.append(f"{line_prefix} {err.source_line}") + parts.append(f"{line_prefix} {err.source_line.expandtabs(4)}") else: - parts.append(f" {err.line} | {err.source_line}") + parts.append(f" {err.line} | {err.source_line.expandtabs(4)}") # Column marker if show_column_marker: - token_len = _estimate_token_length( - err.source_line, err.col - 1, - ) - marker = " " * (err.col + 3) + "^" + raw_start = max(err.col - 1, 0) + display_start = len(err.source_line[:raw_start].expandtabs(4)) + if err.end_col is not None: + raw_end = max(err.end_col - 1, raw_start + 1) + token_len = max( + len(err.source_line[:raw_end].expandtabs(4)) + - display_start, + 1, + ) + else: + token_len = _estimate_token_length(err.source_line, raw_start) + marker_padding = 6 + display_start + marker = " " * marker_padding + "^" if use_color: marker = ( - " " * (err.col + 3) + " " * marker_padding + _color("^", Color.GREEN) ) # Add tildes to indicate token length @@ -246,6 +256,19 @@ def format_error( return "\n".join(parts) +def render_error( + err: DSLSyntaxError, + *, + stream: TextIO, + use_color: Optional[bool] = None, +) -> str: + """Render an error, selecting color from the destination stream.""" + if use_color is None: + is_tty = bool(getattr(stream, "isatty", lambda: False)()) + use_color = is_tty and "NO_COLOR" not in os.environ + return format_error(err, use_color=use_color) + + def _estimate_token_length(source_line: str, col_start: int) -> int: """Estimate the length of the token at the given column position. @@ -301,11 +324,16 @@ def __init__( self.use_color = use_color self.max_errors = max_errors self._errors: list[DSLSyntaxError] = [] + self.limit_reached = False + self._keys: set[tuple[object, ...]] = set() @property def errors(self) -> list[DSLSyntaxError]: """Return the collected errors.""" - return list(self._errors) + return sorted( + self._errors, + key=lambda err: (err.line, err.col, err.error_code or ""), + ) @property def has_errors(self) -> bool: @@ -323,20 +351,17 @@ def add(self, err: DSLSyntaxError) -> None: Args: err: A DSLSyntaxError instance. """ - if len(self._errors) >= self.max_errors: - if not getattr(self, "_max_error_warned", False): - self._max_error_warned = True - msg = ( - f"error limit ({self.max_errors}) reached; " - f"further errors suppressed" - ) - self._errors.append(DSLSyntaxError( - line=0, col=0, message=msg, - filename=self.filename, - )) - return if err.filename is None and self.filename is not None: err.filename = self.filename + key = ( + err.filename, err.line, err.col, err.error_code, err.message, + ) + if key in self._keys: + return + if len(self._errors) >= self.max_errors: + self.limit_reached = True + return + self._keys.add(key) self._errors.append(err) def add_error( @@ -347,6 +372,7 @@ def add_error( source_line: str = "", fix_hint: Optional[str] = None, error_code: Optional[str] = None, + end_col: Optional[int] = None, ) -> None: """Convenience method to add an error by components. @@ -366,6 +392,7 @@ def add_error( filename=self.filename, fix_hint=fix_hint, error_code=error_code, + end_col=end_col, )) def report(self) -> str: @@ -386,9 +413,15 @@ def report(self) -> str: else: parts.append(f"--- {len(self._errors)} error(s) found ---") - for err in self._errors: + for err in self.errors: parts.append(format_error(err, use_color=self.use_color)) + if self.limit_reached: + parts.append( + f"note: error limit ({self.max_errors}) reached; " + "further errors suppressed" + ) + return "\n".join(parts) def report_and_exit(self, exit_code: int = 1) -> None: @@ -404,6 +437,8 @@ def report_and_exit(self, exit_code: int = 1) -> None: def clear(self) -> None: """Clear all collected errors.""" self._errors.clear() + self._keys.clear() + self.limit_reached = False # --------------------------------------------------------------------------- @@ -418,6 +453,7 @@ def make_error( filename: Optional[str] = None, fix_hint: Optional[str] = None, error_code: Optional[str] = None, + end_col: Optional[int] = None, ) -> DSLSyntaxError: """Factory function to create a DSLSyntaxError. @@ -441,4 +477,5 @@ def make_error( filename=filename, fix_hint=fix_hint, error_code=error_code, + end_col=end_col, ) diff --git a/scratchv/frontend/dsl_extended.py b/scratchv/frontend/dsl_extended.py index 585f37c..e8ab463 100644 --- a/scratchv/frontend/dsl_extended.py +++ b/scratchv/frontend/dsl_extended.py @@ -26,6 +26,8 @@ # moved import above from scratchv.frontend.dsl_parser import DSLParser, DSLParseError +from scratchv.frontend.dsl_errors import ErrorCollector +from scratchv.frontend.dsl_validator import DSLValidator from scratchv.ir.builder import IRBuilder from scratchv.ir.types import OpCode, Program, Value @@ -97,7 +99,16 @@ def _fresh_label(self, prefix: str = "L") -> str: # Core parse method (overrides base) # ----------------------------------------------------------------------- - def parse(self, text: str) -> Program: + def validate( + self, text: str, *, filename: str | None = None, + max_errors: int = 20, + ) -> ErrorCollector: + """Validate base and extended DSL syntax without creating IR.""" + return DSLValidator(extended=True).validate( + text, filename=filename, max_errors=max_errors, + ) + + def parse(self, text: str, *, filename: str | None = None) -> Program: """Parse DSL text into IR Program, supporting if/else and while. Args: @@ -106,6 +117,10 @@ def parse(self, text: str) -> Program: Returns: A Program object containing the generated IR. """ + collector = self.validate(text, filename=filename) + if collector.has_errors: + raise collector.errors[0] + # Strip comments before splitting to handle block-level constructs lines_raw = text.split("\n") lines: list[str] = [] diff --git a/scratchv/frontend/dsl_parser.py b/scratchv/frontend/dsl_parser.py index 19aa04b..3d57e9c 100644 --- a/scratchv/frontend/dsl_parser.py +++ b/scratchv/frontend/dsl_parser.py @@ -22,10 +22,8 @@ import re from scratchv.ir.builder import IRBuilder from scratchv.ir.types import Value, Program - - -class DSLParseError(Exception): - pass +from scratchv.frontend.dsl_errors import DSLParseError, ErrorCollector +from scratchv.frontend.dsl_validator import DSLValidator class DSLParser: @@ -36,7 +34,29 @@ def __init__(self): self._vars: dict[str, Value] = {} self._loop_stack: list[str] = [] - def parse(self, text: str) -> Program: + def validate( + self, text: str, *, filename: str | None = None, + max_errors: int = 20, + ) -> ErrorCollector: + return DSLValidator().validate( + text, filename=filename, max_errors=max_errors, + ) + + @staticmethod + def supported_operations() -> set[str]: + return { + "add", "sub", "mul", "div", "neg", "exp", "relu", "gelu", + "dot", "matmul", "softmax", "maxpool", + } + + def parse(self, text: str, *, filename: str | None = None) -> Program: + collector = self.validate(text, filename=filename) + if collector.has_errors: + raise collector.errors[0] + + self.builder = IRBuilder() + self._vars = {} + self._loop_stack = [] lines = text.strip().split("\n") self.builder.new_function("main") self.builder.new_block("entry") @@ -162,6 +182,7 @@ def _dispatch_op(self, op: str, args: list[str]) -> Value: kwargs.get("stride", 2), ), } + assert set(handlers) == self.supported_operations() handler = handlers.get(op) if handler is None: raise DSLParseError(f"Unsupported op: {op}") diff --git a/scratchv/frontend/dsl_validator.py b/scratchv/frontend/dsl_validator.py new file mode 100644 index 0000000..b0ddee4 --- /dev/null +++ b/scratchv/frontend/dsl_validator.py @@ -0,0 +1,300 @@ +"""Source-preserving validation shared by ScratchV DSL parsers.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal, Optional + +from scratchv.frontend.dsl_errors import ErrorCollector + + +@dataclass(frozen=True) +class SourceBuffer: + """Immutable DSL source with one-based physical-line access.""" + + text: str + filename: str = "" + + @property + def lines(self) -> tuple[str, ...]: + return tuple(self.text.replace("\r\n", "\n").replace("\r", "\n").split("\n")) + + @property + def line_count(self) -> int: + return len(self.lines) + + def line_text(self, line: int) -> str: + if line < 1 or line > self.line_count: + return "" + return self.lines[line - 1] + + +@dataclass(frozen=True) +class OpSignature: + positional: int + optional_kwargs: frozenset[str] = frozenset() + required_kwargs: frozenset[str] = frozenset() + numeric_kwargs: frozenset[str] = frozenset() + + +OP_SIGNATURES: dict[str, OpSignature] = { + "add": OpSignature(2), + "sub": OpSignature(2), + "mul": OpSignature(2), + "div": OpSignature(2), + "neg": OpSignature(1), + "exp": OpSignature(1), + "relu": OpSignature(1), + "gelu": OpSignature(1), + "dot": OpSignature(2, frozenset({"len", "length"}), numeric_kwargs=frozenset({"len", "length"})), + "matmul": OpSignature(2, frozenset({"rows", "cols", "inner", "m", "n", "k"}), numeric_kwargs=frozenset({"rows", "cols", "inner", "m", "n", "k"})), + "softmax": OpSignature(1, frozenset({"axis"}), numeric_kwargs=frozenset({"axis"})), + "maxpool": OpSignature(1, frozenset({"kernel", "stride"}), numeric_kwargs=frozenset({"kernel", "stride"})), +} + + +_IDENTIFIER = re.compile(r"^(?!\d)\w+$", re.UNICODE) +_ASSIGNMENT = re.compile( + r"^(?P[^\s=]+)\s*=\s*(?P[A-Za-z_]\w*)\s*" + r"\((?P.*)\)\s*$", + re.UNICODE, +) +_FOR = re.compile( + r"^for\s+(?P[^\s=]+)\s*=\s*(?P\d+)\s*,\s*" + r"(?P\d+)\s*$", +) +_CONDITION = re.compile( + r"^(?Pif|while)\s*\(\s*.+?\s*" + r"(?:==|!=|<=|>=|<|>)\s*.+?\s*\)\s*:?\s*$" +) +_NUMBER = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$") + + +@dataclass +class BlockFrame: + kind: Literal["if", "while", "for"] + line: int + col: int + source_line: str + saw_else: bool = False + + +class DSLValidator: + """Validate DSL syntax without constructing IR.""" + + def __init__(self, *, extended: bool = False): + self.extended = extended + + def validate( + self, + text: str, + *, + filename: Optional[str] = None, + max_errors: int = 20, + ) -> ErrorCollector: + display_name = filename or "" + source = SourceBuffer(text, display_name) + collector = ErrorCollector( + filename=display_name, use_color=False, max_errors=max_errors, + ) + stack: list[BlockFrame] = [] + + for line_no, raw_line in enumerate(source.lines, start=1): + if collector.limit_reached: + break + statement = self._statement(raw_line) + if not statement: + continue + col = len(raw_line) - len(raw_line.lstrip()) + 1 + + if statement.startswith("for "): + match = _FOR.fullmatch(statement) + if match is None: + self._add(collector, line_no, col, raw_line, "E100", "cannot parse for statement") + elif not _IDENTIFIER.fullmatch(match.group("var")): + self._add(collector, line_no, col + 4, raw_line, "E103", "invalid loop variable") + else: + stack.append(BlockFrame("for", line_no, col, raw_line)) + continue + + if statement in {"endfor", "endif", "endwhile"}: + self._close_block(statement, line_no, col, raw_line, stack, collector) + continue + + if statement in {"else", "else:"}: + self._else(line_no, col, raw_line, stack, collector) + continue + + if statement.startswith(("if ", "while ")): + if not self.extended: + self._add(collector, line_no, col, raw_line, "E100", "cannot parse statement") + continue + kind = "if" if statement.startswith("if ") else "while" + if _CONDITION.fullmatch(statement) is None: + code = "E101" if "(" not in statement or ")" not in statement else "E100" + hint = "add matching parentheses around the condition" if code == "E101" else None + self._add(collector, line_no, col, raw_line, code, f"invalid {kind} condition", fix_hint=hint) + else: + stack.append(BlockFrame(kind, line_no, col, raw_line)) + continue + + if statement.startswith("return"): + if re.fullmatch(r"return\s+\S+", statement) is None: + self._add(collector, line_no, col, raw_line, "E100", "return requires a value") + continue + + self._validate_assignment(statement, line_no, col, raw_line, collector) + + for frame in stack: + if collector.limit_reached: + break + expected = {"if": "endif", "while": "endwhile", "for": "endfor"}[frame.kind] + self._add( + collector, frame.line, frame.col, frame.source_line, "E111", + f"unterminated {frame.kind} block", + fix_hint=f"add missing '{expected}'", + ) + return collector + + @staticmethod + def _statement(raw_line: str) -> str: + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + return "" + comment = stripped.find(" #") + if comment >= 0: + stripped = stripped[:comment].rstrip() + return stripped + + def _validate_assignment( + self, statement: str, line: int, col: int, raw: str, + collector: ErrorCollector, + ) -> None: + left_count = statement.count("(") + right_count = statement.count(")") + if left_count != right_count: + if left_count < right_count: + depth = 0 + unmatched_right = 0 + for index, char in enumerate(raw): + if char == "(": + depth += 1 + elif char == ")": + if depth == 0: + unmatched_right = index + break + depth -= 1 + error_col = unmatched_right + 1 + message = "missing opening '('" + hint = "add the missing '('" + else: + openings: list[int] = [] + for index, char in enumerate(raw): + if char == "(": + openings.append(index) + elif char == ")" and openings: + openings.pop() + error_col = openings[0] + 1 + message = "missing closing ')'" + hint = "add the missing ')'" + self._add( + collector, line, error_col, raw, "E101", message, + fix_hint=hint, + ) + return + match = _ASSIGNMENT.fullmatch(statement) + if match is None: + if "=" in statement and "(" in statement and ")" not in statement: + paren_col = raw.find("(") + 1 + self._add(collector, line, paren_col, raw, "E101", "missing closing ')'", fix_hint="add the missing ')'") + else: + hint = "did you mean 'return'?" if statement.split(maxsplit=1)[0].lower() == "retrun" else None + self._add(collector, line, col, raw, "E100", "cannot parse statement", fix_hint=hint) + return + + dest = match.group("dest") + if _IDENTIFIER.fullmatch(dest) is None: + self._add(collector, line, raw.find(dest) + 1, raw, "E103", f"invalid identifier '{dest}'", end_col=raw.find(dest) + len(dest) + 1) + return + + op = match.group("op") + op_col = raw.find(op, raw.find("=")) + 1 + signature = OP_SIGNATURES.get(op) + if signature is None: + hint = "did you mean 'add'?" if op == "ad" else None + self._add(collector, line, op_col, raw, "E200", f"unsupported operation '{op}'", fix_hint=hint, end_col=op_col + len(op)) + return + + args_text = match.group("args") + args = [item.strip() for item in args_text.split(",") if item.strip()] + positional: list[str] = [] + kwargs: dict[str, str] = {} + args_col = raw.find("(", op_col - 1) + 2 + for arg in args: + if ":" not in arg: + positional.append(arg) + continue + key, value = (part.strip() for part in arg.split(":", 1)) + if key in kwargs or key not in signature.optional_kwargs | signature.required_kwargs: + self._add(collector, line, raw.find(key, args_col - 1) + 1, raw, "E202", f"invalid keyword argument '{key}'") + return + if not value: + self._add(collector, line, raw.find(key, args_col - 1) + len(key) + 2, raw, "E203", f"missing value for '{key}'") + return + if key in signature.numeric_kwargs and _NUMBER.fullmatch(value) is None: + self._add(collector, line, raw.find(value, args_col - 1) + 1, raw, "E203", f"'{key}' requires a numeric value") + return + kwargs[key] = value + + if len(positional) != signature.positional: + self._add( + collector, line, args_col, raw, "E201", + f"operation '{op}' expects {signature.positional} positional argument(s), got {len(positional)}", + ) + return + missing = signature.required_kwargs - kwargs.keys() + if missing: + name = sorted(missing)[0] + self._add(collector, line, args_col, raw, "E202", f"missing keyword argument '{name}'") + + def _close_block( + self, closer: str, line: int, col: int, raw: str, + stack: list[BlockFrame], collector: ErrorCollector, + ) -> None: + expected_kind = {"endif": "if", "endwhile": "while", "endfor": "for"}[closer] + if not stack: + self._add(collector, line, col, raw, "E110", f"'{closer}' without matching {expected_kind}") + return + if stack[-1].kind == expected_kind: + stack.pop() + return + expected_closer = {"if": "endif", "while": "endwhile", "for": "endfor"}[stack[-1].kind] + self._add(collector, line, col, raw, "E110", f"found '{closer}', expected '{expected_closer}'") + matching = next((i for i in range(len(stack) - 1, -1, -1) if stack[i].kind == expected_kind), None) + if matching is None: + stack.pop() + else: + del stack[matching:] + + def _else( + self, line: int, col: int, raw: str, + stack: list[BlockFrame], collector: ErrorCollector, + ) -> None: + if not stack or stack[-1].kind != "if": + self._add(collector, line, col, raw, "E112", "else without matching if") + elif stack[-1].saw_else: + self._add(collector, line, col, raw, "E112", "duplicate else in if block") + else: + stack[-1].saw_else = True + + @staticmethod + def _add( + collector: ErrorCollector, line: int, col: int, source_line: str, + code: str, message: str, *, fix_hint: Optional[str] = None, + end_col: Optional[int] = None, + ) -> None: + collector.add_error( + line, max(col, 1), message, source_line=source_line, + fix_hint=fix_hint, error_code=code, end_col=end_col, + ) diff --git a/scratchv/main.py b/scratchv/main.py index 25eaf33..52feff5 100644 --- a/scratchv/main.py +++ b/scratchv/main.py @@ -234,13 +234,22 @@ def main(argv: list[str] | None = None) -> int: # Build config and driver config = args_to_config(args) driver = CompilerDriver(config) + use_dsl = args.dsl is not None or bool( + args.input and args.input.endswith(".dsl") + ) # Compile - result: CompileResult = driver.compile( - input_path=args.input or "", - output_path=args.output, - dsl_source=args.dsl if hasattr(args, 'dsl') else None, - ) + try: + result: CompileResult = driver.compile( + input_path=args.input or "", + output_path=args.output, + dsl_source=args.dsl if hasattr(args, 'dsl') else None, + ) + except Exception as exc: + if not use_dsl: + raise + print(f"internal compiler error: {exc}", file=sys.stderr) + return 2 # Report if result.ir_dump: @@ -262,8 +271,21 @@ def main(argv: list[str] | None = None) -> int: return 0 else: - for err in result.errors: - print(f"Error: {err}", file=sys.stderr) + if result.diagnostics: + from scratchv.frontend.dsl_errors import render_error + for diagnostic in result.diagnostics: + print(render_error( + diagnostic, stream=sys.stderr, use_color=None, + ), file=sys.stderr) + if result.diagnostic_limit_reached: + print( + f"note: error limit ({result.diagnostic_limit}) reached; " + "further errors suppressed", + file=sys.stderr, + ) + else: + for err in result.errors: + print(f"Error: {err}", file=sys.stderr) return 1 diff --git a/tests/test_dsl_diagnostics_cli.py b/tests/test_dsl_diagnostics_cli.py new file mode 100644 index 0000000..a1f44d0 --- /dev/null +++ b/tests/test_dsl_diagnostics_cli.py @@ -0,0 +1,96 @@ +"""End-to-end tests for DSL diagnostics through driver and CLI.""" + +from pathlib import Path + +from scratchv.compiler import CompilerDriver +from scratchv.main import main + + +def test_driver_preserves_structured_dsl_diagnostic(tmp_path: Path): + source = tmp_path / "bad.dsl" + source.write_text("x = ad(a, b)\n", encoding="utf-8") + + result = CompilerDriver().compile(str(source), str(tmp_path / "out.s")) + + assert not result.success + assert len(result.diagnostics) == 1 + diagnostic = result.diagnostics[0] + assert diagnostic.error_code == "E200" + assert diagnostic.filename == str(source) + assert result.errors == [str(diagnostic)] + assert "Parse error:" not in result.errors[0] + + +def test_driver_does_not_mask_extended_dsl_error(tmp_path: Path): + source = tmp_path / "bad.dsl" + source.write_text("if (a > b):\nx = add(a, b)\n", encoding="utf-8") + + result = CompilerDriver().compile(str(source), str(tmp_path / "out.s")) + + assert not result.success + assert result.diagnostics[0].error_code == "E111" + assert "unterminated if block" in result.errors[0] + + +def test_driver_preserves_multiple_diagnostics_and_limit(tmp_path: Path): + source = tmp_path / "many.dsl" + source.write_text( + "\n".join(f"bad statement {index}" for index in range(25)), + encoding="utf-8", + ) + result = CompilerDriver().compile(str(source), str(tmp_path / "out.s")) + assert not result.success + assert len(result.diagnostics) == 20 + assert len(result.errors) == 20 + assert result.diagnostic_limit_reached + assert result.diagnostic_limit == 20 + + +def test_cli_renders_multiple_diagnostics_once(tmp_path: Path, capsys): + source = tmp_path / "many.dsl" + source.write_text( + "retrun x\ny = ad(a, b)\nz = relu(a, b)\n", + encoding="utf-8", + ) + assert main([str(source), "-o", str(tmp_path / "out.s")]) == 1 + captured = capsys.readouterr() + assert captured.err.count(": error[") == 3 + + +def test_cli_renders_diagnostic_once_without_ansi(tmp_path: Path, capsys): + source = tmp_path / "bad.dsl" + source.write_text("retrun x\n", encoding="utf-8") + + exit_code = main([str(source), "-o", str(tmp_path / "out.s")]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert captured.err.count("error[E100]") == 1 + assert "\033[" not in captured.err + assert "Error: Parse error:" not in captured.err + + +def test_cli_reports_unexpected_dsl_internal_error_as_exit_2( + monkeypatch, capsys, +): + def fail(*args, **kwargs): + raise AssertionError("broken invariant") + + monkeypatch.setattr(CompilerDriver, "compile", fail) + exit_code = main(["broken.dsl"]) + captured = capsys.readouterr() + assert exit_code == 2 + assert "internal compiler error: broken invariant" in captured.err + assert "Traceback" not in captured.err + + +def test_cli_does_not_change_non_dsl_internal_error_behavior(monkeypatch): + def fail(*args, **kwargs): + raise AssertionError("onnx invariant") + + monkeypatch.setattr(CompilerDriver, "compile", fail) + try: + main(["model.onnx"]) + assert False, "non-DSL exception should retain its previous behavior" + except AssertionError as exc: + assert str(exc) == "onnx invariant" diff --git a/tests/test_dsl_errors.py b/tests/test_dsl_errors.py index df762a9..047d097 100644 --- a/tests/test_dsl_errors.py +++ b/tests/test_dsl_errors.py @@ -1,12 +1,15 @@ """Tests for the DSL error beautifier module.""" +import io import pytest +from scratchv.frontend.dsl_parser import DSLParseError from scratchv.frontend.dsl_errors import ( DSLSyntaxError, format_error, ErrorCollector, make_error, Color, + render_error, ) @@ -69,6 +72,14 @@ def test_error_is_exception(self): with pytest.raises(DSLSyntaxError): raise err + def test_error_is_compatible_with_dsl_parse_error(self): + err = DSLSyntaxError(1, 1, "msg") + assert isinstance(err, DSLParseError) + + def test_str_never_contains_ansi(self): + err = DSLSyntaxError(1, 1, "msg", source_line="bad") + assert "\033[" not in str(err) + class TestFormatError: """Tests for the format_error() function.""" @@ -149,7 +160,51 @@ def test_format_with_error_code(self): error_code="E001", ) output = format_error(err, use_color=False) - assert "E001" in output + assert "error[E001]: test error" in output + + def test_format_unknown_filename(self): + err = DSLSyntaxError(1, 1, "test error") + assert format_error(err, use_color=False).startswith( + ":1:1: error:" + ) + + def test_format_uses_explicit_span(self): + err = DSLSyntaxError( + 1, 5, "test error", source_line="x = retrun y", end_col=11, + ) + marker = format_error(err, use_color=False).splitlines()[2] + assert "^~~~~~" in marker + + def test_format_expands_tabs_for_marker(self): + err = DSLSyntaxError( + 1, 2, "test error", source_line="\tbad", end_col=5, + ) + output = format_error(err, use_color=False) + assert " 1 | bad" in output + marker = output.splitlines()[2] + assert marker.index("^") == output.splitlines()[1].index("b") + + def test_render_error_auto_color_uses_tty(self, monkeypatch): + class TTY(io.StringIO): + def isatty(self): + return True + + monkeypatch.delenv("NO_COLOR", raising=False) + output = render_error( + DSLSyntaxError(1, 1, "bad"), stream=TTY(), use_color=None, + ) + assert "\033[" in output + + def test_render_error_auto_color_honors_no_color(self, monkeypatch): + class TTY(io.StringIO): + def isatty(self): + return True + + monkeypatch.setenv("NO_COLOR", "1") + output = render_error( + DSLSyntaxError(1, 1, "bad"), stream=TTY(), use_color=None, + ) + assert "\033[" not in output def test_format_column_marker(self): err = DSLSyntaxError( @@ -226,8 +281,35 @@ def test_max_errors_limit(self): collector = ErrorCollector(max_errors=3) for i in range(10): collector.add(DSLSyntaxError(i + 1, 1, f"error {i}")) - # Should only have max_errors items - assert len(collector.errors) <= 4 # 3 real errors + 1 limit message + assert len(collector.errors) == 3 + assert collector.error_count == 3 + assert collector.limit_reached + assert "further errors suppressed" in collector.report() + + def test_deduplicates_and_sorts_errors(self): + collector = ErrorCollector(use_color=False) + second = DSLSyntaxError(2, 3, "second", error_code="E200") + first = DSLSyntaxError(1, 2, "first", error_code="E100") + collector.add(second) + collector.add(first) + collector.add(first) + assert collector.errors == [first, second] + + def test_clear_resets_limit_state(self): + collector = ErrorCollector(max_errors=1) + collector.add(DSLSyntaxError(1, 1, "first")) + collector.add(DSLSyntaxError(2, 1, "second")) + assert collector.limit_reached + collector.clear() + assert not collector.limit_reached + + def test_duplicate_at_capacity_does_not_reach_limit(self): + collector = ErrorCollector(max_errors=1) + error = DSLSyntaxError(1, 1, "first") + collector.add(error) + collector.add(error) + assert collector.error_count == 1 + assert not collector.limit_reached def test_clear_errors(self): collector = ErrorCollector() diff --git a/tests/test_dsl_validator.py b/tests/test_dsl_validator.py new file mode 100644 index 0000000..faedb8e --- /dev/null +++ b/tests/test_dsl_validator.py @@ -0,0 +1,168 @@ +"""Integration tests for structured DSL validation.""" + +import pytest + +from scratchv.frontend.dsl_errors import DSLSyntaxError +from scratchv.frontend.dsl_parser import DSLParser +from scratchv.frontend.dsl_extended import ExtendedDSLParser +from scratchv.frontend.dsl_validator import SourceBuffer + + +class TestSourceBuffer: + def test_preserves_physical_lines_for_lf_and_crlf(self): + lf = SourceBuffer("一\n\t二\n", filename="a.dsl") + crlf = SourceBuffer("一\r\n\t二\r\n", filename="a.dsl") + assert lf.lines == crlf.lines == ("一", "\t二", "") + assert lf.line_text(2) == "\t二" + assert lf.line_count == 3 + + def test_empty_source_has_one_physical_line(self): + source = SourceBuffer("") + assert source.lines == ("",) + assert source.line_text(1) == "" + + +@pytest.mark.parametrize( + ("source", "code", "line", "col"), + [ + ("retrun x", "E100", 1, 1), + ("x = add(a, b", "E101", 1, 8), + ("x = ad(a, b)", "E200", 1, 5), + ("x = add(a)", "E201", 1, 9), + ("x = relu(a, b)", "E201", 1, 10), + ], +) +def test_base_parser_reports_structured_errors(source, code, line, col): + parser = DSLParser() + with pytest.raises(DSLSyntaxError) as caught: + parser.parse(source, filename="bad.dsl") + error = caught.value + assert error.error_code == code + assert (error.line, error.col) == (line, col) + assert error.filename == "bad.dsl" + assert error.source_line == source + + +def test_unsupported_op_has_conservative_spelling_hint(): + collector = DSLParser().validate("x = ad(a, b)") + assert collector.errors[0].fix_hint == "did you mean 'add'?" + + +def test_validation_collects_independent_line_errors(): + collector = DSLParser().validate( + "retrun x\ny = ad(a, b)\nz = relu(a, b)", + filename="many.dsl", + ) + assert [error.error_code for error in collector.errors] == [ + "E100", "E200", "E201", + ] + + +def test_parser_state_is_clean_after_failed_parse(): + parser = DSLParser() + with pytest.raises(DSLSyntaxError): + parser.parse("x = add(a)") + program = parser.parse("x = add(a, b)\nreturn x") + assert len(program.functions) == 1 + assert len(program.functions[0].blocks[0].instructions) == 2 + + +def test_negative_for_bound_is_structured_error_not_internal_exception(): + source = "for i = -1, 3\nendfor" + collector = ExtendedDSLParser().validate(source) + assert collector.errors[0].error_code == "E100" + with pytest.raises(DSLSyntaxError) as caught: + ExtendedDSLParser().parse(source) + assert caught.value.error_code == "E100" + + +def test_unicode_identifier_remains_valid(): + program = DSLParser().parse("结果 = relu(x)\nreturn 结果") + instructions = program.functions[0].blocks[0].instructions + assert [instruction.opcode.name for instruction in instructions] == [ + "RELU", "RETURN", + ] + + +def test_parser_and_signature_registry_have_same_operations(): + from scratchv.frontend.dsl_validator import OP_SIGNATURES + + assert set(OP_SIGNATURES) == DSLParser.supported_operations() + + +@pytest.mark.parametrize( + ("source", "code"), + [ + ("x = softmax(a, unknown:1)", "E202"), + ("x = softmax(a, axis:nope)", "E203"), + ], +) +def test_keyword_argument_diagnostics(source, code): + collector = DSLParser().validate(source) + assert [error.error_code for error in collector.errors] == [code] + + +@pytest.mark.parametrize( + ("source", "expected_col"), + [ + ("x = add a, b)", 13), + ("x = add(a, b))", 14), + ("x = add((a, b)", 8), + ], +) +def test_unbalanced_call_parentheses_are_e101(source, expected_col): + collector = DSLParser().validate(source) + assert [error.error_code for error in collector.errors] == ["E101"] + assert collector.errors[0].col == expected_col + + +def test_validation_error_limit_is_metadata_not_fake_error(): + source = "\n".join(f"bad statement {index}" for index in range(5)) + collector = DSLParser().validate(source, max_errors=3) + assert collector.error_count == 3 + assert collector.limit_reached + assert all(error.line > 0 for error in collector.errors) + assert "--- 3 error(s) found ---" in collector.report() + assert "further errors suppressed" in collector.report() + + +@pytest.mark.parametrize( + ("source", "codes"), + [ + ("endif", ["E110"]), + ("if (a > b):\nx = add(a, b)", ["E111"]), + ("else:\nx = add(a, b)", ["E112"]), + ( + "if (a > b):\nelse:\nelse:\nendif", + ["E112"], + ), + ("while (i < 3):\nendif", ["E110"]), + ( + "if (a > b):\nwhile (i < 3):\nendif", + ["E110"], + ), + ( + "if (a > b):\nwhile (i < 3):\nx = add(a, b)", + ["E111", "E111"], + ), + ], +) +def test_extended_block_validation_and_recovery(source, codes): + collector = ExtendedDSLParser().validate(source, filename="bad.dsl") + assert [error.error_code for error in collector.errors] == codes + + +def test_extended_parser_reports_three_independent_errors(): + source = "endif\nx = ad(a, b)\nwhile (i < 3):" + collector = ExtendedDSLParser().validate(source) + assert [error.error_code for error in collector.errors] == [ + "E110", "E200", "E111", + ] + + +def test_extended_parse_rejects_unterminated_block_before_ir_generation(): + parser = ExtendedDSLParser() + with pytest.raises(DSLSyntaxError) as caught: + parser.parse("if (a > b):\nx = add(a, b)", filename="bad.dsl") + assert caught.value.error_code == "E111" + assert caught.value.line == 1 From 1b3f0ea1b6e36f3cd653e4ab69f979cbefd5b0c7 Mon Sep 17 00:00:00 2001 From: mahiru114514 <243091814+mahiru114514@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:40:04 +0800 Subject: [PATCH 3/7] ci: add DSL diagnostics tests and benchmark reports --- .github/workflows/dsl-diagnostics.yml | 124 +++++++ benchmarks/bench_dsl_diagnostics.py | 349 ++++++++++++++++++ .../plans/2026-09-13-dsl-diagnostics-ci.md | 19 + ...12\346\226\255-CI\344\270\216Benchmark.md" | 90 +++++ memory/memory.md | 3 + tests/test_dsl_diagnostics_benchmark.py | 151 ++++++++ 6 files changed, 736 insertions(+) create mode 100644 .github/workflows/dsl-diagnostics.yml create mode 100644 benchmarks/bench_dsl_diagnostics.py create mode 100644 docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md create mode 100644 "docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" create mode 100644 memory/memory.md create mode 100644 tests/test_dsl_diagnostics_benchmark.py diff --git a/.github/workflows/dsl-diagnostics.yml b/.github/workflows/dsl-diagnostics.yml new file mode 100644 index 0000000..2ba6661 --- /dev/null +++ b/.github/workflows/dsl-diagnostics.yml @@ -0,0 +1,124 @@ +name: DSL Diagnostics CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + inputs: + baseline_ref: + description: Baseline commit or branch for a manual comparison + type: string + default: main + required: true + +permissions: + contents: read + +concurrency: + group: dsl-diagnostics-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Topic 9 tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout event commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install DSL test dependencies + run: python -m pip install -e . "pytest>=7,<10" + + - name: Run DSL diagnostics and parser regressions + run: | + mkdir -p benchmark_reports + python -m pytest \ + tests/test_dsl_errors.py \ + tests/test_dsl_validator.py \ + tests/test_dsl_diagnostics_cli.py \ + tests/test_parser.py \ + tests/test_dsl_extended.py \ + tests/test_dsl_diagnostics_benchmark.py \ + -v --tb=short \ + --junit-xml=benchmark_reports/dsl_test_results.xml + + - name: Upload DSL test results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dsl-test-reports + path: benchmark_reports/dsl_test_results.xml + retention-days: 30 + + benchmark: + name: Topic 9 benchmark + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout event commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + path: current + persist-credentials: false + + - name: Resolve baseline revision + id: baseline + env: + BASE_REF: ${{ github.event.pull_request.base.sha || inputs.baseline_ref || github.event.before }} + run: | + if [ -z "$BASE_REF" ] || [ "$BASE_REF" = "0000000000000000000000000000000000000000" ]; then + echo "::error::No baseline revision; run manually with baseline_ref." + exit 1 + fi + echo "ref=$BASE_REF" >> "$GITHUB_OUTPUT" + + - name: Checkout baseline + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ steps.baseline.outputs.ref }} + path: baseline + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Install shared parser dependencies + working-directory: current + run: python -m pip install -e . + + - name: Run DSL diagnostics and parser benchmark + working-directory: current + run: | + python -m benchmarks.bench_dsl_diagnostics \ + --baseline-root ../baseline \ + --json-output benchmark_reports/dsl_diagnostics.json \ + --markdown benchmark_reports/dsl_diagnostics.md \ + --html benchmark_reports/dsl_diagnostics.html + + - name: Write DSL benchmark summary + if: always() + run: | + REPORT=current/benchmark_reports/dsl_diagnostics.md + if [ -f "$REPORT" ]; then + cat "$REPORT" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload DSL benchmark reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dsl-benchmark-reports + path: current/benchmark_reports/dsl_diagnostics.* + retention-days: 30 diff --git a/benchmarks/bench_dsl_diagnostics.py b/benchmarks/bench_dsl_diagnostics.py new file mode 100644 index 0000000..a2e6d5e --- /dev/null +++ b/benchmarks/bench_dsl_diagnostics.py @@ -0,0 +1,349 @@ +"""Topic 9 diagnostics acceptance and same-corpus parser A/B measurements. + +Run with ``python -m benchmarks.bench_dsl_diagnostics --baseline-root PATH``. +Only the standard library is imported until an isolated worker selects a checkout. +""" + +from __future__ import annotations + +import argparse +import contextlib +import gc +import hashlib +import html +import importlib.metadata +import io +import json +import math +import os +from pathlib import Path +import platform +import statistics +import subprocess +import sys +import tempfile +import time +from typing import Any, Callable + + +ROOT = Path(__file__).resolve().parents[1] + + +def measure(action: Callable[[], Any], warmup: int, groups: int, iterations: int) -> dict: + """Time complete batches, excluding imports, file I/O and explicit GC.""" + for _ in range(warmup): + action() + samples = [] + for _ in range(groups): + gc.collect() + start = time.perf_counter() + for _ in range(iterations): + action() + samples.append(time.perf_counter() - start) + return {"samples_s": samples, "median_s": statistics.median(samples)} + + +def revision(root: Path) -> str | None: + """Do not label an unpacked baseline with its enclosing repository's SHA.""" + try: + top = subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + text=True, encoding="utf-8", stderr=subprocess.DEVNULL, + ).strip() + if Path(top).resolve() != root.resolve(): + return None + return subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "HEAD"], + text=True, encoding="utf-8", stderr=subprocess.DEVNULL, + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def parse_worker(args: argparse.Namespace) -> dict: + from scratchv.frontend import dsl_extended + + parser_file = Path(dsl_extended.__file__).resolve() + if not parser_file.is_relative_to(args.worker_root): + raise RuntimeError(f"parser imported outside selected checkout: {parser_file}") + paths = sorted(args.cases.glob("*.dsl")) + if not paths: + raise ValueError(f"no DSL cases in {args.cases}") + sources = {path.name: path.read_text(encoding="utf-8") for path in paths} + + def parse_one(name: str, source: str) -> Any: + try: + # The legacy parser has no filename keyword. Use its public common API. + return dsl_extended.ExtendedDSLParser().parse(source) + except Exception as exc: + raise RuntimeError(f"{name}: {exc}") from exc + + hashes = { + name: hashlib.sha256(parse_one(name, source).dump().encode("utf-8")).hexdigest() + for name, source in sources.items() + } + + def batch() -> None: + for name, source in sources.items(): + parse_one(name, source) + + return { + **measure(batch, args.warmup, args.groups, args.iterations), + "root": str(args.worker_root), "revision": revision(args.worker_root), + "parser_file": str(parser_file), "parser": "ExtendedDSLParser", + "ir_hashes": hashes, + "case_hashes": { + name: hashlib.sha256(source.encode("utf-8")).hexdigest() + for name, source in sources.items() + }, + } + + +def diagnostic_cases() -> list[dict]: + """Expected locations are fixed independently of the validator output.""" + rows = [ + ("keyword_typo", "retrun x", [("E100", 1, 1)], "did you mean 'return'?"), + ("parenthesis", "x = add(a, b", [("E101", 1, 8)], "add the missing ')'"), + ("unknown_op", "x = ad(a, b)", [("E200", 1, 5)], "did you mean 'add'?"), + ("arity", "x = add(a)", [("E201", 1, 9)], None), + ("stray_closer", "endif", [("E110", 1, 1)], None), + ("missing_closer", "if (a > b):\nx = add(a, b)", [("E111", 1, 1)], "add missing 'endif'"), + ("duplicate_else", "if (a > b):\nelse:\nelse:\nendif", [("E112", 3, 1)], None), + ("keyword_argument", "x = softmax(a, unknown:1)", [("E202", 1, 16)], None), + ("numeric_argument", "x = softmax(a, axis:nope)", [("E203", 1, 21)], None), + ("tab_crlf_unicode", "# 中文\r\n\tretrun x\r\n", [("E100", 2, 2)], "did you mean 'return'?"), + ("three_errors", "retrun x\ny = ad(a, b)\nz = relu(a, b)\n", + [("E100", 1, 1), ("E200", 2, 5), ("E201", 3, 10)], None), + ("error_limit", "\n".join(f"bad statement {i}" for i in range(25)), + [("E100", i, 1) for i in range(1, 21)], None), + ] + return [ + {"name": name, "source": source, "expected": expected, "hint": hint} + for name, source, expected, hint in rows + ] + + +def diagnostics_worker(args: argparse.Namespace) -> dict: + from scratchv.frontend.dsl_extended import ExtendedDSLParser + from scratchv.main import main as compiler_main + + results = [] + with tempfile.TemporaryDirectory(prefix="dsl-diagnostics-") as directory: + for case in diagnostic_cases(): + source = case["source"] + path = Path(directory) / f"{case['name']}.dsl" + path.write_bytes(source.encode("utf-8")) + + def validate() -> Any: + return ExtendedDSLParser().validate(source, filename=str(path)) + + collector = validate() + errors = collector.errors + actual = [(error.error_code, error.line, error.col) for error in errors] + rendered = collector.report() + stdout, stderr = io.StringIO(), io.StringIO() + output = path.with_suffix(".s") + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + exit_code = compiler_main([str(path), "-o", str(output)]) + cli_text = stderr.getvalue() + checks = { + "codes_and_locations": actual == case["expected"], + "source_and_filename": all( + error.filename == str(path) + and error.source_line == source.splitlines()[error.line - 1] + for error in errors + ), + "hint": case["hint"] is None or case["hint"] in rendered, + "plain_output": "\x1b[" not in rendered + cli_text, + "source_marker": "^" in rendered, + "limit": collector.limit_reached == (case["name"] == "error_limit"), + "cli_exit_code": exit_code == 1, + "cli_diagnostics_once": cli_text.count(": error[") == len(case["expected"]), + "cli_locations": all( + f"{path}:{line}:{col}: error[{code}]" in cli_text + for code, line, col in case["expected"] + ), + "no_output_artifact": not output.exists(), + } + results.append({ + "name": case["name"], "passed": all(checks.values()), "checks": checks, + "source": source, "expected": case["expected"], "actual": actual, + "actual_codes": [error.error_code for error in errors], + "actual_count": len(errors), "limit_reached": collector.limit_reached, + "rendered": rendered, "cli_stderr": cli_text, "cli_exit_code": exit_code, + "validation": measure(validate, args.warmup, args.groups, args.iterations), + "rendering": measure(collector.report, args.warmup, args.groups, args.iterations), + }) + return {"passed": all(case["passed"] for case in results), "cases": results} + + +def run_worker(root: Path, mode: str, args: argparse.Namespace) -> dict: + if not (root / "scratchv" / "frontend" / "dsl_extended.py").is_file(): + raise ValueError(f"{mode} checkout has no DSL parser: {root}") + command = [ + sys.executable, "-I", "-X", "utf8", str(Path(__file__).resolve()), + "--worker-root", str(root), "--worker-mode", mode, + "--cases", str(args.cases), "--warmup", str(args.warmup), + "--groups", str(args.groups), "--iterations", str(args.iterations), + ] + result = subprocess.run( + command, cwd=root, capture_output=True, text=True, encoding="utf-8", + timeout=args.worker_timeout, + env={**os.environ, "OMP_NUM_THREADS": "1", "OPENBLAS_NUM_THREADS": "1"}, + ) + if result.returncode: + raise RuntimeError(f"{mode} worker failed at {root}:\n{result.stderr}") + return json.loads(result.stdout) + + +def compare_parsing(baseline: dict, current: dict, target: float) -> dict: + same_cases = baseline["case_hashes"] == current["case_hashes"] + same_ir = baseline["ir_hashes"] == current["ir_hashes"] + ratio = current["median_s"] / baseline["median_s"] + return { + "baseline": baseline, "current": current, "same_cases": same_cases, + "ir_equal": same_ir, "ratio": ratio, "target_ratio": target, + "target_met": ratio <= target, + } + + +def build_report(args: argparse.Namespace) -> dict: + report: dict[str, Any] = { + "schema_version": 1, "benchmark": "DSL diagnostics", "status": "failed", + "environment": { + "python": platform.python_version(), "executable": sys.executable, + "platform": platform.platform(), "processor": platform.processor(), + "dependencies": {name: importlib.metadata.version(name) for name in ("onnx", "numpy")}, + "worker_threads": 1, + }, + "configuration": { + "warmup": args.warmup, "groups": args.groups, "iterations": args.iterations, + "performance_enforced": args.enforce_performance, + "timing_scope": "one batch = iterations through the full corpus; parser construction + parse; excludes imports, file I/O and IR hashing", + }, + "warnings": [], + } + try: + # Record diagnostic evidence even if the legacy parser cannot parse a case. + report["diagnostics"] = run_worker(ROOT, "diagnostics", args) + if args.baseline_root is None: + raise ValueError("--baseline-root is required; no baseline is fabricated") + baseline = run_worker(args.baseline_root, "baseline", args) + current = run_worker(ROOT, "current", args) + parsing = compare_parsing(baseline, current, args.max_parse_ratio) + report["parsing"] = parsing + if not parsing["same_cases"] or not parsing["ir_equal"]: + raise ValueError("baseline/current case corpus or generated IR differs") + if not parsing["target_met"]: + report["warnings"].append( + f"Parsing ratio {parsing['ratio']:.3f}x exceeds target {args.max_parse_ratio:.3f}x." + ) + if report["diagnostics"]["passed"] and ( + parsing["target_met"] or not args.enforce_performance + ): + report["status"] = "passed" + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + return report + + +def markdown_report(report: dict) -> str: + lines = ["# DSL diagnostics", "", f"Functional/report status: **{report['status']}**", ""] + if "error" in report: + lines += ["## Error", "", "```text", report["error"], "```", ""] + env = report["environment"] + lines += [f"Python: {env['python']}; platform: {env['platform']}", ""] + config = report["configuration"] + lines += [f"Warmups: {config['warmup']}; groups: {config['groups']}; iterations/group: {config['iterations']}.", + config["timing_scope"], ""] + if "parsing" in report: + parsing = report["parsing"] + lines += ["## Correct DSL parsing", "", "| Metric | Baseline | Current |", "|---|---:|---:|"] + for key, label in [("revision", "Revision"), ("median_s", "Batch median (s)")]: + lines.append(f"| {label} | {parsing['baseline'][key]} | {parsing['current'][key]} |") + lines += ["", f"Current / baseline: **{parsing['ratio']:.3f}x**; target: {parsing['target_ratio']:.3f}x; target met: **{parsing['target_met']}**.", + f"Same case corpus: {parsing['same_cases']}; identical IR dumps: {parsing['ir_equal']}.", + f"Performance target enforced: {config['performance_enforced']}.", "", + "Cases: " + ", ".join(parsing["current"]["case_hashes"]), ""] + if "diagnostics" in report: + lines += ["## Diagnostic acceptance and timings", "", + "Validation/rendering times are per operation (batch median divided by iterations). CLI runs are correctness checks, not timed.", "", + "| Case | Passed | Errors | Validate (ms) | Render (ms) |", + "|---|---|---:|---:|---:|"] + for case in report["diagnostics"]["cases"]: + scale = 1000 / config["iterations"] + lines.append(f"| {case['name']} | {case['passed']} | {case['actual_count']} | {case['validation']['median_s'] * scale:.4f} | {case['rendering']['median_s'] * scale:.4f} |") + lines += ["", "## Diagnostic examples", ""] + for case in report["diagnostics"]["cases"]: + lines += [f"### {case['name']}", "", "```text", case["rendered"], "```", ""] + if not case["passed"]: + lines.append("Failed checks: " + ", ".join(key for key, value in case["checks"].items() if not value)) + for warning in report["warnings"]: + lines += [f"Warning: {warning}", ""] + return "\n".join(lines) + + +def positive_int(value: str) -> int: + result = int(value) + if result <= 0: + raise argparse.ArgumentTypeError("must be positive") + return result + + +def positive_float(value: str) -> float: + result = float(value) + if not math.isfinite(result) or result <= 0: + raise argparse.ArgumentTypeError("must be finite and positive") + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline-root", type=Path, help="separate checkout of the PR base revision") + parser.add_argument("--cases", type=Path, default=ROOT / "benchmarks" / "cases") + parser.add_argument("--warmup", type=int, default=5) + parser.add_argument("--groups", type=positive_int, default=10) + parser.add_argument("--iterations", type=positive_int, default=100) + parser.add_argument("--max-parse-ratio", type=positive_float, default=1.5) + parser.add_argument("--enforce-performance", action="store_true") + parser.add_argument("--worker-timeout", type=positive_int, default=300) + parser.add_argument("--json", action="store_true", help="emit only JSON to stdout") + parser.add_argument("--json-output", type=Path) + parser.add_argument("--markdown", type=Path) + parser.add_argument("--html", type=Path) + parser.add_argument("--worker-root", type=Path, help=argparse.SUPPRESS) + parser.add_argument("--worker-mode", choices=("baseline", "current", "diagnostics"), help=argparse.SUPPRESS) + args = parser.parse_args(argv) + if args.warmup < 0: + parser.error("--warmup must be non-negative") + args.cases = args.cases.resolve() + if args.baseline_root: + args.baseline_root = args.baseline_root.resolve() + if args.worker_root: + args.worker_root = args.worker_root.resolve() + sys.path.insert(0, str(args.worker_root)) + # Keep stdout a JSON channel even if imported compiler code logs there. + with contextlib.redirect_stdout(sys.stderr): + result = diagnostics_worker(args) if args.worker_mode == "diagnostics" else parse_worker(args) + print(json.dumps(result, ensure_ascii=True)) + return 0 + report = build_report(args) + json_text = json.dumps(report, indent=2, ensure_ascii=True) + "\n" + markdown = markdown_report(report) + html_text = ( + '' + 'DSL diagnostics
' + html.escape(markdown) + '
\n' + ) + for path, content in [(args.json_output, json_text), (args.markdown, markdown), (args.html, html_text)]: + if path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(json_text if args.json else markdown, end="\n") + return 0 if report["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md b/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md new file mode 100644 index 0000000..9b71b3b --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md @@ -0,0 +1,19 @@ +# DSL Diagnostics CI Implementation Plan + +**Goal:** Add independently visible test and benchmark checks for Topic 9 to PR #38. + +**Architecture:** A dedicated GitHub Actions workflow runs the existing DSL tests and a new diagnostics benchmark on hosted Linux runners. The benchmark uses the same current-branch case corpus and measurement script in separate Python processes for the baseline and current checkouts. Functional failures block CI; the documented 1.5x parsing target is reported separately, with optional enforcement. + +**Tech Stack:** Python standard library, pytest, GitHub Actions, JSON/Markdown/standalone HTML. + +The implementation follows the design already discussed with the user. It does not rebase or alter compiler behavior, the constant-merge feature, or the existing model benchmark workflow. + +- [x] Add subprocess regression tests for real diagnostic reports, empty/malformed input corpora, baseline isolation, and report failure propagation. +- [x] Run the new tests and confirm the missing benchmark fails them. +- [x] Implement `benchmarks/bench_dsl_diagnostics.py`: fixed diagnostic expectations, current/base parser measurements, IR fingerprints, environment/revision metadata, and three report formats. Defaults: 5 warmups, 10 groups, 100 corpus iterations per group. +- [x] Add `.github/workflows/dsl-diagnostics.yml` with `test` and `benchmark` jobs, exact event/base checkout, Python 3.12, explicit pytest installation, always-uploaded reports, and benchmark Job Summary. +- [x] Document local commands, baseline selection, timing boundaries, and the distinction between functional checks and the performance target. +- [x] Run targeted tests, full repository tests, the benchmark against the pre-diagnostics revision, YAML validation, and `git diff --check`. Attempt the mandated L2 command and record a missing harness honestly. +- [x] Self-review the implementation and record a reusable memory entry. + +Finalization: stage only task files, commit in English, and push the current PR branch. Validation evidence and known pre-existing failures are recorded in `docs/topics/09-DSL诊断-CI与Benchmark.md`. diff --git "a/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" new file mode 100644 index 0000000..9532385 --- /dev/null +++ "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" @@ -0,0 +1,90 @@ +# 课题 9:DSL 诊断 CI 与 benchmark + +`.github/workflows/dsl-diagnostics.yml` 提供两个检查项:`Topic 9 tests` 和 +`Topic 9 benchmark`。它们使用 GitHub 托管 Linux runner 和 Python 3.12,运行于 +指向 main 的 PR、main 的 push,也支持手动触发。现有全项目 CI 继续保留。 + +## 专项测试 + +```powershell +python -m pip install -e . "pytest>=7,<10" +python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test_dsl_diagnostics_cli.py tests/test_parser.py tests/test_dsl_extended.py tests/test_dsl_diagnostics_benchmark.py -v --tb=short +``` + +测试覆盖错误模型、位置、提示、块恢复、多错误、颜色、CLI 和正常解析回归, +以及 benchmark 的失败传播、基线导入隔离和报告产物。CI 上传 JUnit XML 到 +`dsl-test-reports`。既有全量 CI 也会发现这些测试;专项检查有意提供独立可见的 +课题 9 结果,不改变其他课题的测试范围。 + +## 本地 benchmark + +先为基线建立独立 checkout。以下 `997d2aa` 是本 PR 加入诊断实现之前的历史版本; +rebase 后与最新上游比较时,应改用对应的 PR base SHA。 + +```powershell +git worktree add --detach ../ScratchV-dsl-baseline 997d2aa +python -m benchmarks.bench_dsl_diagnostics --baseline-root ../ScratchV-dsl-baseline --json-output benchmark_reports/dsl_diagnostics.json --markdown benchmark_reports/dsl_diagnostics.md --html benchmark_reports/dsl_diagnostics.html +``` + +`--json` 是布尔开关,仅向 stdout 输出 JSON;文件输出使用 `--json-output`。 +HTML 使用标准库生成,不依赖外部样式、脚本或可视化库。报告同时包含成功与失败信息。 + +### 正确 DSL 解析性能 + +- 两个版本均使用**当前 checkout 的同一批** `benchmarks/cases/*.dsl`,包含基础语法、 + for、if/else、while。统一调用 `ExtendedDSLParser().parse(source)`,覆盖 CLI 使用的解析器。 +- 同一脚本通过两个独立 Python 进程导入各自 checkout 的解析器,并验证模块路径。 + 不用关闭当前 validator 的方式冒充旧版解析器。 +- 两侧复用当前项目安装的依赖及同一个 Python。每侧预热 5 次,再测量 10 组; + 每组解析完整语料 100 次。计时包含创建解析器和解析,排除进程启动、导入、文件读取、 + IR 摘要计算和每组开始前的显式 GC;计时期间保留 Python 默认 GC。 +- 比较每个文件的源码 SHA-256 和 `Program.dump()` SHA-256,任何解析异常、空语料、 + 源码差异或 IR 差异均使检查失败,不跳过失败用例。 +- JSON 记录原始组耗时、中位数、倍率、实际基线/当前 SHA、模块路径、Python、系统和依赖版本。 + 两个 checkout 的提交信息不包含未提交修改,正式报告应使用干净 checkout。 + +### 错误输入诊断 + +12 个内置固定用例独立验收错误码和行列、源码、文件名、提示、无 ANSI 输出、CLI 退出码 1、 +诊断不重复和不产生汇编文件。包含 CRLF/tab/Unicode、三个独立错误以及 25 个错误输入 +触发 20 条诊断上限的情况。分别测量验证/收集和纯文本渲染;CLI 用于正确性验收,不计时。 + +错误样例不放入正常 `benchmarks/cases/`,避免通用 DSL runner 将预期错误当作编译失败。 +旧版本不具备新诊断能力,不参与错误输入的等价性能比较。 + +## 基线与结果判定 + +- PR:基线为事件中的 `pull_request.base.sha`,当前为默认 checkout 的 PR 合并测试提交。 +- main push:基线为事件中的 `before`,当前为该 push 提交。 +- 手动:选择 workflow 的执行分支,并在 `baseline_ref` 指定基线,默认 main。 +- 基线不存在、无法 checkout 或 worker 超时均失败,不回退到另一个版本。 + +**诊断验收、IR 一致性及报告执行错误是硬门禁。** 设计文档的解析倍率目标是 1.5x, +默认明确显示 `target_met` 和超标提示,但不因共享 runner 的计时波动阻止合并。 +因此 CI 绿色不等于已经达到 1.5x 性能目标。需要严格执行该目标时添加 +`--enforce-performance`,阈值由 `--max-parse-ratio` 指定,默认 1.5。 + +benchmark 结束后,Markdown 写入 Job Summary;JSON、Markdown 和 HTML 上传至 +`dsl-benchmark-reports`。上传及汇总步骤使用 `always()`,功能失败仍保留诊断证据。 +这里不计算常量合并次数、TinyFive 指令减少量或 LLVM 指令数收益。 + +## 本地验证与自审记录(2026-09-13) + +环境为 Windows、Python 3.13.3;workflow 指定的 Linux/Python 3.12 运行结果需由 GitHub Actions 确认。 + +- 专项测试:117 passed,包含 12 项 benchmark 回归测试。 +- 全量命令 `python -m pytest tests benchmarks/test_benchmark.py -q`:427 passed、3 failed。 + 三项失败均在独立的 `997d2aa` 基线 checkout 复现: + `TestCompareFiles.test_compare_two_files` 删除未关闭临时文件时遇到 Windows 文件锁; + `TestVerifyAssembly` 的两个测试调用到未定义的 `load_asm`。相关源码与基线无差异, + 本次 CI 接入未修改这些模块,也未新增跳过规则。 +- 23 个正确 DSL 用例全部可解析,基线与当前 IR 摘要一致;12 个固定诊断用例全部通过。 +- 独立运行 benchmark、避免与 pytest 并行:基线组中位数 0.067008 秒,当前 0.117891 秒, + 倍率 **1.759x**,未达到设计文档的 1.5x 目标。基线为 `997d2aa`,当前编译器实现为 + `c9dd532`。这是当前机器的一次测量,不代表 Linux runner 的结果。 +- Python 3.12 语法解析、compileall、workflow YAML 和全部六段 Bash 脚本语法检查通过。 +- 已尝试 L2 命令,但本地 `.Codex/harness/verify/run.py` 不存在;不宣称 L2 通过。 + +自审结论:新增检查只验收 DSL 诊断和解析;基线导入隔离、IR 差异失败传播、失败报告保留、 +HTML 源码转义及性能目标显式标记均有回归覆盖。原有全量测试失败和解析性能目标未达标 +作为已知问题保留,不包装成“全量通过”或性能提升。 diff --git a/memory/memory.md b/memory/memory.md new file mode 100644 index 0000000..e222537 --- /dev/null +++ b/memory/memory.md @@ -0,0 +1,3 @@ +# ScratchV engineering memory + +[2026-09-13] DSL 诊断 benchmark 应用同一脚本和固定正确输入,在独立进程中导入基线与当前 checkout,并检查实际模块路径;同时比较源码与 IR 摘要。适用于课题 9 前端回归,避免 editable install 导致两侧都测到当前代码;错误输入的新增诊断能力单独验收,性能目标达标状态与功能通过状态分开报告。 diff --git a/tests/test_dsl_diagnostics_benchmark.py b/tests/test_dsl_diagnostics_benchmark.py new file mode 100644 index 0000000..0613520 --- /dev/null +++ b/tests/test_dsl_diagnostics_benchmark.py @@ -0,0 +1,151 @@ +"""Exercise the Topic 9 report through its real subprocess interface.""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "benchmarks" / "bench_dsl_diagnostics.py" + + +def run_report(tmp_path: Path, *extra: str) -> tuple[subprocess.CompletedProcess, dict]: + cases = tmp_path / "cases" + cases.mkdir(exist_ok=True) + source = cases / "valid.dsl" + if not source.exists(): + source.write_text("x = add(a, b)\nreturn x\n", encoding="utf-8") + output = tmp_path / "report.json" + result = subprocess.run( + [ + sys.executable, str(SCRIPT), "--baseline-root", str(ROOT), + "--cases", str(cases), "--warmup", "0", "--groups", "1", + "--iterations", "1", "--json-output", str(output), + "--markdown", str(tmp_path / "report.md"), + "--html", str(tmp_path / "report.html"), *extra, + ], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) + report = json.loads(output.read_text(encoding="utf-8")) if output.exists() else {} + return result, report + + +def test_real_report_checks_diagnostics_and_compares_same_corpus(tmp_path): + result, report = run_report(tmp_path, "--json") + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == report + assert report["status"] == "passed" + assert report["parsing"]["ir_equal"] is True + assert report["parsing"]["ratio"] > 0 + assert report["parsing"]["baseline"]["case_hashes"] == report["parsing"]["current"]["case_hashes"] + assert report["parsing"]["current"]["parser_file"].startswith(str(ROOT)) + assert report["parsing"]["current"]["samples_s"] + assert report["diagnostics"]["passed"] is True + checks = {case["name"]: case for case in report["diagnostics"]["cases"]} + assert checks["three_errors"]["actual_codes"] == ["E100", "E200", "E201"] + assert checks["error_limit"]["actual_count"] == 20 + assert checks["error_limit"]["limit_reached"] is True + assert checks["three_errors"]["cli_exit_code"] == 1 + assert "\x1b[" not in checks["three_errors"]["rendered"] + assert "DSL diagnostics" in (tmp_path / "report.md").read_text(encoding="utf-8") + html = (tmp_path / "report.html").read_text(encoding="utf-8") + assert "" in html.lower() + assert " b):" not in html From b10f9ec8f5695affedd6f703533f803daa9971d4 Mon Sep 17 00:00:00 2001 From: mahiru114514 <243091814+mahiru114514@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:29:27 +0800 Subject: [PATCH 4/7] ci: integrate DSL checks into the existing pipeline --- .github/workflows/ci.yml | 41 ++++-- .github/workflows/dsl-diagnostics.yml | 124 ------------------ .../plans/2026-09-13-dsl-diagnostics-ci.md | 8 +- ...12\346\226\255-CI\344\270\216Benchmark.md" | 32 +++-- memory/memory.md | 2 + 5 files changed, 59 insertions(+), 148 deletions(-) delete mode 100644 .github/workflows/dsl-diagnostics.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46ff9d2..80ab30a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,15 +30,17 @@ jobs: rm -rf "$WORKSPACE" cp -a "$MIRROR" "$WORKSPACE" cd "$WORKSPACE" - # Try to fetch latest; fallback to local copy if network fails - git fetch origin main --depth=1 2>/dev/null || echo "WARNING: git fetch failed, using local mirror" - git checkout -f "$GITHUB_SHA" 2>/dev/null || git checkout -f origin/main 2>/dev/null || true + # Test this event's commit; never silently substitute main. + git fetch origin "$GITHUB_REF" --depth=1 + git checkout --detach -f "$GITHUB_SHA" - name: Install dependencies run: | python3.12 -m pip install --upgrade pip python3.12 -m pip install -e ".[all]" + python3.12 -m pip install "pytest>=7,<10" + # Includes Topic 9 parser, diagnostic, CLI and benchmark regression tests. - name: Run all topic tests run: | mkdir -p benchmark_reports @@ -54,7 +56,7 @@ jobs: -o benchmark_reports/tests.html - name: Upload test reports - if: github.ref == 'refs/heads/main' + if: always() uses: actions/upload-artifact@v4 with: name: test-reports @@ -78,15 +80,33 @@ jobs: rm -rf "$WORKSPACE" cp -a "$MIRROR" "$WORKSPACE" cd "$WORKSPACE" - # Try to fetch latest; fallback to local copy if network fails - git fetch origin main --depth=1 2>/dev/null || echo "WARNING: git fetch failed, using local mirror" - git checkout -f "$GITHUB_SHA" 2>/dev/null || git checkout -f origin/main 2>/dev/null || true + # Test this event's commit; never silently substitute main. + git fetch origin "$GITHUB_REF" --depth=1 + git checkout --detach -f "$GITHUB_SHA" - name: Install dependencies run: | python3.12 -m pip install --upgrade pip python3.12 -m pip install -e ".[all]" - python3.12 -m pip install markdown + python3.12 -m pip install markdown "pytest>=7,<10" + + - name: Topic 9 DSL diagnostics benchmark + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + echo "::error::No baseline commit available for DSL comparison." + exit 1 + fi + git fetch origin "$BASE_SHA" --depth=1 + BASELINE=$(mktemp -d "$RUNNER_TEMP/dsl-baseline.XXXXXX") + git worktree add --detach "$BASELINE" "$BASE_SHA" + trap 'git worktree remove --force "$BASELINE"' EXIT + python3.12 -m benchmarks.bench_dsl_diagnostics \ + --baseline-root "$BASELINE" \ + --json-output benchmark_reports/dsl_diagnostics.json \ + --markdown benchmark_reports/dsl_diagnostics.md \ + --html benchmark_reports/dsl_diagnostics.html # ── 3.1 ONNX 模型管线基准测试 ────────────────────────────────────── - name: ONNX model pipeline benchmarks @@ -156,6 +176,7 @@ jobs: # ── 上报 ────────────────────────────────────────────────────────── - name: Upload benchmark reports + if: always() uses: actions/upload-artifact@v4 with: name: benchmark-reports @@ -206,7 +227,11 @@ jobs: path: benchmark_reports/ - name: Write job summary + if: always() run: | + if [ -f benchmark_reports/dsl_diagnostics.md ]; then + cat benchmark_reports/dsl_diagnostics.md >> "$GITHUB_STEP_SUMMARY" + fi if [ -f benchmark_reports/github_summary.md ]; then cat benchmark_reports/github_summary.md >> $GITHUB_STEP_SUMMARY fi diff --git a/.github/workflows/dsl-diagnostics.yml b/.github/workflows/dsl-diagnostics.yml deleted file mode 100644 index 2ba6661..0000000 --- a/.github/workflows/dsl-diagnostics.yml +++ /dev/null @@ -1,124 +0,0 @@ -name: DSL Diagnostics CI - -on: - pull_request: - branches: [main] - push: - branches: [main] - workflow_dispatch: - inputs: - baseline_ref: - description: Baseline commit or branch for a manual comparison - type: string - default: main - required: true - -permissions: - contents: read - -concurrency: - group: dsl-diagnostics-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - test: - name: Topic 9 tests - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout event commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - - - name: Install DSL test dependencies - run: python -m pip install -e . "pytest>=7,<10" - - - name: Run DSL diagnostics and parser regressions - run: | - mkdir -p benchmark_reports - python -m pytest \ - tests/test_dsl_errors.py \ - tests/test_dsl_validator.py \ - tests/test_dsl_diagnostics_cli.py \ - tests/test_parser.py \ - tests/test_dsl_extended.py \ - tests/test_dsl_diagnostics_benchmark.py \ - -v --tb=short \ - --junit-xml=benchmark_reports/dsl_test_results.xml - - - name: Upload DSL test results - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: dsl-test-reports - path: benchmark_reports/dsl_test_results.xml - retention-days: 30 - - benchmark: - name: Topic 9 benchmark - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout event commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - path: current - persist-credentials: false - - - name: Resolve baseline revision - id: baseline - env: - BASE_REF: ${{ github.event.pull_request.base.sha || inputs.baseline_ref || github.event.before }} - run: | - if [ -z "$BASE_REF" ] || [ "$BASE_REF" = "0000000000000000000000000000000000000000" ]; then - echo "::error::No baseline revision; run manually with baseline_ref." - exit 1 - fi - echo "ref=$BASE_REF" >> "$GITHUB_OUTPUT" - - - name: Checkout baseline - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: ${{ steps.baseline.outputs.ref }} - path: baseline - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - - - name: Install shared parser dependencies - working-directory: current - run: python -m pip install -e . - - - name: Run DSL diagnostics and parser benchmark - working-directory: current - run: | - python -m benchmarks.bench_dsl_diagnostics \ - --baseline-root ../baseline \ - --json-output benchmark_reports/dsl_diagnostics.json \ - --markdown benchmark_reports/dsl_diagnostics.md \ - --html benchmark_reports/dsl_diagnostics.html - - - name: Write DSL benchmark summary - if: always() - run: | - REPORT=current/benchmark_reports/dsl_diagnostics.md - if [ -f "$REPORT" ]; then - cat "$REPORT" >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Upload DSL benchmark reports - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: dsl-benchmark-reports - path: current/benchmark_reports/dsl_diagnostics.* - retention-days: 30 diff --git a/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md b/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md index 9b71b3b..d0abf52 100644 --- a/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md +++ b/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md @@ -1,17 +1,17 @@ # DSL Diagnostics CI Implementation Plan -**Goal:** Add independently visible test and benchmark checks for Topic 9 to PR #38. +**Goal:** Integrate Topic 9 tests and benchmarks into the existing CI pipeline for PR #38. -**Architecture:** A dedicated GitHub Actions workflow runs the existing DSL tests and a new diagnostics benchmark on hosted Linux runners. The benchmark uses the same current-branch case corpus and measurement script in separate Python processes for the baseline and current checkouts. Functional failures block CI; the documented 1.5x parsing target is reported separately, with optional enforcement. +**Architecture:** The existing `ci.yml` test job discovers the DSL regressions through `pytest tests/`; its benchmark job runs the diagnostics benchmark and publishes results with the existing artifacts and summary. The benchmark uses the same current-branch case corpus and measurement script in separate Python processes for the baseline and current checkouts. Functional failures block CI; the documented 1.5x parsing target is reported separately, with optional enforcement. **Tech Stack:** Python standard library, pytest, GitHub Actions, JSON/Markdown/standalone HTML. -The implementation follows the design already discussed with the user. It does not rebase or alter compiler behavior, the constant-merge feature, or the existing model benchmark workflow. +The implementation incorporates the maintainer's request to reuse the original pipeline. It does not rebase or alter compiler behavior or the constant-merge feature. - [x] Add subprocess regression tests for real diagnostic reports, empty/malformed input corpora, baseline isolation, and report failure propagation. - [x] Run the new tests and confirm the missing benchmark fails them. - [x] Implement `benchmarks/bench_dsl_diagnostics.py`: fixed diagnostic expectations, current/base parser measurements, IR fingerprints, environment/revision metadata, and three report formats. Defaults: 5 warmups, 10 groups, 100 corpus iterations per group. -- [x] Add `.github/workflows/dsl-diagnostics.yml` with `test` and `benchmark` jobs, exact event/base checkout, Python 3.12, explicit pytest installation, always-uploaded reports, and benchmark Job Summary. +- [x] Integrate with the existing `test` and `benchmark` jobs in `.github/workflows/ci.yml`; remove the separate DSL workflow. Preserve event/base checkout accuracy, explicit pytest installation, always-uploaded reports, and benchmark Job Summary. - [x] Document local commands, baseline selection, timing boundaries, and the distinction between functional checks and the performance target. - [x] Run targeted tests, full repository tests, the benchmark against the pre-diagnostics revision, YAML validation, and `git diff --check`. Attempt the mandated L2 command and record a missing harness honestly. - [x] Self-review the implementation and record a reusable memory entry. diff --git "a/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" index 9532385..ae574ff 100644 --- "a/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" +++ "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" @@ -1,8 +1,9 @@ # 课题 9:DSL 诊断 CI 与 benchmark -`.github/workflows/dsl-diagnostics.yml` 提供两个检查项:`Topic 9 tests` 和 -`Topic 9 benchmark`。它们使用 GitHub 托管 Linux runner 和 Python 3.12,运行于 -指向 main 的 PR、main 的 push,也支持手动触发。现有全项目 CI 继续保留。 +课题 9 接入原有 `.github/workflows/ci.yml`,不新增 workflow 或 job。 +现有 `test` job 运行 DSL 测试,现有 `benchmark` job 增加 +`Topic 9 DSL diagnostics benchmark` 步骤。沿用原有 self-hosted runner、Python 3.12 +和触发条件:指向 main 的 PR,以及 main、wjy_dev、jzj_dev 的 push。 ## 专项测试 @@ -12,9 +13,9 @@ python -m pytest tests/test_dsl_errors.py tests/test_dsl_validator.py tests/test ``` 测试覆盖错误模型、位置、提示、块恢复、多错误、颜色、CLI 和正常解析回归, -以及 benchmark 的失败传播、基线导入隔离和报告产物。CI 上传 JUnit XML 到 -`dsl-test-reports`。既有全量 CI 也会发现这些测试;专项检查有意提供独立可见的 -课题 9 结果,不改变其他课题的测试范围。 +以及 benchmark 的失败传播、基线导入隔离和报告产物。原有 `pytest tests/` 会自动 +发现这些文件,不重复执行专项测试。结果合并到 `benchmark_reports/test_results.xml`, +由原有 `test-reports` artifact 上传,PR 和失败运行也保留测试报告。 ## 本地 benchmark @@ -54,10 +55,12 @@ HTML 使用标准库生成,不依赖外部样式、脚本或可视化库。报 ## 基线与结果判定 -- PR:基线为事件中的 `pull_request.base.sha`,当前为默认 checkout 的 PR 合并测试提交。 -- main push:基线为事件中的 `before`,当前为该 push 提交。 -- 手动:选择 workflow 的执行分支,并在 `baseline_ref` 指定基线,默认 main。 -- 基线不存在、无法 checkout 或 worker 超时均失败,不回退到另一个版本。 +- PR:基线为事件中的 `pull_request.base.sha`,当前为事件的 PR 合并测试提交。 +- push:基线为事件中的 `before`,当前为该 push 提交。新分支首次 push 的全零 + `before` 无法用于对比,该步骤会明确失败;打开指向 main 的 PR 后使用 PR base。 +- 基线通过临时 Git worktree 准备,退出步骤时清理,不混入报告 artifact。 +- 两个 job 都必须 checkout 到事件的 `GITHUB_SHA`;获取提交、准备基线或 worker + 执行失败均使相应步骤失败,不静默回退到 main。 **诊断验收、IR 一致性及报告执行错误是硬门禁。** 设计文档的解析倍率目标是 1.5x, 默认明确显示 `target_met` 和超标提示,但不因共享 runner 的计时波动阻止合并。 @@ -65,12 +68,12 @@ HTML 使用标准库生成,不依赖外部样式、脚本或可视化库。报 `--enforce-performance`,阈值由 `--max-parse-ratio` 指定,默认 1.5。 benchmark 结束后,Markdown 写入 Job Summary;JSON、Markdown 和 HTML 上传至 -`dsl-benchmark-reports`。上传及汇总步骤使用 `always()`,功能失败仍保留诊断证据。 +原有 `benchmark-reports`。上传及汇总步骤使用 `always()`,功能失败仍保留诊断证据。 这里不计算常量合并次数、TinyFive 指令减少量或 LLVM 指令数收益。 ## 本地验证与自审记录(2026-09-13) -环境为 Windows、Python 3.13.3;workflow 指定的 Linux/Python 3.12 运行结果需由 GitHub Actions 确认。 +环境为 Windows、Python 3.13.3;CI runner 的 Python 3.12 运行结果需由 GitHub Actions 确认。 - 专项测试:117 passed,包含 12 项 benchmark 回归测试。 - 全量命令 `python -m pytest tests benchmarks/test_benchmark.py -q`:427 passed、3 failed。 @@ -88,3 +91,8 @@ benchmark 结束后,Markdown 写入 Job Summary;JSON、Markdown 和 HTML 上 自审结论:新增检查只验收 DSL 诊断和解析;基线导入隔离、IR 差异失败传播、失败报告保留、 HTML 源码转义及性能目标显式标记均有回归覆盖。原有全量测试失败和解析性能目标未达标 作为已知问题保留,不包装成“全量通过”或性能提升。 + +合并回原有 pipeline 后复验:117 项专项测试通过,benchmark 执行成功;原有 CI 测试入口 +`pytest tests/ --ignore=tests/test_simulator.py` 在本地为 398 passed、1 failed,失败仍为 +上述 Windows 临时文件锁问题。`ci.yml` 的 YAML 和所有 Bash 步骤语法检查通过; +jobs 仍只有原来的 `test`、`benchmark`、`deploy-pages`,未增加独立 pipeline。 diff --git a/memory/memory.md b/memory/memory.md index e222537..8b7c45f 100644 --- a/memory/memory.md +++ b/memory/memory.md @@ -1,3 +1,5 @@ # ScratchV engineering memory +[2026-09-13] 维护者要求课题测试与 benchmark 接入原有 `.github/workflows/ci.yml` 的 test/benchmark jobs,不新建独立 pipeline;pytest tests/ 已自动发现 DSL 测试,报告复用现有 artifacts。适用于本仓库课题 PR 的 CI 接入。 + [2026-09-13] DSL 诊断 benchmark 应用同一脚本和固定正确输入,在独立进程中导入基线与当前 checkout,并检查实际模块路径;同时比较源码与 IR 摘要。适用于课题 9 前端回归,避免 editable install 导致两侧都测到当前代码;错误输入的新增诊断能力单独验收,性能目标达标状态与功能通过状态分开报告。 From ca3f296fb0ba28a26855a1980e22b8519a8c25ad Mon Sep 17 00:00:00 2001 From: mahiru114514 <243091814+mahiru114514@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:27:45 +0800 Subject: [PATCH 5/7] docs: remove obsolete standalone CI planning records --- .../plans/2026-09-13-dsl-diagnostics-ci.md | 19 -------------- ...12\346\226\255-CI\344\270\216Benchmark.md" | 26 ------------------- 2 files changed, 45 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md diff --git a/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md b/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md deleted file mode 100644 index d0abf52..0000000 --- a/docs/superpowers/plans/2026-09-13-dsl-diagnostics-ci.md +++ /dev/null @@ -1,19 +0,0 @@ -# DSL Diagnostics CI Implementation Plan - -**Goal:** Integrate Topic 9 tests and benchmarks into the existing CI pipeline for PR #38. - -**Architecture:** The existing `ci.yml` test job discovers the DSL regressions through `pytest tests/`; its benchmark job runs the diagnostics benchmark and publishes results with the existing artifacts and summary. The benchmark uses the same current-branch case corpus and measurement script in separate Python processes for the baseline and current checkouts. Functional failures block CI; the documented 1.5x parsing target is reported separately, with optional enforcement. - -**Tech Stack:** Python standard library, pytest, GitHub Actions, JSON/Markdown/standalone HTML. - -The implementation incorporates the maintainer's request to reuse the original pipeline. It does not rebase or alter compiler behavior or the constant-merge feature. - -- [x] Add subprocess regression tests for real diagnostic reports, empty/malformed input corpora, baseline isolation, and report failure propagation. -- [x] Run the new tests and confirm the missing benchmark fails them. -- [x] Implement `benchmarks/bench_dsl_diagnostics.py`: fixed diagnostic expectations, current/base parser measurements, IR fingerprints, environment/revision metadata, and three report formats. Defaults: 5 warmups, 10 groups, 100 corpus iterations per group. -- [x] Integrate with the existing `test` and `benchmark` jobs in `.github/workflows/ci.yml`; remove the separate DSL workflow. Preserve event/base checkout accuracy, explicit pytest installation, always-uploaded reports, and benchmark Job Summary. -- [x] Document local commands, baseline selection, timing boundaries, and the distinction between functional checks and the performance target. -- [x] Run targeted tests, full repository tests, the benchmark against the pre-diagnostics revision, YAML validation, and `git diff --check`. Attempt the mandated L2 command and record a missing harness honestly. -- [x] Self-review the implementation and record a reusable memory entry. - -Finalization: stage only task files, commit in English, and push the current PR branch. Validation evidence and known pre-existing failures are recorded in `docs/topics/09-DSL诊断-CI与Benchmark.md`. diff --git "a/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" index ae574ff..b011488 100644 --- "a/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" +++ "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" @@ -70,29 +70,3 @@ HTML 使用标准库生成,不依赖外部样式、脚本或可视化库。报 benchmark 结束后,Markdown 写入 Job Summary;JSON、Markdown 和 HTML 上传至 原有 `benchmark-reports`。上传及汇总步骤使用 `always()`,功能失败仍保留诊断证据。 这里不计算常量合并次数、TinyFive 指令减少量或 LLVM 指令数收益。 - -## 本地验证与自审记录(2026-09-13) - -环境为 Windows、Python 3.13.3;CI runner 的 Python 3.12 运行结果需由 GitHub Actions 确认。 - -- 专项测试:117 passed,包含 12 项 benchmark 回归测试。 -- 全量命令 `python -m pytest tests benchmarks/test_benchmark.py -q`:427 passed、3 failed。 - 三项失败均在独立的 `997d2aa` 基线 checkout 复现: - `TestCompareFiles.test_compare_two_files` 删除未关闭临时文件时遇到 Windows 文件锁; - `TestVerifyAssembly` 的两个测试调用到未定义的 `load_asm`。相关源码与基线无差异, - 本次 CI 接入未修改这些模块,也未新增跳过规则。 -- 23 个正确 DSL 用例全部可解析,基线与当前 IR 摘要一致;12 个固定诊断用例全部通过。 -- 独立运行 benchmark、避免与 pytest 并行:基线组中位数 0.067008 秒,当前 0.117891 秒, - 倍率 **1.759x**,未达到设计文档的 1.5x 目标。基线为 `997d2aa`,当前编译器实现为 - `c9dd532`。这是当前机器的一次测量,不代表 Linux runner 的结果。 -- Python 3.12 语法解析、compileall、workflow YAML 和全部六段 Bash 脚本语法检查通过。 -- 已尝试 L2 命令,但本地 `.Codex/harness/verify/run.py` 不存在;不宣称 L2 通过。 - -自审结论:新增检查只验收 DSL 诊断和解析;基线导入隔离、IR 差异失败传播、失败报告保留、 -HTML 源码转义及性能目标显式标记均有回归覆盖。原有全量测试失败和解析性能目标未达标 -作为已知问题保留,不包装成“全量通过”或性能提升。 - -合并回原有 pipeline 后复验:117 项专项测试通过,benchmark 执行成功;原有 CI 测试入口 -`pytest tests/ --ignore=tests/test_simulator.py` 在本地为 398 passed、1 failed,失败仍为 -上述 Windows 临时文件锁问题。`ci.yml` 的 YAML 和所有 Bash 步骤语法检查通过; -jobs 仍只有原来的 `test`、`benchmark`、`deploy-pages`,未增加独立 pipeline。 From ce94bb84e8ff1f511332ee8334f61b9252c134f9 Mon Sep 17 00:00:00 2001 From: mahiru114514 <243091814+mahiru114514@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:17:13 +0800 Subject: [PATCH 6/7] fix: align DSL diagnostic markers for multi-digit line numbers --- memory/memory.md | 2 ++ scratchv/frontend/dsl_errors.py | 2 +- tests/test_dsl_errors.py | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/memory/memory.md b/memory/memory.md index 8b7c45f..a98a31c 100644 --- a/memory/memory.md +++ b/memory/memory.md @@ -1,5 +1,7 @@ # ScratchV engineering memory +[2026-09-13] DSL 错误标记缩进须按实际行号前缀的可见宽度计算,不能固定为 6;覆盖 9/10、99/100 位数边界及空格/tab、有色和无色输出,避免多位行号导致 caret 左移。ANSI 控制序列不计入宽度。 + [2026-09-13] 维护者要求课题测试与 benchmark 接入原有 `.github/workflows/ci.yml` 的 test/benchmark jobs,不新建独立 pipeline;pytest tests/ 已自动发现 DSL 测试,报告复用现有 artifacts。适用于本仓库课题 PR 的 CI 接入。 [2026-09-13] DSL 诊断 benchmark 应用同一脚本和固定正确输入,在独立进程中导入基线与当前 checkout,并检查实际模块路径;同时比较源码与 IR 摘要。适用于课题 9 前端回归,避免 editable install 导致两侧都测到当前代码;错误输入的新增诊断能力单独验收,性能目标达标状态与功能通过状态分开报告。 diff --git a/scratchv/frontend/dsl_errors.py b/scratchv/frontend/dsl_errors.py index 0de67af..acb4c53 100644 --- a/scratchv/frontend/dsl_errors.py +++ b/scratchv/frontend/dsl_errors.py @@ -233,7 +233,7 @@ def format_error( ) else: token_len = _estimate_token_length(err.source_line, raw_start) - marker_padding = 6 + display_start + marker_padding = len(f" {err.line} | ") + display_start marker = " " * marker_padding + "^" if use_color: marker = ( diff --git a/tests/test_dsl_errors.py b/tests/test_dsl_errors.py index 047d097..2ed883c 100644 --- a/tests/test_dsl_errors.py +++ b/tests/test_dsl_errors.py @@ -357,6 +357,24 @@ def test_make_error_with_all_fields(self): assert err.error_code == "E001" +@pytest.mark.parametrize("line", [1, 9, 10, 99, 100, 1000]) +@pytest.mark.parametrize("indent", ["", " ", "\t", " \t"]) +@pytest.mark.parametrize("use_color", [False, True]) +def test_marker_aligns_with_token_across_line_number_widths(line, indent, use_color): + import re + + source = indent + "x = ad(a, b)" + col = source.index("ad") + 1 + error = DSLSyntaxError( + line, col, "unsupported operation", source_line=source, + error_code="E200", end_col=col + 2, + ) + rendered = format_error(error, use_color=use_color) + plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered) + source_display, marker = plain.splitlines()[1:3] + assert marker.index("^") == source_display.index("ad") + + class TestColor: """Tests for ANSI color definitions.""" From d32a76c045a42cff47481e4fc5167e0282ca79dd Mon Sep 17 00:00:00 2001 From: mahiru114514 <243091814+mahiru114514@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:20:03 +0800 Subject: [PATCH 7/7] feat: collapse detailed DSL diagnostic logs in reports --- benchmarks/bench_dsl_diagnostics.py | 25 +++++++++++++++---- ...12\346\226\255-CI\344\270\216Benchmark.md" | 2 ++ memory/memory.md | 2 ++ tests/test_dsl_diagnostics_benchmark.py | 18 +++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/benchmarks/bench_dsl_diagnostics.py b/benchmarks/bench_dsl_diagnostics.py index a2e6d5e..054330a 100644 --- a/benchmarks/bench_dsl_diagnostics.py +++ b/benchmarks/bench_dsl_diagnostics.py @@ -247,7 +247,7 @@ def build_report(args: argparse.Namespace) -> dict: return report -def markdown_report(report: dict) -> str: +def markdown_report(report: dict, *, include_examples: bool = True) -> str: lines = ["# DSL diagnostics", "", f"Functional/report status: **{report['status']}**", ""] if "error" in report: lines += ["## Error", "", "```text", report["error"], "```", ""] @@ -273,11 +273,15 @@ def markdown_report(report: dict) -> str: for case in report["diagnostics"]["cases"]: scale = 1000 / config["iterations"] lines.append(f"| {case['name']} | {case['passed']} | {case['actual_count']} | {case['validation']['median_s'] * scale:.4f} | {case['rendering']['median_s'] * scale:.4f} |") - lines += ["", "## Diagnostic examples", ""] for case in report["diagnostics"]["cases"]: - lines += [f"### {case['name']}", "", "```text", case["rendered"], "```", ""] if not case["passed"]: - lines.append("Failed checks: " + ", ".join(key for key, value in case["checks"].items() if not value)) + lines += ["", f"{case['name']} failed checks: " + ", ".join(key for key, value in case["checks"].items() if not value), ""] + if include_examples: + lines += ["", "## Diagnostic examples", ""] + for case in report["diagnostics"]["cases"]: + label = f"{case['name']}: {case['actual_count']} error(s) - view diagnostic log" + lines += ["
", f"{html.escape(label)}", "", + "```text", case["rendered"], "```", "", "
", ""] for warning in report["warnings"]: lines += [f"Warning: {warning}", ""] return "\n".join(lines) @@ -330,12 +334,23 @@ def main(argv: list[str] | None = None) -> int: report = build_report(args) json_text = json.dumps(report, indent=2, ensure_ascii=True) + "\n" markdown = markdown_report(report) + html_examples = [] + for case in report.get("diagnostics", {}).get("cases", []): + label = f"{case['name']}: {case['actual_count']} error(s) - view diagnostic log" + html_examples.append( + '
' + html.escape(label) + '
'
+            + html.escape(case["rendered"]) + '
' + ) html_text = ( '' 'DSL diagnostics
' + html.escape(markdown) + '
\n' + 'details{margin:12px 0;border:1px solid #d8dee6;border-radius:6px}' + 'summary{padding:12px;cursor:pointer}details pre{margin:0}' + '
' + html.escape(markdown_report(report, include_examples=False))
+        + '
' + ('

Diagnostic examples

' if html_examples else '') + + ''.join(html_examples) + '\n' ) for path, content in [(args.json_output, json_text), (args.markdown, markdown), (args.html, html_text)]: if path: diff --git "a/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" index 8de68d3..9cd0e8c 100644 --- "a/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" +++ "b/docs/topics/09-DSL\350\257\212\346\226\255-CI\344\270\216Benchmark.md" @@ -70,4 +70,6 @@ HTML 使用标准库生成,不依赖外部样式、脚本或可视化库。报 benchmark 结束后,Markdown 写入 Job Summary;JSON、Markdown 和 HTML 上传至 原有 `benchmark-reports`。上传及汇总步骤使用 `always()`,功能失败仍保留诊断证据。 +Markdown 摘要和本地 HTML 中,每个用例的完整诊断日志默认折叠,点击用例名称展开。 +汇总表、失败检查和性能提示直接可见;JSON 保留完整诊断内容。 这里不计算常量合并次数、TinyFive 指令减少量或 LLVM 指令数收益。 diff --git a/memory/memory.md b/memory/memory.md index a98a31c..0c80287 100644 --- a/memory/memory.md +++ b/memory/memory.md @@ -1,5 +1,7 @@ # ScratchV engineering memory +[2026-09-13] DSL benchmark 的详细日志按用例使用默认关闭的 details/summary 折叠,汇总表、失败检查和性能提示保留在外层;GitHub Markdown 与本地 HTML 均需支持,HTML 中的诊断源码必须转义。 + [2026-09-13] DSL 错误标记缩进须按实际行号前缀的可见宽度计算,不能固定为 6;覆盖 9/10、99/100 位数边界及空格/tab、有色和无色输出,避免多位行号导致 caret 左移。ANSI 控制序列不计入宽度。 [2026-09-13] 维护者要求课题测试与 benchmark 接入原有 `.github/workflows/ci.yml` 的 test/benchmark jobs,不新建独立 pipeline;pytest tests/ 已自动发现 DSL 测试,报告复用现有 artifacts。适用于本仓库课题 PR 的 CI 接入。 diff --git a/tests/test_dsl_diagnostics_benchmark.py b/tests/test_dsl_diagnostics_benchmark.py index 0613520..ef154a9 100644 --- a/tests/test_dsl_diagnostics_benchmark.py +++ b/tests/test_dsl_diagnostics_benchmark.py @@ -149,3 +149,21 @@ def test_html_escapes_diagnostic_source(tmp_path): html = (tmp_path / "report.html").read_text(encoding="utf-8") assert "if (a > b):" in html assert "if (a > b):" not in html + + +def test_diagnostic_logs_are_collapsed_in_markdown_and_html(tmp_path): + result, report = run_report(tmp_path) + assert result.returncode == 0, result.stderr + count = len(report["diagnostics"]["cases"]) + for extension in ("md", "html"): + content = (tmp_path / f"report.{extension}").read_text(encoding="utf-8") + assert content.count("
") == count + assert content.count("
") == count + assert "
error_limit: 20 error(s)" in content + for case in report["diagnostics"]["cases"]: + import html + + expected = html.escape(case["rendered"]) if extension == "html" else case["rendered"] + assert expected in content + assert content.index("Diagnostic acceptance and timings") < content.index("
")