// Validation logic for Imhotep DSL // Validates relation-option combinations, units, and authoring constraints // Early validation with clear, actionable diagnostics import type { RelationAssertion, Diagnostic, DiagnosticCode, DiagnosticCategory, } from 'imhotep-core' import { createDiagnostic } from 'imhotep-core' import { getPredicateValidOptions, collectSpatialPredicateNames, collectSizePredicateNames, collectQuantifierCompatiblePredicateNames, isUnaryPredicate, } from 'imhotep-core' // ---- Validation Result ---- export interface ValidationResult { valid: boolean diagnostics: Diagnostic[] } // ---- Supported Relations ---- const SPATIAL_RELATIONS = new Set(collectSpatialPredicateNames()) const SIZE_RELATIONS = new Set(collectSizePredicateNames()) // Fluent API dotted size variants — not standalone predicates, but valid input syntax. const DOTTED_SIZE_RELATIONS = new Set([ 'size.atLeast', 'size.atMost', 'size.between', ]) // DSL keyword that maps to 'aspectRatio' canonical — not a distinct predicate. const ASPECT_RATIO_DSL_KEYWORD = 'aspectRatioBetween' const ALL_RELATIONS = new Set([ ...collectSpatialPredicateNames(), ...collectSizePredicateNames(), ...DOTTED_SIZE_RELATIONS, ASPECT_RATIO_DSL_KEYWORD, ]) // ---- Valid Options Per Relation ---- const DOTTED_SIZE_OPTIONS: Record> = { 'size.atLeast': new Set(['dimension', 'value', 'mode', 'space']), 'size.atMost': new Set(['dimension', 'value', 'mode', 'space']), 'size.between': new Set(['dimension', 'min', 'max', 'mode', 'space']), } const ASPECT_RATIO_OPTIONS = new Set(['min', 'max']) function getValidOptionsForRelation(relation: string): Set { // Dotted size variants have their own option schema. if (DOTTED_SIZE_OPTIONS[relation]) return DOTTED_SIZE_OPTIONS[relation] if (relation === ASPECT_RATIO_DSL_KEYWORD) return ASPECT_RATIO_OPTIONS // Resolve alias names (e.g. 'under' → 'below') to get canonical spec options. const options = getPredicateValidOptions(relation) if (options.length > 0) return new Set(options) return new Set() } // ---- Valid Quantifiers Per Relation ---- const QUANTIFIER_COMPATIBLE = new Set([ ...collectQuantifierCompatiblePredicateNames(), // Fluent API dotted size variants also support quantifiers. ...DOTTED_SIZE_RELATIONS, ]) // ---- Tolerance Parsing ---- export function parseTolerance(input: number | string | undefined | { value: number; unit?: string }): { value: number; unit: 'px' | 'jnd' } | null { if (input === undefined) return null if (typeof input === 'object' && input !== null && 'value' in input) { return { value: input.value, unit: input.unit === 'jnd' ? 'jnd' : 'px' } } if (typeof input === 'number') { return { value: input, unit: 'px' } } const trimmed = String(input).trim() if (trimmed.endsWith('jnd')) { const num = parseFloat(trimmed.slice(0, -3)) if (Number.isNaN(num)) return null return { value: num, unit: 'jnd' } } const num = parseFloat(trimmed) if (Number.isNaN(num)) return null return { value: num, unit: 'px' } } // ---- Gap Parsing ---- export function parseGap(input: number | string | undefined): { value: number; unit: string } | null { if (input === undefined) return null if (typeof input === 'number') { return { value: input, unit: 'px' } } const trimmed = String(input).trim() const num = parseFloat(trimmed) if (Number.isNaN(num)) return null return { value: num, unit: 'px' } } // ---- Helper to create validation diagnostic ---- function makeDiag( code: DiagnosticCode, category: DiagnosticCategory, message: string, position?: RelationAssertion['position'], ): Diagnostic { return createDiagnostic(code, category, 'error', message, { position, source: 'imhotep-dsl', fixHints: [], }) } // ---- Main Validation ---- export function validateRelation(assertion: RelationAssertion): ValidationResult { const diagnostics: Diagnostic[] = [] // Access extended properties via cast const extended = assertion as unknown as Record // 1. Unknown relation if (!ALL_RELATIONS.has(assertion.relation)) { diagnostics.push( makeDiag( 'IMH_VALID_ILLEGAL_RELATION_OPTION', 'validation-error', `Unknown relation "${assertion.relation}". Supported relations: ${Array.from(ALL_RELATIONS).join(', ')}.`, assertion.position, ), ) return { valid: false, diagnostics } } // 2. Invalid options for relation const allowed = getValidOptionsForRelation(assertion.relation) const opts = assertion.options || {} const rawOpts = opts as unknown as Record const provided = Object.keys(rawOpts).filter(k => k !== 'type') for (const key of provided) { if (!allowed.has(key)) { diagnostics.push( makeDiag( 'IMH_VALID_ILLEGAL_RELATION_OPTION', 'validation-error', `Option "${key}" is not valid for relation "${assertion.relation}". Allowed options: ${Array.from(allowed).join(', ') || 'none'}.`, assertion.position, ), ) } } // 3. Option value parsing if ('tolerance' in rawOpts) { const parsed = parseTolerance(rawOpts.tolerance as number | string | { value: number; unit?: string }) if (!parsed) { diagnostics.push( makeDiag( 'IMH_VALID_INVALID_UNIT', 'validation-error', `Invalid tolerance value "${rawOpts.tolerance}". Must be a number or a string ending in "jnd".`, assertion.position, ), ) } } if ('minGap' in rawOpts) { const parsed = parseGap(rawOpts.minGap as number | string) if (!parsed || parsed.value < 0) { diagnostics.push( makeDiag( 'IMH_VALID_INVALID_UNIT', 'validation-error', `Invalid minGap value "${rawOpts.minGap}". Must be a non-negative number or length string.`, assertion.position, ), ) } } if ('maxGap' in rawOpts) { const parsed = parseGap(rawOpts.maxGap as number | string) if (!parsed || parsed.value < 0) { diagnostics.push( makeDiag( 'IMH_VALID_INVALID_UNIT', 'validation-error', `Invalid maxGap value "${rawOpts.maxGap}". Must be a non-negative number or length string.`, assertion.position, ), ) } } if ('minGap' in rawOpts && 'maxGap' in rawOpts) { const minParsed = parseGap(rawOpts.minGap as number | string) const maxParsed = parseGap(rawOpts.maxGap as number | string) if (minParsed && maxParsed && minParsed.value > maxParsed.value) { diagnostics.push( makeDiag( 'IMH_VALID_CONTRADICTION', 'validation-error', `minGap (${minParsed.value}) cannot be greater than maxGap (${maxParsed.value}).`, assertion.position, ), ) } } // 4. Quantifier compatibility const quantifier = extended.quantifier as string | undefined if (quantifier && !QUANTIFIER_COMPATIBLE.has(assertion.relation)) { diagnostics.push( makeDiag( 'IMH_VALID_INVALID_QUANTIFIER_NESTING', 'validation-error', `Quantifier "${quantifier}" is not compatible with relation "${assertion.relation}".`, assertion.position, ), ) } // 5. Selector non-empty if (!assertion.subject.value.trim()) { diagnostics.push( makeDiag( 'IMH_SELECTOR_ZERO_MATCHES', 'validation-error', 'Subject selector must not be empty.', assertion.position, ), ) } // Unary relations: spec says arity=1, plus dotted size variants const isUnary = isUnaryPredicate(assertion.relation) || DOTTED_SIZE_RELATIONS.has(assertion.relation) || assertion.relation === ASPECT_RATIO_DSL_KEYWORD if (!isUnary && !assertion.reference.value.trim()) { diagnostics.push( makeDiag( 'IMH_SELECTOR_ZERO_MATCHES', 'validation-error', 'Reference selector must not be empty.', assertion.position, ), ) } // 6. Compound parts: validate each part's relation name and options if (assertion.compoundOperator && assertion.compoundParts && assertion.compoundParts.length > 1) { for (let i = 0; i < assertion.compoundParts.length; i++) { const part = assertion.compoundParts[i] const partAllowed = getValidOptionsForRelation(part.relation) const partOpts = part.options as unknown as Record const partProvided = Object.keys(partOpts).filter(k => k !== 'type') for (const key of partProvided) { if (!partAllowed.has(key)) { diagnostics.push( makeDiag( 'IMH_VALID_ILLEGAL_RELATION_OPTION', 'validation-error', `Option "${key}" is not valid for compound part "${part.relation}". Allowed options: ${Array.from(partAllowed).join(', ') || 'none'}.`, assertion.position, ), ) } } } } return { valid: diagnostics.length === 0, diagnostics, } } // ---- Compound Assertion Validation ---- function validateSizeAssertion(assertion: any): ValidationResult { const diagnostics: Diagnostic[] = [] const subject = assertion.subject as { value: string } | undefined if (!subject?.value.trim()) { diagnostics.push( makeDiag( 'IMH_SELECTOR_ZERO_MATCHES', 'validation-error', 'Subject selector must not be empty.', assertion.position, ), ) } return { valid: diagnostics.length === 0, diagnostics } } function validateTopologyAssertion(assertion: any): ValidationResult { const diagnostics: Diagnostic[] = [] const subject = assertion.subject as { value: string } | undefined if (!subject?.value.trim()) { diagnostics.push( makeDiag( 'IMH_SELECTOR_ZERO_MATCHES', 'validation-error', 'Subject selector must not be empty.', assertion.position, ), ) } const ref = assertion.reference as { value: string } | undefined if (ref && !ref.value.trim()) { diagnostics.push( makeDiag( 'IMH_SELECTOR_ZERO_MATCHES', 'validation-error', 'Reference selector must not be empty.', assertion.position, ), ) } return { valid: diagnostics.length === 0, diagnostics } } /** * Validate any assertion node, including compound assertions. * Recursively validates children of compound assertions. */ export function validateAssertion(assertion: any): ValidationResult { if (!assertion) return { valid: true, diagnostics: [] } if (assertion.type === 'CompoundAssertion') { const allDiagnostics: Diagnostic[] = [] for (const child of assertion.children || []) { const result = validateAssertion(child) allDiagnostics.push(...result.diagnostics) } return { valid: allDiagnostics.length === 0, diagnostics: allDiagnostics } } if (assertion.type === 'SizeAssertion') { return validateSizeAssertion(assertion) } if (assertion.type === 'TopologyAssertion') { return validateTopologyAssertion(assertion) } if (assertion.type === 'RelationAssertion') { return validateRelation(assertion) } throw new Error(`validateAssertion: unknown assertion type "${String(assertion.type)}"`) } // ---- Batch Validation ---- export function validateProgram(assertions: any[]): ValidationResult { const allDiagnostics: Diagnostic[] = [] for (const assertion of assertions) { const result = validateAssertion(assertion) allDiagnostics.push(...result.diagnostics) } return { valid: allDiagnostics.length === 0, diagnostics: allDiagnostics, } }