fix(代码规则): 给 C 题配的规则一半是哑弹,C++ 题根本配不了
Some checks failed
Deploy / deploy (push) Has been cancelled

后台的节点下拉是一张 C/Python 混在一起的 15 条表,整份铺给每种语言。给 C 题选到
只有 Python 有的 list_comprehension、f-string,规则存得进去,判题时拿裸名去比节点
类型,C 的语法树里永远不存在它——「必须使用列表推导式」永远失败、「不能使用
f-string」永远通过,两头都不报错,只有学生受着。反过来 mappings 支持的 do_while、
switch、struct、include 在表里没有,编辑器根本选不到。

标签表改成按语言分组,和判题机 mappings 的键集逐条对齐;保存时校验规则与语言是否
匹配,不匹配给中文提示。C 的 target 从 8 个可用变成 14 个。

C++ 接上了 tree-sitter-cpp,386 道 C++ 题从此能配规则。它继承 tree-sitter-c 的语法,
C 那 14 个 target 实测全部通用,另加范围 for、类定义、try-catch、throw、namespace、
模板、lambda、using 共 22 个。调用形态和 C/Python 都不同,一并处理:a.push_back()
和 p->push_back() 是 call_expression + field_expression,不是 Python 的 attribute;
std::sort(...) 的 function 是 qualified_identifier 而不是 identifier,所以额外比一次
:: 末段,否则学生写没写 using namespace std 会得到不同判定。

一起收掉的几处:

- Java/Golang/JavaScript 配的规则一条都不会跑,题目页却照常把它们渲染成「要求」
  挂给学生看。现在后台不给这些语言开 tab,下发给学生的要求也按语言过滤。
- 「出现次数」不填数字存下来是一条恒真规则,描述还退化成光秃秃一个「for 循环」。
  切换引擎时给默认值,保存时拦下,读取时整条丢弃。
- 次数规则失败只说「if 条件 出现 2 次 ✗」,学生不知道自己写了几次,补上「当前 N 次」。
  旧栈的引擎其实算了这个数,但 checker 只取 describe,算完就扔。
- must_have_nesting 的文案没走标签表,学生看到的是「必须使用 for_loop 嵌套」。
- 运算符文案给的是逻辑名,C 题的学生看到「必须使用 and 运算符」,而 C 里写的是 &&。

语义校验放在 astRulesError() 而不是 zod 的 refine 上:astRulesSchema 同时用于读后台
题目详情,在读路径上抛错会让历史脏数据把整个题目详情打不开。保存前先 pickAstRules()
剔除够不着的分组再校验,否则早年配过 C++ 规则的题会把老师锁死——tab 里看不到那组
规则,保存却被拦下。

生产库那 17 条规则(全是 Python3 的 must_exist_node / count_node)行为不变,逐条实跑
核对过。C++ 的 22 个节点 target、25 个运算符也逐个跑了,没有恒假的哑弹。改了带 wasm
内嵌的 ast.ts,dev 和编译两种形态都验过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:53:37 -06:00
parent 280443c892
commit f38444c97a
9 changed files with 444 additions and 94 deletions

View File

@@ -1,14 +1,21 @@
import {
AST_NODE_TARGETS_BY_LANGUAGE,
AST_OPERATOR_TARGETS_BY_LANGUAGE,
astNodeLabel,
astOperatorLabel,
astRuleIsMeaningful,
astRuleSchema,
AST_NODE_TARGET_LABELS,
AST_SUPPORTED_LANGUAGES,
type AstRequirement,
type AstRequirements,
type AstRule,
type AstRules,
} from "@oj2/contract"
import { Language, Parser, type Node } from "web-tree-sitter"
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root解析不到 node_modules。见 vendor/jieba.ts
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" }
// web-tree-sitter 自己的运行时 wasmParser.init() 要用
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
@@ -20,8 +27,17 @@ export type { AstRule } from "@oj2/contract"
export interface AstResult {
description: string
passed: boolean
/** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */
actual?: number
}
/**
* target → tree-sitter 节点类型。恒等的条目(`+`、`==` 这些运算符)不列,
* 走 `mapping[target] ?? target` 回落。
*
* **这里的键集是契约 AST_NODE_TARGETS_BY_LANGUAGE 的另一半**,两边必须同增同减:
* 那边决定后台下拉能选什么,这边决定判题机认得什么。只加一边就是静默错判。
*/
const mappings: Record<string, Record<string, string>> = {
C: {
for_loop: "for_statement",
@@ -42,6 +58,35 @@ const mappings: Record<string, Record<string, string>> = {
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",
@@ -79,7 +124,12 @@ async function loadLanguage(language: string) {
const cached = languages.get(language)
if (cached) return cached
const loaded = await Language.load(language === "C" ? cWasmPath : pythonWasmPath)
const wasmPath = language === "C"
? cWasmPath
: language === "C++"
? cppWasmPath
: pythonWasmPath
const loaded = await Language.load(wasmPath)
languages.set(language, loaded)
return loaded
}
@@ -95,9 +145,9 @@ function hasNode(root: Node, type: string): boolean {
return root.children.some((child) => hasNode(child, type))
}
function targetName(rule: AstRule) {
function targetName(rule: AstRule, language?: string) {
const target = rule.target ?? ""
return rule.label || AST_NODE_TARGET_LABELS[target] || target || "指定语法"
return rule.label || astNodeLabel(target, language) || "指定语法"
}
function countPhrase(verb: string, rule: AstRule) {
@@ -114,9 +164,9 @@ function countPhrase(verb: string, rule: AstRule) {
* 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现,
* 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。
*/
export function describeAstRule(rule: AstRule): string {
export function describeAstRule(rule: AstRule, language?: string): string {
if (rule.message) return rule.message
const name = targetName(rule)
const name = targetName(rule, language)
const target = rule.target ?? ""
switch (rule.engine) {
case "must_exist_node":
@@ -136,10 +186,12 @@ export function describeAstRule(rule: AstRule): string {
case "must_not_call_method":
return `不能调用 .${target}()`
case "must_use_operator":
return `必须使用 ${target} 运算符`
return `必须使用 ${astOperatorLabel(target, language)} 运算符`
case "must_have_nesting": {
const outer = rule.outer ?? ""
const inner = rule.inner ?? ""
// 这两个走 astNodeLabel 而不是裸值 —— 少了这一步文案就是
// 「必须使用 for_loop 嵌套」,旧栈 ast_checker/engines/nesting.py 是翻的
const outer = astNodeLabel(rule.outer ?? "", language)
const inner = astNodeLabel(rule.inner ?? "", language)
return outer === inner
? `必须使用 ${outer} 嵌套`
: `必须在 ${outer} 中嵌套使用 ${inner}`
@@ -157,6 +209,10 @@ function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
/**
* 把规则原文投影成下发给学生的「代码要求」。规则里的 engine / target 不出现在
* 响应里 —— 阶段 3 泄露评审收掉 ast_rules 时要的就是这个,见契约的注释。
*
* 只投影判题机真检查得了的语言。原来这里不看语言,给 C++ 题配的规则照样渲染成
* 「必须使用 for 循环」挂在题目页上,而 loadLanguage 对 C++ 返回 null、
* checkAst 直接放行 —— 学生看得见要求,判题从不检查。
*/
export function astRequirements(value: unknown): AstRequirements | null {
const grouped = value && typeof value === "object" && !Array.isArray(value)
@@ -166,11 +222,13 @@ export function astRequirements(value: unknown): AstRequirements | null {
const out: AstRequirements = {}
for (const [language, rules] of Object.entries(grouped)) {
if (!Array.isArray(rules)) continue
if (!AST_SUPPORTED_LANGUAGES.includes(language)) continue
const items = rules.flatMap((rule) => {
const parsed = astRuleSchema.safeParse(rule)
if (!parsed.success) return []
if (!astRuleIsMeaningful(parsed.data)) return []
return [{
description: describeAstRule(parsed.data),
description: describeAstRule(parsed.data, language),
kind: requirementKind(parsed.data.engine),
}]
})
@@ -179,6 +237,63 @@ export function astRequirements(value: unknown): AstRequirements | null {
return Object.keys(out).length > 0 ? out : null
}
/**
* AST 规则的语义校验。zod 只管形状engine 在枚举里、min 是整数),管不了
* 「给 C 题选了只有 Python 才有的 list_comprehension」这类组合 —— 那种规则存得进去,
* 判题时 `mapping[target] ?? target` 拿裸名去比节点类型,永远失败或永远通过,
* 两头都不报错,只有学生受着。
*
* 放这儿而不是 astRulesSchema 的 refine 上:那个 schema 同时用于**读**后台题目详情,
* 在读路径上抛错会让历史脏数据直接把题目详情打不开。
*/
export function astRulesError(astRules: AstRules | null): string | null {
if (!astRules) return null
for (const [language, rules] of Object.entries(astRules)) {
if (rules.length === 0) continue
if (!AST_SUPPORTED_LANGUAGES.includes(language)) {
return `代码规则暂不支持 ${language},判题机只检查 ${AST_SUPPORTED_LANGUAGES.join(" / ")}`
}
const nodes = AST_NODE_TARGETS_BY_LANGUAGE[language] ?? {}
const operators = AST_OPERATOR_TARGETS_BY_LANGUAGE[language] ?? {}
for (const [index, rule] of rules.entries()) {
const at = `代码规则 ${language}${index + 1}`
const target = rule.target ?? ""
if (rule.engine.endsWith("_node")) {
if (!(target in nodes)) return `${at}${language} 没有「${target}」这种语法`
} else if (rule.engine === "must_use_operator") {
if (!(target in operators)) return `${at}${language} 没有「${target}」运算符`
} else if (rule.engine === "must_have_nesting") {
for (const value of [rule.outer ?? "", rule.inner ?? ""]) {
if (!(value in nodes)) return `${at}${language} 没有「${value}」这种语法`
}
} else if (!target.trim()) {
return `${at}:要检查的函数名/方法名不能为空`
}
if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字`
}
}
return null
}
/**
* 保存前清掉够不着的规则分组:不在题目 languages 里的(老师改过语言列表),
* 以及判题机检查不了的C++ / Java / …)。两者编辑器都不给开 tab留着就是死数据。
*
* 必须先剔除再校验,否则历史脏数据会把老师锁死:一道 languages 含 C++ 的题,
* 早年配过 C++ 规则,如今 tab 里看不到那组规则,保存却被「暂不支持 C++」拦下,
* 老师在界面上无从修改。
*/
export function pickAstRules(astRules: AstRules | null, languages: string[]): AstRules | null {
if (!astRules) return null
const out: AstRules = {}
for (const [language, rules] of Object.entries(astRules)) {
if (!languages.includes(language)) continue
if (!AST_SUPPORTED_LANGUAGES.includes(language)) continue
if (rules.length > 0) out[language] = rules
}
return Object.keys(out).length > 0 ? out : null
}
function rangePassed(count: number, rule: AstRule) {
if (rule.exact !== undefined && count !== rule.exact) return false
if (rule.min !== undefined && count < rule.min) return false
@@ -186,16 +301,41 @@ function rangePassed(count: number, rule: AstRule) {
return true
}
const CALL_NODE_TYPES: Record<string, string> = {
C: "call_expression",
"C++": "call_expression",
Python3: "call",
}
function functionCalls(root: Node, target: string, language: string) {
const callType = language === "C" ? "call_expression" : "call"
const callType = CALL_NODE_TYPES[language] ?? "call"
return collectNodes(root, callType).filter((call) => {
const fn = call.childForFieldName("function")
return fn?.type === "identifier" && fn.text === target
if (!fn) return false
if (fn.type === "identifier") return fn.text === target
// `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于
// 有没有 using namespace std两种都得认所以末段也比一次
if (language === "C++" && fn.type === "qualified_identifier") {
return fn.text === target || fn.text.split("::").pop() === target
}
return false
})
}
function methodCalls(root: Node, target: string, language: string) {
if (language === "C") return []
// C++ 的 `a.push_back()` / `p->push_back()` 都是 call_expression + field_expression
// 和 Python 的 attribute 不是一回事 —— 少了这个分支C++ 的「必须调用 .push_back()」
// 会静默地永远失败
if (language === "C++") {
return collectNodes(root, "call_expression").filter((call) => {
const fn = call.childForFieldName("function")
return (
fn?.type === "field_expression" &&
fn.childForFieldName("field")?.text === target
)
})
}
if (language !== "Python3") return []
return collectNodes(root, "call").filter((call) => {
const fn = call.childForFieldName("function")
return (
@@ -217,51 +357,53 @@ function evaluateRule(
switch (rule.engine) {
case "must_exist_node":
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: hasNode(root, nodeType),
}
case "must_not_exist_node":
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: !hasNode(root, nodeType),
}
case "count_node": {
const count = collectNodes(root, nodeType).length
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: rangePassed(count, rule),
actual: count,
}
}
case "must_call_function":
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length > 0,
}
case "must_not_call_function":
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length === 0,
}
case "count_function_call": {
const count = functionCalls(root, target, language).length
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: rangePassed(count, rule),
actual: count,
}
}
case "must_call_method":
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length > 0,
}
case "must_not_call_method":
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length === 0,
}
case "must_use_operator":
return {
description: describeAstRule(rule),
description: describeAstRule(rule, language),
passed: hasNode(root, nodeType),
}
case "must_have_nesting": {
@@ -272,7 +414,7 @@ function evaluateRule(
const passed = collectNodes(root, outerType).some((node) =>
node.children.some((child) => hasNode(child, innerType)),
)
return { description: describeAstRule(rule), passed }
return { description: describeAstRule(rule, language), passed }
}
default:
return null
@@ -297,6 +439,7 @@ export async function checkAst(
try {
const mapping = mappings[language] ?? {}
const results = rules
.filter(astRuleIsMeaningful)
.map((rule) => evaluateRule(tree.rootNode, rule, language, mapping))
.filter((result): result is AstResult => result !== null)
return { passed: results.every((result) => result.passed), results }

View File

@@ -11,6 +11,7 @@ import {
sqlPreviewRequestSchema,
sqlTestCaseScriptSchema,
uploadTestCaseResponseSchema,
type AstRules,
type SqlConfig,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
@@ -20,6 +21,7 @@ import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { astRulesError, pickAstRules } from "../../judge/ast"
import { buildSqlDisplay } from "../../judge/sql"
import { completeChat } from "../../services/ai"
import { contestStatus } from "../../services/contest"
@@ -176,7 +178,10 @@ function commonChecks(data: {
samples: unknown[]
sqlConfig: SqlConfig | null
answers: Record<string, unknown>[]
astRules: AstRules | null
}): { error: string } | { sql: boolean } {
const astError = astRulesError(pickAstRules(data.astRules, data.languages))
if (astError) return { error: astError }
if (data.languages.includes("SQL")) {
if (data.languages.length !== 1) return { error: "SQL problem cannot be mixed with other languages" }
if (!data.sqlConfig) return { error: "SQL problem requires sql_config" }
@@ -247,7 +252,7 @@ function problemValues(data: ReturnType<typeof createProblemRequestSchema.parse>
showFlowchart: data.showFlowchart,
mermaidCode: data.mermaidCode,
flowchartHint: data.flowchartHint,
astRules: data.astRules ?? null,
astRules: pickAstRules(data.astRules, data.languages),
answers: data.answers,
prompt: data.prompt,
// 防脏数据:非 SQL 题不应携带 SQL 配置,对齐旧 common_checks