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:
175
src/server/checker/expect/content.ts
Normal file
175
src/server/checker/expect/content.ts
Normal 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 ?? "";
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
32
src/server/checker/expect/key-value.ts
Normal file
32
src/server/checker/expect/key-value.ts
Normal 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 };
|
||||
}
|
||||
133
src/server/checker/expect/matcher.ts
Normal file
133
src/server/checker/expect/matcher.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
225
src/server/checker/expect/validate-matcher.ts
Normal file
225
src/server/checker/expect/validate-matcher.ts
Normal 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;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user