refactor: convert validator to use predicate spec table

Replace 5 static Sets/maps (SPATIAL_RELATIONS, SIZE_RELATIONS,
VALID_OPTIONS, QUANTIFIER_COMPATIBLE, UNARY_RELATIONS) with
derivation from the unified PredicateSpec table:
  - collectSpatialPredicateNames / collectSizePredicateNames
    for category sets
  - getPredicateValidOptions for option validation
  - collectQuantifierCompatiblePredicateNames for quantifier checks
  - isUnaryPredicate for unary detection

Local override map retained for fluent API dotted size variants
('size.atLeast' etc.) and 'aspectRatioBetween' DSL keyword, which
are input conventions, not distinct predicates.

595 SDK + 57 E2E tests pass.
This commit is contained in:
John Dvorak
2026-05-22 12:46:41 -07:00
parent c0357b152f
commit 6f82849b4f
+41 -92
View File
@@ -10,6 +10,13 @@ import type {
} from 'imhotep-core' } from 'imhotep-core'
import { createDiagnostic } from 'imhotep-core' import { createDiagnostic } from 'imhotep-core'
import {
getPredicateValidOptions,
collectSpatialPredicateNames,
collectSizePredicateNames,
collectQuantifierCompatiblePredicateNames,
isUnaryPredicate,
} from 'imhotep-core'
// ---- Validation Result ---- // ---- Validation Result ----
export interface ValidationResult { export interface ValidationResult {
@@ -19,109 +26,53 @@ export interface ValidationResult {
// ---- Supported Relations ---- // ---- Supported Relations ----
const SPATIAL_RELATIONS = new Set([ const SPATIAL_RELATIONS = new Set(collectSpatialPredicateNames())
'leftOf',
'rightOf',
'above',
'below',
'alignedWith',
'leftAlignedWith',
'rightAlignedWith',
'topAlignedWith',
'bottomAlignedWith',
'centeredWithin',
'inside',
'contains',
'overlaps',
'intersects',
'touches',
'separatedFrom',
'hasGap',
// Spatial aliases
'beside',
'nextTo',
'adjacent',
'touching',
'near',
'under',
'within',
])
const SIZE_RELATIONS = new Set([ const SIZE_RELATIONS = new Set(collectSizePredicateNames())
'aspectRatioBetween',
'between',
])
// Fluent API dotted size variants — not standalone predicates, but valid input syntax.
const DOTTED_SIZE_RELATIONS = new Set([ const DOTTED_SIZE_RELATIONS = new Set([
'size.atLeast', 'size.atLeast',
'size.atMost', 'size.atMost',
'size.between', 'size.between',
]) ])
const ALL_RELATIONS = new Set([...SPATIAL_RELATIONS, ...SIZE_RELATIONS, ...DOTTED_SIZE_RELATIONS]) // 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 ---- // ---- Valid Options Per Relation ----
const VALID_OPTIONS: Record<string, Set<string>> = { const DOTTED_SIZE_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']),
intersects: new Set(['tolerance', 'space']),
touches: new Set(['tolerance', 'inStackingContext', 'space']),
separatedFrom: new Set(['minGap', 'maxGap', 'tolerance', 'space']),
hasGap: new Set(['minGap', 'maxGap', 'tolerance', 'space']),
// 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.atLeast': new Set(['dimension', 'value', 'mode', 'space']),
'size.atMost': new Set(['dimension', 'value', 'mode', 'space']), 'size.atMost': new Set(['dimension', 'value', 'mode', 'space']),
'size.between': new Set(['dimension', 'min', 'max', 'mode', 'space']), 'size.between': new Set(['dimension', 'min', 'max', 'mode', 'space']),
} }
const ASPECT_RATIO_OPTIONS = new Set(['min', 'max'])
function getValidOptionsForRelation(relation: string): Set<string> {
// 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 ---- // ---- Valid Quantifiers Per Relation ----
const QUANTIFIER_COMPATIBLE = new Set([ const QUANTIFIER_COMPATIBLE = new Set([
'leftOf', ...collectQuantifierCompatiblePredicateNames(),
'rightOf', // Fluent API dotted size variants also support quantifiers.
'above', ...DOTTED_SIZE_RELATIONS,
'below',
'alignedWith',
'centeredWithin',
'inside',
'contains',
'overlaps',
'intersects',
'touches',
'separatedFrom',
'hasGap',
'beside',
'nextTo',
'adjacent',
'touching',
'near',
'under',
'within',
'size.atLeast',
'size.atMost',
'size.between',
'between',
]) ])
// ---- Tolerance Parsing ---- // ---- Tolerance Parsing ----
@@ -195,7 +146,7 @@ export function validateRelation(assertion: RelationAssertion): ValidationResult
} }
// 2. Invalid options for relation // 2. Invalid options for relation
const allowed = VALID_OPTIONS[assertion.relation] || new Set() const allowed = getValidOptionsForRelation(assertion.relation)
const opts = assertion.options || {} const opts = assertion.options || {}
const rawOpts = opts as unknown as Record<string, unknown> const rawOpts = opts as unknown as Record<string, unknown>
const provided = Object.keys(rawOpts).filter(k => k !== 'type') const provided = Object.keys(rawOpts).filter(k => k !== 'type')
@@ -295,14 +246,12 @@ export function validateRelation(assertion: RelationAssertion): ValidationResult
) )
} }
const UNARY_RELATIONS = new Set([ // Unary relations: spec says arity=1, plus dotted size variants
'size.atLeast', const isUnary = isUnaryPredicate(assertion.relation)
'size.atMost', || DOTTED_SIZE_RELATIONS.has(assertion.relation)
'size.between', || assertion.relation === ASPECT_RATIO_DSL_KEYWORD
'aspectRatioBetween',
])
if (!UNARY_RELATIONS.has(assertion.relation) && !assertion.reference.value.trim()) { if (!isUnary && !assertion.reference.value.trim()) {
diagnostics.push( diagnostics.push(
makeDiag( makeDiag(
'IMH_SELECTOR_ZERO_MATCHES', 'IMH_SELECTOR_ZERO_MATCHES',