From 5bf66f75008a6ffa1353779442703e6de45c9143 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 17:05:54 +0800 Subject: [PATCH] refactor(domain): one owner for the non-array record predicate (#10588) * refactor(domain): one owner for the non-array record predicate Thirty-four source files each declared isRecord as a non-null object that is not an array, and six more spelled the same predicate as isObject, isSchemaRecord or isStorageRecord. They now import isRecord from src/domain/isRecord.ts, whose overloads keep JsonValue narrowing for the desktop and environment readers. DshCordisPatch's predicate also excludes Cordis expressions, so it is renamed isPatchRecord. architecture-guards refuses a new local copy under any name. The seven files that still hold one are listed and must leave the list when their copy goes. * fix(guards): count record predicate copies precisely * test(hopper): retry after confirmed application exit * fix: reject stale record predicate exceptions --- scripts/verify/architecture-guards.mjs | 38 +++++++++++++++++++ scripts/verify/record-predicate-guard.d.mts | 4 ++ scripts/verify/record-predicate-guard.mjs | 13 +++++++ src/application/ClientConfigurationFiles.ts | 13 +++---- src/application/DshCordisPatch.ts | 13 ++++--- .../desktop/DesktopApplicationExport.ts | 6 +-- .../environment/environmentCompareDiff.ts | 10 ++--- .../environment/environmentCompareIds.ts | 22 +++++------ src/browser/CdpCaptureValues.ts | 4 +- src/browser/CdpConnection.ts | 4 +- src/browser/ElectronFileScope.ts | 4 +- src/browser/protocol/CdpFrameTree.ts | 4 +- src/cli/jsonLines.ts | 4 +- src/cliLogging.ts | 4 +- src/contracts/advertisedDialect.ts | 4 +- src/contracts/toolSchemaMetadata.ts | 17 +++------ .../analysisView/environmentStateCounters.ts | 8 ++-- src/domain/browserScenarioNormalization.ts | 6 +-- src/domain/desktop/appTaskChecks.ts | 6 +-- src/domain/desktop/appTaskDraft.ts | 6 +-- src/domain/desktop/appTaskDraftAdded.ts | 6 +-- src/domain/desktop/appTaskDraftJson.ts | 6 +-- src/domain/desktop/appTaskDraftNamed.ts | 6 +-- src/domain/desktop/appTaskDraftNoise.ts | 6 +-- src/domain/desktop/appTaskDraftRows.ts | 6 +-- src/domain/desktop/appTaskDraftText.ts | 6 +-- src/domain/desktop/appTaskDraftTree.ts | 6 +-- src/domain/desktop/appTaskDraftValues.ts | 6 +-- src/domain/desktop/compactValue.ts | 6 +-- src/domain/desktop/jsonRecordPairing.ts | 6 +-- src/domain/desktop/memberPairing.ts | 6 +-- src/domain/desktop/positionalKeys.ts | 6 +-- src/domain/desktop/seedScrub.ts | 6 +-- src/domain/desktop/selectorCandidates.ts | 10 ++--- src/domain/desktop/stateChanges.ts | 6 +-- src/domain/desktop/viewPointer.ts | 6 +-- src/domain/desktop/zipView.ts | 6 +-- src/domain/isRecord.ts | 13 +++++++ src/domain/jsonShape.ts | 4 +- src/domain/jsonValue.ts | 3 +- src/domain/sourceMapEnvelope.ts | 4 +- src/mcpDoctor.ts | 4 +- src/pcap/TsharkJsonPackets.ts | 10 ++--- src/server/EvidenceMcpServerCore.ts | 4 +- .../environment/environmentDataPlane.ts | 4 +- src/server/environment/serveEnvironment.ts | 4 +- src/server/environment/stateIntake.ts | 4 +- src/server/mcpClientMetadata.ts | 4 +- tests/evaluation/recordPredicateGuard.test.ts | 19 ++++++++++ 49 files changed, 168 insertions(+), 205 deletions(-) create mode 100644 scripts/verify/record-predicate-guard.d.mts create mode 100644 scripts/verify/record-predicate-guard.mjs create mode 100644 src/domain/isRecord.ts create mode 100644 tests/evaluation/recordPredicateGuard.test.ts diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 44de75689c..8b49ffd3c6 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { readFile, readdir, writeFile } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { recordPredicateCounts } from "./record-predicate-guard.mjs"; import { compareCountedBaseline, recordLoweredBaseline, @@ -167,6 +168,43 @@ check( ], ); +// "Non-null object other than an array" has one owner: src/domain/isRecord.ts. +// A local predicate with that body, under any name, is a copy. The listed files +// still hold one and may only be removed from the list; a listed file that no +// longer matches fails so the list shrinks with the code. +const RECORD_PREDICATE_COPIES = [ + // Exported under a module-specific name; importers still use that name. + "src/browser/history/CaptureRedaction.ts", + "src/contracts/compactJsonSchema.ts", + "src/domain/analysisView/environmentStateRules.ts", + // Owned by the advertised-schema workstream. + "src/contracts/inputSchemaPresentation.ts", + // Narrows a protocol-specific parameter type, not unknown. + "src/browser/execution/CdpRuntimeInitiator.ts", + // Named `object`, which also spells the typeof result in these files. + "src/pcap/PcapDnsWire.ts", + "src/pcap/PcapOutput.ts", +]; +const mismatchedRecordCopies = files.filter(({ path, content }) => { + if (path === "src/domain/isRecord.ts") return false; + const { named, bodies } = recordPredicateCounts(content); + return ( + named !== 0 || bodies !== (RECORD_PREDICATE_COPIES.includes(path) ? 1 : 0) + ); +}); +const pathsWithRecordCopies = new Set(files.map(({ path }) => path)); +const missingRecordCopies = RECORD_PREDICATE_COPIES.filter( + (path) => !pathsWithRecordCopies.has(path), +); +if (mismatchedRecordCopies.length > 0) + failures.push( + `local non-array record predicate count changed (import isRecord and update the baseline):\n${mismatchedRecordCopies.map(({ path }) => ` ${path}`).join("\n")}`, + ); +if (missingRecordCopies.length > 0) + failures.push( + `record predicate baseline references missing files; remove those entries:\n${missingRecordCopies.map((path) => ` ${path}`).join("\n")}`, + ); + // Node clamps a timer delay above the signed 32-bit bound to 1 ms. Durations // use MAX_NODE_TIMER_DELAY_MS or timerDurationMsSchema from their one owner. // The allowed files bound signed 32-bit integers that are not timer delays. diff --git a/scripts/verify/record-predicate-guard.d.mts b/scripts/verify/record-predicate-guard.d.mts new file mode 100644 index 0000000000..f57c6b3de6 --- /dev/null +++ b/scripts/verify/record-predicate-guard.d.mts @@ -0,0 +1,4 @@ +export function recordPredicateCounts(content: string): { + named: number; + bodies: number; +}; diff --git a/scripts/verify/record-predicate-guard.mjs b/scripts/verify/record-predicate-guard.mjs new file mode 100644 index 0000000000..460558035a --- /dev/null +++ b/scripts/verify/record-predicate-guard.mjs @@ -0,0 +1,13 @@ +/** Count declarations named isRecord and declarations with the shared predicate. */ +export const recordPredicateCounts = (content) => { + const arrow = + /\bconst\s+[\w$]+\s*=\s*\(\s*([\w$]+)[^)]*\)\s*(?::[^=]+)?=>\s*typeof\s+\1\s*===\s*["']object["']\s*&&\s*\1\s*!==\s*null\s*&&\s*!Array\.isArray\(\1\)\s*(?=;|\})/gu; + const declaration = + /\bfunction\s+[\w$]+\s*\(\s*([\w$]+)[^)]*\)\s*(?::[^{]+)?\{\s*return\s+typeof\s+\1\s*===\s*["']object["']\s*&&\s*\1\s*!==\s*null\s*&&\s*!Array\.isArray\(\1\)\s*(?=;|\})/gu; + return { + named: [...content.matchAll(/\b(?:const|function)\s+isRecord\b/gu)].length, + bodies: + [...content.matchAll(arrow)].length + + [...content.matchAll(declaration)].length, + }; +}; diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts index a490aa5472..84e70f9c72 100644 --- a/src/application/ClientConfigurationFiles.ts +++ b/src/application/ClientConfigurationFiles.ts @@ -14,6 +14,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j import type { ConfigurationError } from "../domain/configurationErrors.js"; import { mergeEnvironmentOverrides } from "../process/snapshotEnvironment.js"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { isRecord } from "../domain/isRecord.js"; /** A parsed layer retains its path so rereading the write target replaces only that layer. */ export interface ClientConfigurationSource extends ClientConfigurationDocument { @@ -167,7 +168,7 @@ export const readClientConfigurationFiles = async ( /** OpenCode merges server objects in file order, replacing arrays and scalar values. */ const mergeConfigurationValue = (previous: unknown, next: unknown): unknown => { - if (!isObject(previous) || !isObject(next)) return next; + if (!isRecord(previous) || !isRecord(next)) return next; return Object.fromEntries( [...new Set([...Object.keys(previous), ...Object.keys(next)])].map( (key) => [ @@ -179,8 +180,6 @@ const mergeConfigurationValue = (previous: unknown, next: unknown): unknown => { ), ); }; -const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); /** Native OpenCode dialect applies across all selected configuration layers. */ export const clientWriteDialect = ( @@ -260,12 +259,12 @@ export const mergeClientConfigurationDocuments = ( const key = document.environmentKey; if ( name === PRODUCT_IDENTITY.mcpServerKey && - isObject(merged) && - isObject(merged[key]) + isRecord(merged) && + isRecord(merged[key]) ) { const before = - isObject(previous) && isObject(previous[key]) ? previous[key] : {}; - const after = isObject(next) && isObject(next[key]) ? next[key] : {}; + isRecord(previous) && isRecord(previous[key]) ? previous[key] : {}; + const after = isRecord(next) && isRecord(next[key]) ? next[key] : {}; servers[name] = { ...merged, [key]: mergeEnvironmentOverrides(before, after, platform), diff --git a/src/application/DshCordisPatch.ts b/src/application/DshCordisPatch.ts index ab1f0cf065..07321a4548 100644 --- a/src/application/DshCordisPatch.ts +++ b/src/application/DshCordisPatch.ts @@ -11,7 +11,7 @@ export const DSH_ENTRY_ID = "rea-mcp"; export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client"; const LAUNCHER_KEYS = new Set(["serverName", "transport", "command", "args"]); const SELECTOR_KEYS = new Set(["id", "name"]); -const isRecord = (value: unknown): value is Record => +const isPatchRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) && @@ -133,7 +133,8 @@ export class DshCordisDocument { : undefined; const config = this.#field("config"); // Ownership survives malformed config so uninstall can remove the source row. - this.config = insertions === 0 ? undefined : isRecord(config) ? config : {}; + this.config = + insertions === 0 ? undefined : isPatchRecord(config) ? config : {}; } #map(node: unknown): YAMLMap | undefined { @@ -209,7 +210,7 @@ export class DshCordisDocument { }, ), ); - if (config !== undefined && !isRecord(config)) + if (config !== undefined && !isPatchRecord(config)) return err( new ConfigurationError("Cordis REA config must be a mapping", { settings: [ @@ -221,7 +222,7 @@ export class DshCordisDocument { }), ); const raw = this.config?.env; - if (raw !== undefined && !isRecord(raw)) + if (raw !== undefined && !isPatchRecord(raw)) return err( new ConfigurationError("Cordis server environment must be a mapping"), ); @@ -268,7 +269,7 @@ export class DshCordisDocument { const environment = this.environment(overrides); if (!environment.ok) return environment; const env = mergeEnvironmentOverrides( - isRecord(this.config?.env) ? this.config.env : {}, + isPatchRecord(this.config?.env) ? this.config.env : {}, overrides, ); const desired = { ...this.config, ...dshClientRegistrationEntry(command) }; @@ -378,7 +379,7 @@ export class DshCordisDocument { this.#yaml.setValue(config, key, desired[key]); const env = this.#map(this.#yaml.field(sourceConfig, "env")); const desiredEnv = desired.env; - if (isRecord(desiredEnv)) { + if (isPatchRecord(desiredEnv)) { const nextEnv = env === undefined ? this.#yaml.document.createNode({}) diff --git a/src/application/desktop/DesktopApplicationExport.ts b/src/application/desktop/DesktopApplicationExport.ts index b0d6a05c18..6f97d14dda 100644 --- a/src/application/desktop/DesktopApplicationExport.ts +++ b/src/application/desktop/DesktopApplicationExport.ts @@ -78,6 +78,7 @@ import { type TaskSummary, } from "./DesktopTaskExport.js"; import { taskReads } from "./DesktopExportReaders.js"; +import { isRecord } from "../../domain/isRecord.js"; const OPERATION = "export_desktop_application"; /** Largest seed file the export copies; the state view reads only 64 MiB. */ @@ -136,11 +137,6 @@ export interface DesktopExportResult { readonly consented_files: readonly string[]; } -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** The application's dconf groups at the seed, as a dconf key file with the identity scrubbed from its strings. */ const seedDconf = ( state: DesktopApplicationState, diff --git a/src/application/environment/environmentCompareDiff.ts b/src/application/environment/environmentCompareDiff.ts index a596f133eb..ee022c9003 100644 --- a/src/application/environment/environmentCompareDiff.ts +++ b/src/application/environment/environmentCompareDiff.ts @@ -23,6 +23,7 @@ import { translateValue, type IdMap, } from "./environmentCompareIds.js"; +import { isRecord } from "../../domain/isRecord.js"; export const DIFFERENCE_CATEGORIES = [ "status", @@ -67,9 +68,6 @@ const TIME_FIELD_CAMEL = /(?:At|Time|Date|Timestamp|Created|Updated|Modified)$/u; const ID_LIST_FIELD = /^(ids|uuids|guids)$/u; -const isObject = (value: JsonValue | undefined): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value); - const shown = (value: JsonValue | undefined): string => { const text = value === undefined ? "" : JSON.stringify(value); return text.length > VALUE_CHARS ? `${text.slice(0, VALUE_CHARS)}…` : text; @@ -517,7 +515,7 @@ class Differ { ): void { if (Array.isArray(live) && Array.isArray(env)) this.array(live, env, pointer, at.clockKey ?? at.key, generation); - else if (isObject(live) && isObject(env)) + else if (isRecord(live) && isRecord(env)) this.object( live, env, @@ -528,8 +526,8 @@ class Differ { else if ( Array.isArray(live) || Array.isArray(env) || - isObject(live) || - isObject(env) + isRecord(live) || + isRecord(env) ) this.add({ category: "value", diff --git a/src/application/environment/environmentCompareIds.ts b/src/application/environment/environmentCompareIds.ts index 8862fdb512..cb086f0be7 100644 --- a/src/application/environment/environmentCompareIds.ts +++ b/src/application/environment/environmentCompareIds.ts @@ -4,13 +4,11 @@ import type { BodyAddress } from "../../domain/analysisView/environmentStateWrit import { isUuid } from "../../domain/analysisView/environmentStateWriteValues.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import type { ProbeToolCall } from "./environmentCompareMatch.js"; +import { isRecord } from "../../domain/isRecord.js"; /** Field names that hold an identifier: `id`, `uuid`, `guid`, or names ending in `id` or `_id`. */ export const ID_FIELD = /^(id|uuid|guid)$|(_id|Id|ID|_uuid|Uuid)$/u; -const isObject = (value: JsonValue | undefined): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value); - const isScalarId = (value: JsonValue | undefined): value is string | number => (typeof value === "string" && value !== "") || (typeof value === "number" && Number.isFinite(value)); @@ -179,7 +177,7 @@ const pairing = ( ), by, }); - if (!live.every(isObject) || !env.every(isObject)) + if (!live.every(isRecord) || !env.every(isRecord)) return byPosition("position"); const liveItems = live as readonly JsonObject[]; const envItems = env as readonly JsonObject[]; @@ -218,7 +216,7 @@ const keyedByIds = ( ([key, value]) => /^\d+$/u.test(key) || isUuid(key) || - (isObject(value) && + (isRecord(value) && Object.entries(value).some( ([field, held]) => IDENTITY_FIELD.test(field) && @@ -237,7 +235,7 @@ export const keyedByLearnedIds = ( if (!entries.some(([key]) => map.hasLive(key))) return false; return entries.some(([key]) => Object.hasOwn(env, key)) ? keyedByIds(entries) - : entries.every(([, value]) => isObject(value)) || keyedByIds(entries); + : entries.every(([, value]) => isRecord(value)) || keyedByIds(entries); }; /** @@ -258,8 +256,8 @@ const learnKeyedEntries = ( liveEntries.length === 0 || envEntries.length === 0 || shared === liveEntries.length || - !liveEntries.every(([, value]) => isObject(value)) || - !envEntries.every(([, value]) => isObject(value)) + !liveEntries.every(([, value]) => isRecord(value)) || + !envEntries.every(([, value]) => isRecord(value)) ) return false; const paired = pairing( @@ -340,7 +338,7 @@ const learnAt = ( }); return; } - if (!isObject(live) || !isObject(env)) { + if (!isRecord(live) || !isRecord(env)) { if ( context.identity && place.at === context.entityAt && @@ -423,7 +421,7 @@ const inEnvTerms = ( export const translateValue = (value: JsonValue, map: IdMap): JsonValue => { if (Array.isArray(value)) return value.map((item) => translateValue(item, map)); - if (isObject(value)) + if (isRecord(value)) return Object.fromEntries( Object.entries(value).map(([key, item]) => [ key, @@ -455,7 +453,7 @@ const translateBulkBody = ( ? value.map((entry) => address.by === "ids" ? id(entry) - : isObject(entry) && Object.hasOwn(entry, idField) + : isRecord(entry) && Object.hasOwn(entry, idField) ? { ...(translateValue(entry, map) as JsonObject), [idField]: id(entry[idField]!), @@ -464,7 +462,7 @@ const translateBulkBody = ( ) : translateValue(value, map); if (address.member === null) return entries(body); - return isObject(body) + return isRecord(body) ? Object.fromEntries( Object.entries(body).map(([key, value]) => [ key, diff --git a/src/browser/CdpCaptureValues.ts b/src/browser/CdpCaptureValues.ts index f40c1eeb67..3ff6f4a652 100644 --- a/src/browser/CdpCaptureValues.ts +++ b/src/browser/CdpCaptureValues.ts @@ -4,12 +4,10 @@ import type { BrowserObservationOperation } from "../domain/browserObservationEr import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { createWebTextArtifact } from "../domain/webContentArtifact.js"; import { err, ok, type Result } from "../domain/result.js"; +import { isRecord } from "../domain/isRecord.js"; export type UnknownRecord = Readonly>; -const isRecord = (value: unknown): value is UnknownRecord => - typeof value === "object" && value !== null && !Array.isArray(value); - export const recordValue = (value: unknown): UnknownRecord | undefined => isRecord(value) ? value : undefined; diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index 68bbac315c..6f908805ca 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -23,6 +23,7 @@ import { } from "../domain/browserDeadline.js"; import { safeParseJson } from "../domain/safeJson.js"; import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; +import { isRecord } from "../domain/isRecord.js"; // ws currently stores maxPayload as a signed 32-bit integer. Larger values can // wrap to a negative number, which disables its limit check. @@ -625,9 +626,6 @@ const rawBytes = (data: RawData): Buffer => ? data : Buffer.from(data); -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - const waitForOpen = async ( socket: WebSocket, operation: BrowserObservationOperation, diff --git a/src/browser/ElectronFileScope.ts b/src/browser/ElectronFileScope.ts index 29ba753b41..ba4e074c19 100644 --- a/src/browser/ElectronFileScope.ts +++ b/src/browser/ElectronFileScope.ts @@ -31,6 +31,7 @@ import { DEFAULT_ASAR_HEADER_ADMISSION, type AsarHeaderAdmission, } from "../config/asarHeaderAdmission.js"; +import { isRecord } from "../domain/isRecord.js"; type ElectronFileOperation = | "inspect_electron_page" @@ -374,9 +375,6 @@ const isAsarRegularFile = ( ); }; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - const throwIfAborted = (signal?: AbortSignal): void => { assertArtifactReadActive(signal); }; diff --git a/src/browser/protocol/CdpFrameTree.ts b/src/browser/protocol/CdpFrameTree.ts index b71060c0f1..c197d3e957 100644 --- a/src/browser/protocol/CdpFrameTree.ts +++ b/src/browser/protocol/CdpFrameTree.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isRecord } from "../../domain/isRecord.js"; import { observeCdpFrameUrl, @@ -59,9 +60,6 @@ interface MutableNode extends CdpFrameTreeNode { readonly children: CdpFrameTreeNode[]; } -const isRecord = (value: unknown): value is UnknownRecord => - recordValue(value) !== undefined; - const treeRecord = z.custom(isRecord, { message: "Expected a frame tree object.", }); diff --git a/src/cli/jsonLines.ts b/src/cli/jsonLines.ts index aae88418e1..4e7ca008ee 100644 --- a/src/cli/jsonLines.ts +++ b/src/cli/jsonLines.ts @@ -4,6 +4,7 @@ import { err, ok, type Result } from "../domain/result.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { jsonParts } from "../domain/jsonSerialization.js"; +import { isRecord } from "../domain/isRecord.js"; /** Reserved control-record marker, not an advertised external JSON Schema. */ export const JSON_LINES_PROTOCOL = "jsonl/v1"; @@ -56,9 +57,6 @@ export interface JsonLinesPlan extends Collection { readonly filter: string | undefined; } -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - const atPath = (value: unknown, path: readonly string[]): unknown => { for (const key of path) { if (!isRecord(value) || !Object.hasOwn(value, key)) return undefined; diff --git a/src/cliLogging.ts b/src/cliLogging.ts index e2f16ddd0b..88002ef867 100644 --- a/src/cliLogging.ts +++ b/src/cliLogging.ts @@ -8,9 +8,7 @@ import { type AnalysisErrorProjection, } from "./domain/analysisErrorProjection.js"; import type { Logger } from "pino"; - -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); +import { isRecord } from "./domain/isRecord.js"; type CliFailureIdentity = Pick< AnalysisErrorProjection, diff --git a/src/contracts/advertisedDialect.ts b/src/contracts/advertisedDialect.ts index baf30a4aee..aa1a160c11 100644 --- a/src/contracts/advertisedDialect.ts +++ b/src/contracts/advertisedDialect.ts @@ -3,6 +3,7 @@ import { schemaDepth, serializedBytes, } from "./compactJsonSchema.js"; +import { isRecord } from "../domain/isRecord.js"; import { schemaArrays, schemaChildren, @@ -335,9 +336,6 @@ export const portablePatternViolations = ( type SchemaKind = "input" | "output"; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - /** Visit every schema position; instance literals stay opaque. */ const visitSchemas = ( schema: unknown, diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index 02330d0c49..27344a19d0 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -15,6 +15,7 @@ import { } from "./jsonSchemaKeywords.js"; import { digestCanonicalValue } from "../domain/canonicalDigest.js"; import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; +import { isRecord } from "../domain/isRecord.js"; const PROPERTY_DESCRIPTIONS: Readonly> = { addresses: "Ordered provider-normalized procedure addresses to analyze.", @@ -171,9 +172,9 @@ export const transformAdvertisedInputJsonSchema = ( transform: (projected: Record) => Record, ): Schema => { const standard = Reflect.get(schema as object, "~standard"); - if (!isSchemaRecord(standard)) return schema; + if (!isRecord(standard)) return schema; const jsonSchema = Reflect.get(standard, "jsonSchema"); - if (!isSchemaRecord(jsonSchema)) return schema; + if (!isRecord(jsonSchema)) return schema; const project = Reflect.get(jsonSchema, "input"); if (typeof project !== "function") return schema; const byTarget = new Map>(); @@ -187,11 +188,8 @@ export const transformAdvertisedInputJsonSchema = ( if (cached !== undefined) return cached; } const projected = (project as (options: unknown) => unknown)(options); - const transformed = isSchemaRecord(projected) - ? transform(projected) - : projected; - if (cacheable && isSchemaRecord(transformed)) - byTarget.set(target, transformed); + const transformed = isRecord(projected) ? transform(projected) : projected; + if (cacheable && isRecord(transformed)) byTarget.set(target, transformed); return transformed; }; return { @@ -226,7 +224,7 @@ const hasRelativeSchemaReference = ( if (current === null || typeof current !== "object") continue; const { position } = entry; const scope = - position !== "map" && isSchemaRecord(current) + position !== "map" && isRecord(current) ? references.enter(current, entry.scope) : entry.scope; if (scope === undefined) return true; @@ -322,9 +320,6 @@ const memoizeByTarget = ( }; }; -const isSchemaRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - const fallbackPropertyDescription = (property: string): string | undefined => { const explicit = Object.hasOwn(PROPERTY_DESCRIPTIONS, property) ? PROPERTY_DESCRIPTIONS[property] diff --git a/src/domain/analysisView/environmentStateCounters.ts b/src/domain/analysisView/environmentStateCounters.ts index bd2764c7fc..e41f08aab4 100644 --- a/src/domain/analysisView/environmentStateCounters.ts +++ b/src/domain/analysisView/environmentStateCounters.ts @@ -1,11 +1,9 @@ import { jsonPointerTokens } from "../jsonPointer.js"; import type { JsonValue } from "../jsonValue.js"; +import { isRecord } from "../isRecord.js"; type JsonObject = { [key: string]: JsonValue }; -const isObject = (value: JsonValue | undefined): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value); - /** A list counter's member names: a top-level member's name not starting with `/`, else a pointer (`/pageInfo/totalRows`, `/~1total`). */ export const counterPath = (counter: string): string[] => counter.startsWith("/") ? jsonPointerTokens(counter) : [counter]; @@ -17,7 +15,7 @@ export const counterAt = ( ): JsonValue | undefined => counterPath(counter).reduce( (value, name) => - isObject(value) && Object.hasOwn(value, name) ? value[name] : undefined, + isRecord(value) && Object.hasOwn(value, name) ? value[name] : undefined, body, ); @@ -30,7 +28,7 @@ export const withCounter = ( const set = (node: JsonValue, path: readonly string[]): JsonValue => { const [name, ...rest] = path; if (name === undefined) return value; - if (!isObject(node) || !Object.hasOwn(node, name)) return node; + if (!isRecord(node) || !Object.hasOwn(node, name)) return node; return { ...node, [name]: set(node[name]!, rest) }; }; return set(body, counterPath(counter)); diff --git a/src/domain/browserScenarioNormalization.ts b/src/domain/browserScenarioNormalization.ts index 5aa3f070b3..f76d9ee201 100644 --- a/src/domain/browserScenarioNormalization.ts +++ b/src/domain/browserScenarioNormalization.ts @@ -8,6 +8,7 @@ import { type BrowserScenarioNormalization, type BrowserScenarioNormalizationRule, } from "./browserScenarioDiffValues.js"; +import { isRecord } from "./isRecord.js"; /** Canonically order and commit a parsed browser scenario normalization policy. */ export const commitBrowserScenarioNormalization = ( @@ -78,7 +79,7 @@ export const normalizeScenarioValue = ( /** Compare parsed storage records by their fixed fields without serializing them. */ const compareStorageEntries = (left: unknown, right: unknown): number => { - if (!isStorageRecord(left) || !isStorageRecord(right)) + if (!isRecord(left) || !isRecord(right)) return compareUnicodeCodePoints( digestCanonicalValue(left, "Browser scenario"), digestCanonicalValue(right, "Browser scenario"), @@ -103,9 +104,6 @@ const compareStorageEntries = (left: unknown, right: unknown): number => { return 0; }; -const isStorageRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - const compareStorageField = (left: unknown, right: unknown): number => { if (left === right) return 0; if (left === null) return -1; diff --git a/src/domain/desktop/appTaskChecks.ts b/src/domain/desktop/appTaskChecks.ts index 4ce6056938..bf43c5976b 100644 --- a/src/domain/desktop/appTaskChecks.ts +++ b/src/domain/desktop/appTaskChecks.ts @@ -22,6 +22,7 @@ import { zipEntryAt, zipLeavesOutAt, } from "./zipView.js"; +import { isRecord } from "../isRecord.js"; /** One file as the app's data/get reports it: its view, or why it has none. An absent file has no entry. */ export interface TaskFileView { @@ -83,11 +84,6 @@ const undecided = (file: string): Verdict => const notFound = (description: string, cut: boolean): Verdict => cut ? unread(description) : fail(description); -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** JSON equality: numbers by value, objects by their keys, arrays in order. */ export const sameJson = ( actual: JsonValue | undefined, diff --git a/src/domain/desktop/appTaskDraft.ts b/src/domain/desktop/appTaskDraft.ts index 7f105c55d3..99bf0c3139 100644 --- a/src/domain/desktop/appTaskDraft.ts +++ b/src/domain/desktop/appTaskDraft.ts @@ -35,6 +35,7 @@ import { import { diffNamedMembers, diffStateViews } from "./stateChanges.js"; import { DRAFT_VIEW_CHARS } from "./valueCuts.js"; import { resolvePointer } from "./viewPointer.js"; +import { isRecord } from "../isRecord.js"; type Version = DesktopStatePath["versions"][number]; type Suggestion = DesktopTaskCheckInput; @@ -52,11 +53,6 @@ const versions = (baseline: Version, final: Version) => ({ final: final.view, }); -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** A JSON value cut to a size an agent can read, and whether anything was cut. */ const bounded = ( value: JsonValue | null, diff --git a/src/domain/desktop/appTaskDraftAdded.ts b/src/domain/desktop/appTaskDraftAdded.ts index 8539a89afe..4719b23136 100644 --- a/src/domain/desktop/appTaskDraftAdded.ts +++ b/src/domain/desktop/appTaskDraftAdded.ts @@ -22,6 +22,7 @@ import { import { pairMembers } from "./memberPairing.js"; import { elementText, isXmlElement, resolvePointer } from "./viewPointer.js"; import { selectorOf } from "./viewSelector.js"; +import { isRecord } from "../isRecord.js"; type Suggestion = DesktopTaskCheckInput; @@ -68,11 +69,6 @@ type Element = Extract> & { readonly children: JsonValue[]; }; -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - const attributesOf = (element: Element) => isRecord(element.attributes) ? element.attributes : {}; diff --git a/src/domain/desktop/appTaskDraftJson.ts b/src/domain/desktop/appTaskDraftJson.ts index d032d6bc89..efb4159c07 100644 --- a/src/domain/desktop/appTaskDraftJson.ts +++ b/src/domain/desktop/appTaskDraftJson.ts @@ -31,6 +31,7 @@ const locatePointer = (view: JsonValue | null, pointer: string) => const resolvePointer = (view: JsonValue | null, pointer: string) => resolveView(view, pointer, "json"); import { selectorOf, spellCondition } from "./viewSelector.js"; +import { isRecord } from "../isRecord.js"; type Suggestion = DesktopTaskCheckInput; @@ -182,11 +183,6 @@ export const ownNamesUnreadOtherwise = ( return kept; }; -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** The array a member pointer sits in, when its parent is one in a view. The empty pointer is the view itself, as a file whose top level is an array. */ const arrayOf = (view: JsonValue | null, pointer: string) => { const list = pointer.replace(/\/[^/]+$/u, ""); diff --git a/src/domain/desktop/appTaskDraftNamed.ts b/src/domain/desktop/appTaskDraftNamed.ts index fac9b92826..6d78fa5b7f 100644 --- a/src/domain/desktop/appTaskDraftNamed.ts +++ b/src/domain/desktop/appTaskDraftNamed.ts @@ -49,14 +49,10 @@ import { valueFormat, type CheckTree, } from "./appTaskDraftTree.js"; +import { isRecord } from "../isRecord.js"; type Suggestion = DesktopTaskCheckInput; -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** Pointers without a listed descendant: a container listed with its members is suggested through them. */ export const innermost = (pointers: readonly string[]): readonly string[] => pointers.filter( diff --git a/src/domain/desktop/appTaskDraftNoise.ts b/src/domain/desktop/appTaskDraftNoise.ts index c5e83a8a9c..1198219329 100644 --- a/src/domain/desktop/appTaskDraftNoise.ts +++ b/src/domain/desktop/appTaskDraftNoise.ts @@ -13,6 +13,7 @@ import { idReferencesOf, mirrorsOf } from "./appTaskDraftMirrors.js"; import { isPositionalKey, positionalKeys } from "./positionalKeys.js"; import type { DesktopStateDifference } from "./stateChanges.js"; import { elementText, isXmlElement, resolvePointer } from "./viewPointer.js"; +import { isRecord } from "../isRecord.js"; /** Why a change is what an app writes as it saves or shows a document, rather than what its user made. */ export const NOISE_REASONS = [ @@ -138,11 +139,6 @@ type Element = Record & { children: JsonValue[]; }; -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** The elements a pointer steps into through `children`, read from the view, outermost first. */ const elementsAlong = ( view: JsonValue | null, diff --git a/src/domain/desktop/appTaskDraftRows.ts b/src/domain/desktop/appTaskDraftRows.ts index ac05aecde8..3cc22ca10a 100644 --- a/src/domain/desktop/appTaskDraftRows.ts +++ b/src/domain/desktop/appTaskDraftRows.ts @@ -4,15 +4,11 @@ import { countTaskRows } from "./appTaskChecks.js"; import type { DesktopStatePath } from "./desktopState.js"; import { sqliteRowIdentity } from "./stateChanges.js"; import { appendAll } from "../appendAll.js"; +import { isRecord } from "../isRecord.js"; type Version = DesktopStatePath["versions"][number]; type Suggestion = DesktopTaskCheckInput; -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - interface TableView { readonly columns: readonly string[]; readonly key: readonly string[]; diff --git a/src/domain/desktop/appTaskDraftText.ts b/src/domain/desktop/appTaskDraftText.ts index 19d250d5bf..2b5da9b1ff 100644 --- a/src/domain/desktop/appTaskDraftText.ts +++ b/src/domain/desktop/appTaskDraftText.ts @@ -12,6 +12,7 @@ import { valueEqualsCheck } from "./appTaskDraftScalars.js"; import { rankAttributes } from "./attributeRank.js"; import type { DesktopStateDifference } from "./stateChanges.js"; import { elementText, isXmlElement, resolvePointer } from "./viewPointer.js"; +import { isRecord } from "../isRecord.js"; /** The pointers a diff lists, of every kind. */ type Listed = Pick; @@ -79,11 +80,6 @@ const parentBelowRoot = (pointer: string): string | undefined => { const namedByAttribute = (pointer: string) => (pointer.split("/").at(-1) ?? "").includes("[@"); -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** An element's attributes; none for any other value. */ const attributesOf = (value: JsonValue | undefined) => isXmlElement(value) && isRecord(value.attributes) ? value.attributes : {}; diff --git a/src/domain/desktop/appTaskDraftTree.ts b/src/domain/desktop/appTaskDraftTree.ts index 6c617fa66b..fc8fa1dffc 100644 --- a/src/domain/desktop/appTaskDraftTree.ts +++ b/src/domain/desktop/appTaskDraftTree.ts @@ -10,11 +10,7 @@ import { zipLeavesOutAt, zipView, } from "./zipView.js"; - -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +import { isRecord } from "../isRecord.js"; /** * The entry a pointer into a zip view goes into, when it has a view and both diff --git a/src/domain/desktop/appTaskDraftValues.ts b/src/domain/desktop/appTaskDraftValues.ts index e761b0f873..794574c045 100644 --- a/src/domain/desktop/appTaskDraftValues.ts +++ b/src/domain/desktop/appTaskDraftValues.ts @@ -16,6 +16,7 @@ import { holdsFields, isRecordAt } from "./appTaskDraftText.js"; import { attributesOf, compactValue, previewWriter } from "./compactValue.js"; import { pairMembers, pairedAtSeed } from "./memberPairing.js"; import { isXmlElement, locatePointer, resolvePointer } from "./viewPointer.js"; +import { isRecord } from "../isRecord.js"; /** Why a value's pointer cannot go into a check as it is, or why the value is noise. */ export const VALUE_NOTES = ["by_position", ...NOISE_REASONS] as const; @@ -91,11 +92,6 @@ export const VALUE_LIMITS = { references: 5, } as const; -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** The longest value a reference is resolved from. */ const MAX_REFERENCE_CHARS = 64; diff --git a/src/domain/desktop/compactValue.ts b/src/domain/desktop/compactValue.ts index 1e4aabb28d..c9deba5c12 100644 --- a/src/domain/desktop/compactValue.ts +++ b/src/domain/desktop/compactValue.ts @@ -1,10 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { isXmlElement } from "./viewPointer.js"; - -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +import { isRecord } from "../isRecord.js"; export const cut = (text: string, max: number) => text.length > max ? `${text.slice(0, max)}…` : text; diff --git a/src/domain/desktop/jsonRecordPairing.ts b/src/domain/desktop/jsonRecordPairing.ts index fee9ba999e..6917e08a0f 100644 --- a/src/domain/desktop/jsonRecordPairing.ts +++ b/src/domain/desktop/jsonRecordPairing.ts @@ -6,6 +6,7 @@ import { isPlaceholder, } from "./attributeRank.js"; import { MAX_INSIDE_STEPS, spellCondition } from "./viewSelector.js"; +import { isRecord } from "../isRecord.js"; /** A field naming a JSON record, as TreeLine's `uid`: `id`, `uid`, `uuid`, `guid`, `key`, or a name ending in `_id`, `-id` or `Id`. */ const ID_FIELD = /^(?:_?id|uid|uuid|guid|key)$|[_-]id$|[a-z]Id$/u; @@ -23,11 +24,6 @@ const MAX_NAME = 64; /** The most kind fields and paths a version's records are counted by, each a pass over them, so odd records cost no more. */ const MAX_TALLIES = 64; -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** Whether every member holds a string at a key a selector can carry, each its own. */ const namesEach = (members: readonly JsonValue[], key: string) => { const values = members.map((member) => diff --git a/src/domain/desktop/memberPairing.ts b/src/domain/desktop/memberPairing.ts index 3535975e5f..530ef8ba69 100644 --- a/src/domain/desktop/memberPairing.ts +++ b/src/domain/desktop/memberPairing.ts @@ -28,11 +28,7 @@ import { import { pairRecordsById } from "./jsonRecordPairing.js"; import { resolvePointer } from "./viewPointer.js"; import { selectorOf } from "./viewSelector.js"; - -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +import { isRecord } from "../isRecord.js"; const own = (record: Record, key: string) => Object.hasOwn(record, key) ? record[key] : undefined; diff --git a/src/domain/desktop/positionalKeys.ts b/src/domain/desktop/positionalKeys.ts index 901cce4cfe..55435318be 100644 --- a/src/domain/desktop/positionalKeys.ts +++ b/src/domain/desktop/positionalKeys.ts @@ -1,10 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { isIdentifyingAttribute } from "./attributeRank.js"; - -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +import { isRecord } from "../isRecord.js"; /** An identifier of the members with one tag, as `tag` NUL `key`. */ const tagKey = (tag: string, key: string) => `${tag}\u0000${key}`; diff --git a/src/domain/desktop/seedScrub.ts b/src/domain/desktop/seedScrub.ts index 8e6597c354..246ac3cce1 100644 --- a/src/domain/desktop/seedScrub.ts +++ b/src/domain/desktop/seedScrub.ts @@ -16,6 +16,7 @@ import { isTextualFormat, textSyntaxFormat, } from "./stateFormats.js"; +import { isRecord } from "../isRecord.js"; /** What the export ships for one seed. */ export interface SeedScrub { @@ -291,11 +292,6 @@ const scrubGVariant = ( return { text: scrubbed, decoded: [scrubbed, ...strings].join("\n") }; }; -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** * A dconf view's groups as the key file the computer compiles, each value's * strings scrubbed, and whether it still holds the identity, strings decoded. diff --git a/src/domain/desktop/selectorCandidates.ts b/src/domain/desktop/selectorCandidates.ts index 946026d3d0..7a5a74751e 100644 --- a/src/domain/desktop/selectorCandidates.ts +++ b/src/domain/desktop/selectorCandidates.ts @@ -10,11 +10,7 @@ import { rankAttributes, } from "./attributeRank.js"; import { selectorOf, spellCondition, type Condition } from "./viewSelector.js"; - -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +import { isRecord } from "../isRecord.js"; const own = (record: Record, key: string) => Object.hasOwn(record, key) ? record[key] : undefined; @@ -94,7 +90,9 @@ const stepTag = (tag: JsonValue | undefined): tag is string => /** The element children of a member, none for any other value. */ const elementsOf = (member: JsonValue): Record[] => { const children = isRecord(member) ? own(member, "children") : undefined; - return (Array.isArray(children) ? children : []).filter(isRecord); + return (Array.isArray(children) ? children : []).filter( + (child): child is Record => isRecord(child), + ); }; /** A child's text a condition could name an element by, and whether the child joins it from elements of its own. */ diff --git a/src/domain/desktop/stateChanges.ts b/src/domain/desktop/stateChanges.ts index 446aee3670..26ff175cf1 100644 --- a/src/domain/desktop/stateChanges.ts +++ b/src/domain/desktop/stateChanges.ts @@ -4,6 +4,7 @@ import type { DesktopStateFormat } from "./stateFormats.js"; import { holdsTagOnly, pairMembers } from "./memberPairing.js"; import { memberLookup } from "./viewPointer.js"; import { zipEntryAt, zipLeavesOut } from "./zipView.js"; +import { isRecord } from "../isRecord.js"; const MAX_LISTED = 100; @@ -20,11 +21,6 @@ export interface DesktopStateDifference { readonly complete: boolean; } -const isRecord = ( - value: JsonValue | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** Longest JSON pointer listed; a longer one is only counted, so deep paths never fill the lists. */ const MAX_LISTED_POINTER = 4096; diff --git a/src/domain/desktop/viewPointer.ts b/src/domain/desktop/viewPointer.ts index e3b38e9890..4ddcffce08 100644 --- a/src/domain/desktop/viewPointer.ts +++ b/src/domain/desktop/viewPointer.ts @@ -7,6 +7,7 @@ import { selectorOf, type Condition, } from "./viewSelector.js"; +import { isRecord } from "../isRecord.js"; /** The arrays a pointer stepped into, each as the pointer to it. */ interface ArraySteps { @@ -33,11 +34,6 @@ type Stepped = const INDEX = /^(?:0|[1-9][0-9]*)$/u; -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - const own = (record: Record, key: string) => Object.hasOwn(record, key) ? record[key] : undefined; diff --git a/src/domain/desktop/zipView.ts b/src/domain/desktop/zipView.ts index cfadd58f48..5880896f01 100644 --- a/src/domain/desktop/zipView.ts +++ b/src/domain/desktop/zipView.ts @@ -14,6 +14,7 @@ import { desktopViewByteDisplay, type DesktopViewPolicy, } from "./desktopViewPolicy.js"; +import { isRecord } from "../isRecord.js"; /** Why an archive has no view; the exported reader words it the same. */ export const UNREADABLE_ZIP = "not a readable zip archive"; @@ -111,11 +112,6 @@ export const zipView = ( truncated: count > entries.length, }); -const isRecord = ( - value: JsonValue | null | undefined, -): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** Whether an entry's view is missing or holds only part of it. */ const entryLeavesOut = (entry: JsonValue): boolean => isRecord(entry) && diff --git a/src/domain/isRecord.ts b/src/domain/isRecord.ts new file mode 100644 index 0000000000..f6aa4c6022 --- /dev/null +++ b/src/domain/isRecord.ts @@ -0,0 +1,13 @@ +import type { JsonValue } from "./jsonValue.js"; + +/** + * Whether a value is a non-null object other than an array. Its keys, + * prototype and property values are not inspected. + */ +export function isRecord( + value: JsonValue | null | undefined, +): value is { [key: string]: JsonValue }; +export function isRecord(value: unknown): value is Record; +export function isRecord(value: unknown): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/domain/jsonShape.ts b/src/domain/jsonShape.ts index 9f6145b420..0e0f6c5376 100644 --- a/src/domain/jsonShape.ts +++ b/src/domain/jsonShape.ts @@ -5,6 +5,7 @@ import { safeParseJson } from "./safeJson.js"; import { jsonStringByteLength } from "./jsonStringByteLength.js"; import { jsonValueByteLength } from "./jsonValueByteLength.js"; import { err, ok, type Result } from "./result.js"; +import { isRecord } from "./isRecord.js"; const jsonValueTypeSchema = z.enum([ "object", @@ -278,9 +279,6 @@ export const jsonValueType = (value: unknown): JsonValueType => { } }; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - /** Reverse sibling order for the traversal stack; array elements sort first. */ const orderedChildren = (node: ShapeNode): ShapeNode[] => [...(node.children ?? [])] diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts index 9a54dd1bf8..2453c34155 100644 --- a/src/domain/jsonValue.ts +++ b/src/domain/jsonValue.ts @@ -1,6 +1,7 @@ import { types } from "node:util"; import { z } from "zod"; import { isImmutableJsonSnapshot } from "./immutableJson.js"; +import { isRecord } from "./isRecord.js"; /** Maximum JSON nesting accepted before recursive result cloning. */ export const MAX_JSON_DEPTH = 1000; @@ -14,8 +15,6 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); const immutableValidation = new WeakMap(); /** Validate JSON meaning and nesting without cloning a trusted immutable value. */ diff --git a/src/domain/sourceMapEnvelope.ts b/src/domain/sourceMapEnvelope.ts index bacf3e048e..e2686d6c3d 100644 --- a/src/domain/sourceMapEnvelope.ts +++ b/src/domain/sourceMapEnvelope.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isRecord } from "./isRecord.js"; /** Artifact source declarations do not require generated-position decoding. */ type SourceMapRecord = Readonly>; @@ -53,9 +54,6 @@ export const sourceMapJsonText = (text: string): string => { return newline === -1 ? "" : withoutBom.slice(newline); }; -const isRecord = (value: unknown): value is SourceMapRecord => - typeof value === "object" && value !== null && !Array.isArray(value); - /** Classify section ownership once; an external URL takes precedence over map. */ export const classifySourceMapSection = ( section: unknown, diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 5a7397723b..5de84f9446 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -39,6 +39,7 @@ import { } from "./mcpStartupPolicy.js"; import { MCP_DOCTOR_ALIAS, MCP_DOCTOR_OPTIONS } from "./mcpDoctorOptions.js"; import { OwnedMcpStdioTransport } from "./process/OwnedMcpStdioTransport.js"; +import { isRecord } from "./domain/isRecord.js"; const OUTPUT_FORMATS = CLI_DOCUMENT_OUTPUT_FORMATS; type OutputFormat = (typeof OUTPUT_FORMATS)[number]; @@ -672,9 +673,6 @@ const captureStderr = () => { }; }; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); diff --git a/src/pcap/TsharkJsonPackets.ts b/src/pcap/TsharkJsonPackets.ts index 738da51967..89412efd6a 100644 --- a/src/pcap/TsharkJsonPackets.ts +++ b/src/pcap/TsharkJsonPackets.ts @@ -1,4 +1,5 @@ import { setImmediate as yieldToEventLoop } from "node:timers/promises"; +import { isRecord } from "../domain/isRecord.js"; import { err, ok, type Result } from "../domain/result.js"; /** @@ -31,9 +32,6 @@ export type TsharkJsonFailure = | { readonly kind: "json-bytes"; readonly maxBytes: number } | { readonly kind: "json-expansion"; readonly maxDecodedBytes: number }; -const object = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - const YIELD_INTERVAL = 65_536; /** @@ -96,9 +94,9 @@ export const parseTsharkJsonPackets = ( const packets: TsharkPacketLayers[] = []; for (const [index, packet] of decoded.entries()) { if ( - !object(packet) || - !object(packet._source) || - !object(packet._source.layers) + !isRecord(packet) || + !isRecord(packet._source) || + !isRecord(packet._source.layers) ) return err({ kind: "malformed-packet", diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index cd28b97e89..e8faff245f 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -122,9 +122,6 @@ const hasAdvertisedInputJsonSchema = (value: unknown): boolean => { ); }; -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - /** Recognize the SDK's argument diagnostic after its wire size is checked. */ const sdkInputValidationDiagnostic = ( text: string | undefined, @@ -289,6 +286,7 @@ export const forgetPendingMcpRequest = ( pendingCompletions.delete(id); }; import type { PromptCompletionSource } from "./promptCompletion.js"; +import { isRecord } from "../domain/isRecord.js"; /** Standard-schema registration boundary shared with deferred SDK bindings. */ export interface StandardToolRegistration { diff --git a/src/server/environment/environmentDataPlane.ts b/src/server/environment/environmentDataPlane.ts index 07ea71eef3..73610c1f28 100644 --- a/src/server/environment/environmentDataPlane.ts +++ b/src/server/environment/environmentDataPlane.ts @@ -11,6 +11,7 @@ import { type RpcError, type StateIntake, } from "./stateIntake.js"; +import { isRecord } from "../../domain/isRecord.js"; /** One data-plane method's outcome; undefined from a method table means the method is not served. */ export type RpcOutcome = @@ -24,9 +25,6 @@ export type RpcMethods = ( signal?: AbortSignal, ) => Promise; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - /** The first document problem as `-32602`, located by a JSON pointer into the document. */ const documentError = (error: AnalysisInputError): RpcError => { const issue = error.issues[0]; diff --git a/src/server/environment/serveEnvironment.ts b/src/server/environment/serveEnvironment.ts index f64a3ddc2b..1332ddab81 100644 --- a/src/server/environment/serveEnvironment.ts +++ b/src/server/environment/serveEnvironment.ts @@ -62,6 +62,7 @@ import { bufferedJsonParts, jsonParts, } from "../../domain/jsonSerialization.js"; +import { isRecord } from "../../domain/isRecord.js"; /** Where the environment card is served. */ export const ENVIRONMENT_CARD_PATH = "/.well-known/agent-env.json"; @@ -81,9 +82,6 @@ class EnvironmentMcpServer extends RequestTrackingMcpServer { } } -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - /** At most this many messages in one data-plane batch; a larger batch is refused whole. */ const MAX_RPC_BATCH_MESSAGES = 100; diff --git a/src/server/environment/stateIntake.ts b/src/server/environment/stateIntake.ts index b8b31823da..cf21e75a65 100644 --- a/src/server/environment/stateIntake.ts +++ b/src/server/environment/stateIntake.ts @@ -19,6 +19,7 @@ import { RegularFileCleanupFailure, withOwnedRegularFile, } from "../../filesystem/OwnedRegularFile.js"; +import { isRecord } from "../../domain/isRecord.js"; /** Why a named file or download could not be read, or a call was cancelled: a category naming neither the uri nor the content. */ export type IntakeFailure = @@ -125,9 +126,6 @@ const fileFailure = (cause: unknown): IntakeFailure => ? FILE_FAILURES.get(String(cause.code)) : undefined) ?? "read-failed"; -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - const MAX_INLINE_BASE64_CHARACTERS = Math.ceil(STATE_INTAKE_LIMIT_BYTES / 3) * 4; diff --git a/src/server/mcpClientMetadata.ts b/src/server/mcpClientMetadata.ts index a3ac138bf0..ec3584a8da 100644 --- a/src/server/mcpClientMetadata.ts +++ b/src/server/mcpClientMetadata.ts @@ -8,6 +8,7 @@ import { import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js"; import type { ConnectedClientIdentity } from "../serverIdentity.js"; +import { isRecord } from "../domain/isRecord.js"; /** Initialize-scoped client metadata the SDK keeps for a 2025-era connection. */ export type McpConnectionClientMetadata = Pick< @@ -69,6 +70,3 @@ const capabilityFeatures = (value: unknown): ClientFeatureAvailability => { sampling: capabilities.sampling !== undefined, }; }; - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/tests/evaluation/recordPredicateGuard.test.ts b/tests/evaluation/recordPredicateGuard.test.ts new file mode 100644 index 0000000000..bcd2c6e941 --- /dev/null +++ b/tests/evaluation/recordPredicateGuard.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from "vitest"; +import { recordPredicateCounts } from "../../scripts/verify/record-predicate-guard.mjs"; + +it("counts both function forms and does not depend on the parameter name", () => { + const predicate = + 'typeof input === "object" && input !== null && !Array.isArray(input)'; + expect( + recordPredicateCounts(`function isObject(input) { return ${predicate}; }`) + .bodies, + ).toBe(1); + expect( + recordPredicateCounts(`const isObject = (input) => ${predicate};`).bodies, + ).toBe(1); + expect( + recordPredicateCounts( + `const a = (input) => ${predicate}; const b = (input) => ${predicate};`, + ).bodies, + ).toBe(2); +});