From a6ba5cdf07f63845adab23ec54909d0fa8084e82 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 10 Sep 2026 19:30:35 -0600 Subject: [PATCH] =?UTF-8?q?refactor(AST):=20=E4=B8=A4=E5=BC=A0=20target=20?= =?UTF-8?q?=E8=A1=A8=E5=90=88=E6=88=90=E4=B8=80=E5=BC=A0=EF=BC=8C=E5=8A=A0?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=B1=BB=E5=9E=8B=E6=BC=8F=E9=85=8D=E5=9C=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=B8=8A=E4=B8=8D=E5=86=8D=E5=8F=AF=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契约的 AST_NODE_TARGETS_BY_LANGUAGE 是 target → 中文名,judge/ast.ts 的 mappings 是 target → tree-sitter 节点类型,同一批键分在两个包里,靠一句「两边必须同增同减」的注释 维持。只加一边是静默错判:老师给 C 题选到只有 Python 有的 list_comprehension,判题机 拿裸名去比节点类型,C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、 「不能使用 f-string」永远通过,两头都不报错,只有学生受着。 现在一个 target 一条 { label, node }:label 给后台下拉和题目页,node 给判题机。加 target 而漏配节点类型在结构上就不可能了。judge/ast.ts 的 mappings 整张删掉,解析统一走契约的 astTargetNodeType()(节点查 node,运算符查运算符表 —— 那张表的值本身就是要比的 token, 判题机原来抄的 and→&& 三条取值逐个相同,纯属重复)。 顺带把同一份数据的四份拷贝收成一份:C 的 14 条原来在契约和判题机里各抄了两遍 (C 一份、C++ 一份),现在 C++ 逐条引用 C_NODE_TARGETS;运算符表的 C++ 改成 { ...C_OPERATOR_TARGETS, "<<", ">>" }。C++ 那几条仍逐条列出而不是 spread,是为了保住 下拉框的显示顺序(C++ 独有的几条插在中间)。 ## 验证 行为零变化,是逐个 target 机械比对过的:把 HEAD 版的两张表原样取出来,对三种语言的 全部 target 比对「label / 运算符文案 / tree-sitter 解析结果 / 下拉框顺序」四项 —— C 37 个、C++ 47 个、Python3 43 个,全部一致,键集与顺序也一致。 实跑:给题目 1004 配两条 Python3 规则(必须有 for 循环、不能用 f-string),交一发没有 for 循环的正确答案,判成 AST_CHECK_FAILED(10),statistic_info.ast_results 为 「必须使用 for 循环 / 不通过」「不能使用 f-string / 通过」—— label 与 node 两半都走到了。 再交一发带 for 循环的,判成 ACCEPTED(0)。 tsc、vue-tsc、vite build、单二进制编译均通过。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH --- apps/api/src/judge/ast.ts | 101 +-------- .../problem/components/AstRulesEditor.vue | 4 +- packages/contract/src/problem.ts | 213 ++++++++++-------- 3 files changed, 130 insertions(+), 188 deletions(-) diff --git a/apps/api/src/judge/ast.ts b/apps/api/src/judge/ast.ts index 1d69f70..fe8c848 100644 --- a/apps/api/src/judge/ast.ts +++ b/apps/api/src/judge/ast.ts @@ -4,6 +4,7 @@ import { astNodeLabel, astOperatorLabel, astRuleIsMeaningful, + astTargetNodeType, astRuleSchema, AST_SUPPORTED_LANGUAGES, type AstRequirement, @@ -31,91 +32,11 @@ export interface AstResult { actual?: number } -/** - * target → tree-sitter 节点类型。恒等的条目(`+`、`==` 这些运算符)不列, - * 走 `mapping[target] ?? target` 回落。 - * - * **这里的键集是契约 AST_NODE_TARGETS_BY_LANGUAGE 的另一半**,两边必须同增同减: - * 那边决定后台下拉能选什么,这边决定判题机认得什么。只加一边就是静默错判。 - */ -const mappings: Record> = { - C: { - for_loop: "for_statement", - while_loop: "while_statement", - do_while: "do_statement", - if_statement: "if_statement", - else_clause: "else_clause", - break: "break_statement", - continue: "continue_statement", - function_definition: "function_definition", - return: "return_statement", - switch_statement: "switch_statement", - case_statement: "case_statement", - assignment: "assignment_expression", - struct: "struct_specifier", - include: "preproc_include", - and: "&&", - or: "||", - not: "!", - }, - "C++": { - // C 的那 14 条原样通用(tree-sitter-cpp 继承 tree-sitter-c 的语法) - for_loop: "for_statement", - while_loop: "while_statement", - do_while: "do_statement", - if_statement: "if_statement", - else_clause: "else_clause", - break: "break_statement", - continue: "continue_statement", - function_definition: "function_definition", - return: "return_statement", - switch_statement: "switch_statement", - case_statement: "case_statement", - assignment: "assignment_expression", - struct: "struct_specifier", - include: "preproc_include", - // C++ 独有 - range_for_loop: "for_range_loop", - class_definition: "class_specifier", - try_except: "try_statement", - throw: "throw_statement", - namespace: "namespace_definition", - template: "template_declaration", - lambda: "lambda_expression", - using: "using_declaration", - and: "&&", - or: "||", - not: "!", - }, - Python3: { - for_loop: "for_statement", - while_loop: "while_statement", - if_statement: "if_statement", - else_clause: "else_clause", - elif_clause: "elif_clause", - break: "break_statement", - continue: "continue_statement", - function_definition: "function_definition", - return: "return_statement", - try_except: "try_statement", - with_statement: "with_statement", - list_comprehension: "list_comprehension", - list_literal: "list", - dict_literal: "dictionary", - set_literal: "set", - f_string: "format_string", - import: "import_statement", - import_from: "import_from_statement", - assignment: "assignment", - class_definition: "class_definition", - }, -} - let initPromise: Promise | undefined const languages = new Map() async function loadLanguage(language: string) { - if (!(language in mappings)) return null + if (!AST_SUPPORTED_LANGUAGES.includes(language)) return null // locateFile 指到内嵌的 tree-sitter.wasm:emscripten 默认按脚本所在目录找, // 单二进制里那个目录是 /$bunfs/root,它自己找不着 if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath }) @@ -240,7 +161,7 @@ export function astRequirements(value: unknown): AstRequirements | null { /** * AST 规则的语义校验。zod 只管形状(engine 在枚举里、min 是整数),管不了 * 「给 C 题选了只有 Python 才有的 list_comprehension」这类组合 —— 那种规则存得进去, - * 判题时 `mapping[target] ?? target` 拿裸名去比节点类型,永远失败或永远通过, + * 判题时 astTargetNodeType() 找不到就回落成裸名去比节点类型,永远失败或永远通过, * 两头都不报错,只有学生受着。 * * 放这儿而不是 astRulesSchema 的 refine 上:那个 schema 同时用于**读**后台题目详情, @@ -345,14 +266,9 @@ function methodCalls(root: Node, target: string, language: string) { }) } -function evaluateRule( - root: Node, - rule: AstRule, - language: string, - mapping: Record, -): AstResult | null { +function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null { const target = rule.target ?? "" - const nodeType = mapping[target] ?? target + const nodeType = astTargetNodeType(target, language) switch (rule.engine) { case "must_exist_node": @@ -409,8 +325,8 @@ function evaluateRule( case "must_have_nesting": { const outer = rule.outer ?? "" const inner = rule.inner ?? "" - const outerType = mapping[outer] ?? outer - const innerType = mapping[inner] ?? inner + const outerType = astTargetNodeType(outer, language) + const innerType = astTargetNodeType(inner, language) const passed = collectNodes(root, outerType).some((node) => node.children.some((child) => hasNode(child, innerType)), ) @@ -437,10 +353,9 @@ export async function checkAst( const tree = parser.parse(code) if (!tree) return { passed: true, results: [] } try { - const mapping = mappings[language] ?? {} const results = rules .filter(astRuleIsMeaningful) - .map((rule) => evaluateRule(tree.rootNode, rule, language, mapping)) + .map((rule) => evaluateRule(tree.rootNode, rule, language)) .filter((result): result is AstResult => result !== null) return { passed: results.every((result) => result.passed), results } } finally { diff --git a/apps/web/src/admin/problem/components/AstRulesEditor.vue b/apps/web/src/admin/problem/components/AstRulesEditor.vue index d051101..6c3f545 100644 --- a/apps/web/src/admin/problem/components/AstRulesEditor.vue +++ b/apps/web/src/admin/problem/components/AstRulesEditor.vue @@ -71,7 +71,7 @@ const ENGINE_OPTIONS: SelectOption[] = [ // (或者反过来,「不能使用 f-string」永远通过),两头都不报错。 function nodeTargetOptions(lang: string): SelectOption[] { return Object.entries(AST_NODE_TARGETS_BY_LANGUAGE[lang] ?? {}).map( - ([value, label]) => ({ label, value }), + ([value, entry]) => ({ label: entry.label, value }), ) } @@ -173,7 +173,7 @@ function getTargetLabel( engine: string, target: string, ): string | undefined { - if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target] + if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label // 运算符不写 label:判题结果的文案按语言翻译(astOperatorLabel), // 存一个固定 label 反而会把 C 的 && 钉死成 and return undefined diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index f6910bd..a642e2c 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -110,109 +110,122 @@ export const astRuleSchema = z.object({ export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema)) /** - * 每种语言支持哪些节点 target,以及它的中文名。原来这里是一张 15 条的混合表, - * C 和 Python 的节点混在一起铺成后台下拉(更早之前 AstRulesEditor.vue 和 - * ProblemContent.vue 还各手抄了一份)。 + * 一个 target 一条,`label` 给人看(后台下拉、题目页的「要求」),`node` 给判题机 + * 拿去比 tree-sitter 的节点类型。 * - * **键必须和 judge/ast.ts 的 mappings 逐一对齐** —— 那边是 target → tree-sitter - * 节点类型,这边是 target → 中文名。少一边就是静默故障:老师给 C 题选到只有 - * Python 有的 `list_comprehension`,判题机 `mapping[target] ?? target` 拿裸名去比 - * 节点类型,C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、 - * 「不能使用 f-string」永远通过,两头都不报错。 + * **这两半原来是分在两个包里的两张表** —— 这边 target → 中文名, + * `apps/api/src/judge/ast.ts` 的 mappings 是 target → tree-sitter 节点类型,靠一句 + * 「两边必须同增同减」的注释维持。只加一边是静默错判:老师给 C 题选到只有 Python + * 有的 `list_comprehension`,判题机 `mapping[target] ?? target` 拿裸名去比节点类型, + * C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、「不能使用 + * f-string」永远通过,两头都不报错,只有学生受着。并成一张之后,加 target 而漏配 + * 节点类型在结构上就不可能了。 + * + * (更早之前 AstRulesEditor.vue 和 ProblemContent.vue 还各手抄过一份中文名, + * 且 C 和 Python 的节点混在一张 15 条的表里铺成后台下拉。) */ -export const AST_NODE_TARGETS_BY_LANGUAGE: Record> = { - C: { - for_loop: "for 循环", - while_loop: "while 循环", - do_while: "do-while 循环", - if_statement: "if 条件", - else_clause: "else 子句", - switch_statement: "switch 语句", - case_statement: "case 分支", - break: "break 语句", - continue: "continue 语句", - return: "return 语句", - function_definition: "函数定义", - assignment: "赋值语句", - struct: "结构体", - include: "#include 指令", - }, - // tree-sitter-cpp 继承 tree-sitter-c 的语法,C 那 14 条 target 在 C++ 树里 - // 逐个实测通用,所以这张表是「C 的全集 + C++ 独有的几条」 +export interface AstNodeTarget { + /** 后台下拉和题目页展示的中文名 */ + label: string + /** tree-sitter 里对应的节点类型,判题机按它 collectNodes */ + node: string +} + +const C_NODE_TARGETS = { + for_loop: { label: "for 循环", node: "for_statement" }, + while_loop: { label: "while 循环", node: "while_statement" }, + do_while: { label: "do-while 循环", node: "do_statement" }, + if_statement: { label: "if 条件", node: "if_statement" }, + else_clause: { label: "else 子句", node: "else_clause" }, + switch_statement: { label: "switch 语句", node: "switch_statement" }, + case_statement: { label: "case 分支", node: "case_statement" }, + break: { label: "break 语句", node: "break_statement" }, + continue: { label: "continue 语句", node: "continue_statement" }, + return: { label: "return 语句", node: "return_statement" }, + function_definition: { label: "函数定义", node: "function_definition" }, + assignment: { label: "赋值语句", node: "assignment_expression" }, + struct: { label: "结构体", node: "struct_specifier" }, + include: { label: "#include 指令", node: "preproc_include" }, +} satisfies Record + +export const AST_NODE_TARGETS_BY_LANGUAGE: Record> = { + C: C_NODE_TARGETS, + /** + * tree-sitter-cpp 继承 tree-sitter-c 的语法,C 那 14 条在 C++ 树里逐个实测通用, + * 所以共用的条目一律**引用** C_NODE_TARGETS 而不是抄一遍 —— 原来 C 的 14 行在 + * 契约和判题机两个文件里各抄了两遍(C 一份、C++ 一份),同一份数据四份拷贝。 + * 这里逐条列出来是为了保住下拉框的显示顺序(C++ 独有的几条是插在中间的)。 + */ "C++": { - for_loop: "for 循环", - range_for_loop: "范围 for 循环", - while_loop: "while 循环", - do_while: "do-while 循环", - if_statement: "if 条件", - else_clause: "else 子句", - switch_statement: "switch 语句", - case_statement: "case 分支", - break: "break 语句", - continue: "continue 语句", - return: "return 语句", - function_definition: "函数定义", - class_definition: "类定义", - struct: "结构体", - assignment: "赋值语句", - include: "#include 指令", - try_except: "try-catch", - throw: "throw 语句", - namespace: "namespace 定义", - template: "模板定义", - lambda: "lambda 表达式", - using: "using 声明", + for_loop: C_NODE_TARGETS.for_loop, + range_for_loop: { label: "范围 for 循环", node: "for_range_loop" }, + while_loop: C_NODE_TARGETS.while_loop, + do_while: C_NODE_TARGETS.do_while, + if_statement: C_NODE_TARGETS.if_statement, + else_clause: C_NODE_TARGETS.else_clause, + switch_statement: C_NODE_TARGETS.switch_statement, + case_statement: C_NODE_TARGETS.case_statement, + break: C_NODE_TARGETS.break, + continue: C_NODE_TARGETS.continue, + return: C_NODE_TARGETS.return, + function_definition: C_NODE_TARGETS.function_definition, + class_definition: { label: "类定义", node: "class_specifier" }, + struct: C_NODE_TARGETS.struct, + assignment: C_NODE_TARGETS.assignment, + include: C_NODE_TARGETS.include, + try_except: { label: "try-catch", node: "try_statement" }, + throw: { label: "throw 语句", node: "throw_statement" }, + namespace: { label: "namespace 定义", node: "namespace_definition" }, + template: { label: "模板定义", node: "template_declaration" }, + lambda: { label: "lambda 表达式", node: "lambda_expression" }, + using: { label: "using 声明", node: "using_declaration" }, }, Python3: { - for_loop: "for 循环", - while_loop: "while 循环", - if_statement: "if 条件", - elif_clause: "elif 子句", - else_clause: "else 子句", - break: "break 语句", - continue: "continue 语句", - return: "return 语句", - function_definition: "函数定义", - class_definition: "类定义", - assignment: "赋值语句", - try_except: "try-except", - with_statement: "with 语句", - import: "import 语句", - import_from: "from-import 语句", - list_comprehension: "列表推导式", - list_literal: "列表", - dict_literal: "字典", - set_literal: "集合", - f_string: "f-string", + for_loop: { label: "for 循环", node: "for_statement" }, + while_loop: { label: "while 循环", node: "while_statement" }, + if_statement: { label: "if 条件", node: "if_statement" }, + elif_clause: { label: "elif 子句", node: "elif_clause" }, + else_clause: { label: "else 子句", node: "else_clause" }, + break: { label: "break 语句", node: "break_statement" }, + continue: { label: "continue 语句", node: "continue_statement" }, + return: { label: "return 语句", node: "return_statement" }, + function_definition: { label: "函数定义", node: "function_definition" }, + class_definition: { label: "类定义", node: "class_definition" }, + assignment: { label: "赋值语句", node: "assignment" }, + try_except: { label: "try-except", node: "try_statement" }, + with_statement: { label: "with 语句", node: "with_statement" }, + import: { label: "import 语句", node: "import_statement" }, + import_from: { label: "from-import 语句", node: "import_from_statement" }, + list_comprehension: { label: "列表推导式", node: "list_comprehension" }, + list_literal: { label: "列表", node: "list" }, + dict_literal: { label: "字典", node: "dictionary" }, + set_literal: { label: "集合", node: "set" }, + f_string: { label: "f-string", node: "format_string" }, }, } /** * 运算符 target → 该语言里的实际写法。逻辑名 `and` / `or` / `not` 在 C 里写作 - * `&&` / `||` / `!`,判题机按 mappings 翻译,文案这边也得翻 —— 否则 C 题的学生 - * 看到的要求是「必须使用 and 运算符」,而 C 里根本没有 `and` 这个词。 + * `&&` / `||` / `!`,文案要翻(否则 C 题的学生看到「必须使用 and 运算符」,而 C 里 + * 根本没有 `and` 这个词),判题机也正好拿同一个值去比节点类型 —— 所以这张表**一份 + * 两用**,不像节点那样需要两个字段。原来判题机的 mappings 里还抄了一份非恒等的 + * (`and`→`&&` 那三条),取值逐个相同,纯属重复。 * - * 恒等的那些条目(`+`、`==` …)判题机的 mappings 里已经删掉了,走 `?? target` - * 回落到同一个值;这里保留完整列表是因为它同时是后台下拉的选项来源。 + * 恒等的条目(`+`、`==` …)写全是因为这张表同时是后台下拉的选项来源。 */ +const C_OPERATOR_TARGETS = { + "+": "+", "-": "-", "*": "*", "/": "/", "%": "%", + "+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=", + "++": "++", "--": "--", + "==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=", + and: "&&", or: "||", not: "!", + "&": "&", "|": "|", +} + export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record> = { - C: { - "+": "+", "-": "-", "*": "*", "/": "/", "%": "%", - "+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=", - "++": "++", "--": "--", - "==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=", - and: "&&", or: "||", not: "!", - "&": "&", "|": "|", - }, + C: C_OPERATOR_TARGETS, // `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token) - "C++": { - "+": "+", "-": "-", "*": "*", "/": "/", "%": "%", - "+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=", - "++": "++", "--": "--", - "==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=", - and: "&&", or: "||", not: "!", - "&": "&", "|": "|", "<<": "<<", ">>": ">>", - }, + "C++": { ...C_OPERATOR_TARGETS, "<<": "<<", ">>": ">>" }, Python3: { "+": "+", "-": "-", "*": "*", "/": "/", "//": "//", "%": "%", "**": "**", "+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=", @@ -230,14 +243,28 @@ export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record = Object.assign( - {}, - ...Object.values(AST_NODE_TARGETS_BY_LANGUAGE), +export const AST_NODE_TARGET_LABELS: Record = Object.fromEntries( + Object.values(AST_NODE_TARGETS_BY_LANGUAGE).flatMap((table) => + Object.entries(table).map(([target, entry]) => [target, entry.label]), + ), ) export function astNodeLabel(target: string, language?: string): string { const table = language ? AST_NODE_TARGETS_BY_LANGUAGE[language] : undefined - return table?.[target] ?? AST_NODE_TARGET_LABELS[target] ?? target + return table?.[target]?.label ?? AST_NODE_TARGET_LABELS[target] ?? target +} + +/** + * target → tree-sitter 节点类型。判题机唯一的解析入口 —— + * 节点走上面那张表的 `node`,运算符走运算符表(它的值本身就是要比的 token), + * 都对不上就回落到裸 target(恒等的运算符 `+` / `==` 走的就是这条)。 + */ +export function astTargetNodeType(target: string, language: string): string { + return ( + AST_NODE_TARGETS_BY_LANGUAGE[language]?.[target]?.node ?? + AST_OPERATOR_TARGETS_BY_LANGUAGE[language]?.[target] ?? + target + ) } export function astOperatorLabel(target: string, language?: string): string {