diff --git a/CLAUDE.md b/CLAUDE.md index beead18..16343d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,33 @@ dev 直接起不来。 这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是 这套编码,所以只能新增、不能改已有的含义。题目表情 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 模式 没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"。 diff --git a/apps/api/package.json b/apps/api/package.json index 205d1ea..1e65b23 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/judge/ast.ts b/apps/api/src/judge/ast.ts index f245ea7..1d69f70 100644 --- a/apps/api/src/judge/ast.ts +++ b/apps/api/src/judge/ast.ts @@ -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> = { C: { for_loop: "for_statement", @@ -42,6 +58,35 @@ const mappings: Record> = { 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 = { + 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 } diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 041ffb3..6eb1978 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -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[] + 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 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 diff --git a/apps/web/src/admin/problem/components/AstRulesEditor.vue b/apps/web/src/admin/problem/components/AstRulesEditor.vue index 7268d4e..d051101 100644 --- a/apps/web/src/admin/problem/components/AstRulesEditor.vue +++ b/apps/web/src/admin/problem/components/AstRulesEditor.vue @@ -1,5 +1,9 @@ diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index 4429968..f74c063 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -178,6 +178,13 @@ const columns: DataTableColumn[] = [ {{ rule.description }} + + + 当前 {{ rule.actual }} 次 + diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index d67b9f1..07810f2 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -289,7 +289,12 @@ export interface StatisticInfo { err_info?: string time_cost?: number memory_cost?: number - ast_results?: Array<{ description: string; passed: boolean }> + ast_results?: Array<{ + description: string + passed: boolean + /** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */ + actual?: number + }> } /** diff --git a/bun.lock b/bun.lock index 6e8b74a..fcde3ef 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,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", @@ -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-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=="], "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=="], + "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=="], "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=="], diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index 2fa7f7d..57bbb6d 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -99,26 +99,156 @@ export const astRuleSchema = z.object({ export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema)) /** - * 节点类型的中文名。后台编辑器的下拉选项、后端生成要求文案都要用它 —— - * 原来在 AstRulesEditor.vue(下拉 options)和 ProblemContent.vue - * (NODE_TARGET_LABELS)各存了一份同样的 15 条。 + * 每种语言支持哪些节点 target,以及它的中文名。原来这里是一张 15 条的混合表, + * C 和 Python 的节点混在一起铺成后台下拉(更早之前 AstRulesEditor.vue 和 + * 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 = { - for_loop: "for 循环", - while_loop: "while 循环", - if_statement: "if 条件", - else_clause: "else 子句", - function_definition: "函数定义", - return: "return 语句", - break: "break 语句", - continue: "continue 语句", - list_comprehension: "列表推导式", - list_literal: "列表", - dict_literal: "字典", - set_literal: "集合", - f_string: "f-string", - try_except: "try-except", - class_definition: "类定义", +export const AST_NODE_TARGETS_BY_LANGUAGE: Record> = { + C: { + 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: "函数定义", + assignment: "赋值语句", + struct: "结构体", + 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> = { + 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 = 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 } /**