Skip to content
← Public packages

@kentcdodds/x

X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.

src/schema-input-errors.ts

118 lines · 3.9 KB · TypeScript
import type { ValidationError } from 'remix/data-schema'

/** Cheap similarity for "did you mean" suggestions (case/punct insensitive). */
export function suggestClosestKey(unknownKey: string, knownKeys: readonly string[]): string | null {
	const norm = (s: string) => s.toLowerCase().replace(/[_-]/g, '')
	const target = norm(unknownKey)
	if (!target) return null

	let best: { key: string; score: number } | null = null
	for (const key of knownKeys) {
		const candidate = norm(key)
		if (!candidate) continue
		if (candidate === target) return key
		let score = 0
		if (candidate.startsWith(target) || target.startsWith(candidate)) {
			score = Math.min(candidate.length, target.length) * 2
		}
		const dist = levenshtein(candidate, target)
		score = Math.max(score, Math.max(candidate.length, target.length) - dist * 2)
		const maxLen = Math.max(candidate.length, target.length)
		if (dist <= 2 || dist / maxLen <= 0.34) {
			if (!best || score > best.score) best = { key, score }
		}
	}
	return best?.key ?? null
}

function levenshtein(a: string, b: string): number {
	const m = a.length
	const n = b.length
	const dp = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))
	for (let i = 0; i <= m; i++) dp[i]![0] = i
	for (let j = 0; j <= n; j++) dp[0]![j] = j
	for (let i = 1; i <= m; i++) {
		for (let j = 1; j <= n; j++) {
			const cost = a[i - 1] === b[j - 1] ? 0 : 1
			dp[i]![j] = Math.min(
				dp[i - 1]![j]! + 1,
				dp[i]![j - 1]! + 1,
				dp[i - 1]![j - 1]! + cost,
			)
		}
	}
	return dp[m]![n]!
}

function extractUnknownKey(issue: { path?: unknown; message?: string }): string | null {
	if (Array.isArray(issue.path) && issue.path.length === 1 && typeof issue.path[0] === 'string') {
		return issue.path[0]
	}
	const message = issue.message ?? ''
	const patterns = [
		/unrecognized key[:\s]+['"]?([^'"]+)['"]?/i,
		/unknown key[:\s]+['"]?([^'"]+)['"]?/i,
		/unexpected key[:\s]+['"]?([^'"]+)['"]?/i,
		/key ['"]([^'"]+)['"] is (?:not allowed|unknown|unrecognized)/i,
	]
	for (const re of patterns) {
		const match = message.match(re)
		if (match?.[1]) return match[1]
	}
	return null
}

export type FormatSchemaValidationOptions = {
	surface: string
	/** Keys accepted by the schema (including aliases before transform). */
	knownKeys: readonly string[]
	/** Short agent-facing shape / alias hint appended to every validation error. */
	hint: string
	/** Optional raw input — used to list unknown top-level keys when issues are opaque. */
	input?: unknown
}

/** Turn a Remix Schema ValidationError into an agent-readable Error. */
export function formatSchemaValidationError(
	error: ValidationError,
	options: FormatSchemaValidationOptions,
): Error {
	const known = options.knownKeys
	const issueLines = (error.issues ?? []).map((issue) => {
		const path =
			Array.isArray(issue.path) && issue.path.length ? issue.path.join('.') : '(root)'
		const unknownKey = extractUnknownKey(issue)
		let line = `${path}: ${issue.message}`
		if (unknownKey) {
			const suggestion = suggestClosestKey(unknownKey, known)
			if (suggestion) line += ` (did you mean ${suggestion}?)`
			else line += ` (unknown key ${JSON.stringify(unknownKey)})`
		}
		return line
	})

	if (
		(!issueLines.length || issueLines.every((l) => l.includes('(root)'))) &&
		options.input &&
		typeof options.input === 'object' &&
		!Array.isArray(options.input)
	) {
		const unknownTop = Object.keys(options.input as Record<string, unknown>).filter(
			(k) => !known.includes(k),
		)
		for (const key of unknownTop) {
			const suggestion = suggestClosestKey(key, known)
			const extra = suggestion
				? `unknown key ${JSON.stringify(key)} (did you mean ${suggestion}?)`
				: `unknown key ${JSON.stringify(key)}`
			if (!issueLines.some((l) => l.includes(JSON.stringify(key)) || l.includes(key + ':'))) {
				issueLines.push(extra)
			}
		}
	}

	const details = issueLines.join('; ')
	return new Error(
		`${options.surface} input invalid: ${details || error.message}. ${options.hint}`,
	)
}