import { readFileSync } from "node:fs"; import type { TSchema } from "typebox"; import { Settings } from "typebox/system"; import { Value } from "typebox/value"; import { ResearchConfig } from "./schema.ts"; // research.json 是用户写的配置,属于系统边界(CLAUDE.md"罕见情况怎么办"),所以一次列出全部错误, // 每条写明字段路径和错在哪里。 type SchemaError = ReturnType[number]; const TYPE_TEXT: Readonly> = { string: "字符串", number: "数字", integer: "整数", boolean: "true 或 false", object: "对象", array: "数组", null: "null", }; function reasonOf(e: SchemaError): string { const p = e.params as Record; switch (e.keyword) { case "type": return `应为${TYPE_TEXT[String(p.type)] ?? String(p.type)}`; case "enum": return `只能是 ${(p.allowedValues as unknown[]).map((v) => JSON.stringify(v)).join("、")} 之一`; case "const": return `只能是 ${JSON.stringify(p.allowedValue)}`; case "minimum": return `不能小于 ${String(p.limit)}`; case "maximum": return `不能大于 ${String(p.limit)}`; case "exclusiveMinimum": return `必须大于 ${String(p.limit)}`; case "exclusiveMaximum": return `必须小于 ${String(p.limit)}`; case "minItems": return `至少要有 ${String(p.limit)} 项`; case "pattern": return p.pattern === "\\S" ? "不能为空,也不能只有空白" : `格式不对,应符合 ${String(p.pattern)}`; default: return e.message; } } /** 按 JSON 指针("#/properties/flow/anyOf/0"、"/flow/ablation")往下走。 */ function walk(root: unknown, pointer: string): unknown { let node = root; for (const key of pointer.split("/").filter((k) => k !== "" && k !== "#")) { node = typeof node === "object" && node !== null ? (node as Record)[key] : undefined; } return node; } /** * 几选一(Union)报错时,typebox 会把每个分支的错误都报一遍。这里按区分字段(每个分支都有、取值是常量的那个字段, * 如 preset、enabled)认出配置想写的是哪一支,只报那一支的错误;一支都对不上,就报区分字段该写什么。 */ function unionErrors(u: SchemaError, errors: SchemaError[], value: unknown): string[] { const branches = (walk(ResearchConfig, u.schemaPath) as { anyOf: TSchema[] }).anyOf; const props = branches.map((b) => (b as { properties: Record }).properties); const key = Object.keys(props[0]!).find((k) => props.every((p) => p[k] !== undefined && "const" in p[k]))!; const written = walk(value, u.instancePath); if (typeof written !== "object" || written === null || Array.isArray(written)) return [`${u.instancePath}:应为对象`]; const k = props.findIndex((p) => p[key]!.const === (written as Record)[key]); if (k < 0) { const allowed = props.map((p) => JSON.stringify(p[key]!.const)).join("、"); return [`${u.instancePath}/${key}:缺少或取值不对,只能是 ${allowed} 之一`]; } const prefix = `${u.schemaPath}/anyOf/${k}`; const inside = errors.filter((e) => e.schemaPath === prefix || e.schemaPath.startsWith(`${prefix}/`)); return errorLines(inside, prefix, value); } /** scope 以内的错误逐条写成"字段路径:原因";更深一层分支里的错误交给那个 Union 去挑。 */ function errorLines(errors: SchemaError[], scope: string, value: unknown): string[] { const lines: string[] = []; for (const e of errors) { if (e.schemaPath.slice(scope.length).includes("/anyOf/")) continue; if (e.keyword === "anyOf") { lines.push(...unionErrors(e, errors, value)); } else if (e.keyword === "required") { for (const name of e.params.requiredProperties as string[]) { lines.push(`${e.instancePath}/${name}:缺少这一项(每个字段都必填,不补默认值)`); } } else if (e.keyword === "additionalProperties") { for (const name of e.params.additionalProperties as string[]) { lines.push(`${e.instancePath}/${name}:不认识的字段`); } } else { lines.push(`${e.instancePath || "/"}:${reasonOf(e)}`); } } return lines; } /** 校验一份配置(读 research.json 后、从账本读回冻结的配置时都用它);不合格就报错,列出全部错误。 */ export function checkConfig(value: unknown): ResearchConfig { if (Value.Check(ResearchConfig, value)) return value; // typebox 默认最多收 8 条错误,Union 的每个分支又各报一遍,不放开就会漏报;放开只在这一次调用里。 const cap = Settings.Get().maxErrors; Settings.Set({ maxErrors: Infinity }); let errors: SchemaError[]; try { errors = [...Value.Errors(ResearchConfig, value)]; } finally { Settings.Set({ maxErrors: cap }); } const lines = errorLines(errors, "#", value); throw new Error(`研究配置不合格,共 ${lines.length} 处:\n${lines.join("\n")}`); } /** 读取并校验 research.json。 */ export function loadConfig(path: string): ResearchConfig { const text = readFileSync(path, "utf8"); let value: unknown; try { value = JSON.parse(text); } catch (error) { throw new Error(`${path} 不是合法的 JSON:${(error as Error).message}`); } return checkConfig(value); }