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>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user