Skip to main content
Glama
validation-errors.ts14.1 kB
/** * Validation Error Formatting * * Phase B1: User-friendly error messages with file:line:column positions. * Provides detailed context for validation failures. * * @package WP_Navigator_MCP * @since 1.2.0 */ // ============================================================================= // Types // ============================================================================= /** * Position in a source file */ export interface SourcePosition { /** 1-based line number */ line: number; /** 1-based column number */ column: number; /** 0-based character offset from start of file */ offset: number; } /** * A validation error with full context */ export interface ValidationError { /** Error code for programmatic handling */ code: string; /** Human-readable error message */ message: string; /** File path where error occurred */ filePath: string; /** Position in file (if determinable) */ position?: SourcePosition; /** JSON path to the field (e.g., "brand.palette.primary") */ fieldPath?: string; /** Expected type or format */ expected?: string; /** Actual value that was found */ actual?: string; /** Suggestion for how to fix */ suggestion?: string; } /** * Result of locating a JSON key in source */ interface KeyLocation { /** Position of the key */ position: SourcePosition; /** Position of the value (after the colon) */ valuePosition: SourcePosition; } // ============================================================================= // Position Finding // ============================================================================= /** * Convert a 0-based character offset to line:column position * * @param source - Source text * @param offset - 0-based character offset * @returns Line and column (1-based) */ export function offsetToPosition(source: string, offset: number): SourcePosition { let line = 1; let column = 1; let currentOffset = 0; for (let i = 0; i < offset && i < source.length; i++) { if (source[i] === '\n') { line++; column = 1; } else { column++; } currentOffset = i + 1; } return { line, column, offset: currentOffset }; } /** * Find the position of a JSON key in source content * * Handles nested paths like "brand.palette.primary" by finding each * level of nesting in sequence. * * @param source - JSON/JSONC source text * @param fieldPath - Dot-separated path (e.g., "brand.palette.primary") * @returns Key location or undefined if not found */ export function findKeyPosition(source: string, fieldPath: string): KeyLocation | undefined { const parts = parseFieldPath(fieldPath); if (parts.length === 0) return undefined; let searchStart = 0; let lastKeyPos: KeyLocation | undefined; for (const part of parts) { const result = findNextKey(source, part, searchStart); if (!result) return undefined; lastKeyPos = result; // Continue searching after this key's value position searchStart = result.valuePosition.offset; } return lastKeyPos; } /** * Parse a field path into parts, handling array indices * * Examples: * "brand.palette.primary" => ["brand", "palette", "primary"] * "pages[0].slug" => ["pages", "0", "slug"] * "plugins.akismet.enabled" => ["plugins", "akismet", "enabled"] */ function parseFieldPath(fieldPath: string): string[] { const parts: string[] = []; let current = ''; for (let i = 0; i < fieldPath.length; i++) { const char = fieldPath[i]; if (char === '.') { if (current) { parts.push(current); current = ''; } } else if (char === '[') { if (current) { parts.push(current); current = ''; } // Find closing bracket const closeIdx = fieldPath.indexOf(']', i); if (closeIdx > i + 1) { parts.push(fieldPath.slice(i + 1, closeIdx)); i = closeIdx; } } else if (char !== ']') { current += char; } } if (current) { parts.push(current); } return parts; } /** * Find a specific key in JSON source starting from an offset */ function findNextKey(source: string, key: string, startOffset: number): KeyLocation | undefined { // Handle array index (numeric key) if (/^\d+$/.test(key)) { return findArrayIndex(source, parseInt(key, 10), startOffset); } // Search for "key": pattern const keyPattern = `"${key}"`; let searchPos = startOffset; while (searchPos < source.length) { const keyIdx = source.indexOf(keyPattern, searchPos); if (keyIdx === -1) return undefined; // Find the colon after the key let colonIdx = keyIdx + keyPattern.length; while (colonIdx < source.length && /\s/.test(source[colonIdx])) { colonIdx++; } if (source[colonIdx] === ':') { // Find start of value (skip whitespace after colon) let valueIdx = colonIdx + 1; while (valueIdx < source.length && /\s/.test(source[valueIdx])) { valueIdx++; } return { position: offsetToPosition(source, keyIdx), valuePosition: offsetToPosition(source, valueIdx), }; } searchPos = keyIdx + 1; } return undefined; } /** * Find an array element by index */ function findArrayIndex(source: string, index: number, startOffset: number): KeyLocation | undefined { // Find opening bracket let bracketIdx = source.indexOf('[', startOffset); if (bracketIdx === -1) return undefined; let depth = 1; let elementCount = 0; let elementStart = bracketIdx + 1; // Skip initial whitespace while (elementStart < source.length && /\s/.test(source[elementStart])) { elementStart++; } if (index === 0 && source[elementStart] !== ']') { return { position: offsetToPosition(source, elementStart), valuePosition: offsetToPosition(source, elementStart), }; } for (let i = bracketIdx + 1; i < source.length && depth > 0; i++) { const char = source[i]; if (char === '[' || char === '{') { depth++; } else if (char === ']' || char === '}') { depth--; } else if (char === ',' && depth === 1) { elementCount++; if (elementCount === index) { // Found the element after this comma let valueStart = i + 1; while (valueStart < source.length && /\s/.test(source[valueStart])) { valueStart++; } return { position: offsetToPosition(source, valueStart), valuePosition: offsetToPosition(source, valueStart), }; } } } return undefined; } // ============================================================================= // Error Formatting // ============================================================================= /** * Format a validation error as a user-friendly string * * Example output: * ``` * Error: wpnavigator.jsonc:15:5 * brand.palette.primary: Invalid color format * Expected: Hex color (#RRGGBB) * Got: "blue" * Suggestion: Use a hex color like "#1a73e8" * ``` */ export function formatValidationError(error: ValidationError): string { const lines: string[] = []; // Location line if (error.position) { lines.push(`Error: ${error.filePath}:${error.position.line}:${error.position.column}`); } else { lines.push(`Error: ${error.filePath}`); } // Field path and message if (error.fieldPath) { lines.push(` ${error.fieldPath}: ${error.message}`); } else { lines.push(` ${error.message}`); } // Expected value if (error.expected) { lines.push(` Expected: ${error.expected}`); } // Actual value if (error.actual !== undefined) { lines.push(` Got: ${error.actual}`); } // Suggestion if (error.suggestion) { lines.push(` Suggestion: ${error.suggestion}`); } return lines.join('\n'); } /** * Format multiple validation errors */ export function formatValidationErrors(errors: ValidationError[]): string { return errors.map(formatValidationError).join('\n\n'); } /** * Format a validation error as JSON for structured output */ export function formatValidationErrorJson(error: ValidationError): Record<string, unknown> { return { code: error.code, message: error.message, file: error.filePath, position: error.position ? { line: error.position.line, column: error.position.column, } : undefined, field: error.fieldPath, expected: error.expected, actual: error.actual, suggestion: error.suggestion, }; } // ============================================================================= // Error Creation Helpers // ============================================================================= /** * Known validation error types with templates */ export const ValidationErrorTemplates = { // Type errors INVALID_TYPE: { code: 'INVALID_TYPE', getMessage: (field: string, expected: string, actual: string) => `Invalid type for ${field}`, getSuggestion: (expected: string) => `Provide a value of type ${expected}`, }, // Missing required fields MISSING_REQUIRED: { code: 'MISSING_REQUIRED', getMessage: (field: string) => `Missing required field`, getSuggestion: (field: string) => `Add the "${field}" field to your configuration`, }, // Format errors INVALID_FORMAT: { code: 'INVALID_FORMAT', getMessage: (field: string, format: string) => `Invalid format`, getSuggestion: (format: string) => `Use the correct format: ${format}`, }, // Value errors INVALID_VALUE: { code: 'INVALID_VALUE', getMessage: (field: string, allowed: string[]) => `Invalid value`, getSuggestion: (allowed: string[]) => `Use one of: ${allowed.join(', ')}`, }, // Schema version errors VERSION_TOO_OLD: { code: 'VERSION_TOO_OLD', getMessage: (current: string, minimum: string) => `Schema version ${current} is older than minimum supported ${minimum}`, getSuggestion: () => 'Update your manifest to use a newer schema version', }, VERSION_TOO_NEW: { code: 'VERSION_TOO_NEW', getMessage: (current: string, maximum: string) => `Schema version ${current} is newer than supported ${maximum}`, getSuggestion: () => 'Update wp-navigator-mcp to support this schema version', }, // JSON syntax errors INVALID_JSON: { code: 'INVALID_JSON', getMessage: (parseError: string) => `Invalid JSON syntax: ${parseError}`, getSuggestion: () => 'Check for missing commas, brackets, or quotes', }, // Environment variable errors UNRESOLVED_ENV_VAR: { code: 'UNRESOLVED_ENV_VAR', getMessage: (varName: string) => `Environment variable ${varName} is not set`, getSuggestion: (varName: string) => `Set the ${varName} environment variable or update your config`, }, // Color format errors INVALID_COLOR: { code: 'INVALID_COLOR', getMessage: () => 'Invalid color format', expected: 'Hex color (#RRGGBB or #RGB)', getSuggestion: () => 'Use a hex color like "#1a73e8" or "#f00"', }, // URL format errors INVALID_URL: { code: 'INVALID_URL', getMessage: () => 'Invalid URL format', expected: 'Valid URL (https://example.com)', getSuggestion: () => 'Provide a valid URL starting with http:// or https://', }, } as const; /** * Create a validation error with position from source * * @param template - Error template from ValidationErrorTemplates * @param filePath - Path to the file * @param source - File source content (for position finding) * @param fieldPath - Dot-separated path to the field * @param options - Additional error options */ export function createValidationError( code: string, message: string, filePath: string, source: string, fieldPath?: string, options?: { expected?: string; actual?: string; suggestion?: string; } ): ValidationError { let position: SourcePosition | undefined; if (fieldPath) { const keyLoc = findKeyPosition(source, fieldPath); if (keyLoc) { position = keyLoc.position; } } return { code, message, filePath, position, fieldPath, ...options, }; } /** * Parse JSON syntax error to extract position * * JSON.parse errors typically include "at position N" or "line N column N" */ export function parseJsonSyntaxError( error: Error, source: string ): { message: string; position?: SourcePosition } { const message = error.message; // Try to extract position from "at position N" const posMatch = message.match(/at position (\d+)/i); if (posMatch) { const offset = parseInt(posMatch[1], 10); return { message: message.replace(/at position \d+/i, '').trim(), position: offsetToPosition(source, offset), }; } // Try to extract from "line N column N" const lineColMatch = message.match(/line (\d+) column (\d+)/i); if (lineColMatch) { return { message: message.replace(/line \d+ column \d+/i, '').trim(), position: { line: parseInt(lineColMatch[1], 10), column: parseInt(lineColMatch[2], 10), offset: 0, // Can't determine offset from line:col alone }, }; } return { message }; } // ============================================================================= // Validation Helpers // ============================================================================= /** * Validate a hex color value */ export function isValidHexColor(value: string): boolean { return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value); } /** * Validate a URL */ export function isValidUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; } } /** * Validate a semver-like version string */ export function isValidVersion(value: string): boolean { return /^\d+\.\d+(\.\d+)?$/.test(value); } /** * Get type name for display */ export function getTypeName(value: unknown): string { if (value === null) return 'null'; if (Array.isArray(value)) return 'array'; return typeof value; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/littlebearapps/wp-navigator-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server