diff --git a/openspec/specs/backend-code-quality/spec.md b/openspec/specs/backend-code-quality/spec.md index 187e6fb..a1d73ef 100644 --- a/openspec/specs/backend-code-quality/spec.md +++ b/openspec/specs/backend-code-quality/spec.md @@ -94,6 +94,40 @@ - **WHEN** 输入包含不同 intervalMs 值的多个 target - **THEN** `groupBy(targets, t => t.intervalMs)` SHALL 返回 key 为 intervalMs 值的分组对象,值为对应 target 数组 +### Requirement: 使用原生 API 进行数组类型判断 +系统 SHALL 使用原生 `Array.isArray()` 替代 `es-toolkit/compat` 的 `isArray`,用于 checker 模块中所有数组类型判断场景。 + +#### Scenario: 数组值判定为数组 +- **WHEN** 校验值为数组(如 `[1, 2, 3]`) +- **THEN** `Array.isArray(value)` SHALL 返回 true + +#### Scenario: 非数组值判定为非数组 +- **WHEN** 校验值为对象 `{}`、字符串 `"abc"`、数字 `123`、null 等 +- **THEN** `Array.isArray(value)` SHALL 返回 false + +### Requirement: 使用原生 API 进行对象类型判断 +系统 SHALL 使用原生 `typeof x === 'object' && x !== null` 替代 `es-toolkit/compat` 的 `isObject`,用于 checker 模块中需要判断值为对象类型(排除 null)的场景。 + +#### Scenario: 普通对象判定为对象 +- **WHEN** 值为普通对象 `{ key: "val" }` +- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 true + +#### Scenario: 数组判定为对象 +- **WHEN** 值为数组 `[1, 2, 3]` +- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 true + +#### Scenario: Headers 实例判定为对象 +- **WHEN** 值为 `Headers` 实例 +- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 true + +#### Scenario: null 判定为非对象 +- **WHEN** 值为 null +- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 false + +#### Scenario: 原始值判定为非对象 +- **WHEN** 值为字符串、数字、布尔值、undefined +- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 false + ### Requirement: 使用 Bun 内置 API 进行 Headers 转换 系统 SHALL 使用 `Object.fromEntries(headers)` 标准 Web API 替代手写的 `headersToRecord` 函数,用于将 Fetch API 的 Headers 对象转换为键值对。 diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index 2a99c1c..1c6afec 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -1,5 +1,4 @@ import { isNumber, isPlainObject, isString } from "es-toolkit"; -import { isArray } from "es-toolkit/compat"; import { dirname, resolve } from "node:path"; import type { ConfigValidationIssue } from "./schema/issues"; @@ -151,7 +150,7 @@ function resolveTarget( function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; - if (!isArray(config.targets) || config.targets.length === 0) { + if (!Array.isArray(config.targets) || config.targets.length === 0) { issues.push(issue("required", "targets", "配置文件必须包含至少一个 target")); return issues; } diff --git a/src/server/checker/expect/content.ts b/src/server/checker/expect/content.ts index 083e877..237f81b 100644 --- a/src/server/checker/expect/content.ts +++ b/src/server/checker/expect/content.ts @@ -1,6 +1,5 @@ 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"; @@ -165,7 +164,7 @@ function parseJsonSource(source: unknown): ParsedJsonResult { } function xpathValue(result: unknown): unknown { - if (!isArray(result)) return result; + if (!Array.isArray(result)) return result; if (result.length === 0) return undefined; const node = (result as unknown[])[0]!; diff --git a/src/server/checker/expect/failure.ts b/src/server/checker/expect/failure.ts index 95b542a..3723633 100644 --- a/src/server/checker/expect/failure.ts +++ b/src/server/checker/expect/failure.ts @@ -1,5 +1,4 @@ import { isString } from "es-toolkit"; -import { isObject } from "es-toolkit/compat"; import type { CheckFailure } from "../types"; @@ -32,7 +31,7 @@ export function mismatchFailure( export function truncateActual(value: unknown, maxLen = 200): unknown { if (value === undefined || value === null) return value; - const str = isString(value) ? value : isObject(value) ? JSON.stringify(value) : undefined; + const str = isString(value) ? value : typeof value === "object" && value !== null ? JSON.stringify(value) : undefined; if (str === undefined) return value; if (str.length <= maxLen) return value; return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`; diff --git a/src/server/checker/expect/matcher.ts b/src/server/checker/expect/matcher.ts index 89180c2..da783b3 100644 --- a/src/server/checker/expect/matcher.ts +++ b/src/server/checker/expect/matcher.ts @@ -1,5 +1,4 @@ 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"; @@ -98,7 +97,7 @@ export function evaluateJsonPath(json: unknown, path: string): unknown { if (current === null || current === undefined) return undefined; current = (current as Record)[bracketMatch[1]!]; const idx = parseInt(bracketMatch[2]!, 10); - if (!isArray(current) || idx >= current.length) return undefined; + if (!Array.isArray(current) || idx >= current.length) return undefined; current = current[idx]; } else { if (current === null || current === undefined) return undefined; @@ -123,7 +122,7 @@ function compareNumber( } function isEmptyValue(value: unknown): boolean { - return isNil(value) || value === "" || (isArray(value) && value.length === 0) || isEmptyObject(value); + return isNil(value) || value === "" || (Array.isArray(value) && value.length === 0) || isEmptyObject(value); } function stringValue(actual: unknown, options: { stringifyNonString?: boolean }): string { diff --git a/src/server/checker/expect/validate-matcher.ts b/src/server/checker/expect/validate-matcher.ts index 73b82fe..2b56a5e 100644 --- a/src/server/checker/expect/validate-matcher.ts +++ b/src/server/checker/expect/validate-matcher.ts @@ -1,6 +1,5 @@ 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"; @@ -19,7 +18,7 @@ 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 (Array.isArray(value)) return value.every(isJsonValue); if (isPlainObject(value)) return Object.values(value).every(isJsonValue); return false; } @@ -29,7 +28,7 @@ export function isPlainRecord(value: unknown): value is Record } export function validateContentRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)]; + if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)]; return rules.flatMap((rule, index) => validateContentRule(rule, `${path}[${index}]`, targetName)); } diff --git a/src/server/checker/runner/db/execute.ts b/src/server/checker/runner/db/execute.ts index b5fd958..35a1e86 100644 --- a/src/server/checker/runner/db/execute.ts +++ b/src/server/checker/runner/db/execute.ts @@ -1,6 +1,5 @@ import { SQL } from "bun"; import { isError } from "es-toolkit"; -import { isArray } from "es-toolkit/compat"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; @@ -126,7 +125,7 @@ export class DbChecker implements CheckerDefinition { durationMs, failure: durationResult.failure, matched: false, - statusDetail: `${isArray(rows) ? rows.length : 0} rows`, + statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, targetId: t.id, timestamp, }; @@ -134,13 +133,13 @@ export class DbChecker implements CheckerDefinition { // rowCount 断言 if (t.expect?.rowCount) { - const rowCountResult = checkRowCount(isArray(rows) ? rows.length : 0, t.expect.rowCount); + const rowCountResult = checkRowCount(Array.isArray(rows) ? rows.length : 0, t.expect.rowCount); if (!rowCountResult.matched) { return { durationMs, failure: rowCountResult.failure, matched: false, - statusDetail: `${isArray(rows) ? rows.length : 0} rows`, + statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, targetId: t.id, timestamp, }; @@ -155,7 +154,7 @@ export class DbChecker implements CheckerDefinition { durationMs, failure: rowsResult.failure, matched: false, - statusDetail: `${isArray(rows) ? rows.length : 0} rows`, + statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, targetId: t.id, timestamp, }; @@ -163,7 +162,7 @@ export class DbChecker implements CheckerDefinition { } if (t.expect?.result && t.expect.result.length > 0) { - const rowCount = isArray(rows) ? rows.length : 0; + const rowCount = Array.isArray(rows) ? rows.length : 0; const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" }); if (!resultCheck.matched) { return { @@ -181,7 +180,7 @@ export class DbChecker implements CheckerDefinition { durationMs, failure: null, matched: true, - statusDetail: `${isArray(rows) ? rows.length : 0} rows`, + statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, targetId: t.id, timestamp, }; diff --git a/src/server/checker/runner/db/expect.ts b/src/server/checker/runner/db/expect.ts index 6177a86..7e7707c 100644 --- a/src/server/checker/runner/db/expect.ts +++ b/src/server/checker/runner/db/expect.ts @@ -1,5 +1,4 @@ import { isPlainObject } from "es-toolkit"; -import { isArray } from "es-toolkit/compat"; import type { ExpectResult, KeyValueExpect, ValueMatcher } from "../../expect/types"; @@ -16,7 +15,7 @@ export function checkRowCount(actual: number, matcher: ValueMatcher): ExpectResu } export function checkRows(rows: unknown, rules: KeyValueExpect[]): ExpectResult { - if (!isArray(rows)) { + if (!Array.isArray(rows)) { return { failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"), matched: false, diff --git a/src/server/checker/runner/db/validate.ts b/src/server/checker/runner/db/validate.ts index 2450117..e403b0b 100644 --- a/src/server/checker/runner/db/validate.ts +++ b/src/server/checker/runner/db/validate.ts @@ -1,5 +1,4 @@ import { isPlainObject, isString } from "es-toolkit"; -import { isArray } from "es-toolkit/compat"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; @@ -54,7 +53,7 @@ function validateDbExpect(target: Record, path: string): Config } if (expect["rows"] !== undefined) { - if (!isArray(expect["rows"])) { + if (!Array.isArray(expect["rows"])) { issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName)); } else { issues.push(...collectRowExpects(expect["rows"], joinPath(expectPath, "rows"), targetName)); diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index 1d3083d..c0d1242 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -1,5 +1,4 @@ import { isError } from "es-toolkit"; -import { isObject } from "es-toolkit/compat"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; @@ -161,7 +160,10 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin const method = init.method?.toUpperCase(); if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) { - const headers = isObject(init.headers) ? { ...(init.headers as Record) } : undefined; + const headers = + typeof init.headers === "object" && init.headers !== null + ? { ...(init.headers as Record) } + : undefined; if (headers) { for (const key of Object.keys(headers)) { const lower = key.toLowerCase(); @@ -176,7 +178,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin try { const fromOrigin = new URL(fromUrl).origin; const toOrigin = new URL(toUrl).origin; - if (fromOrigin !== toOrigin && isObject(newInit.headers)) { + if (fromOrigin !== toOrigin && typeof newInit.headers === "object" && newInit.headers !== null) { const headers = { ...(newInit.headers as Record) }; for (const key of Object.keys(headers)) { if (SENSITIVE_HEADERS.has(key.toLowerCase())) { diff --git a/src/server/checker/runner/http/validate.ts b/src/server/checker/runner/http/validate.ts index b2c5628..c8cf80f 100644 --- a/src/server/checker/runner/http/validate.ts +++ b/src/server/checker/runner/http/validate.ts @@ -1,5 +1,4 @@ import { isNumber, isString } from "es-toolkit"; -import { isArray } from "es-toolkit/compat"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; @@ -76,7 +75,7 @@ function validateHttpExpect(target: Record, path: string): Conf issues.push(...validateContentRules(expect["body"], joinPath(expectPath, "body"), targetName)); } - if (isArray(expect["status"])) { + if (Array.isArray(expect["status"])) { issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName)); } diff --git a/src/server/checker/runner/llm/validate.ts b/src/server/checker/runner/llm/validate.ts index 9a663c9..b4b92db 100644 --- a/src/server/checker/runner/llm/validate.ts +++ b/src/server/checker/runner/llm/validate.ts @@ -1,5 +1,4 @@ import { isBoolean, isNumber, isString } from "es-toolkit"; -import { isArray } from "es-toolkit/compat"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; @@ -73,7 +72,7 @@ function validateLlmExpect( const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); - if (isArray(expect["status"])) { + if (Array.isArray(expect["status"])) { issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName)); } if (expect["headers"] !== undefined) { @@ -144,7 +143,7 @@ function validateLlmOptions(options: unknown, path: string, targetName?: string) } if (options["stopSequences"] !== undefined) { - if (!isArray(options["stopSequences"])) { + if (!Array.isArray(options["stopSequences"])) { issues.push(issue("invalid-type", joinPath(path, "stopSequences"), "必须为字符串数组", targetName)); } else { for (let i = 0; i < options["stopSequences"].length; i++) { diff --git a/src/server/checker/schema/validate.ts b/src/server/checker/schema/validate.ts index ac5a4bc..33fc18c 100644 --- a/src/server/checker/schema/validate.ts +++ b/src/server/checker/schema/validate.ts @@ -2,7 +2,6 @@ import type { ErrorObject } from "ajv"; import Ajv from "ajv"; import { isPlainObject, isString } from "es-toolkit"; -import { isArray } from "es-toolkit/compat"; import type { CheckerRegistry } from "../runner/registry"; import type { ConfigValidationIssue } from "./issues"; @@ -34,7 +33,7 @@ export function validateProbeConfigContract( if (isPlainObject(config)) { const configRecord = config as Record; const targetsValue: unknown = configRecord["targets"]; - if (!isArray(targetsValue)) + if (!Array.isArray(targetsValue)) return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] }; const targets = targetsValue; for (let i = 0; i < targets.length; i++) { @@ -142,7 +141,7 @@ function targetDisplayNameFromPath(root: unknown, path: string): string | undefi if (!match || !isPlainObject(root)) return undefined; const rootRecord = root as Record; const targetsValue: unknown = rootRecord["targets"]; - if (!isArray(targetsValue)) return undefined; + if (!Array.isArray(targetsValue)) return undefined; const target: unknown = targetsValue[Number(match[1])]; if (!isPlainObject(target)) return undefined; const targetRecord = target as Record; diff --git a/src/server/checker/variables.ts b/src/server/checker/variables.ts index c369c33..0c2dd8d 100644 --- a/src/server/checker/variables.ts +++ b/src/server/checker/variables.ts @@ -1,5 +1,4 @@ import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit"; -import { isArray } from "es-toolkit/compat"; import type { ConfigValidationIssue } from "./schema/issues"; import type { VariableValue } from "./types"; @@ -66,7 +65,7 @@ export function resolveVariables(config: unknown): { config: unknown; issues: Co } const configRecord = config as Record; const rawTargets: unknown = configRecord["targets"]; - if (!isArray(rawTargets)) { + if (!Array.isArray(rawTargets)) { return { config, issues }; } @@ -76,7 +75,7 @@ export function resolveVariables(config: unknown): { config: unknown; issues: Co function describeInvalidVariableValue(value: unknown): string { if (value === null) return "null"; - if (isArray(value)) return "array"; + if (Array.isArray(value)) return "array"; return typeof value; } @@ -156,7 +155,7 @@ function resolveValue( if (isString(value)) { return replaceStringValue(value, variables, issues, { ...context, path }); } - if (isArray(value)) { + if (Array.isArray(value)) { return value.map((item, index) => resolveValue(item, `${path}[${index}]`, variables, issues, { ...context, path: `${path}[${index}]` }), );