← 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 · TypeScriptimport 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}`,
)
}