Deploy / deploy (push) Waiting to run
上游 QingdaoU/JudgeServer 停更在 2024-04(registry 上的 latest 和 1.6.1 是同一份 镜像,编译器停在 gcc-13),没有新版可拉,所以自己重编。docker/judge/ 是只改工具链 的 Dockerfile 分叉,server/ 和 Judger/ 从上游固定 commit b28aa56 拉,一行没动。 镜像 oj2-judge-2(不在任何 registry 上:本机 build.sh --save → scp → docker load) - gcc/g++ 13 → 14.2,Python 3.12 → 3.13.5,都是 trixie 默认 - Go / JDK / Node 整套删掉:前端的题目语言复选框从来只给 C / C++ / Python / SQL, 12 万条提交里 Java 44 条、Golang 15、JavaScript 3,全是很早以前的 - 体积 1.1GB → 433MB;默认走清华源,构建 12 分钟 → 40 秒(--no-mirror 换回官方) - deploy.sh 加一道自检:镜像不在本机就中止,并打印该跑的三条命令 C 的编译参数加三个 -Wno-error(implicit-function-declaration / int-conversion / incompatible-pointer-types):gcc-14 把它们从 warning 提成了 error,而 -w 压不住。 语言值统一成 Python(迁移 0019 / 0020) - 0019:Python3(104527 条提交)与 Python2(3 条)并成 Python,一并改掉 937 道题的 languages、75 个 template 键、15 个 ast_rules 键、257 条 answers、1235 个用户的 成就指标 _languages(languages_used 重算,总和 1928 → 1925,少的 3 个是同时用过 两种 Python 的人) - 0020:把 Java / JavaScript / Golang 从 84 道题的可选语言里摘掉 —— 不摘的话那些题 的语言下拉还能选 Java,提交必 SYSTEM_ERROR - 契约新增 normalizeLanguage() 别名表,判题侧一律走 judgeConfigFor():旧客户端 localStorage 里的 Python3、迁移前排进队列的任务都还能判;协作的语言归一也走它, 否则上线那一刻学生页面里的 Python3 会静默落到 C - 回滚要连数据一起回,只滚代码会让所有 Python 提交变 SYSTEM_ERROR 实跑 - 判题冒烟 docker/judge/smoke.ts 13 条全过:三种语言、六种状态码、gcc 宽松度 - 拿备份里的真实代码逐文件比对新旧镜像的编译结果,0 差异:C 提交 1951 份 (1725 过 / 226 CE)、C++ 882 份、Python 2000 份、20 篇 C 教程的 93 个代码块。 不加那三个 -Wno-error 的话,C 有 26 份会从能过变成 CE - 迁移在灌了 12.4 万行真实数据的一次性库里跑过:0 残留、没有题目被清空; dev 库用真正的执行器跑通 - check:ast 56 个 target 全过,前后端 typecheck 均 0 顺带记下一个升级之前就有的坑(现在随 Go 一起消失,写在 README 里):GOCACHE 指向 容器的 tmpfs,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒 的编译预算,于是重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
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
|
||
}
|
||
|
||
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":
|
||
return {
|
||
description: describeAstRule(rule, language),
|
||
passed: !hasNode(root, nodeType),
|
||
}
|
||
case "count_node": {
|
||
const count = collectNodes(root, nodeType).length
|
||
return {
|
||
description: describeAstRule(rule, language),
|
||
passed: rangePassed(count, rule),
|
||
actual: count,
|
||
}
|
||
}
|
||
case "must_call_function":
|
||
return {
|
||
description: describeAstRule(rule, language),
|
||
passed: functionCalls(root, target, language).length > 0,
|
||
}
|
||
case "must_not_call_function":
|
||
return {
|
||
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, language),
|
||
passed: rangePassed(count, rule),
|
||
actual: count,
|
||
}
|
||
}
|
||
case "must_call_method":
|
||
return {
|
||
description: describeAstRule(rule, language),
|
||
passed: methodCalls(root, target, language).length > 0,
|
||
}
|
||
case "must_not_call_method":
|
||
return {
|
||
description: describeAstRule(rule, language),
|
||
passed: methodCalls(root, target, language).length === 0,
|
||
}
|
||
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()
|
||
}
|
||
}
|