Files
Imhotep/packages/imhotep-dsl/src/validator.ts
T
John Dvorak 6a98d8ce9e refactor: compiler pipeline hardening — parser diagnostics + exhaustive switches + golden equivalence
grammar.ts:
  - Replace 2 parseAssertion console.warn + return-null patterns with
    this.errorWithCode() throws, producing structured ParseError
    diagnostics instead of silently skipping invalid clauses
  - Maintain parseBlock's return-null contract for unmatched tokens
    (caller loops over tokens expecting null for non-block starters)

compiler.ts:
  - buildOptionsFromAssertion: convert 3 standalone ifs to if/else if/else
    with never-exhausted throw on unknown assertion type
  - compileToFormula: replace silent return-null with throw on unknown
    assertion type

validator.ts:
  - validateAssertion: replace silent return {valid:true} for unknown
    assertion types with throw

fol-compiler.ts:
  - compileSingleAssertion: replace silent return-null with throw

Golden equivalence tests (fol-equivalence-golden.test.ts):
  7 new deterministic tests covering gaps identified in REFACTOR item 5:
    - Size assertions: atLeast, atMost, between (fluent vs dense)
    - Compound assertions: .and chain, .or chain
    - Options: gap, tolerance in jnd
  Documents known discrepancy: fluent size.* prefix vs dense canonical name
  (fol-compiler normalizes at FormulaNode level, canonical path does not)

Existing property-based equivalence tests cover spatial, quantifier,
and frame equivalence. Topology predicates have no fluent API surface
(dense-DSL-only), so equivalence must be verified at evaluation level
(already covered by hard E2E topology tests).

662 tests pass (315 DSL + 141 core + 149 solver + 57 E2E).
2026-05-22 15:23:29 -07:00

384 lines
12 KiB
TypeScript

// 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<string, Set<string>> = {
'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<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 ----
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<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 = getValidOptionsForRelation(assertion.relation)
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,
),
)
}
// 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<string, unknown>
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,
}
}