From 91712b482dbf8b0771204633642ae7edc4685ae9 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 10 Sep 2026 19:33:19 -0600 Subject: [PATCH] =?UTF-8?q?fix(AST):=20f-string=20=E8=A7=84=E5=88=99?= =?UTF-8?q?=E4=BB=8E=E4=B8=8A=E7=BA=BF=E8=B5=B7=E5=B0=B1=E6=B2=A1=E7=94=9F?= =?UTF-8?q?=E6=95=88=E8=BF=87=EF=BC=9B=E5=8A=A0=20check:ast=20=E6=8A=8A?= =?UTF-8?q?=E8=BF=99=E7=B1=BB=E9=9D=99=E9=BB=98=E9=94=99=E5=88=A4=E5=8F=98?= =?UTF-8?q?=E6=88=90=E6=9C=BA=E5=99=A8=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## check:ast 判题机拿 target 的 node 去比 tree-sitter 节点类型,**对不上不报错**:collectNodes 一个 都收不到,于是「必须使用 X」永远失败、「不能使用 X」永远通过,两头不报错,只有学生 受着。上一个提交把两张表合成一张,杜绝了「漏配」,但「配错」照样静默 —— 所以加一个 检查,逐个 target 去问语法:这个节点类型你到底有没有。 bun run --filter '@oj2/api' check:ast 升级 tree-sitter-* 之后必须跑:语法改节点名是常事,后果全静默。它只验节点类型存在, 不验语义对不对(把 while_loop 配成 for_statement 这种两个都存在,机器看不出来)。 ## 它抓出来的那个 56 个 target 里坏了一个:Python3 的 f_string 一直配的是 format_string,而这个版本的 tree-sitter-python **根本没有这种节点** —— f-string 是一个 string,靠 string_start 为 f" 和内部的 interpolation 子节点来认。也就是说「不能使用 f-string」这条规则从上线起 就一直判成通过,「必须使用 f-string」一直判成失败。 改成 interpolation。实测:带占位符的 f-string(单双引号都有)命中,而 % 格式化、 .format()、普通字符串、字符串拼接都不误伤。代价是 f"abc" 这种没有占位符的 f-string 认不出来 —— 它确实不含 interpolation,但没占位符的 f-string 本来也没意义,比起原来 「一个都认不出来」是严格的改善。这条写在表里的注释上了。 ## 验证 给题目 1004 配「必须有 for 循环 + 不能用 f-string」两条规则实跑: - 有 for、用了 f-string → 修复前 ACCEPTED(0),修复后 AST_CHECK_FAILED(10), ast_results 为「必须使用 for 循环/通过」「不能使用 f-string/不通过」; - 有 for、不用 f-string → ACCEPTED(0); - check:ast 修复前 exit 1 并指出这一条,修复后 56 个全过、exit 0。 tsc、check:routes、vue-tsc、vite build、单二进制编译均通过。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH --- CLAUDE.md | 32 +++++++--- apps/api/package.json | 1 + apps/api/src/scripts/check-ast-targets.ts | 72 +++++++++++++++++++++++ packages/contract/src/problem.ts | 13 +++- 4 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/scripts/check-ast-targets.ts 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