Files
OJ2/apps/api/src/judge/ast.ts
T
xuyueandClaude Opus 5.5 0faf78ad8f feat(挑战): 做对之后可以加一条写法上的限制再做一遍,判题时记下谁做到了
借小丑牌的赌注档:同一道题加一条限制(「整首歌只用 1 次 print」)再做一遍,
给课上做得快的学生找事做(做完一节课的题,中位要空等 13 分钟)。
判定沿用 AST 规则,挑战配在 problem.challenges,做到的记在 challenge_success;
做对的提交顺手判一遍,挑战没做到也照样算做对。没过的规则带上是第几行。
0039 灌进第一批 56 道常用题的 73 条挑战,api 起来后拿历史上做对的代码补一遍谁已经做到了。
出题页能配挑战、从做对的代码里找候选;课堂看板的格子带上挑战状态。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:52:50 +08:00

422 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AST_NODE_TARGETS_BY_LANGUAGE,
AST_OPERATOR_TARGETS_BY_LANGUAGE,
astNodeLabel,
astOperatorLabel,
astRuleIsMeaningful,
astTargetNodeType,
astRuleSchema,
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" }
// AstRule 的形状在契约里(astRuleSchema)—— 原来这份和前端两份各写各的。
// 这里 re-export,判题机的调用方不用再去 import 契约。
export type { AstRule } from "@oj2/contract"
export interface AstResult {
description: string
passed: boolean
/** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */
actual?: number
/** 没过的「不能用 X」「X 太多」:X 在第几行(1 起),挑战没做到时编辑器里标出来 */
lines?: number[]
}
/** 节点所在的行,去重排序。submission.code 只是学生写的那段,不含题目模板,行号不用再扣 */
function linesOf(nodes: Node[]) {
return [...new Set(nodes.map((node) => node.startPosition.row + 1))].sort((a, b) => a - b)
}
/** 没过才带行号:过了的规则不用标 */
function withLines(result: AstResult, nodes: Node[]): AstResult {
return result.passed || nodes.length === 0 ? result : { ...result, lines: linesOf(nodes) }
}
let initPromise: Promise<void> | undefined
const languages = new Map<string, Language>()
async function loadLanguage(language: string) {
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return null
// locateFile 指到内嵌的 tree-sitter.wasm:emscripten 默认按脚本所在目录找,
// 单二进制里那个目录是 /$bunfs/root,它自己找不着
if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
await initPromise
const cached = languages.get(language)
if (cached) return cached
const wasmPath = language === "C" ? cWasmPath : language === "C++" ? cppWasmPath : pythonWasmPath
const loaded = await Language.load(wasmPath)
languages.set(language, loaded)
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)
return result
}
function hasNode(root: Node, type: string): boolean {
if (root.type === type) return true
return root.children.some((child) => hasNode(child, type))
}
function targetName(rule: AstRule, language?: string) {
const target = rule.target ?? ""
return rule.label || astNodeLabel(target, language) || "指定语法"
}
function countPhrase(verb: string, rule: AstRule) {
if (rule.exact !== undefined) return `${verb} ${rule.exact} 次`
if (rule.min !== undefined && rule.max !== undefined) return `${verb} ${rule.min}~${rule.max} 次`
if (rule.min !== undefined) return `至少${verb} ${rule.min} 次`
if (rule.max !== undefined) return `至多${verb} ${rule.max} 次`
return ""
}
/**
* 一条规则的中文描述。判题结果(statistic_info.ast_results)和题目页的「要求」
* 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现,
* 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。
*/
export function describeAstRule(rule: AstRule, language?: string): string {
if (rule.message) return rule.message
const name = targetName(rule, language)
const target = rule.target ?? ""
switch (rule.engine) {
case "must_exist_node":
return `必须使用 ${name}`
case "must_not_exist_node":
return `不能使用 ${name}`
case "count_node":
return `${name} ${countPhrase("出现", rule)}`.trim()
case "must_call_function":
return `必须调用 ${target}()`
case "must_not_call_function":
return `不能调用 ${target}()`
case "count_function_call":
return `${target}() ${countPhrase("调用", rule)}`.trim()
case "must_call_method":
return `必须调用 .${target}()`
case "must_not_call_method":
return `不能调用 .${target}()`
case "must_use_operator":
return `必须使用 ${astOperatorLabel(target, language)} 运算符`
case "must_have_nesting": {
// 这两个走 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}`
}
}
}
/** 标签配色用的粗分类,见契约 astRequirementSchema */
function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
if (engine.startsWith("must_not")) return "forbid"
if (engine.startsWith("count")) return "count"
return "require"
}
/**
* 把规则原文投影成下发给学生的「代码要求」。规则里的 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)
? (value as Record<string, unknown>)
: null
if (!grouped) return 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, language),
kind: requirementKind(parsed.data.engine),
},
]
})
if (items.length > 0) out[language] = items
}
return Object.keys(out).length > 0 ? out : null
}
/**
* AST 规则的语义校验。zod 只管形状(engine 在枚举里、min 是整数),管不了
* 「给 C 题选了只有 Python 才有的 list_comprehension」这类组合 —— 那种规则存得进去,
* 判题时 astTargetNodeType() 找不到就回落成裸名去比节点类型,永远失败或永远通过,
* 两头都不报错,只有学生受着。
*
* 放这儿而不是 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
if (rule.max !== undefined && count > rule.max) return false
return true
}
const CALL_NODE_TYPES: Record<string, string> = {
C: "call_expression",
"C++": "call_expression",
Python: "call",
}
function functionCalls(root: Node, target: string, language: string) {
const callType = CALL_NODE_TYPES[language] ?? "call"
return collectNodes(root, callType).filter((call) => {
const fn = call.childForFieldName("function")
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) {
// 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 !== "Python") return []
return collectNodes(root, "call").filter((call) => {
const fn = call.childForFieldName("function")
return fn?.type === "attribute" && fn.childForFieldName("attribute")?.text === target
})
}
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null {
const target = rule.target ?? ""
const nodeType = astTargetNodeType(target, language)
switch (rule.engine) {
case "must_exist_node":
return {
description: describeAstRule(rule, language),
passed: hasNode(root, nodeType),
}
case "must_not_exist_node": {
const nodes = collectNodes(root, nodeType)
return withLines(
{ description: describeAstRule(rule, language), passed: nodes.length === 0 },
nodes,
)
}
case "count_node": {
const nodes = collectNodes(root, nodeType)
return withLines(
{
description: describeAstRule(rule, language),
passed: rangePassed(nodes.length, rule),
actual: nodes.length,
},
// 次数太少时没有「哪几行」可标
rule.max !== undefined || rule.exact !== undefined ? nodes : [],
)
}
case "must_call_function":
return {
description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length > 0,
}
case "must_not_call_function": {
const calls = functionCalls(root, target, language)
return withLines(
{ description: describeAstRule(rule, language), passed: calls.length === 0 },
calls,
)
}
case "count_function_call": {
const calls = functionCalls(root, target, language)
return withLines(
{
description: describeAstRule(rule, language),
passed: rangePassed(calls.length, rule),
actual: calls.length,
},
rule.max !== undefined || rule.exact !== undefined ? calls : [],
)
}
case "must_call_method":
return {
description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length > 0,
}
case "must_not_call_method": {
const calls = methodCalls(root, target, language)
return withLines(
{ description: describeAstRule(rule, language), passed: calls.length === 0 },
calls,
)
}
case "must_use_operator":
return {
description: describeAstRule(rule, language),
passed: hasNode(root, nodeType),
}
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)),
)
return { description: describeAstRule(rule, language), passed }
}
default:
return null
}
}
export async function checkAst(
code: string,
language: string,
rules: AstRule[],
): Promise<{ passed: boolean; results: AstResult[] }> {
if (rules.length === 0) return { passed: true, results: [] }
const treeSitterLanguage = await loadLanguage(language)
if (!treeSitterLanguage) return { passed: true, results: [] }
const parser = new Parser()
try {
parser.setLanguage(treeSitterLanguage)
const tree = parser.parse(code)
if (!tree) return { passed: true, results: [] }
try {
const results = rules
.filter(astRuleIsMeaningful)
.map((rule) => evaluateRule(tree.rootNode, rule, language))
.filter((result): result is AstResult => result !== null)
return { passed: results.every((result) => result.passed), results }
} finally {
tree.delete()
}
} catch {
return { passed: true, results: [] }
} finally {
parser.delete()
}
}
/**
* 一份代码对着几组规则各判一遍,只解析一次。挑战用:一道题几条挑战各是一组,补历史记录时
* 一份代码要判全部挑战,每组 checkAst 一次就是把同一份代码解析好几遍。
*
* 和 checkAst 不同,**判不了时返回 null 而不是「通过」**:checkAst 是题目的硬要求,
* 判不了就放行、别冤枉学生;挑战判不了就是没做到,放行等于白送。
*/
export async function checkAstGroups(
code: string,
language: string,
groups: AstRule[][],
): Promise<{ passed: boolean; results: AstResult[] }[] | null> {
const treeSitterLanguage = await loadLanguage(language)
if (!treeSitterLanguage) return null
const parser = new Parser()
try {
parser.setLanguage(treeSitterLanguage)
const tree = parser.parse(code)
if (!tree) return null
try {
return groups.map((rules) => {
const results = rules
.filter(astRuleIsMeaningful)
.map((rule) => evaluateRule(tree.rootNode, rule, language))
.filter((result): result is AstResult => result !== null)
return { passed: results.length > 0 && results.every((r) => r.passed), results }
})
} finally {
tree.delete()
}
} catch {
return null
} finally {
parser.delete()
}
}