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

@@ -102,6 +102,33 @@ dev 直接起不来。
这些整数是**落库的值**12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是 这些整数是**落库的值**12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。 这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。
### AST 代码规则有两张表,必须同增同减
契约的 `AST_NODE_TARGETS_BY_LANGUAGE`target → 中文名)决定后台下拉能选什么,
`apps/api/src/judge/ast.ts``mappings`target → tree-sitter 节点类型)决定判题机
认得什么。**加节点类型时两边都要加**,运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 同理。
只加一边是**静默错判**:判题机 `mapping[target] ?? target` 拿裸名去比节点类型,
C 的语法树里永远不存在 `list_comprehension`,于是「必须使用列表推导式」永远失败、
「不能使用 f-string」永远通过两头都不报错只有学生受着。原来那张表是 C/Python
混在一起的 15 条,整份铺成下拉,给 C 题也能选到 Python 专有节点——就是这么来的。
判题机只认 `AST_SUPPORTED_LANGUAGES` 里的语言C / C++ / Python3。别的语言配了规则
一条都不会跑,所以后台不给它们开 tab题目页也不把它们的规则展示成「要求」——
**看得见却不检查**比没有更糟。
C++ 的语法表是「C 的全集 + C++ 独有的几条」,因为 tree-sitter-cpp 继承 tree-sitter-c
C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者不同**,加语言时必须一起看:
`a.push_back()``p->push_back()` 在 C++ 都是 `call_expression` + `field_expression`
不是 Python 的 `attribute``std::sort(...)` 的 function 是 `qualified_identifier`
而不是 `identifier`,所以 `functionCalls` 对 C++ 额外比一次 `::` 末段——否则学生写了
`using namespace std` 与否会得到不同的判定结果。
规则的语义校验在 `astRulesError()`,不在 zod 的 refine 上:`astRulesSchema` 同时用于
**读**后台题目详情,在读路径上抛错会让历史脏数据把整个题目详情打不开。同理,保存前
`pickAstRules()` 剔除够不着的分组再校验,否则早年配过 C++ 规则的题会把老师锁死
——tab 里看不到那组规则,保存却被拦下。
### 比赛只有 ACM 模式 ### 比赛只有 ACM 模式
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"。 没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"。

View File

@@ -29,6 +29,7 @@
"postgres": "^3.4.9", "postgres": "^3.4.9",
"sql.js": "^1.14.2", "sql.js": "^1.14.2",
"tree-sitter-c": "^0.24.1", "tree-sitter-c": "^0.24.1",
"tree-sitter-cpp": "^0.23.4",
"tree-sitter-python": "^0.25.0", "tree-sitter-python": "^0.25.0",
"web-tree-sitter": "^0.26.13", "web-tree-sitter": "^0.26.13",
"zod": "^4.4.3" "zod": "^4.4.3"

View File

@@ -1,14 +1,21 @@
import { import {
AST_NODE_TARGETS_BY_LANGUAGE,
AST_OPERATOR_TARGETS_BY_LANGUAGE,
astNodeLabel,
astOperatorLabel,
astRuleIsMeaningful,
astRuleSchema, astRuleSchema,
AST_NODE_TARGET_LABELS, AST_SUPPORTED_LANGUAGES,
type AstRequirement, type AstRequirement,
type AstRequirements, type AstRequirements,
type AstRule, type AstRule,
type AstRules,
} from "@oj2/contract" } from "@oj2/contract"
import { Language, Parser, type Node } from "web-tree-sitter" import { Language, Parser, type Node } from "web-tree-sitter"
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)` // 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root解析不到 node_modules。见 vendor/jieba.ts // 编译成单二进制后 import.meta.dir 是 /$bunfs/root解析不到 node_modules。见 vendor/jieba.ts
import cWasmPath from "tree-sitter-c/tree-sitter-c.wasm" with { type: "file" } 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 pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { type: "file" }
// web-tree-sitter 自己的运行时 wasmParser.init() 要用 // web-tree-sitter 自己的运行时 wasmParser.init() 要用
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" } 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 { export interface AstResult {
description: string description: string
passed: boolean passed: boolean
/** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */
actual?: number
} }
/**
* target → tree-sitter 节点类型。恒等的条目(`+`、`==` 这些运算符)不列,
* 走 `mapping[target] ?? target` 回落。
*
* **这里的键集是契约 AST_NODE_TARGETS_BY_LANGUAGE 的另一半**,两边必须同增同减:
* 那边决定后台下拉能选什么,这边决定判题机认得什么。只加一边就是静默错判。
*/
const mappings: Record<string, Record<string, string>> = { const mappings: Record<string, Record<string, string>> = {
C: { C: {
for_loop: "for_statement", for_loop: "for_statement",
@@ -42,6 +58,35 @@ const mappings: Record<string, Record<string, string>> = {
or: "||", or: "||",
not: "!", 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: { Python3: {
for_loop: "for_statement", for_loop: "for_statement",
while_loop: "while_statement", while_loop: "while_statement",
@@ -79,7 +124,12 @@ async function loadLanguage(language: string) {
const cached = languages.get(language) const cached = languages.get(language)
if (cached) return cached 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) languages.set(language, loaded)
return loaded return loaded
} }
@@ -95,9 +145,9 @@ function hasNode(root: Node, type: string): boolean {
return root.children.some((child) => hasNode(child, type)) return root.children.some((child) => hasNode(child, type))
} }
function targetName(rule: AstRule) { function targetName(rule: AstRule, language?: string) {
const target = rule.target ?? "" 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) { function countPhrase(verb: string, rule: AstRule) {
@@ -114,9 +164,9 @@ function countPhrase(verb: string, rule: AstRule) {
* 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现, * 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现,
* 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。 * 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。
*/ */
export function describeAstRule(rule: AstRule): string { export function describeAstRule(rule: AstRule, language?: string): string {
if (rule.message) return rule.message if (rule.message) return rule.message
const name = targetName(rule) const name = targetName(rule, language)
const target = rule.target ?? "" const target = rule.target ?? ""
switch (rule.engine) { switch (rule.engine) {
case "must_exist_node": case "must_exist_node":
@@ -136,10 +186,12 @@ export function describeAstRule(rule: AstRule): string {
case "must_not_call_method": case "must_not_call_method":
return `不能调用 .${target}()` return `不能调用 .${target}()`
case "must_use_operator": case "must_use_operator":
return `必须使用 ${target} 运算符` return `必须使用 ${astOperatorLabel(target, language)} 运算符`
case "must_have_nesting": { case "must_have_nesting": {
const outer = rule.outer ?? "" // 这两个走 astNodeLabel 而不是裸值 —— 少了这一步文案就是
const inner = rule.inner ?? "" // 「必须使用 for_loop 嵌套」,旧栈 ast_checker/engines/nesting.py 是翻的
const outer = astNodeLabel(rule.outer ?? "", language)
const inner = astNodeLabel(rule.inner ?? "", language)
return outer === inner return outer === inner
? `必须使用 ${outer} 嵌套` ? `必须使用 ${outer} 嵌套`
: `必须在 ${outer} 中嵌套使用 ${inner}` : `必须在 ${outer} 中嵌套使用 ${inner}`
@@ -157,6 +209,10 @@ function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
/** /**
* 把规则原文投影成下发给学生的「代码要求」。规则里的 engine / target 不出现在 * 把规则原文投影成下发给学生的「代码要求」。规则里的 engine / target 不出现在
* 响应里 —— 阶段 3 泄露评审收掉 ast_rules 时要的就是这个,见契约的注释。 * 响应里 —— 阶段 3 泄露评审收掉 ast_rules 时要的就是这个,见契约的注释。
*
* 只投影判题机真检查得了的语言。原来这里不看语言,给 C++ 题配的规则照样渲染成
* 「必须使用 for 循环」挂在题目页上,而 loadLanguage 对 C++ 返回 null、
* checkAst 直接放行 —— 学生看得见要求,判题从不检查。
*/ */
export function astRequirements(value: unknown): AstRequirements | null { export function astRequirements(value: unknown): AstRequirements | null {
const grouped = value && typeof value === "object" && !Array.isArray(value) const grouped = value && typeof value === "object" && !Array.isArray(value)
@@ -166,11 +222,13 @@ export function astRequirements(value: unknown): AstRequirements | null {
const out: AstRequirements = {} const out: AstRequirements = {}
for (const [language, rules] of Object.entries(grouped)) { for (const [language, rules] of Object.entries(grouped)) {
if (!Array.isArray(rules)) continue if (!Array.isArray(rules)) continue
if (!AST_SUPPORTED_LANGUAGES.includes(language)) continue
const items = rules.flatMap((rule) => { const items = rules.flatMap((rule) => {
const parsed = astRuleSchema.safeParse(rule) const parsed = astRuleSchema.safeParse(rule)
if (!parsed.success) return [] if (!parsed.success) return []
if (!astRuleIsMeaningful(parsed.data)) return []
return [{ return [{
description: describeAstRule(parsed.data), description: describeAstRule(parsed.data, language),
kind: requirementKind(parsed.data.engine), kind: requirementKind(parsed.data.engine),
}] }]
}) })
@@ -179,6 +237,63 @@ export function astRequirements(value: unknown): AstRequirements | null {
return Object.keys(out).length > 0 ? out : 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) { function rangePassed(count: number, rule: AstRule) {
if (rule.exact !== undefined && count !== rule.exact) return false if (rule.exact !== undefined && count !== rule.exact) return false
if (rule.min !== undefined && count < rule.min) return false if (rule.min !== undefined && count < rule.min) return false
@@ -186,16 +301,41 @@ function rangePassed(count: number, rule: AstRule) {
return true 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) { 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) => { return collectNodes(root, callType).filter((call) => {
const fn = call.childForFieldName("function") 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) { 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) => { return collectNodes(root, "call").filter((call) => {
const fn = call.childForFieldName("function") const fn = call.childForFieldName("function")
return ( return (
@@ -217,51 +357,53 @@ function evaluateRule(
switch (rule.engine) { switch (rule.engine) {
case "must_exist_node": case "must_exist_node":
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, nodeType),
} }
case "must_not_exist_node": case "must_not_exist_node":
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: !hasNode(root, nodeType), passed: !hasNode(root, nodeType),
} }
case "count_node": { case "count_node": {
const count = collectNodes(root, nodeType).length const count = collectNodes(root, nodeType).length
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: rangePassed(count, rule), passed: rangePassed(count, rule),
actual: count,
} }
} }
case "must_call_function": case "must_call_function":
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length > 0, passed: functionCalls(root, target, language).length > 0,
} }
case "must_not_call_function": case "must_not_call_function":
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length === 0, passed: functionCalls(root, target, language).length === 0,
} }
case "count_function_call": { case "count_function_call": {
const count = functionCalls(root, target, language).length const count = functionCalls(root, target, language).length
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: rangePassed(count, rule), passed: rangePassed(count, rule),
actual: count,
} }
} }
case "must_call_method": case "must_call_method":
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length > 0, passed: methodCalls(root, target, language).length > 0,
} }
case "must_not_call_method": case "must_not_call_method":
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length === 0, passed: methodCalls(root, target, language).length === 0,
} }
case "must_use_operator": case "must_use_operator":
return { return {
description: describeAstRule(rule), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, nodeType),
} }
case "must_have_nesting": { case "must_have_nesting": {
@@ -272,7 +414,7 @@ function evaluateRule(
const passed = collectNodes(root, outerType).some((node) => const passed = collectNodes(root, outerType).some((node) =>
node.children.some((child) => hasNode(child, innerType)), node.children.some((child) => hasNode(child, innerType)),
) )
return { description: describeAstRule(rule), passed } return { description: describeAstRule(rule, language), passed }
} }
default: default:
return null return null
@@ -297,6 +439,7 @@ export async function checkAst(
try { try {
const mapping = mappings[language] ?? {} const mapping = mappings[language] ?? {}
const results = rules const results = rules
.filter(astRuleIsMeaningful)
.map((rule) => evaluateRule(tree.rootNode, rule, language, mapping)) .map((rule) => evaluateRule(tree.rootNode, rule, language, mapping))
.filter((result): result is AstResult => result !== null) .filter((result): result is AstResult => result !== null)
return { passed: results.every((result) => result.passed), results } return { passed: results.every((result) => result.passed), results }

View File

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

View File

@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { AST_NODE_TARGET_LABELS } from "@oj2/contract" import {
AST_NODE_TARGETS_BY_LANGUAGE,
AST_OPERATOR_TARGETS_BY_LANGUAGE,
AST_SUPPORTED_LANGUAGES,
} from "@oj2/contract"
import type { AstRule, AstRules, LANGUAGE } from "utils/types" import type { AstRule, AstRules, LANGUAGE } from "utils/types"
interface Props { interface Props {
@@ -12,7 +16,17 @@ const emit = defineEmits<{
(e: "update:modelValue", value: AstRules | null): void (e: "update:modelValue", value: AstRules | null): void
}>() }>()
const activeTab = ref(props.languages[0] || "Python3") // 判题机只认 C / Python3别的语言配了规则也一条都不会跑judge/ast.ts 的
// loadLanguage 返回 null 就直接放行)。原来这里按题目的全部语言开 tab老师给
// C++ 配的规则存得下、题目页也照常显示成「要求」,判题却从不检查。
const supportedLanguages = computed(() =>
props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)),
)
const unsupportedLanguages = computed(() =>
props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)),
)
const activeTab = ref(supportedLanguages.value[0] || "Python3")
const ENGINE_OPTIONS: SelectOption[] = [ const ENGINE_OPTIONS: SelectOption[] = [
{ {
@@ -52,32 +66,21 @@ const ENGINE_OPTIONS: SelectOption[] = [
}, },
] ]
// 选项从契约的 AST_NODE_TARGET_LABELS 生成 —— 这 15 条原来在这里和 // 选项按语言生成。原来是一张 C/Python 混合的 15 条表整份铺开,给 C 题也能选到
// ProblemContent.vue 各手抄一份 // 列表推导式、f-string 这些 C 根本没有的东西 —— 存得进去,判题时永远失败
const NODE_TARGET_OPTIONS: SelectOption[] = Object.entries( // (或者反过来,「不能使用 f-string」永远通过两头都不报错。
AST_NODE_TARGET_LABELS, function nodeTargetOptions(lang: string): SelectOption[] {
).map(([value, label]) => ({ label, value })) return Object.entries(AST_NODE_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
([value, label]) => ({ label, value }),
)
}
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [ // 逻辑名 and/or/not 在 C 里显示成 && / || / !,存进去的还是逻辑名
{ label: "+", value: "+" }, function operatorTargetOptions(lang: string): SelectOption[] {
{ label: "-", value: "-" }, return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
{ label: "*", value: "*" }, ([value, label]) => ({ label: label === value ? value : `${label}${value}`, value }),
{ label: "/", value: "/" }, )
{ label: "//", value: "//" }, }
{ label: "%", value: "%" },
{ label: "**", value: "**" },
{ label: "+=", value: "+=" },
{ label: "-=", value: "-=" },
{ label: "==", value: "==" },
{ label: "!=", value: "!=" },
{ label: ">", value: ">" },
{ label: ">=", value: ">=" },
{ label: "<", value: "<" },
{ label: "<=", value: "<=" },
{ label: "and / &&", value: "and" },
{ label: "or / ||", value: "or" },
{ label: "not / !", value: "not" },
]
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"] const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
const FUNCTION_ENGINES = [ const FUNCTION_ENGINES = [
@@ -122,6 +125,9 @@ function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
delete rule.min delete rule.min
delete rule.max delete rule.max
} else { } else {
// 留空的 count 规则恒真rangePassed 三个字段全 undefined 就返回 true
// 描述还会退化成光秃秃一个「for 循环」。切过来先给个 1
rule.min = rule.min ?? 1
delete rule.exact delete rule.exact
} }
rules[index] = rule rules[index] = rule
@@ -162,12 +168,14 @@ function updateRules(lang: string, rules: AstRule[]) {
emit("update:modelValue", Object.keys(current).length > 0 ? current : null) emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
} }
function getTargetLabel(engine: string, target: string): string | undefined { function getTargetLabel(
if (isNodeEngine(engine)) lang: string,
return (NODE_TARGET_OPTIONS.find((o) => o.value === target) as any)?.label engine: string,
if (isOperatorEngine(engine)) target: string,
return (OPERATOR_TARGET_OPTIONS.find((o) => o.value === target) as any) ): string | undefined {
?.label if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]
// 运算符不写 label判题结果的文案按语言翻译astOperatorLabel
// 存一个固定 label 反而会把 C 的 && 钉死成 and
return undefined return undefined
} }
@@ -176,7 +184,7 @@ function addRule(lang: string) {
rules.push({ rules.push({
engine: "must_exist_node", engine: "must_exist_node",
target: "for_loop", target: "for_loop",
label: "for 循环", label: getTargetLabel(lang, "must_exist_node", "for_loop"),
message: "", message: "",
}) })
updateRules(lang, rules) updateRules(lang, rules)
@@ -196,10 +204,10 @@ function updateRule(lang: string, index: number, field: string, value: any) {
rule.engine = value rule.engine = value
if (isNodeEngine(value)) { if (isNodeEngine(value)) {
rule.target = "for_loop" rule.target = "for_loop"
rule.label = "for 循环" rule.label = getTargetLabel(lang, value, "for_loop")
} else if (isOperatorEngine(value)) { } else if (isOperatorEngine(value)) {
rule.target = "+" rule.target = "+"
rule.label = "+" delete rule.label
} else { } else {
rule.target = "" rule.target = ""
delete rule.label delete rule.label
@@ -207,9 +215,11 @@ function updateRule(lang: string, index: number, field: string, value: any) {
delete rule.min delete rule.min
delete rule.max delete rule.max
delete rule.exact delete rule.exact
// 次数引擎不给默认值的话,存下去就是一条恒真规则
if (isCountEngine(value)) rule.exact = 1
} else if (field === "target") { } else if (field === "target") {
rule.target = value rule.target = value
const lbl = getTargetLabel(rule.engine, value) const lbl = getTargetLabel(lang, rule.engine, value)
if (lbl) rule.label = lbl if (lbl) rule.label = lbl
else delete rule.label else delete rule.label
} else if (field === "min") { } else if (field === "min") {
@@ -226,22 +236,32 @@ function updateRule(lang: string, index: number, field: string, value: any) {
updateRules(lang, rules) updateRules(lang, rules)
} }
watch( watch(supportedLanguages, (langs) => {
() => props.languages, if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
(langs) => { activeTab.value = langs[0]
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) { }
activeTab.value = langs[0] })
}
},
)
</script> </script>
<template> <template>
<n-collapse> <n-collapse>
<n-collapse-item title="代码规则检查(选填)" name="ast-rules"> <n-collapse-item title="代码规则检查(选填)" name="ast-rules">
<n-tabs v-if="languages.length" type="segment" v-model:value="activeTab"> <n-alert
v-if="unsupportedLanguages.length"
type="info"
:bordered="false"
style="margin-bottom: 8px"
>
{{ unsupportedLanguages.join("、") }} 暂不支持代码规则检查判题机只能检查
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }}
</n-alert>
<n-tabs
v-if="supportedLanguages.length"
type="segment"
v-model:value="activeTab"
>
<n-tab-pane <n-tab-pane
v-for="lang in languages" v-for="lang in supportedLanguages"
:key="lang" :key="lang"
:name="lang" :name="lang"
:tab="lang" :tab="lang"
@@ -264,7 +284,7 @@ watch(
/> />
<n-select <n-select
v-if="needsTargetDropdown(rule.engine)" v-if="needsTargetDropdown(rule.engine)"
:options="NODE_TARGET_OPTIONS" :options="nodeTargetOptions(lang)"
:value="rule.target" :value="rule.target"
@update:value=" @update:value="
(v: string) => updateRule(lang, index, 'target', v) (v: string) => updateRule(lang, index, 'target', v)
@@ -285,7 +305,7 @@ watch(
/> />
<n-select <n-select
v-if="needsOperatorDropdown(rule.engine)" v-if="needsOperatorDropdown(rule.engine)"
:options="OPERATOR_TARGET_OPTIONS" :options="operatorTargetOptions(lang)"
:value="rule.target" :value="rule.target"
@update:value=" @update:value="
(v: string) => updateRule(lang, index, 'target', v) (v: string) => updateRule(lang, index, 'target', v)
@@ -370,7 +390,14 @@ watch(
</n-flex> </n-flex>
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
<n-empty v-else description="请先选择编程语言" /> <n-empty
v-else
:description="
languages.length
? '当前语言不支持代码规则检查'
: '请先选择编程语言'
"
/>
</n-collapse-item> </n-collapse-item>
</n-collapse> </n-collapse>
</template> </template>

View File

@@ -178,6 +178,13 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" /> <Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
</n-icon> </n-icon>
<span>{{ rule.description }}</span> <span>{{ rule.description }}</span>
<!-- 次数类规则光说「出现 2 次 ✗」,学生不知道自己写了几次 -->
<span
v-if="!rule.passed && rule.actual !== undefined"
:style="{ color: theme.errorColor }"
>
当前 {{ rule.actual }} 次
</span>
</n-flex> </n-flex>
</n-flex> </n-flex>
</n-card> </n-card>

View File

@@ -289,7 +289,12 @@ export interface StatisticInfo {
err_info?: string err_info?: string
time_cost?: number time_cost?: number
memory_cost?: number memory_cost?: number
ast_results?: Array<{ description: string; passed: boolean }> ast_results?: Array<{
description: string
passed: boolean
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
actual?: number
}>
} }
/** /**

View File

@@ -24,6 +24,7 @@
"postgres": "^3.4.9", "postgres": "^3.4.9",
"sql.js": "^1.14.2", "sql.js": "^1.14.2",
"tree-sitter-c": "^0.24.1", "tree-sitter-c": "^0.24.1",
"tree-sitter-cpp": "^0.23.4",
"tree-sitter-python": "^0.25.0", "tree-sitter-python": "^0.25.0",
"web-tree-sitter": "^0.26.13", "web-tree-sitter": "^0.26.13",
"zod": "^4.4.3", "zod": "^4.4.3",
@@ -1365,6 +1366,8 @@
"tree-sitter-c": ["tree-sitter-c@0.24.1", "https://registry.npmjs.com/tree-sitter-c/-/tree-sitter-c-0.24.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="], "tree-sitter-c": ["tree-sitter-c@0.24.1", "https://registry.npmjs.com/tree-sitter-c/-/tree-sitter-c-0.24.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="],
"tree-sitter-cpp": ["tree-sitter-cpp@0.23.4", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2", "tree-sitter-c": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw=="],
"tree-sitter-python": ["tree-sitter-python@0.25.0", "https://registry.npmjs.com/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="], "tree-sitter-python": ["tree-sitter-python@0.25.0", "https://registry.npmjs.com/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="],
"treemate": ["treemate@0.3.11", "https://registry.npmjs.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="], "treemate": ["treemate@0.3.11", "https://registry.npmjs.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
@@ -1537,6 +1540,8 @@
"strip-literal/js-tokens": ["js-tokens@10.0.0", "https://registry.npmjs.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "strip-literal/js-tokens": ["js-tokens@10.0.0", "https://registry.npmjs.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"tree-sitter-cpp/tree-sitter-c": ["tree-sitter-c@0.23.6", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="],
"tsx/esbuild": ["esbuild@0.28.1", "https://registry.npmjs.com/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "tsx/esbuild": ["esbuild@0.28.1", "https://registry.npmjs.com/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"unplugin-vue-components/magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "unplugin-vue-components/magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],

View File

@@ -99,26 +99,156 @@ export const astRuleSchema = z.object({
export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema)) export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema))
/** /**
* 节点类型的中文名。后台编辑器的下拉选项、后端生成要求文案都要用它 —— * 每种语言支持哪些节点 target以及它的中文名。原来这里是一张 15 条的混合表,
* 原来在 AstRulesEditor.vue下拉 options和 ProblemContent.vue * C 和 Python 的节点混在一起铺成后台下拉(更早之前 AstRulesEditor.vue 和
* NODE_TARGET_LABELS各存了一份同样的 15 条 * ProblemContent.vue 还各手抄了一份)
*
* **键必须和 judge/ast.ts 的 mappings 逐一对齐** —— 那边是 target → tree-sitter
* 节点类型,这边是 target → 中文名。少一边就是静默故障:老师给 C 题选到只有
* Python 有的 `list_comprehension`,判题机 `mapping[target] ?? target` 拿裸名去比
* 节点类型C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、
* 「不能使用 f-string」永远通过两头都不报错。
*/ */
export const AST_NODE_TARGET_LABELS: Record<string, string> = { export const AST_NODE_TARGETS_BY_LANGUAGE: Record<string, Record<string, string>> = {
for_loop: "for 循环", C: {
while_loop: "while 循环", for_loop: "for 循环",
if_statement: "if 条件", while_loop: "while 循环",
else_clause: "else 子句", do_while: "do-while 循环",
function_definition: "函数定义", if_statement: "if 条件",
return: "return 语句", else_clause: "else 子句",
break: "break 语句", switch_statement: "switch 语句",
continue: "continue 语句", case_statement: "case 分支",
list_comprehension: "列表推导式", break: "break 语句",
list_literal: "列表", continue: "continue 语句",
dict_literal: "字典", return: "return 语句",
set_literal: "集合", function_definition: "函数定义",
f_string: "f-string", assignment: "赋值语句",
try_except: "try-except", struct: "结构体",
class_definition: "类定义", include: "#include 指令",
},
// tree-sitter-cpp 继承 tree-sitter-c 的语法C 那 14 条 target 在 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 声明",
},
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",
},
}
/**
* 运算符 target → 该语言里的实际写法。逻辑名 `and` / `or` / `not` 在 C 里写作
* `&&` / `||` / `!`,判题机按 mappings 翻译,文案这边也得翻 —— 否则 C 题的学生
* 看到的要求是「必须使用 and 运算符」,而 C 里根本没有 `and` 这个词。
*
* 恒等的那些条目(`+`、`==` …)判题机的 mappings 里已经删掉了,走 `?? target`
* 回落到同一个值;这里保留完整列表是因为它同时是后台下拉的选项来源。
*/
export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record<string, Record<string, string>> = {
C: {
"+": "+", "-": "-", "*": "*", "/": "/", "%": "%",
"+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=",
"++": "++", "--": "--",
"==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=",
and: "&&", or: "||", not: "!",
"&": "&", "|": "|",
},
// `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token
"C++": {
"+": "+", "-": "-", "*": "*", "/": "/", "%": "%",
"+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=",
"++": "++", "--": "--",
"==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=",
and: "&&", or: "||", not: "!",
"&": "&", "|": "|", "<<": "<<", ">>": ">>",
},
Python3: {
"+": "+", "-": "-", "*": "*", "/": "/", "//": "//", "%": "%", "**": "**",
"+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=",
"==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=",
and: "and", or: "or", not: "not",
"&": "&", "|": "|",
},
}
/**
* 判题机真正能做 AST 检查的语言。设计文档写的是"支持全部 6 种语言",落地的只有
* 这两种 —— 其余语言 judge/ast.ts 的 loadLanguage 返回 null规则一条都不会跑。
* 所以后台不给别的语言开 tab题目页也不把它们的规则展示成「要求」。
*/
export const AST_SUPPORTED_LANGUAGES = Object.keys(AST_NODE_TARGETS_BY_LANGUAGE)
/** 全语言的节点中文名并集,只给拿不到语言的场合做回落。有语言一律走 astNodeLabel() */
export const AST_NODE_TARGET_LABELS: Record<string, string> = Object.assign(
{},
...Object.values(AST_NODE_TARGETS_BY_LANGUAGE),
)
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
}
export function astOperatorLabel(target: string, language?: string): string {
return (language ? AST_OPERATOR_TARGETS_BY_LANGUAGE[language]?.[target] : undefined) ?? target
}
/**
* count_* 引擎至少要有一个数字约束,否则这条规则恒真 —— rangePassed 三个字段全
* undefined 直接返回 true描述也退化成光秃秃一个「for 循环」。编辑器切到
* 「出现次数」时会清掉 exact/min/max老师不填数字就会存出这种规则。
*
* 读取路径(判题、题目页要求)用它把这种规则整条丢掉,而不是让 schema 校验失败:
* astRulesSchema 同时用于**读**后台题目详情,在那儿抛错会让整个详情打不开。
*/
export function astRuleIsMeaningful(rule: {
engine: string
exact?: number
min?: number
max?: number
}): boolean {
if (!rule.engine.startsWith("count")) return true
return rule.exact !== undefined || rule.min !== undefined || rule.max !== undefined
} }
/** /**