refactor(契约): AST 代码要求收进契约,三份各写各的合成一份

同一个形状原来在三个地方各定义了一份,三份都不一样:

  apps/api/src/judge/ast.ts        engine target outer inner label message exact min max
  apps/web/src/utils/types.ts      engine target                  message       min max
  AstRulesEditor.vue(本地)        engine target       label message exact min max
  ProblemContent.vue(本地)        engine target       label message exact min max

判题机那份是真的(evaluateRule 按 engine 分支读哪几个字段),契约里则压根没有,
`astRules: z.unknown()`。后果是后台编辑器写得出 label 和 exact、判题机也认,
但 utils/types.ts 那个类型描述不了它们 —— 生产库 12 道带 AST 规则的题里,
15 条规则带 label、6 条带 exact,全都在类型之外。

现在契约里一份 astRuleSchema,四处都指向它。engine 收成枚举,列全判题机
实现的十种。**存量数据核过**:12 道题逐条过新 schema,12/12 通过。

顺带三件:

- `astRules` 的响应和请求 schema 从 z.unknown() 换成 astRulesSchema。之前
  engine 写错一个字母能存进去,判题机 evaluateRule 走 `default: return null`
  静默跳过 —— 老师设了规则、规则不生效、没有任何提示。现在保存时就 400,
  错误信息把十个合法值列出来。
- judge/run.ts 的 astRulesForLanguage 原来是整片 `rules as AstRule[]` 硬转,
  改成逐条 safeParse:认不出的丢掉那一条,行为和 evaluateRule 的 default 分支
  一致,只是提前到读取处,也不再骗类型系统。
- 判题机实现了 must_have_nesting,但后台编辑器没有对应选项,目前只能手工造
  数据才用得上。枚举里留着并加了注释,没有顺手去补 UI(那是加功能不是清理)。

## 验证

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打:

- 把生产库那条带 label/exact 的规则种进本地库,后台题目详情 200、字段齐全;
  原样 PUT 回去 200,库里 label 和 exact 都在(旧类型描述不了的那两个)。
- engine 传 "must_do_magic" → 400,错误信息列出十个合法值。
- 直接调 checkAst:三条规则(两条合法 + 一条 engine 认不出)进去,判题机收下
  两条;两个 if/else 的代码 passed=true,一个的 passed=false,描述文案
  「if 条件 出现 2 次」正确用上了 label。

冒烟改动已还原(problem 2 的 ast_rules 复位成 null,测试标签删掉)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 23:15:52 -06:00
parent 3f55d231c3
commit f919d41199
6 changed files with 75 additions and 48 deletions

View File

@@ -1,3 +1,4 @@
import type { AstRule } 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
@@ -6,17 +7,9 @@ import pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { t
// 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" }
export interface AstRule { // AstRule 的形状在契约里astRuleSchema—— 原来这份和前端两份各写各的。
engine?: string // 这里 re-export判题机的调用方不用再去 import 契约。
target?: string export type { AstRule } from "@oj2/contract"
outer?: string
inner?: string
label?: string
message?: string
exact?: number
min?: number
max?: number
}
export interface AstResult { export interface AstResult {
description: string description: string

View File

@@ -1,5 +1,6 @@
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { astRuleSchema } from "@oj2/contract"
import { and, eq, inArray } from "drizzle-orm" import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config" import { config } from "../config"
@@ -54,9 +55,19 @@ function templateForLanguage(value: unknown, language: string) {
return typeof template === "string" ? template : null return typeof template === "string" ? template : null
} }
/**
* 取某个语言的 AST 规则。逐条过 astRuleSchema 而不是整片 `as AstRule[]` 硬转 ——
* 这是从库里读出来的 JSONB形状不对比如 engine 是个 evaluateRule 不认识的值)
* 时丢掉那一条,行为和 evaluateRule 的 `default: return null` 一致,只是提前到了
* 这里、并且不再骗类型系统。
*/
function astRulesForLanguage(value: unknown, language: string): AstRule[] { function astRulesForLanguage(value: unknown, language: string): AstRule[] {
const rules = objectValue(value)[language] const rules = objectValue(value)[language]
return Array.isArray(rules) ? (rules as AstRule[]) : [] if (!Array.isArray(rules)) return []
return rules.flatMap((rule) => {
const parsed = astRuleSchema.safeParse(rule)
return parsed.success ? [parsed.data] : []
})
} }
async function requestJudge( async function requestJudge(

View File

@@ -1,24 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LANGUAGE } from "utils/types" import type { AstRule, AstRules, LANGUAGE } from "utils/types"
interface AstRule {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
interface Props { interface Props {
modelValue: { [key: string]: AstRule[] } | null modelValue: AstRules | null
languages: LANGUAGE[] languages: LANGUAGE[]
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void (e: "update:modelValue", value: AstRules | null): void
}>() }>()
const activeTab = ref(props.languages[0] || "Python3") const activeTab = ref(props.languages[0] || "Python3")

View File

@@ -6,7 +6,7 @@ import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import { DIFFICULTY } from "utils/constants" import { DIFFICULTY } from "utils/constants"
import type { Problem, ProblemStatus } from "utils/types" import type { AstRule, Problem, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue" import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
@@ -115,16 +115,6 @@ const NODE_TARGET_LABELS: Record<string, string> = {
class_definition: "类定义", class_definition: "类定义",
} }
type AstRule = {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
function ruleDescription(rule: AstRule): string { function ruleDescription(rule: AstRule): string {
if (rule.message) return rule.message if (rule.message) return rule.message
const target = rule.target || "" const target = rule.target || ""

View File

@@ -13,6 +13,7 @@ import type {
ProblemDetail, ProblemDetail,
ProblemDifficulty, ProblemDifficulty,
JudgeStatus, JudgeStatus,
AstRules,
CreateAnnouncementRequest, CreateAnnouncementRequest,
} from "@oj2/contract" } from "@oj2/contract"
@@ -111,7 +112,6 @@ export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
/** /**
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化: * 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
* - `languages` / `template` 的键窄化成 LANGUAGE组件按语言查模板要靠它 * - `languages` / `template` 的键窄化成 LANGUAGE组件按语言查模板要靠它
* - `astRules` 契约里是 unknown这里给出组件实际读的形状
*/ */
export type Problem = Omit<ProblemDetail, "languages" | "template"> & { export type Problem = Omit<ProblemDetail, "languages" | "template"> & {
languages: LANGUAGE[] languages: LANGUAGE[]
@@ -122,15 +122,11 @@ export type Problem = Omit<ProblemDetail, "languages" | "template"> & {
answers?: { language: LANGUAGE; code: string }[] answers?: { language: LANGUAGE; code: string }[]
} }
export type AstRules = { /**
[key: string]: { * AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner ——
engine: string * 编辑器写得出 label 和 exact判题机也认只有这个类型不认。形状在契约里。
target?: string */
min?: number export type { AstRule, AstRules, AstRuleEngine } from "@oj2/contract"
max?: number
message: string
}[]
}
export type { export type {
ProblemDetail, ProblemDetail,

View File

@@ -43,6 +43,9 @@ export const createAnnouncementRequestSchema = z.object({
export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
export type AstRule = z.infer<typeof astRuleSchema>
export type AstRules = z.infer<typeof astRulesSchema>
export type ProblemSample = z.infer<typeof problemSampleSchema> export type ProblemSample = z.infer<typeof problemSampleSchema>
export type ProblemAnswer = z.infer<typeof problemAnswerSchema> export type ProblemAnswer = z.infer<typeof problemAnswerSchema>
export type ProblemTestCaseScore = z.infer<typeof problemTestCaseScoreSchema> export type ProblemTestCaseScore = z.infer<typeof problemTestCaseScoreSchema>
@@ -566,6 +569,50 @@ export const problemTestCaseScoreSchema = z.object({
score: z.coerce.number().int().min(0), score: z.coerce.number().int().min(0),
}) })
/**
* AST 代码要求。同一个形状原来在**三个地方**各写了一份,三份都不一样:
* apps/api/src/judge/ast.ts 的 AstRule判题机真读的那份九个字段
* apps/web/src/utils/types.ts 的 AstRules少了 label / exact / outer / inner
* AstRulesEditor.vue 里的本地 AstRule少了 outer / inner
* 编辑器写得出 label / exact题目类型却描述不了它们。现在以这里为准。
*
* 除 engine 外全部可选:判题机每条规则只读自己那几个字段
* (见 ast.ts 的 evaluateRule缺了就走默认文案。
*/
export const astRuleEngineSchema = z.enum([
"must_exist_node",
"must_not_exist_node",
"count_node",
"must_call_function",
"must_not_call_function",
"count_function_call",
"must_call_method",
"must_not_call_method",
"must_use_operator",
// 判题机实现了,但后台编辑器还没有对应的选项,目前只能手工造数据用上
"must_have_nesting",
])
export const astRuleSchema = z.object({
engine: astRuleEngineSchema,
/** 检查目标:节点类型 / 函数名 / 方法名 / 运算符,按 engine 而定 */
target: z.string().optional(),
/** must_have_nesting 专用:外层、内层节点 */
outer: z.string().optional(),
inner: z.string().optional(),
/** 展示用的中文名,缺省回落到 target */
label: z.string().optional(),
/** 自定义提示。生产库里存的是空串而不是缺键,判题机按 `||` 回落到默认文案 */
message: z.string().optional(),
/** count_* 引擎的次数约束 */
exact: z.number().int().optional(),
min: z.number().int().optional(),
max: z.number().int().optional(),
})
/** 按语言分组:`{ Python3: [...], C: [...] }`,键是 languages 里的语言名 */
export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema))
/** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */ /** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */
export const adminProblemSchema = z.object({ export const adminProblemSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -599,7 +646,7 @@ export const adminProblemSchema = z.object({
showFlowchart: z.boolean(), showFlowchart: z.boolean(),
mermaidCode: z.string().nullable(), mermaidCode: z.string().nullable(),
flowchartHint: z.string().nullable(), flowchartHint: z.string().nullable(),
astRules: z.unknown(), astRules: astRulesSchema.nullable(),
answers: z.array(problemAnswerSchema), answers: z.array(problemAnswerSchema),
prompt: z.string().nullable(), prompt: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(), sqlConfig: sqlConfigSchema.nullable(),
@@ -631,7 +678,7 @@ export const createProblemRequestSchema = z.object({
showFlowchart: z.boolean().default(false), showFlowchart: z.boolean().default(false),
mermaidCode: z.string().nullable().default(null), mermaidCode: z.string().nullable().default(null),
flowchartHint: z.string().nullable().default(null), flowchartHint: z.string().nullable().default(null),
astRules: z.unknown().default(null), astRules: astRulesSchema.nullable().default(null),
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的 // order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
// `BooleanField(default=False)` 一致 // `BooleanField(default=False)` 一致
sqlConfig: sqlConfigSchema sqlConfig: sqlConfigSchema