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:
2026-10-02 08:08:18 -04:00
parent 4dc7ac9d62
commit d2bfb971ab
13 changed files with 1045 additions and 20 deletions
+125
View File
@@ -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);
}