Files
InfoRSI/src/config/check.ts
T
iomgaa d2bfb971ab feat: 配置 config 模块(任务书 04)
- src/config:research.json 的字段定义(TypeBox,全部必填、不认识的字段报错,每个字段带中文说明和类别)、一次列出全部错误的校验、步骤清单纯函数。
- 流程写成完整流程 / 朴素循环两选一,第零轮、消融写成开 / 关两选一,冲突组合写不出来,不另写检查。
- 规则名取自 rules 登记表,config 改为依赖 rules(结构.md 第二部分、.dependency-cruiser.cjs)。
- ledger 顺手两处(结构.md 第四部分):消融结算按轮次唯一、去掉消融编号;私人笔记去掉版本号;表结构版本号升到 2。
- 结构.md 第八部分补 config 对外内容、更新 ledger 一节;WORK_LOG 更新。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:08:18 -04:00

126 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof Value.Errors>[number];
const TYPE_TEXT: Readonly<Record<string, string>> = {
string: "字符串",
number: "数字",
integer: "整数",
boolean: "true 或 false",
object: "对象",
array: "数组",
null: "null",
};
function reasonOf(e: SchemaError): string {
const p = e.params as Record<string, unknown>;
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<string, unknown>)[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<string, { const?: unknown }> }).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<string, unknown>)[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);
}