将 checker 架构重构为完全内聚模式:每个 checker 目录包含自身的 types、schema、validate、execute、expect 和 index,新增 checker 只需创建一个目录并在 runner/index.ts 添加一行注册。 主要变更: - runner/shared/ 拆分:断言基础设施迁入 checker/expect/, body.ts 迁入 http/,text.ts 迁入 command/ - config-contract/ 重命名为 schema/,schema.ts → builder.ts - size.ts + parseDuration 合并为 utils.ts - 顶层 types.ts 改为 base interface + index signature, checker 专属类型下沉到各自 types.ts - runner/index.ts 改为显式数组注册模式 - 更新 DEVELOPMENT.md 项目结构和开发新 Checker 指南
99 lines
3.9 KiB
TypeScript
99 lines
3.9 KiB
TypeScript
import type { ConfigValidationIssue } from "../../schema/issues";
|
|
import type { CheckerValidationInput } from "../types";
|
|
|
|
import { validateOperatorObject } from "../../expect/validate-operator";
|
|
import { issue, joinPath } from "../../schema/issues";
|
|
import { parseSize } from "../../utils";
|
|
|
|
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)];
|
|
}
|
|
}
|
|
|
|
function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
|
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
|
|
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
|
|
}
|