diff --git a/CLAUDE.md b/CLAUDE.md index b8fedcc..96789ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ bun run dev # api(3000) + worker + web(5173) 一起起 ```bash bun run --filter '@oj2/api' typecheck # 后端类型检查 bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下 +bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑 cd apps/web && bun run type-check # 前端类型检查 cd apps/web && bun run build # 前端构建 ``` @@ -147,16 +148,31 @@ query 里的筛选值要和收窄过的列比较时走 `routes/helpers.ts` 的 ` 唯一还留着 `parse` 的地方是 `judge/events.ts` 的 `parseSubmissionEvent` —— 那是从 Redis 收回来的报文,真边界,且失败返回 `null` 而不是 500。 -### AST 代码规则有两张表,必须同增同减 +### AST 代码规则:一张表,外加一个机器检查 -契约的 `AST_NODE_TARGETS_BY_LANGUAGE`(target → 中文名)决定后台下拉能选什么, -`apps/api/src/judge/ast.ts` 的 `mappings`(target → tree-sitter 节点类型)决定判题机 -认得什么。**加节点类型时两边都要加**,运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 同理。 +契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表,一个 target 一条 +`{ label, node }`:`label` 给后台下拉和题目页,`node` 给判题机比 tree-sitter 节点类型。 +运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 一份两用(它的值既是文案又是要比的 token)。 +判题机侧没有第二张表,解析统一走契约的 `astTargetNodeType()`。 -只加一边是**静默错判**:判题机 `mapping[target] ?? target` 拿裸名去比节点类型, -C 的语法树里永远不存在 `list_comprehension`,于是「必须使用列表推导式」永远失败、 -「不能使用 f-string」永远通过,两头都不报错,只有学生受着。原来那张表是 C/Python -混在一起的 15 条,整份铺成下拉,给 C 题也能选到 Python 专有节点——就是这么来的。 +> 这里原来是两张表:契约那张 target → 中文名,`judge/ast.ts` 的 `mappings` 是 +> target → 节点类型,靠一句「两边必须同增同减」的注释维持。**加 target 而漏配节点类型 +> 现在在结构上不可能了**,那条注释也就不必再守。 + +但**配错**仍然可能,而且完全静默:节点类型对不上就是一个都收不到,于是「必须使用 X」 +永远失败、「不能使用 X」永远通过,两头不报错,只有学生受着。所以有: + +```bash +bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里是否真实存在 +``` + +**升级 `tree-sitter-*` 依赖之后一定要跑一次** —— 语法改节点名是常事,后果全静默。 +加这个检查那天,56 个 target 里就抓出一个:`f_string` 一直配的是 `format_string`, +而这个版本的 tree-sitter-python 根本没有这种节点(f-string 是 `string` 里带 +`interpolation`),所以「不能使用 f-string」从上线起就没生效过。 + +它只验节点类型**存在**,不验语义对不对(把 `while_loop` 配成 `for_statement` +这种两个都存在,机器看不出来),语义那层还是得实跑。 判题机只认 `AST_SUPPORTED_LANGUAGES` 里的语言(C / C++ / Python3)。别的语言配了规则 一条都不会跑,所以后台不给它们开 tab,题目页也不把它们的规则展示成「要求」—— diff --git a/apps/api/package.json b/apps/api/package.json index 6e40088..1951951 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "backfill:problemsets": "bun src/main.ts backfill-problemsets", "typecheck": "tsc --noEmit", "check:routes": "bun src/scripts/check-route-shadowing.ts", + "check:ast": "bun src/scripts/check-ast-targets.ts", "db:pull": "drizzle-kit pull", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate" diff --git a/apps/api/src/scripts/check-ast-targets.ts b/apps/api/src/scripts/check-ast-targets.ts new file mode 100644 index 0000000..3951b40 --- /dev/null +++ b/apps/api/src/scripts/check-ast-targets.ts @@ -0,0 +1,72 @@ +/** + * 检查契约里每个 AST target 的 `node` 在对应语言的语法里真实存在。 + * + * bun run --filter '@oj2/api' check:ast + * + * ## 为什么需要这个 + * + * 判题机拿 `node` 去比 tree-sitter 的节点类型,**对不上不会报错**:collectNodes + * 一个都收不到,于是「必须使用 X」永远失败、「不能使用 X」永远通过。两头都不报错, + * 只有学生受着 —— 他明明写了 f-string,「不能使用 f-string」却judge成通过。 + * + * 这正是本仓库真实踩过的坑:`f_string` 一直配的是 `format_string`,而这个版本的 + * tree-sitter-python 里根本没有这种节点(f-string 是 `string` 里带 `interpolation`), + * 所以那条规则从上线起就没生效过。加这个检查那天,56 个 target 里就它一个是坏的。 + * + * 升级 tree-sitter-* 依赖之后一定要跑一次:语法改个节点名是很常见的事, + * 而它造成的故障完全静默。 + * + * 只验节点类型**存在**,不验语义对不对(比如把 `while_loop` 配成 `for_statement` + * 这种,语法里两个都存在,机器看不出来)。语义那一层还是得靠实跑。 + */ + +import { AST_NODE_TARGETS_BY_LANGUAGE } from "@oj2/contract" +import { Language, Parser } from "web-tree-sitter" + +import cWasmPath from "tree-sitter-c/tree-sitter-c.wasm" with { type: "file" } +import cppWasmPath from "tree-sitter-cpp/tree-sitter-cpp.wasm" with { type: "file" } +import pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { type: "file" } +import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" } + +const WASM_BY_LANGUAGE: Record = { + C: cWasmPath, + "C++": cppWasmPath, + Python3: pythonWasmPath, +} + +await Parser.init({ locateFile: () => treeSitterWasmPath }) + +let checked = 0 +const missing: Array<{ language: string; target: string; node: string }> = [] + +for (const [language, table] of Object.entries(AST_NODE_TARGETS_BY_LANGUAGE)) { + const wasmPath = WASM_BY_LANGUAGE[language] + if (!wasmPath) { + console.log(`⚠ ${language} 在 AST_NODE_TARGETS_BY_LANGUAGE 里,但这个脚本没有它的语法 wasm`) + console.log(` 加语言时记得同步 WASM_BY_LANGUAGE 和 judge/ast.ts 的 loadLanguage`) + process.exit(2) + } + const loaded = await Language.load(wasmPath) + // 语法里声明过的全部节点类型名 + const declared = new Set() + for (let id = 0; id < loaded.nodeTypeCount; id++) { + const name = loaded.nodeTypeForId(id) + if (name) declared.add(name) + } + for (const [target, entry] of Object.entries(table)) { + checked++ + if (!declared.has(entry.node)) missing.push({ language, target, node: entry.node }) + } +} + +console.log(`检查了 ${checked} 个 AST target 的节点类型`) +if (missing.length === 0) { + console.log("✓ 每个 target 的 node 都在对应语言的语法里真实存在") + process.exit(0) +} +for (const { language, target, node } of missing) { + console.log(`\n⚠ ${language} 的 ${target} → "${node}"`) + console.log(` 这个节点类型在语法里不存在,规则永远失败(或永远通过),且不报错`) + console.log(` 改法:在 packages/contract/src/problem.ts 把它的 node 改成语法里真实的名字`) +} +process.exit(1) diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index a642e2c..5c61347 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -200,7 +200,18 @@ export const AST_NODE_TARGETS_BY_LANGUAGE: Record