2025-08-15 10:00:00 -07:00
|
|
|
// 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'
|
|
|
|
|
// ---- Validation Result ----
|
|
|
|
|
|
|
|
|
|
export interface ValidationResult {
|
|
|
|
|
valid: boolean
|
|
|
|
|
diagnostics: Diagnostic[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Supported Relations ----
|
|
|
|
|
|
|
|
|
|
const SPATIAL_RELATIONS = new Set([
|
|
|
|
|
'leftOf',
|
|
|
|
|
'rightOf',
|
|
|
|
|
'above',
|
|
|
|
|
'below',
|
|
|
|
|
'alignedWith',
|
|
|
|
|
'leftAlignedWith',
|
|
|
|
|
'rightAlignedWith',
|
|
|
|
|
'topAlignedWith',
|
|
|
|
|
'bottomAlignedWith',
|
|
|
|
|
'centeredWithin',
|
|
|
|
|
'inside',
|
|
|
|
|
'contains',
|
|
|
|
|
'overlaps',
|
2026-05-22 10:20:06 -07:00
|
|
|
'intersects',
|
|
|
|
|
'touches',
|
2025-08-15 10:00:00 -07:00
|
|
|
'separatedFrom',
|
2026-05-22 10:20:06 -07:00
|
|
|
'hasGap',
|
2025-08-15 10:00:00 -07:00
|
|
|
// Spatial aliases
|
|
|
|
|
'beside',
|
|
|
|
|
'nextTo',
|
|
|
|
|
'adjacent',
|
|
|
|
|
'touching',
|
|
|
|
|
'near',
|
|
|
|
|
'under',
|
|
|
|
|
'within',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const SIZE_RELATIONS = new Set([
|
|
|
|
|
'aspectRatioBetween',
|
|
|
|
|
'between',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const DOTTED_SIZE_RELATIONS = new Set([
|
|
|
|
|
'size.atLeast',
|
|
|
|
|
'size.atMost',
|
|
|
|
|
'size.between',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const ALL_RELATIONS = new Set([...SPATIAL_RELATIONS, ...SIZE_RELATIONS, ...DOTTED_SIZE_RELATIONS])
|
|
|
|
|
|
|
|
|
|
// ---- Valid Options Per Relation ----
|
|
|
|
|
|
|
|
|
|
const VALID_OPTIONS: Record<string, Set<string>> = {
|
|
|
|
|
leftOf: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
rightOf: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
above: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
below: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
alignedWith: new Set(['axis', 'tolerance', 'space']),
|
|
|
|
|
leftAlignedWith: new Set(['tolerance', 'space']),
|
|
|
|
|
rightAlignedWith: new Set(['tolerance', 'space']),
|
|
|
|
|
topAlignedWith: new Set(['tolerance', 'space']),
|
|
|
|
|
bottomAlignedWith: new Set(['tolerance', 'space']),
|
|
|
|
|
centeredWithin: new Set(['tolerance', 'space']),
|
|
|
|
|
inside: new Set(['tolerance', 'minGap', 'space']),
|
|
|
|
|
contains: new Set(['tolerance', 'space']),
|
|
|
|
|
overlaps: new Set(['tolerance', 'space']),
|
2026-05-22 10:20:06 -07:00
|
|
|
intersects: new Set(['tolerance', 'space']),
|
|
|
|
|
touches: new Set(['tolerance', 'inStackingContext', 'space']),
|
2025-08-15 10:00:00 -07:00
|
|
|
separatedFrom: new Set(['minGap', 'maxGap', 'tolerance', 'space']),
|
2026-05-22 10:20:06 -07:00
|
|
|
hasGap: new Set(['minGap', 'maxGap', 'tolerance', 'space']),
|
2025-08-15 10:00:00 -07:00
|
|
|
// Spatial alias options
|
|
|
|
|
beside: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
nextTo: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
adjacent: new Set(['tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
touching: new Set(['tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
near: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
under: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
|
|
|
|
within: new Set(['tolerance', 'minGap', 'space']),
|
|
|
|
|
aspectRatioBetween: new Set(['min', 'max']),
|
|
|
|
|
between: new Set(['dimension', 'min', 'max']),
|
|
|
|
|
'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']),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Valid Quantifiers Per Relation ----
|
|
|
|
|
|
|
|
|
|
const QUANTIFIER_COMPATIBLE = new Set([
|
|
|
|
|
'leftOf',
|
|
|
|
|
'rightOf',
|
|
|
|
|
'above',
|
|
|
|
|
'below',
|
|
|
|
|
'alignedWith',
|
|
|
|
|
'centeredWithin',
|
|
|
|
|
'inside',
|
|
|
|
|
'contains',
|
|
|
|
|
'overlaps',
|
2026-05-22 10:20:06 -07:00
|
|
|
'intersects',
|
|
|
|
|
'touches',
|
2025-08-15 10:00:00 -07:00
|
|
|
'separatedFrom',
|
2026-05-22 10:20:06 -07:00
|
|
|
'hasGap',
|
2025-08-15 10:00:00 -07:00
|
|
|
'beside',
|
|
|
|
|
'nextTo',
|
|
|
|
|
'adjacent',
|
|
|
|
|
'touching',
|
|
|
|
|
'near',
|
|
|
|
|
'under',
|
|
|
|
|
'within',
|
|
|
|
|
'size.atLeast',
|
|
|
|
|
'size.atMost',
|
|
|
|
|
'size.between',
|
|
|
|
|
'between',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// ---- 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<string, unknown>
|
|
|
|
|
|
|
|
|
|
// 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 = VALID_OPTIONS[assertion.relation] || new Set()
|
|
|
|
|
const opts = assertion.options || {}
|
|
|
|
|
const rawOpts = opts as unknown as Record<string, unknown>
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const UNARY_RELATIONS = new Set([
|
|
|
|
|
'size.atLeast',
|
|
|
|
|
'size.atMost',
|
|
|
|
|
'size.between',
|
|
|
|
|
'aspectRatioBetween',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
if (!UNARY_RELATIONS.has(assertion.relation) && !assertion.reference.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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { valid: true, diagnostics: [] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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,
|
|
|
|
|
}
|
|
|
|
|
}
|