后台的节点下拉是一张 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:
@@ -29,6 +29,7 @@
|
||||
"postgres": "^3.4.9",
|
||||
"sql.js": "^1.14.2",
|
||||
"tree-sitter-c": "^0.24.1",
|
||||
"tree-sitter-cpp": "^0.23.4",
|
||||
"tree-sitter-python": "^0.25.0",
|
||||
"web-tree-sitter": "^0.26.13",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -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 自己的运行时 wasm,Parser.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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user