fix(AST): f-string 规则从上线起就没生效过;加 check:ast 把这类静默错判变成机器检查
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
## 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
This commit is contained in:
32
CLAUDE.md
32
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,题目页也不把它们的规则展示成「要求」——
|
||||
|
||||
@@ -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"
|
||||
|
||||
72
apps/api/src/scripts/check-ast-targets.ts
Normal file
72
apps/api/src/scripts/check-ast-targets.ts
Normal file
@@ -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<string, string> = {
|
||||
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<string>()
|
||||
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)
|
||||
@@ -200,7 +200,18 @@ export const AST_NODE_TARGETS_BY_LANGUAGE: Record<string, Record<string, AstNode
|
||||
list_literal: { label: "列表", node: "list" },
|
||||
dict_literal: { label: "字典", node: "dictionary" },
|
||||
set_literal: { label: "集合", node: "set" },
|
||||
f_string: { label: "f-string", node: "format_string" },
|
||||
/**
|
||||
* f-string 认的是 `interpolation`(`f"{x}"` 里的 `{x}`),不是 `format_string` ——
|
||||
* 这个版本的 tree-sitter-python 里**没有** format_string 这种节点,f-string 是
|
||||
* 一个 `string`,靠 `string_start` 为 `f"` 和内部的 interpolation 子节点来认。
|
||||
* 配成 format_string 的那阵子,「不能使用 f-string」从上线起就一直判成通过。
|
||||
*
|
||||
* 代价是 `f"abc"` 这种**没有占位符**的 f-string 认不出来(它确实不含
|
||||
* interpolation)。没占位符的 f-string 本来也没有意义,而且比起「一个都认不出来」
|
||||
* 这已经是严格的改善。实测 `%` 格式化、`.format()`、普通字符串、字符串拼接
|
||||
* 都不会误伤。
|
||||
*/
|
||||
f_string: { label: "f-string", node: "interpolation" },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user