chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
Deploy / deploy (push) Has been cancelled

原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5 committed 2026-09-16 08:27:34 -06:00
1 parent e600fd24cf
commit ed56a209ea
122 files changed
+10557 -4843

No files matched your search

+33 -18
View File
@@ -39,17 +39,19 @@ 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 })
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 wasmPath =
language === "C"
? cWasmPath
: language === "C++"
? cppWasmPath
: pythonWasmPath
const loaded = await Language.load(wasmPath)
languages.set(language, loaded)
return loaded
@@ -136,9 +138,10 @@ function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
* checkAst 直接放行 —— 学生看得见要求,判题从不检查。
*/
export function astRequirements(value: unknown): AstRequirements | null {
const grouped = value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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)) {
@@ -148,10 +151,12 @@ export function astRequirements(value: unknown): AstRequirements | null {
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),
}]
return [
{
description: describeAstRule(parsed.data, language),
kind: requirementKind(parsed.data.engine),
},
]
})
if (items.length > 0) out[language] = items
}
@@ -180,12 +185,15 @@ export function astRulesError(astRules: AstRules | null): string | null {
const at = `代码规则 ${language} 第 ${index + 1} 条`
const target = rule.target ?? ""
if (rule.engine.endsWith("_node")) {
if (!(target in nodes)) return `${at}:${language} 没有「${target}」这种语法`
if (!(target in nodes))
return `${at}:${language} 没有「${target}」这种语法`
} else if (rule.engine === "must_use_operator") {
if (!(target in operators)) return `${at}:${language} 没有「${target}」运算符`
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}」这种语法`
if (!(value in nodes))
return `${at}:${language} 没有「${value}」这种语法`
}
} else if (!target.trim()) {
return `${at}:要检查的函数名/方法名不能为空`
@@ -204,7 +212,10 @@ export function astRulesError(astRules: AstRules | null): string | null {
* 早年配过 C++ 规则,如今 tab 里看不到那组规则,保存却被「暂不支持 C++」拦下,
* 老师在界面上无从修改。
*/
export function pickAstRules(astRules: AstRules | null, languages: string[]): AstRules | null {
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)) {
@@ -266,7 +277,11 @@ function methodCalls(root: Node, target: string, language: string) {
})
}
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null {
function evaluateRule(
root: Node,
rule: AstRule,
language: string,
): AstResult | null {
const target = rule.target ?? ""
const nodeType = astTargetNodeType(target, language)
+5 -1
View File
@@ -1,4 +1,8 @@
const defaultEnv = ["LANG=en_US.UTF-8", "LANGUAGE=en_US:en", "LC_ALL=en_US.UTF-8"]
const defaultEnv = [
"LANG=en_US.UTF-8",
"LANGUAGE=en_US:en",
"LC_ALL=en_US.UTF-8",
]
export const languageConfigs: Record<string, Record<string, unknown>> = {
C: {
+118 -62
View File
@@ -6,17 +6,16 @@ import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { updateAchievementsForProblemSet, updateAchievementsForSubmission } from "../services/achievements"
import {
updateAchievementsForProblemSet,
updateAchievementsForSubmission,
} from "../services/achievements"
import { recordSolvedProblem } from "../services/problemset"
import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job"
import { languageConfigs } from "./languages"
import {
isAccepted,
JudgeStatus,
type JudgeStatusValue,
} from "./status"
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
import { parseProblemTemplate } from "./template"
import { runSqlCase } from "./sql"
import { readInfo } from "../services/test-case"
@@ -79,7 +78,8 @@ async function requestJudge(
testCaseId: string,
) {
const languageConfig = languageConfigs[language]
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
if (!languageConfig)
throw new Error(`Unsupported judge language: ${language}`)
const token = createHash("sha256")
.update(config.judgeServerToken)
@@ -173,8 +173,7 @@ async function persistResult(
.update(schema.problem)
.set({
submissionNumber: problem.submissionNumber + 1,
acceptedNumber:
problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
acceptedNumber: problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
statisticInfo: problemStatistics,
})
.where(eq(schema.problem.id, problemId))
@@ -233,7 +232,10 @@ async function persistResult(
submissionInfo: {},
})
.onConflictDoNothing({
target: [schema.acmContestRank.contestId, schema.acmContestRank.userId],
target: [
schema.acmContestRank.contestId,
schema.acmContestRank.userId,
],
})
const [rank] = await tx
@@ -267,7 +269,8 @@ async function persistResult(
const acTime = Math.max(
0,
Math.floor(
(Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) /
(Date.parse(submissionCreateTime) -
Date.parse(contest.startTime)) /
1000,
),
)
@@ -293,7 +296,11 @@ async function persistResult(
})
}
async function markSystemError(submissionId: string, userId: number, error: unknown) {
async function markSystemError(
submissionId: string,
userId: number,
error: unknown,
) {
const message = error instanceof Error ? error.message : String(error)
const updated = await db
.update(schema.submission)
@@ -337,7 +344,10 @@ async function markSystemError(submissionId: string, userId: number, error: unkn
* 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的
* 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。
*/
export async function failAbandonedSubmission(submissionId: string, error: unknown) {
export async function failAbandonedSubmission(
submissionId: string,
error: unknown,
) {
const [row] = await db
.select({ userId: schema.submission.userId })
.from(schema.submission)
@@ -354,7 +364,10 @@ export async function judgeSubmission(job: JudgeJobData) {
problem: schema.problem,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(
and(
eq(schema.submission.id, job.submissionId),
@@ -364,7 +377,11 @@ export async function judgeSubmission(job: JudgeJobData) {
.limit(1)
if (!row) throw new Error(`Submission ${job.submissionId} does not exist`)
if (![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(row.submission.result as 6 | 7)) {
if (
![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(
row.submission.result as 6 | 7,
)
) {
return
}
@@ -391,15 +408,16 @@ export async function judgeSubmission(job: JudgeJobData) {
// SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集,
// 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。
const response = row.submission.language === "SQL"
? await judgeSqlSubmission(row.problem, row.submission.code)
: await requestJudge(
row.submission.language,
source,
row.problem.timeLimit,
row.problem.memoryLimit,
row.problem.testCaseId,
)
const response =
row.submission.language === "SQL"
? await judgeSqlSubmission(row.problem, row.submission.code)
: await requestJudge(
row.submission.language,
source,
row.problem.timeLimit,
row.problem.memoryLimit,
row.problem.testCaseId,
)
let result: JudgeStatusValue
let info: unknown = {}
@@ -422,11 +440,19 @@ export async function judgeSubmission(job: JudgeJobData) {
(left, right) => Number(left.test_case) - Number(right.test_case),
)
info = { err: null, data: cases }
const firstFailure = cases.find((item) => item.result !== JudgeStatus.ACCEPTED)
const firstFailure = cases.find(
(item) => item.result !== JudgeStatus.ACCEPTED,
)
result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED)
statisticInfo = {
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)),
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)),
time_cost: Math.max(
0,
...cases.map((item) => Number(item.cpu_time) || 0),
),
memory_cost: Math.max(
0,
...cases.map((item) => Number(item.memory) || 0),
),
score: 0,
}
// SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的
@@ -435,7 +461,8 @@ export async function judgeSubmission(job: JudgeJobData) {
const failedMessage = cases.find(
(item) => item.result !== JudgeStatus.ACCEPTED && item.error_message,
)?.error_message
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage
if (typeof failedMessage === "string")
statisticInfo.err_info = failedMessage
if (result === JudgeStatus.ACCEPTED) {
const rules = astRulesForLanguage(
@@ -483,43 +510,60 @@ export async function judgeSubmission(job: JudgeJobData) {
row.submission.createTime,
)
if (earned.length > 0) {
await publishAchievementNotification(row.submission.userId, earned.map((badge) => ({
id: badge.id,
name: badge.name,
description: badge.description,
icon: badge.icon,
rarity: "bronze",
kind: "badge",
})))
await publishAchievementNotification(
row.submission.userId,
earned.map((badge) => ({
id: badge.id,
name: badge.name,
description: badge.description,
icon: badge.icon,
rarity: "bronze",
kind: "badge",
})),
)
}
if (updated > 0) {
const unlocked = await updateAchievementsForProblemSet(row.submission.userId)
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})))
const unlocked = await updateAchievementsForProblemSet(
row.submission.userId,
)
await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})),
)
}
} catch (error) {
console.error(`Failed to record problem set progress for ${row.submission.id}`, error)
console.error(
`Failed to record problem set progress for ${row.submission.id}`,
error,
)
}
}
try {
const unlocked = await updateAchievementsForSubmission(row.submission.id)
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})))
await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})),
)
} catch (error) {
console.error(`Failed to update achievements for ${row.submission.id}`, error)
console.error(
`Failed to update achievements for ${row.submission.id}`,
error,
)
}
await publishSubmissionUpdate(row.submission.userId, {
@@ -528,7 +572,9 @@ export async function judgeSubmission(job: JudgeJobData) {
result,
status: "finished",
score:
typeof statisticInfo.score === "number" ? statisticInfo.score : undefined,
typeof statisticInfo.score === "number"
? statisticInfo.score
: undefined,
})
} catch (error) {
console.error(`Failed to judge submission ${row.submission.id}`, error)
@@ -536,7 +582,6 @@ export async function judgeSubmission(job: JudgeJobData) {
}
}
/**
* SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构,
* 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。
@@ -554,15 +599,23 @@ async function judgeSqlSubmission(
const answers = Array.isArray(problem.answers) ? problem.answers : []
const refSql = answers
.map((item) => objectValue(item))
.find((item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim())?.code
.find(
(item) =>
item.language === "SQL" &&
typeof item.code === "string" &&
item.code.trim(),
)?.code
if (typeof refSql !== "string") throw new Error("题目缺少 SQL 标准答案")
const info = await readInfo(problem.testCaseId)
if (!info) throw new Error("测试点信息读取失败")
if (!info.sql) throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
if (!info.sql)
throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
// 按 "1","2",… 的数字序遍历,保证测试点顺序稳定
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b))
const keys = Object.keys(info.test_cases ?? {}).sort(
(a, b) => Number(a) - Number(b),
)
if (keys.length === 0) throw new Error("题目没有任何测试点")
const cases: JudgeCase[] = []
@@ -571,7 +624,9 @@ async function judgeSqlSubmission(
const initSql = await readFile(
resolvePath(config.testCaseDirectory, problem.testCaseId, inputName),
"utf8",
).catch(() => { throw new Error(`测试点脚本 ${inputName} 读取失败`) })
).catch(() => {
throw new Error(`测试点脚本 ${inputName} 读取失败`)
})
const outcome = await runSqlCase({
kind: "judge",
@@ -585,7 +640,8 @@ async function judgeSqlSubmission(
})
if (!outcome.ok) {
// 初始化/标准答案执行失败属出题配置问题,整题 SYSTEM_ERROR
if (outcome.result === JudgeStatus.SYSTEM_ERROR) throw new Error(outcome.message)
if (outcome.result === JudgeStatus.SYSTEM_ERROR)
throw new Error(outcome.message)
// 子进程被杀(超时/内存)也走这里,按学生错误记成一个测试点
cases.push({
test_case: String(index + 1),
+11 -3
View File
@@ -29,7 +29,12 @@ export type SqlJob =
timeLimitMs: number
memoryLimitMb: number
}
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
| {
kind: "display"
initSql: string
refSql: string
mode: "query" | "modify"
}
/**
* 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」
@@ -90,10 +95,13 @@ export async function runSqlChild() {
// WASM 堆触顶时 emscripten 抛的是普通 Error("Aborted"/"out of memory"),
// 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错
const message = String((error as Error)?.message ?? error)
const memoryish = message.includes("out of memory") || message.includes("Aborted")
const memoryish =
message.includes("out of memory") || message.includes("Aborted")
finish({
ok: false,
result: memoryish ? JudgeStatus.MEMORY_LIMIT_EXCEEDED : JudgeStatus.SYSTEM_ERROR,
result: memoryish
? JudgeStatus.MEMORY_LIMIT_EXCEEDED
: JudgeStatus.SYSTEM_ERROR,
message: memoryish ? "内存超出限制" : message.slice(0, 200),
})
}
+194 -55
View File
@@ -45,10 +45,17 @@ const DISPLAY_ROW_LIMIT = 20
const ERROR_MESSAGE_MAX_LEN = 200
/** prepare 阶段的语法类错误,映射为 COMPILE_ERROR */
const SYNTAX_ERROR_MARKERS = ["syntax error", "unrecognized token", "incomplete input"]
const SYNTAX_ERROR_MARKERS = [
"syntax error",
"unrecognized token",
"incomplete input",
]
export class SqlCaseError extends Error {
constructor(readonly result: JudgeStatusValue, readonly detail: string) {
constructor(
readonly result: JudgeStatusValue,
readonly detail: string,
) {
super(detail)
}
}
@@ -82,9 +89,11 @@ type Canonical = string
*/
function canonicalValue(value: unknown): Canonical {
if (value === null || value === undefined) return "null"
if (value instanceof Uint8Array) return `blob:${Buffer.from(value).toString("hex")}`
if (value instanceof Uint8Array)
return `blob:${Buffer.from(value).toString("hex")}`
if (typeof value === "number") {
if (Number.isInteger(value) && Math.abs(value) < 2 ** 53) return `num:${value}`
if (Number.isInteger(value) && Math.abs(value) < 2 ** 53)
return `num:${value}`
// Python 的 format(v, ".6g")
return `num:${formatG6(value)}`
}
@@ -96,7 +105,10 @@ function canonicalValue(value: unknown): Canonical {
function formatG6(value: number) {
const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)))
if (exponent < -4 || exponent >= 6) {
return value.toExponential(5).replace(/\.?0+e/, "e").replace(/e([+-])(\d)$/, "e$10$2")
return value
.toExponential(5)
.replace(/\.?0+e/, "e")
.replace(/e([+-])(\d)$/, "e$10$2")
}
const text = value.toPrecision(6)
return text.includes(".") ? text.replace(/\.?0+$/, "") : text
@@ -138,9 +150,11 @@ interface PreparedStatement {
}
function iterate(db: Database, script: string): Iterable<PreparedStatement> {
return (db as unknown as {
iterateStatements(sql: string): Iterable<PreparedStatement>
}).iterateStatements(script)
return (
db as unknown as {
iterateStatements(sql: string): Iterable<PreparedStatement>
}
).iterateStatements(script)
}
/**
@@ -157,9 +171,17 @@ function leadingKeyword(statement: PreparedStatement) {
}
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE,退回到原文剥注释
if (!text) {
text = statement.getSQL().replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ")
text = statement
.getSQL()
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--[^\n]*/g, " ")
}
return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? ""
return (
text
.trimStart()
.split(/[\s(;]/, 1)[0]
?.toUpperCase() ?? ""
)
}
/**
@@ -187,11 +209,17 @@ class ByteBudget {
? Buffer.byteLength(value)
: 8 // 数字和 NULL 按定长算,撑不出内存
if (bytes > this.maxBytes) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"单个数据值超出内存限制",
)
}
this.used += bytes
if (this.used > this.maxBytes) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "查询结果超出内存限制")
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"查询结果超出内存限制",
)
}
}
}
@@ -226,12 +254,17 @@ function executeStatements(
budget?.charge(row)
rows.push(canonicalRow(row))
if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT} 行`)
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
`查询结果超过 ${ROW_LIMIT} 行`,
)
}
}
last = { columns: names.length, rows }
} else {
while (statement.step()) { /* 无结果集语句,推进到结束 */ }
while (statement.step()) {
/* 无结果集语句,推进到结束 */
}
}
} finally {
statement.free()
@@ -242,7 +275,10 @@ function executeStatements(
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
function dumpTables(db: Database, budget?: ByteBudget) {
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
const names = queryColumn(
db,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
const state: Record<string, { columns: number; rows: string[] }> = {}
for (const table of names) {
const quoted = String(table).replaceAll('"', '""')
@@ -253,7 +289,10 @@ function dumpTables(db: Database, budget?: ByteBudget) {
return canonicalRow(row as unknown[])
})
if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `表 ${table} 超过 ${ROW_LIMIT} 行`)
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
`表 ${table} 超过 ${ROW_LIMIT} 行`,
)
}
state[String(table)] = {
// 空表 exec 不返回结果,列数用 table_info 兜底
@@ -278,14 +317,25 @@ function trustedErrorText(message: string) {
}
/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */
function executeTrusted(db: Database, script: string, deadline: number, prefix: string) {
function executeTrusted(
db: Database,
script: string,
deadline: number,
prefix: string,
) {
try {
return executeStatements(db, script, deadline)
} catch (error) {
if (error instanceof SqlCaseError) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${error.detail}`)
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`${prefix}: ${error.detail}`,
)
}
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${trustedErrorText(String((error as Error).message))}`)
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`${prefix}: ${trustedErrorText(String((error as Error).message))}`,
)
}
}
@@ -300,36 +350,63 @@ function runStudent(
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
if (mode === "query") db.run("PRAGMA query_only=1")
// 把题目的 memoryLimit 变成学生看得见的约束,替代旧实现的 setlimit(LIMIT_LENGTH)
const budget = new ByteBudget(Math.max(Math.trunc(memoryLimitMb), 1) * 1024 * 1024)
const budget = new ByteBudget(
Math.max(Math.trunc(memoryLimitMb), 1) * 1024 * 1024,
)
try {
const last = executeStatements(db, script, deadline, (statement) => {
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
if (leadingKeyword(statement) === "PRAGMA") {
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "禁止使用 PRAGMA 语句")
}
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
applyLimits(db, memoryLimitMb)
if (mode === "query") db.run("PRAGMA query_only=1")
}, budget)
const last = executeStatements(
db,
script,
deadline,
(statement) => {
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
if (leadingKeyword(statement) === "PRAGMA") {
throw new SqlCaseError(
JudgeStatus.RUNTIME_ERROR,
"禁止使用 PRAGMA 语句",
)
}
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
applyLimits(db, memoryLimitMb)
if (mode === "query") db.run("PRAGMA query_only=1")
},
budget,
)
if (mode === "query") return last
return dumpTables(db, budget)
} catch (error) {
if (error instanceof SqlCaseError) throw error
const message = String((error as Error).message)
if (message.includes("interrupted")) {
throw new SqlCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时")
throw new SqlCaseError(
JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
"SQL 执行超时",
)
}
if (message.includes("database or disk is full")) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制")
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"数据量超出内存限制",
)
}
// WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限
if (message.includes("too big") || message.includes("out of memory") || message.includes("Aborted")) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
if (
message.includes("too big") ||
message.includes("out of memory") ||
message.includes("Aborted")
) {
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"单个数据值超出内存限制",
)
}
if (message.includes("readonly database")) {
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)")
throw new SqlCaseError(
JudgeStatus.RUNTIME_ERROR,
"本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)",
)
}
if (SYNTAX_ERROR_MARKERS.some((marker) => message.includes(marker))) {
throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message))
@@ -337,7 +414,11 @@ function runStudent(
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message))
} finally {
if (mode === "query") {
try { db.run("PRAGMA query_only=0") } catch { /* 连接可能已不可用 */ }
try {
db.run("PRAGMA query_only=0")
} catch {
/* 连接可能已不可用 */
}
}
}
}
@@ -412,14 +493,22 @@ export async function runCase(
const refDb = newDatabase(SQL, options.memoryLimitMb)
try {
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败")
const last = executeTrusted(
refDb,
refSql,
trustedDeadline,
"标准答案执行失败",
)
if (options.mode === "query") {
expected = last
} else {
try {
expected = dumpTables(refDb)
} catch (error) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超出限制: ${(error as SqlCaseError).detail}`)
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案结果超出限制: ${(error as SqlCaseError).detail}`,
)
}
}
} finally {
@@ -460,7 +549,13 @@ export async function runCase(
} catch (error) {
elapsed = Date.now() - start
const failure = error as SqlCaseError
return { ...result, result: failure.result, error_message: failure.detail, cpu_time: elapsed, real_time: elapsed }
return {
...result,
result: failure.result,
error_message: failure.detail,
cpu_time: elapsed,
real_time: elapsed,
}
}
elapsed = Date.now() - start
} finally {
@@ -496,20 +591,35 @@ interface DisplayTable {
/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */
function dumpDisplayTables(db: Database, only?: Set<string>): DisplayTable[] {
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
const names = queryColumn(
db,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
)
const tables: DisplayTable[] = []
for (const raw of names) {
const name = String(raw)
if (only && !only.has(name)) continue
const quoted = name.replaceAll('"', '""')
const columns = (db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []).map((row) => ({
const columns = (
db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []
).map((row) => ({
name: String(row[1]),
type: String(row[2] ?? ""),
}))
const total = Number(db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0)
const rows = (db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]?.values ?? [])
.map((row) => (row as unknown[]).map(displayValue))
tables.push({ name, columns, rows, total_rows: total, truncated: total > DISPLAY_ROW_LIMIT })
const total = Number(
db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0,
)
const rows = (
db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]
?.values ?? []
).map((row) => (row as unknown[]).map(displayValue))
tables.push({
name,
columns,
rows,
total_rows: total,
truncated: total > DISPLAY_ROW_LIMIT,
})
}
return tables
}
@@ -546,17 +656,27 @@ export async function buildDisplay(
for (const statement of iterate(db, refSql)) {
try {
const names = statement.getColumnNames()
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
if (names.length === 0) {
while (statement.step()) {
/* 无结果集 */
}
continue
}
const rows: unknown[][] = []
while (statement.step()) {
rows.push(statement.get())
if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超过 ${ROW_LIMIT} 行`)
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案结果超过 ${ROW_LIMIT} 行`,
)
}
}
expected = {
columns: queryResultColumns(names, tables),
rows: rows.slice(0, DISPLAY_ROW_LIMIT).map((row) => row.map(displayValue)),
rows: rows
.slice(0, DISPLAY_ROW_LIMIT)
.map((row) => row.map(displayValue)),
total_rows: rows.length,
truncated: rows.length > DISPLAY_ROW_LIMIT,
}
@@ -566,10 +686,16 @@ export async function buildDisplay(
}
} catch (error) {
if (error instanceof SqlCaseError) throw error
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`)
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`,
)
}
if (expected === null) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
"标准答案未产生查询结果集",
)
}
return { tables, expected }
}
@@ -578,18 +704,31 @@ export async function buildDisplay(
executeTrusted(db, refSql, deadline, "标准答案执行失败")
const after = dumpTables(db)
const changed = new Set<string>()
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name)
for (const name of new Set([
...Object.keys(before),
...Object.keys(after),
])) {
if (JSON.stringify(before[name]) !== JSON.stringify(after[name]))
changed.add(name)
}
if (changed.size === 0) {
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置")
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
"标准答案未修改任何表数据,请检查题目配置",
)
}
const changedTables = dumpDisplayTables(db, changed)
// 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」)
const existing = new Set(changedTables.map((table) => table.name))
for (const table of tables) {
if (changed.has(table.name) && !existing.has(table.name)) {
changedTables.push({ ...table, rows: [], total_rows: 0, truncated: false, dropped: true })
changedTables.push({
...table,
rows: [],
total_rows: 0,
truncated: false,
dropped: true,
})
}
}
return { tables, expected: { changed_tables: changedTables } }
+34 -10
View File
@@ -85,16 +85,24 @@ const PHASE_FAILURE: Record<string, SqlJobFailure> = {
result: JudgeStatus.SYSTEM_ERROR,
message: "初始化脚本或标准答案超时/内存超限,请检查题目配置",
},
student: { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" },
student: {
ok: false,
result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
message: "SQL 执行超时",
},
}
async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<T>> {
async function runJob<T>(
job: SqlJob,
budget: JobBudget,
): Promise<SqlJobOutcome<T>> {
// 递归闸。子进程里绝不允许再 spawn 子进程 —— 见文件头「为什么必须有这道闸」。
if (process.env[CHILD_MARKER]) {
return {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message: "SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
message:
"SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
}
}
@@ -115,7 +123,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
child.stdin.write(JSON.stringify(job))
await child.stdin.end()
let timer = setTimeout(() => child.kill("SIGKILL"), budget.trustedMs + STARTUP_SLACK_MS)
let timer = setTimeout(
() => child.kill("SIGKILL"),
budget.trustedMs + STARTUP_SLACK_MS,
)
let phase = ""
// stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了
const readStderr = (async () => {
@@ -132,7 +143,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
phase = latest
if (phase === "student" && budget.studentMs !== null) {
clearTimeout(timer)
timer = setTimeout(() => child.kill("SIGKILL"), budget.studentMs + STUDENT_SLACK_MS)
timer = setTimeout(
() => child.kill("SIGKILL"),
budget.studentMs + STUDENT_SLACK_MS,
)
}
}
}
@@ -140,7 +154,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
let stdout = ""
try {
;[stdout] = await Promise.all([new Response(child.stdout).text(), readStderr])
;[stdout] = await Promise.all([
new Response(child.stdout).text(),
readStderr,
])
await child.exited
} finally {
clearTimeout(timer)
@@ -160,12 +177,15 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
try {
const parsed = JSON.parse(stdout) as
| { ok: true; case?: CaseResult; display?: unknown }
| SqlJobFailure
{ ok: true; case?: CaseResult; display?: unknown } | SqlJobFailure
if (!parsed.ok) return parsed
return { ok: true, value: (parsed.case ?? parsed.display) as T }
} catch {
return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "SQL 判题子进程返回了无法解析的结果" }
return {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message: "SQL 判题子进程返回了无法解析的结果",
}
}
}
@@ -176,7 +196,11 @@ export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
})
}
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
export function buildSqlDisplay(
initSql: string,
refSql: string,
mode: "query" | "modify",
) {
// 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的
// SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍
return runJob<SqlDisplay>(
+7 -2
View File
@@ -16,7 +16,9 @@ export const JudgeStatus = {
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
export function isAccepted(result: number) {
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
return (
result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
)
}
/**
@@ -48,7 +50,10 @@ export function judgeStatusName(result: number) {
* 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。
* 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。
*/
export const UNJUDGED_RESULTS: JudgeStatusValue[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING]
export const UNJUDGED_RESULTS: JudgeStatusValue[] = [
JudgeStatus.PENDING,
JudgeStatus.JUDGING,
]
/**
* **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED,那也是答案对了)