1
0

refactor: 统一 expect 断言体系,引入共享 ValueMatcher/ContentRules/KeyValueExpect 模型

- 引入共享 ValueMatcher(equals/contains/regex/exists/empty/gt/gte/lt/lte)
- 引入共享 ContentRules 数组(direct/json/css/xpath 提取器)
- 引入共享 KeyValueExpect(动态键值断言,字面量等价 equals)
- maxDurationMs → durationMs: ValueMatcher(所有 checker)
- match → regex(固定无 flags)
- Ping max* → packetLossPercent/avgLatencyMs/maxLatencyMs(ValueMatcher)
- LLM finishReason/rawFinishReason → ValueMatcher
- DB 新增 result: ContentRules
- TCP banner → ContentRules 数组
- 删除旧模块:operator.ts、validate-operator.ts、duration.ts、body.ts、text.ts、output.ts
- 更新全部 checker schema/validate/expect/execute
- 更新 probe-config.schema.json、probes.example.yaml
- 更新 README.md、DEVELOPMENT.md(含 expect 字段选择规范)
- 同步 10 个 delta specs 到主 specs,归档 change
This commit is contained in:
2026-05-19 14:24:27 +08:00
parent 349896bd02
commit 7a635a0a9f
85 changed files with 4290 additions and 2028 deletions

View File

@@ -0,0 +1,175 @@
import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath";
import type { CheckFailure } from "../types";
import type {
ContentCssRule,
ContentJsonRule,
ContentRule,
ContentRules,
ContentXpathRule,
ExpectResult,
} from "./types";
import { errorFailure, mismatchFailure } from "./failure";
import { applyMatcher, evaluateJsonPath } from "./matcher";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
export function checkContentRules(
source: unknown,
rules: ContentRules | undefined,
options: { path?: string; phase: CheckFailure["phase"] },
): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };
const basePath = options.path ?? options.phase;
let parsedJson: ParsedJsonResult | undefined;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
if ("json" in rule && parsedJson === undefined) {
parsedJson = parseJsonSource(source);
}
const result = checkSingleContentRule(source, rule, `${basePath}[${i}]`, options.phase, parsedJson);
if (!result.matched) return result;
}
return { failure: null, matched: true };
}
function checkCssRule(
source: unknown,
rule: ContentCssRule,
rulePath: string,
phase: CheckFailure["phase"],
): ExpectResult {
const { attr, selector, ...matcher } = rule;
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
let $: cheerio.CheerioAPI;
try {
$ = cheerio.load(contentText(source));
} catch {
return { failure: errorFailure(phase, fullPath, "failed to parse HTML"), matched: false };
}
const el = $(selector).first();
const actual = el.length === 0 ? undefined : attr ? el.attr(attr) : el.text();
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
if (!applyMatcher(actual, effectiveMatcher)) {
return {
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `css selector ${selector} mismatch`),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkJsonRule(
rule: ContentJsonRule,
rulePath: string,
phase: CheckFailure["phase"],
parsedJson?: ParsedJsonResult,
): ExpectResult {
const { path, ...matcher } = rule;
const fullPath = `${rulePath}.json(${path})`;
if (!parsedJson?.ok) {
return { failure: errorFailure(phase, fullPath, parsedJson?.error ?? "content is not valid JSON"), matched: false };
}
const actual = evaluateJsonPath(parsedJson.value, path);
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
if (!applyMatcher(actual, effectiveMatcher)) {
return {
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `json path ${path} mismatch`),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkSingleContentRule(
source: unknown,
rule: ContentRule,
rulePath: string,
phase: CheckFailure["phase"],
parsedJson?: ParsedJsonResult,
): ExpectResult {
if ("json" in rule) return checkJsonRule(rule.json, rulePath, phase, parsedJson);
if ("css" in rule) return checkCssRule(source, rule.css, rulePath, phase);
if ("xpath" in rule) return checkXpathRule(source, rule.xpath, rulePath, phase);
if (!applyMatcher(source, rule, { stringifyNonString: true })) {
return {
failure: mismatchFailure(phase, rulePath, rule, source, `${phase} rule mismatch`),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkXpathRule(
source: unknown,
rule: ContentXpathRule,
rulePath: string,
phase: CheckFailure["phase"],
): ExpectResult {
const { path, ...matcher } = rule;
const fullPath = `${rulePath}.xpath(${path})`;
let doc: ReturnType<DOMParser["parseFromString"]>;
try {
doc = new DOMParser().parseFromString(contentText(source), "text/xml");
} catch {
return { failure: errorFailure(phase, fullPath, "failed to parse XML/HTML"), matched: false };
}
const result = xpath.select(path, doc as unknown as Node);
const actual = xpathValue(result);
const effectiveMatcher = Object.keys(matcher).length === 0 ? { exists: true } : matcher;
if (!applyMatcher(actual, effectiveMatcher)) {
return {
failure: mismatchFailure(phase, fullPath, effectiveMatcher, actual, `xpath ${path} mismatch`),
matched: false,
};
}
return { failure: null, matched: true };
}
function contentText(source: unknown): string {
if (source === null || source === undefined) return "";
if (typeof source === "string") return source;
if (typeof source === "number" || typeof source === "boolean" || typeof source === "bigint") return String(source);
if (typeof source === "symbol") return source.description ?? "";
if (typeof source === "function") return source.name;
return JSON.stringify(source) ?? "";
}
function parseJsonSource(source: unknown): ParsedJsonResult {
if (typeof source !== "string") return { ok: true, value: source };
try {
return { ok: true, value: JSON.parse(source) as unknown };
} catch {
return { error: "content is not valid JSON", ok: false };
}
}
function xpathValue(result: unknown): unknown {
if (!isArray(result)) return result;
if (result.length === 0) return undefined;
const node = (result as unknown[])[0]!;
if (typeof node !== "object" || node === null) return node;
const asNode = node as Node;
return asNode.nodeValue ?? (asNode as unknown as Element).textContent ?? "";
}

View File

@@ -1,20 +0,0 @@
import type { ExpectResult } from "./types";
import { mismatchFailure } from "./failure";
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
if (maxDurationMs === undefined) return { failure: null, matched: true };
if (durationMs > maxDurationMs) {
return {
failure: mismatchFailure(
"duration",
"duration",
`<=${maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${maxDurationMs}ms`,
),
matched: false,
};
}
return { failure: null, matched: true };
}

View File

@@ -0,0 +1,32 @@
import type { CheckFailure } from "../types";
import type { ExpectResult, KeyValueExpect } from "./types";
import { mismatchFailure } from "./failure";
import { checkExpectValue } from "./matcher";
export function checkKeyValueExpect(
actual: Record<string, unknown>,
expected: KeyValueExpect | undefined,
options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] },
): ExpectResult {
if (!expected) return { failure: null, matched: true };
const normalizeKey = options.normalizeKey ?? ((key: string) => key);
const basePath = options.path ?? options.phase;
const actualMap = new Map<string, unknown>();
for (const [key, value] of Object.entries(actual)) {
actualMap.set(normalizeKey(key), value);
}
for (const [key, expectedValue] of Object.entries(expected)) {
const actualValue = actualMap.get(normalizeKey(key));
if (!checkExpectValue(actualValue, expectedValue)) {
return {
failure: mismatchFailure(options.phase, `${basePath}.${key}`, expectedValue, actualValue, `${key} mismatch`),
matched: false,
};
}
}
return { failure: null, matched: true };
}

View File

@@ -0,0 +1,133 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { CheckFailure, JsonValue } from "../types";
import type { ExpectResult, ValueMatcher } from "./types";
import { mismatchFailure } from "./failure";
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
const MATCHER_KEY_SET = new Set<string>(MatcherKeys);
export function applyMatcher(
actual: unknown,
matcher: ValueMatcher,
options: { stringifyNonString?: boolean } = {},
): boolean {
for (const [key, expected] of Object.entries(matcher)) {
if (expected === undefined) continue;
switch (key) {
case "contains":
if (!stringValue(actual, options).includes(expected as string)) return false;
break;
case "empty": {
const empty = isEmptyValue(actual);
if (expected !== empty) return false;
break;
}
case "equals":
if (!isEqual(actual, expected)) return false;
break;
case "exists":
if (expected) {
if (actual === undefined) return false;
} else {
if (actual !== undefined) return false;
}
break;
case "gt":
if (!compareNumber(actual, expected as number, (left, right) => left > right)) return false;
break;
case "gte":
if (!compareNumber(actual, expected as number, (left, right) => left >= right)) return false;
break;
case "lt":
if (!compareNumber(actual, expected as number, (left, right) => left < right)) return false;
break;
case "lte":
if (!compareNumber(actual, expected as number, (left, right) => left <= right)) return false;
break;
case "regex":
if (!new RegExp(expected as string).test(stringValue(actual, options))) return false;
break;
}
}
return true;
}
export function checkExpectValue(actual: unknown, expected: JsonValue | ValueMatcher): boolean {
if (isValueMatcherObject(expected)) {
return applyMatcher(actual, expected);
}
return applyMatcher(actual, { equals: expected });
}
export function checkValueMatcher(
actual: unknown,
matcher: undefined | ValueMatcher,
options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean },
): ExpectResult {
if (matcher === undefined) return { failure: null, matched: true };
if (applyMatcher(actual, matcher, { stringifyNonString: options.stringifyNonString })) {
return { failure: null, matched: true };
}
return {
failure: mismatchFailure(
options.phase,
options.path,
matcher,
actual,
options.message ?? `${options.path} mismatch`,
),
matched: false,
};
}
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
const segments = path.slice(2).split(".");
let current: unknown = json;
for (const seg of segments) {
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch) {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10);
if (!isArray(current) || idx >= current.length) return undefined;
current = current[idx];
} else {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[seg];
}
}
return current;
}
export function isValueMatcherObject(value: unknown): value is ValueMatcher {
return isPlainObject(value) && Object.keys(value).some((key) => MATCHER_KEY_SET.has(key));
}
function compareNumber(
actual: unknown,
expected: number,
compare: (actual: number, expected: number) => boolean,
): boolean {
const value = Number(actual);
return Number.isFinite(value) && compare(value, expected);
}
function isEmptyValue(value: unknown): boolean {
return isNil(value) || value === "" || (isArray(value) && value.length === 0) || isEmptyObject(value);
}
function stringValue(actual: unknown, options: { stringifyNonString?: boolean }): string {
if (!options.stringifyNonString || typeof actual === "string") return String(actual);
if (actual !== null && typeof actual === "object") return JSON.stringify(actual);
return String(actual);
}

View File

@@ -1,80 +0,0 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ExpectOperator, ExpectValue } from "../types";
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
for (const [key, expected] of Object.entries(op)) {
if (expected === undefined) continue;
switch (key) {
case "contains":
if (!String(actual).includes(expected as string)) return false;
break;
case "empty": {
const isEmpty =
isNil(actual) || actual === "" || (isArray(actual) && actual.length === 0) || isEmptyObject(actual);
if (expected !== isEmpty) return false;
break;
}
case "equals":
if (!isEqual(actual, expected)) return false;
break;
case "exists":
if (expected) {
if (actual === undefined) return false;
} else {
if (actual !== undefined) return false;
}
break;
case "gt":
if (!(Number(actual) > (expected as number))) return false;
break;
case "gte":
if (!(Number(actual) >= (expected as number))) return false;
break;
case "lt":
if (!(Number(actual) < (expected as number))) return false;
break;
case "lte":
if (!(Number(actual) <= (expected as number))) return false;
break;
case "match":
if (!new RegExp(expected as string).test(String(actual))) return false;
break;
}
}
return true;
}
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
if (isPlainObject(expected) && Object.keys(expected).some((key) => OPERATOR_KEYS.has(key))) {
return applyOperator(actual, expected as ExpectOperator);
}
return applyOperator(actual, { equals: expected as Exclude<ExpectValue, ExpectOperator> });
}
export function evaluateJsonPath(json: unknown, path: string): unknown {
if (!path.startsWith("$.")) return undefined;
const segments = path.slice(2).split(".");
let current: unknown = json;
for (const seg of segments) {
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch) {
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10);
if (!isArray(current) || idx >= current.length) return undefined;
current = current[idx];
} else {
if (current === null || current === undefined) return undefined;
current = (current as Record<string, unknown>)[seg];
}
}
return current;
}

View File

@@ -1,6 +1,41 @@
import type { CheckFailure } from "../types";
import type { CheckFailure, JsonValue } from "../types";
export interface ContentCssRule extends ValueMatcher {
attr?: string;
selector: string;
}
export interface ContentJsonRule extends ValueMatcher {
path: string;
}
export type ContentRule =
| ValueMatcher
| { css: ContentCssRule }
| { json: ContentJsonRule }
| { xpath: ContentXpathRule };
export type ContentRules = ContentRule[];
export interface ContentXpathRule extends ValueMatcher {
path: string;
}
export interface ExpectResult {
failure: CheckFailure | null;
matched: boolean;
}
export type KeyValueExpect = Record<string, JsonValue | ValueMatcher>;
export interface ValueMatcher {
contains?: string;
empty?: boolean;
equals?: JsonValue;
exists?: boolean;
gt?: number;
gte?: number;
lt?: number;
lte?: number;
regex?: string;
}

View File

@@ -0,0 +1,225 @@
import { DOMParser } from "@xmldom/xmldom";
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types";
import { issue, joinPath } from "../schema/issues";
import { isUnsafeRegex } from "./redos";
export const MatcherKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "regex"] as const;
const MATCHER_KEY_SET = new Set<string>(MatcherKeys);
const EXTRACTOR_KEYS = ["css", "json", "xpath"] as const;
const EXTRACTOR_KEY_SET = new Set<string>(EXTRACTOR_KEYS);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (isString(value) || isBoolean(value)) return true;
if (isNumber(value)) return Number.isFinite(value);
if (isArray(value)) return value.every(isJsonValue);
if (isPlainObject(value)) return Object.values(value).every(isJsonValue);
return false;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return isPlainObject(value);
}
export function validateContentRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateContentRule(rule, `${path}[${index}]`, targetName));
}
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
if (!path.startsWith("$.") || path.length <= 2) {
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
}
const issues: ConfigValidationIssue[] = [];
const segments = path.slice(2).split(".");
for (const seg of segments) {
if (seg === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
}
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
if (bracketMatch?.[1]!.trim() === "") {
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
}
}
return issues;
}
export function validateKeyValueExpect(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
for (const [key, item] of Object.entries(value)) {
const itemPath = joinPath(path, key);
if (isPlainRecord(item)) {
issues.push(...validateValueMatcher(item, itemPath, targetName));
} else if (!isJsonValue(item)) {
issues.push(issue("invalid-type", itemPath, "必须为 JSON value 或 matcher 对象", targetName));
}
}
return issues;
}
export function validateValueMatcher(
matcher: unknown,
path: string,
targetName?: string,
options: { requireAtLeastOne?: boolean } = {},
): ConfigValidationIssue[] {
const requireAtLeastOne = options.requireAtLeastOne ?? true;
if (!isPlainRecord(matcher)) return [issue("invalid-type", path, "必须为 matcher 对象", targetName)];
const issues: ConfigValidationIssue[] = [];
let found = 0;
for (const [key, value] of Object.entries(matcher)) {
if (!MATCHER_KEY_SET.has(key)) {
issues.push(issue("unknown-matcher", joinPath(path, key), "是未知 matcher", targetName));
continue;
}
if (value === undefined) continue;
found++;
issues.push(...validateMatcherValue(key, value, joinPath(path, key), targetName));
}
if (requireAtLeastOne && found === 0) {
issues.push(issue("empty-matcher", path, "必须包含至少一个合法 matcher", targetName));
}
if (matcher["exists"] === false && found > 1) {
issues.push(issue("invalid-value", joinPath(path, "exists"), "exists:false 不能与其他 matcher 组合", targetName));
}
return issues;
}
function validateContentRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
const extractors = Object.keys(rule).filter((key) => EXTRACTOR_KEY_SET.has(key));
const directMatchers = Object.keys(rule).filter((key) => MATCHER_KEY_SET.has(key));
for (const key of Object.keys(rule)) {
if (!MATCHER_KEY_SET.has(key) && !EXTRACTOR_KEY_SET.has(key)) {
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
}
if (extractors.length > 1) {
issues.push(issue("multiple-content-rules", path, "一条规则不能同时包含多个 extractor", targetName));
}
if (extractors.length === 1 && directMatchers.length > 0) {
issues.push(issue("invalid-content-rule", path, "直接 matcher 不能与 extractor 混用", targetName));
}
if (issues.length > 0) return issues;
if (extractors.length === 0) return validateValueMatcher(rule, path, targetName);
const extractor = extractors[0]!;
switch (extractor) {
case "css":
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
case "json":
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "xpath":
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
}
return [];
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["selector"]) || rule["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in rule && !isString(rule["attr"])) {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
issues.push(...validateExtractorMatcher(rule, new Set(["attr", "selector"]), path, targetName));
return issues;
}
function validateExtractorMatcher(
rule: Record<string, unknown>,
allowedFields: Set<string>,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
const matcher: Record<string, unknown> = {};
const issues: ConfigValidationIssue[] = [];
for (const [key, value] of Object.entries(rule)) {
if (allowedFields.has(key)) continue;
matcher[key] = value;
}
issues.push(...validateValueMatcher(matcher, path, targetName, { requireAtLeastOne: false }));
return issues;
}
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["path"])) {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
}
issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName));
return issues;
}
function validateMatcherValue(key: string, value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
switch (key) {
case "contains":
return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty":
case "exists":
return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt":
case "gte":
case "lt":
case "lte":
return isNumber(value) && Number.isFinite(value)
? []
: [issue("invalid-type", path, "必须为有限数字", targetName)];
case "regex":
if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
default:
return [issue("unknown-matcher", path, "是未知 matcher", targetName)];
}
}
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(rule["path"]) || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
xpath.select(rule["path"], doc as unknown as Node);
} catch {
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
}
}
issues.push(...validateExtractorMatcher(rule, new Set(["path"]), path, targetName));
return issues;
}

View File

@@ -1,84 +0,0 @@
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types";
import { OperatorKeys } from "../schema/fragments";
import { issue, joinPath } from "../schema/issues";
import { isUnsafeRegex } from "./redos";
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (isString(value) || isBoolean(value)) return true;
if (isNumber(value)) return Number.isFinite(value);
if (isArray(value)) return value.every(isJsonValue);
if (isPlainObject(value)) {
return Object.values(value).every(isJsonValue);
}
return false;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return isPlainObject(value);
}
export function validateOperatorObject(
operators: unknown,
path: string,
targetName?: string,
options: { requireAtLeastOne: boolean } = { requireAtLeastOne: true },
): ConfigValidationIssue[] {
if (!isPlainRecord(operators)) return [issue("invalid-type", path, "必须为操作符对象", targetName)];
const issues: ConfigValidationIssue[] = [];
let found = 0;
for (const [key, value] of Object.entries(operators)) {
if (!OPERATOR_KEY_SET.has(key)) {
issues.push(issue("unknown-operator", joinPath(path, key), "是未知 operator", targetName));
continue;
}
if (value === undefined) continue;
found++;
issues.push(...validateOperatorValue(key, value, joinPath(path, key), targetName));
}
if (options.requireAtLeastOne && found === 0) {
issues.push(issue("empty-operator", path, "必须包含至少一个合法 operator", targetName));
}
return issues;
}
export function validateOperatorValue(
key: string,
value: unknown,
path: string,
targetName?: string,
): ConfigValidationIssue[] {
switch (key) {
case "contains":
return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty":
case "exists":
return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt":
case "gte":
case "lt":
case "lte":
return isNumber(value) && Number.isFinite(value)
? []
: [issue("invalid-type", path, "必须为有限数字", targetName)];
case "match":
if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
default:
return [issue("unknown-operator", path, "是未知 operator", targetName)];
}
}