From 55bdf317f1a187774582e2fd6600be343c2989f5 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 3 Nov 2026 13:29:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=AF=AD=E6=B3=95=E8=A6=81=E6=B1=82):=20?= =?UTF-8?q?=E3=80=8C=E8=87=AA=E5=AE=9A=E4=B9=89=E5=87=BD=E6=95=B0=E3=80=8D?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=8A=20main=20=E7=AE=97=E8=BF=9B?= =?UTF-8?q?=E5=8E=BB=EF=BC=8C=E8=BF=90=E7=AE=97=E7=AC=A6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8A=8A=E5=8F=96=E5=9C=B0=E5=9D=80=E3=80=81=E6=8C=87=E9=92=88?= =?UTF-8?q?=E3=80=81=E6=A8=A1=E6=9D=BF=E5=B0=96=E6=8B=AC=E5=8F=B7=E5=BD=93?= =?UTF-8?q?=E6=88=90=E7=94=A8=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C / C++ 的函数定义排掉 main,下拉改叫「自定义函数」。原来「必须使用」永远通过、「不能使用」永远失败, 出题页挑战候选里 C 的「用自定义函数再写一遍」也因此一直挖不出来 - 运算符只认二元运算 / 复合赋值 / 自增自减里的 token,一元只认取反。scanf 的 &、int *p、vector、 *args、import *、**kw、not in、is not、case 1 | 2 不再算「用了」 - Python 的 math.sqrt(4) 算调用了 sqrt()(只认 import 进来的模块) - Python 的「赋值语句」连 s += 1 一起算,和 C 一个口径 - 保存时拦下负数次数、至少比至多还多的次数规则 现有 19 道题的规则都不受影响:1265 条真实提交重判,结论和改之前一致。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/judge/ast.ts | 116 ++++++++++++++++++++++++++----- docs/ast-rules.md | 17 +++++ packages/contract/src/problem.ts | 3 +- 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/apps/api/src/judge/ast.ts b/apps/api/src/judge/ast.ts index 4c6365c..28b9c76 100644 --- a/apps/api/src/judge/ast.ts +++ b/apps/api/src/judge/ast.ts @@ -63,15 +63,74 @@ async function loadLanguage(language: string) { return loaded } -function collectNodes(root: Node, type: string, result: Node[] = []) { - if (root.type === type) result.push(root) - for (const child of root.children) collectNodes(child, type, result) +type Match = (node: Node) => boolean + +function collectNodes(root: Node, match: Match, result: Node[] = []) { + if (match(root)) result.push(root) + for (const child of root.children) collectNodes(child, match, result) return result } -function hasNode(root: Node, type: string): boolean { - if (root.type === type) return true - return root.children.some((child) => hasNode(child, type)) +function hasNode(root: Node, match: Match): boolean { + if (match(root)) return true + return root.children.some((child) => hasNode(child, match)) +} + +const ofType = + (type: string): Match => + (node) => + node.type === type + +/** C / C++ 函数定义的函数名:`int *f()` 的名字在 pointer_declarator 里面一层,沿着 declarator 往里找 */ +function functionName(definition: Node) { + let declarator = definition.childForFieldName('declarator') + while (declarator && declarator.type !== 'function_declarator') declarator = declarator.childForFieldName('declarator') + return declarator?.childForFieldName('declarator')?.text +} + +/** + * 语法 target 认哪些节点。多数就是比节点类型,例外: + * + * C / C++ 的「函数定义」不算 `main` —— 每个 C 程序都有 main,算进去的话「必须使用函数定义」 + * 永远通过、「不能使用」永远失败,老师选它要的是「自己写了函数」。所以 C 那边叫「自定义函数」。 + * + * Python 的「赋值语句」连 `s += 1` 一起算 —— 和 C 一个口径(C 的 assignment_expression + * 本来就包括 `+=`),学生也是这么理解的。tree-sitter-python 里 `+=` 是单独的 augmented_assignment。 + */ +function nodeMatch(target: string, language: string): Match { + const type = astTargetNodeType(target, language) + if (target === 'function_definition' && language !== 'Python') { + return (node) => node.type === type && functionName(node) !== 'main' + } + if (target === 'assignment' && language === 'Python') { + return (node) => node.type === type || node.type === 'augmented_assignment' + } + return ofType(type) +} + +/** + * 运算符只认真在做运算的那一个 token。光比 token 的话,下面这些全都算「用了」: + * C 的 `scanf("%d", &n)` 算 `&`、`int *p` 算 `*`,C++ 的 `vector` 算 `>`, + * Python 的 `*args` / `from m import *` 算 `*`、`**kw` 算 `**`、`not in` / `is not` 算 `not`、 + * `case 1 | 2` 算 `|`。所以看它的父节点:二元运算、复合赋值、自增自减。 + * + * 一元的只认取反(`!x` / `not x`)。负号 `-x` 不算「用了减号」,同理 `*p` 不算乘、`&n` 不算按位与。 + */ +const OPERATOR_PARENTS: Record = { + C: ['binary_expression', 'assignment_expression', 'update_expression'], + 'C++': ['binary_expression', 'assignment_expression', 'update_expression'], + Python: ['binary_operator', 'boolean_operator', 'comparison_operator', 'augmented_assignment'], +} +const NOT_PARENTS: Record = { + C: ['unary_expression'], + 'C++': ['unary_expression'], + Python: ['not_operator'], +} + +function operatorMatch(target: string, language: string): Match { + const type = astTargetNodeType(target, language) + const parents = (target === 'not' ? NOT_PARENTS : OPERATOR_PARENTS)[language] ?? [] + return (node) => node.type === type && node.parent !== null && parents.includes(node.parent.type) } function targetName(rule: AstRule, language?: string) { @@ -201,6 +260,10 @@ export function astRulesError(astRules: AstRules | null): string | null { if (!rule.keyword?.trim()) return `${at}:参数名不能为空` } if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字` + if ([rule.exact, rule.min, rule.max].some((n) => n !== undefined && n < 0)) return `${at}:次数不能是负数` + if (rule.min !== undefined && rule.max !== undefined && rule.min > rule.max) { + return `${at}:至少 ${rule.min} 次比至多 ${rule.max} 次还多,怎么写都过不了` + } } } return null @@ -238,12 +301,32 @@ const CALL_NODE_TYPES: Record = { Python: 'call', } +/** `import math` / `import numpy as np` 引进来的模块名。`import os.path` 绑定的是 `os` */ +function importedModules(root: Node) { + const names = new Set() + for (const statement of collectNodes(root, ofType('import_statement'))) { + for (const item of statement.namedChildren) { + const name = item.type === 'aliased_import' ? item.childForFieldName('alias') : item.type === 'dotted_name' ? item.namedChildren[0] : null + if (name) names.add(name.text) + } + } + return names +} + function functionCalls(root: Node, target: string, language: string) { const callType = CALL_NODE_TYPES[language] ?? 'call' - return collectNodes(root, callType).filter((call) => { + // Python 的 `math.sqrt(4)` 也算调用了 sqrt() —— 和 `from math import sqrt` 后写 `sqrt(4)` + // 是一回事,老师配「必须调用 sqrt()」时想的就是它。只认 import 进来的模块, + // `s.count()` 这种对象上的方法还是归「调用方法」 + const modules = language === 'Python' ? importedModules(root) : null + return collectNodes(root, ofType(callType)).filter((call) => { const fn = call.childForFieldName('function') if (!fn) return false if (fn.type === 'identifier') return fn.text === target + if (modules && fn.type === 'attribute') { + const object = fn.childForFieldName('object') + return object?.type === 'identifier' && modules.has(object.text) && fn.childForFieldName('attribute')?.text === target + } // `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于 // 有没有 using namespace std,两种都得认,所以末段也比一次 if (language === 'C++' && fn.type === 'qualified_identifier') { @@ -265,13 +348,13 @@ function methodCalls(root: Node, target: string, language: string) { // 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」 // 会静默地永远失败 if (language === 'C++') { - return collectNodes(root, 'call_expression').filter((call) => { + return collectNodes(root, ofType('call_expression')).filter((call) => { const fn = call.childForFieldName('function') return fn?.type === 'field_expression' && fn.childForFieldName('field')?.text === target }) } if (language !== 'Python') return [] - return collectNodes(root, 'call').filter((call) => { + return collectNodes(root, ofType('call')).filter((call) => { const fn = call.childForFieldName('function') return fn?.type === 'attribute' && fn.childForFieldName('attribute')?.text === target }) @@ -279,20 +362,20 @@ function methodCalls(root: Node, target: string, language: string) { function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null { const target = rule.target ?? '' - const nodeType = astTargetNodeType(target, language) + const match = rule.engine === 'must_use_operator' ? operatorMatch(target, language) : nodeMatch(target, language) switch (rule.engine) { case 'must_exist_node': return { description: describeAstRule(rule, language), - passed: hasNode(root, nodeType), + passed: hasNode(root, match), } case 'must_not_exist_node': { - const nodes = collectNodes(root, nodeType) + const nodes = collectNodes(root, match) return withLines({ description: describeAstRule(rule, language), passed: nodes.length === 0 }, nodes) } case 'count_node': { - const nodes = collectNodes(root, nodeType) + const nodes = collectNodes(root, match) return withLines( { description: describeAstRule(rule, language), @@ -335,7 +418,7 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | case 'must_use_operator': return { description: describeAstRule(rule, language), - passed: hasNode(root, nodeType), + passed: hasNode(root, match), } case 'must_use_keyword': return { @@ -345,9 +428,8 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | case 'must_have_nesting': { const outer = rule.outer ?? '' const inner = rule.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))) + const innerMatch = nodeMatch(inner, language) + const passed = collectNodes(root, nodeMatch(outer, language)).some((node) => node.children.some((child) => hasNode(child, innerMatch))) return { description: describeAstRule(rule, language), passed } } default: diff --git a/docs/ast-rules.md b/docs/ast-rules.md index 2803835..455c46f 100644 --- a/docs/ast-rules.md +++ b/docs/ast-rules.md @@ -25,6 +25,23 @@ bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里 它只验节点类型**存在**,不验语义对不对(把 `while_loop` 配成 `for_statement` 这种两个都 存在,机器看不出来),语义那层还是得实跑。 +## 光比节点类型不够的几处 + +下面几处都是光比类型就会静默判错的,判题机(`judge/ast.ts`)额外看了一眼上下文: + +- **C / C++ 的函数定义不算 `main`**(`nodeMatch`)。每个 C 程序都有 main,算进去的话「必须使用」 + 永远通过、「不能使用」永远失败。所以 C / C++ 的下拉叫「自定义函数」。 +- **Python 的「赋值语句」连 `s += 1` 一起算**(`nodeMatch`),和 C 一个口径 —— C 的 + `assignment_expression` 本来就包括复合赋值,tree-sitter-python 却把它拆成了 `augmented_assignment`。 +- **运算符只认真在做运算的 token**(`operatorMatch`),看父节点是不是二元运算 / 复合赋值 / 自增自减, + 一元的只认取反。不看的话 `scanf("%d", &n)` 算用了 `&`、`int *p` 算 `*`、`vector` 算 `>`、 + `*args` 和 `from m import *` 算 `*`、`not in` / `is not` 算 `not`。负号 `-x` 不算用了减号。 +- **Python 的 `math.sqrt(4)` 算调用了 `sqrt()`**(`functionCalls`),只认 `import` 进来的模块名; + 对象上的 `s.count()` 仍归「调用方法」。 + +改这几处之后,拿真实提交回放一遍:对着挂了语法要求的题,把 AC 和「语法检查没通过」的提交重新 +`checkAst`,看结论变了多少条、变的是不是该变的。 + ## 只有三种语言真的会跑 判题机只认 `AST_SUPPORTED_LANGUAGES`(C / C++ / Python)。别的语言配了规则一条都不会跑, diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index bf2e1fe..1ecad14 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -145,7 +145,8 @@ const C_NODE_TARGETS = { break: { label: 'break 语句', node: 'break_statement' }, continue: { label: 'continue 语句', node: 'continue_statement' }, return: { label: 'return 语句', node: 'return_statement' }, - function_definition: { label: '函数定义', node: 'function_definition' }, + // 不算 main(判题机 judge/ast.ts 的 nodeMatch 里排掉的),所以不叫「函数定义」 + function_definition: { label: '自定义函数', node: 'function_definition' }, assignment: { label: '赋值语句', node: 'assignment_expression' }, struct: { label: '结构体', node: 'struct_specifier' }, include: { label: '#include 指令', node: 'preproc_include' },