1
0

feat: 重构配置校验为 TypeBox + Ajv + semantic validator,严格禁止未知字段

- 新增 config-contract 模块(TypeBox fragments、Ajv 契约校验、ConfigValidationIssue)
- CheckerDefinition 扩展为含 configKey、schemas、validate 的完整插件接口
- HTTP/Command 各自维护 contract.ts + validate.ts,校验从 resolve 中分离
- resolve 不再承担校验,只做默认值合并和路径/单位解析
- config-loader 流程: unknown → RawProbeConfig → ValidatedProbeConfig → ResolvedConfig
- 导出 probe-config.schema.json,新增 schema/schema:check 脚本
- 更新 DEVELOPMENT.md 新增 1.7 开发新 Checker 完整指引
- 同步更新 4 个 main specs(probe-config、command-checker、expect-body-checkers、checker-runner-abstraction)
This commit is contained in:
2026-05-13 12:19:36 +08:00
parent bce0f8e7a8
commit 7b20b59b79
38 changed files with 3034 additions and 675 deletions

View File

@@ -0,0 +1,93 @@
import type { ConfigValidationIssue } from "../../config-contract/issues";
import type { CheckerValidationInput } from "../types";
import { issue, joinPath } from "../../config-contract/issues";
import { parseSize } from "../../size";
import { validateTextRules } from "../shared/validate";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults =
isRecord(input.defaults) && isRecord(input.defaults["command"]) ? input.defaults["command"] : undefined;
if (isSizeInput(defaults?.["maxOutputBytes"])) {
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.command.maxOutputBytes"));
}
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (target["type"] !== "command") continue;
issues.push(...validateCommandTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
}
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["stdout"] !== undefined) {
issues.push(...validateTextRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
}
if (expect["stderr"] !== undefined) {
issues.push(...validateTextRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
return issues;
}
function validateCommandTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const command = target["command"];
if (!isRecord(command)) {
issues.push(issue("required", joinPath(path, "command"), "缺少 command.exec 字段", targetName));
issues.push(...validateCommandExpect(target, path));
return issues;
}
if (typeof command["exec"] !== "string" || command["exec"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "command"), "exec"), "缺少 command.exec 字段", targetName));
}
if (isSizeInput(command["maxOutputBytes"])) {
issues.push(
...validateSizeValue(
command["maxOutputBytes"],
joinPath(joinPath(path, "command"), "maxOutputBytes"),
targetName,
),
);
}
issues.push(...validateCommandExpect(target, path));
return issues;
}
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
try {
parseSize(value);
return [];
} catch (error) {
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
}
}