v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)

This commit is contained in:
John Dvorak
2025-08-15 10:00:00 -07:00
commit 92deb689cd
321 changed files with 79170 additions and 0 deletions
+997
View File
@@ -0,0 +1,997 @@
// Compiler: lowers AST -> Semantic IR -> Execution IR
// Pure function, no browser dependencies
import type {
RelationAssertion,
SemanticIr,
ExecutionIr,
SemanticClause,
SemanticSubject,
SemanticFrame,
SemanticState,
SemanticTimeline,
SemanticTolerance,
SemanticEnvGuard,
Diagnostic,
Position,
ImhotepId,
FormulaNode,
VariableRef,
DomainRef,
TupleBinding,
PredicateCall,
AssertionNode,
SizeAssertion,
TopologyAssertion,
CompoundAssertion,
SelectorRef,
} from 'imhotep-core'
import { createEmptySemanticIr, getDefaultContext } from 'imhotep-core'
import { parseTolerance, parseGap } from './validator.js'
// DSL grammar FOL types (distinct from solver FormulaNode imported above)
import type {
FormulaNode as DslFormulaNode,
ForAllFormula as DslForAll,
ExistsFormula as DslExists,
AndFormula as DslAnd,
OrFormula as DslOr,
NotFormula as DslNot,
ImpliesFormula as DslImplies,
PredicateCall as DslPredicateCall,
VariableRef as DslVariableRef,
DomainRef as DslDomainRef,
} from './logic-fluent.js'
// ---- Compiler Options (dependency injection) ----
export interface CompilerOptions {
// Injected id generator for determinism in tests
generateId?: (prefix: string) => string
// Default frame when none specified
defaultFrameKind?: string
// Default tolerance when none specified
defaultTolerance?: { value: number; unit: 'px' | 'jnd' }
}
// ---- Id Generator ----
function defaultNextId(prefix: string): string {
return `${prefix}_${getDefaultContext().idGenerator()}`
}
// ---- Option Value Normalization ----
// INVARIANT: Both fluent API and string DSL must lower to equivalent Execution IR.
// The string parser produces LiteralNode / ToleranceLiteralNode AST wrappers,
// while the fluent API stores raw numbers. These normalizers extract the scalar
// value from both formats so parseGap / parseTolerance receive consistent input.
//
// Bug fixed: Previously parseGap(LiteralNode) silently returned null because
// String(object) === "[object Object]", causing string DSL gap options to be
// lost in compilation. The extractGapValue helper now handles range literals.
function normalizeOptionValue(raw: unknown): number | string | undefined {
if (raw === undefined || raw === null) return undefined
if (typeof raw === 'number' || typeof raw === 'string') return raw
// String-parser/fluent path: LiteralNode or ToleranceLiteralNode
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>
if ('value' in obj && typeof obj.value === 'number') {
if ('unit' in obj && typeof obj.unit === 'string') {
return `${obj.value}${obj.unit}`
}
return obj.value
}
}
return undefined
}
// Extract a numeric gap value from fluent raw numbers, string-parser LiteralNodes,
// or string-parser range LiteralNodes ( picking min or max edge ).
function extractGapValue(raw: unknown, edge: 'min' | 'max'): number | undefined {
if (raw === undefined || raw === null) return undefined
if (typeof raw === 'number') return raw
if (typeof raw === 'string') {
const num = parseFloat(raw.trim())
if (!Number.isNaN(num)) return num
return undefined
}
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>
// Simple length literal: { type: 'Literal', kind: 'length', value: number }
if ('value' in obj && typeof obj.value === 'number') {
return obj.value
}
// Range literal: { type: 'Literal', kind: 'range', value: { min: { value }, max: { value } } }
if (obj.kind === 'range' && obj.value && typeof obj.value === 'object') {
const range = obj.value as Record<string, unknown>
const target = edge === 'min' ? range.min : range.max
if (target && typeof target === 'object') {
const targetObj = target as Record<string, unknown>
if (typeof targetObj.value === 'number') {
return targetObj.value
}
}
}
}
return undefined
}
// ---- Semantic Lowering ----
function toSemanticIr(
ast: RelationAssertion[],
opts: CompilerOptions,
): { semanticIr: SemanticIr; diagnostics: Diagnostic[] } {
const generateId = opts.generateId || defaultNextId
const ir = createEmptySemanticIr()
const diagnostics: Diagnostic[] = []
// Default frame
const defaultFrameId = generateId('frame')
ir.frames.set(defaultFrameId, {
id: defaultFrameId,
kind: (opts.defaultFrameKind || 'viewport') as SemanticFrame['kind'],
originX: 0,
originY: 0,
writingMode: 'horizontal-tb',
origin: { astNodeIds: [], positions: [] },
})
// Default state
const defaultStateId = generateId('state')
ir.states.set(defaultStateId, {
id: defaultStateId,
kind: 'default',
origin: { astNodeIds: [], positions: [] },
})
// Default timeline
const defaultTimelineId = generateId('timeline')
ir.timelines.set(defaultTimelineId, {
id: defaultTimelineId,
mode: 'static',
origin: { astNodeIds: [], positions: [] },
})
// Default tolerance
const defaultToleranceId = generateId('tolerance')
ir.tolerances.set(defaultToleranceId, {
id: defaultToleranceId,
value: opts.defaultTolerance?.value ?? 0,
unit: opts.defaultTolerance?.unit ?? 'px',
origin: { astNodeIds: [], positions: [] },
})
for (const assertion of ast) {
// Build origin from source span
const origin = buildOrigin(assertion.position)
// Access extended DSL properties via cast
const extended = assertion as unknown as Record<string, unknown>
// Subject
const subjectId = generateId('subject')
const subjectDef: SemanticSubject = {
id: subjectId,
selector: assertion.subject.value,
kind: 'element',
origin,
}
ir.subjects.set(subjectId, subjectDef)
// Reference
const referenceId = generateId('subject')
const referenceDef: SemanticSubject = {
id: referenceId,
selector: assertion.reference.value,
kind: 'element',
origin,
}
ir.subjects.set(referenceId, referenceDef)
// Frame
let frameId = defaultFrameId
const frameNode = extended.frame as { kind: string; selector?: string; name?: string } | undefined
if (frameNode) {
frameId = generateId('frame')
const frameDef: SemanticFrame = {
id: frameId,
kind: frameNode.kind as SemanticFrame['kind'],
selector: frameNode.selector,
name: frameNode.name,
originX: 0,
originY: 0,
writingMode: 'horizontal-tb',
origin,
}
ir.frames.set(frameId, frameDef)
}
// State
let stateId = defaultStateId
const stateNode = extended.state as { kind: string; name?: string } | undefined
if (stateNode) {
stateId = generateId('state')
const stateDef: SemanticState = {
id: stateId,
kind: stateNode.kind as SemanticState['kind'],
name: stateNode.name,
origin,
}
ir.states.set(stateId, stateDef)
}
// Tolerance
let toleranceId = defaultToleranceId
const rawOpts = assertion.options as unknown as Record<string, unknown> | undefined
const tol = parseTolerance(normalizeOptionValue(rawOpts?.tolerance))
if (tol) {
toleranceId = generateId('tolerance')
ir.tolerances.set(toleranceId, {
id: toleranceId,
value: tol.value,
unit: tol.unit,
origin,
})
}
// Environment guard
const envGuardId = generateId('guard')
const envGuard = extended.envGuard as { condition: string } | undefined
const guardDef: SemanticEnvGuard = {
id: envGuardId,
expression: envGuard?.condition || 'true',
normalizedCases: [],
origin,
}
ir.envGuards.set(envGuardId, guardDef)
// Bounds
const bounds: SemanticClause['bounds'] = {}
if (rawOpts) {
if ('minGap' in rawOpts) {
const minVal = extractGapValue(rawOpts.minGap, 'min')
if (minVal !== undefined) {
bounds.minGap = { value: minVal, unit: 'px' }
}
}
if ('maxGap' in rawOpts) {
const maxVal = extractGapValue(rawOpts.maxGap, 'max')
if (maxVal !== undefined) {
bounds.maxGap = { value: maxVal, unit: 'px' }
}
}
}
// Flags
let flags = 0
const quantifier = extended.quantifier as string | undefined
if (quantifier === 'all') flags |= 1
if (quantifier === 'any') flags |= 2
if (quantifier === 'none') flags |= 4
if (rawOpts?.inStackingContext) flags |= 8
if ((assertion as any).negated) flags |= 16
// Clause
const clauseId = generateId('clause')
const clause: SemanticClause = {
id: clauseId,
origin,
subjectRef: subjectId,
referenceRef: referenceId,
relation: assertion.relation,
frameRef: frameId,
stateRef: stateId,
timelineRef: defaultTimelineId,
envGuardRef: envGuardId,
toleranceRef: toleranceId,
bounds,
}
// Attach flags via cast since core SemanticClause doesn't have flags field yet
;(clause as unknown as Record<string, unknown>).flags = flags
ir.clauses.set(clauseId, clause)
}
return { semanticIr: ir, diagnostics }
}
function buildOrigin(position?: Position): { astNodeIds: string[]; positions: Position[] } {
if (position) {
return { astNodeIds: [], positions: [position] }
}
return { astNodeIds: [], positions: [] }
}
// ---- Execution IR Compilation ----
function toExecutionIr(semanticIr: SemanticIr): ExecutionIr {
const clauses = Array.from(semanticIr.clauses.values())
const count = clauses.length
if (count === 0) {
return {
clauseCount: 0,
clauseType: new Uint16Array(0),
clauseSubject: new Uint32Array(0),
clauseReference: new Uint32Array(0),
clauseFrame: new Uint32Array(0),
clauseState: new Uint32Array(0),
clauseTimeline: new Uint32Array(0),
clauseTolerance: new Uint32Array(0),
clauseEnvGuard: new Uint32Array(0),
clauseArg0: new Float64Array(0),
clauseArg1: new Float64Array(0),
clauseFlags: new Uint32Array(0),
clauseOrigin: new Uint32Array(0),
}
}
// Build lookup maps for numeric indices — iterate Maps directly to avoid Array.from().
const subjectIndex = new Map<ImhotepId, number>()
let idx = 0
for (const k of semanticIr.subjects.keys()) subjectIndex.set(k, idx++)
const frameIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.frames.keys()) frameIndex.set(k, idx++)
const stateIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.states.keys()) stateIndex.set(k, idx++)
const timelineIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.timelines.keys()) timelineIndex.set(k, idx++)
const toleranceIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.tolerances.keys()) toleranceIndex.set(k, idx++)
const guardIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.envGuards.keys()) guardIndex.set(k, idx++)
// Encode relation as small integer
const relationCodes: Record<string, number> = {
leftOf: 1,
rightOf: 2,
above: 3,
below: 4,
alignedWith: 5,
leftAlignedWith: 6,
rightAlignedWith: 7,
topAlignedWith: 8,
bottomAlignedWith: 9,
centeredWithin: 10,
inside: 11,
contains: 12,
overlaps: 13,
separatedFrom: 14,
aspectRatioBetween: 15,
between: 16,
}
const clauseType = new Uint16Array(count)
const clauseSubject = new Uint32Array(count)
const clauseReference = new Uint32Array(count)
const clauseFrame = new Uint32Array(count)
const clauseState = new Uint32Array(count)
const clauseTimeline = new Uint32Array(count)
const clauseTolerance = new Uint32Array(count)
const clauseEnvGuard = new Uint32Array(count)
const clauseArg0 = new Float64Array(count)
const clauseArg1 = new Float64Array(count)
const clauseFlags = new Uint32Array(count)
const clauseOrigin = new Uint32Array(count)
for (let i = 0; i < count; i++) {
const c = clauses[i]
clauseType[i] = relationCodes[c.relation] || 0
clauseSubject[i] = subjectIndex.get(c.subjectRef) ?? 0
clauseReference[i] = c.referenceRef ? (subjectIndex.get(c.referenceRef) ?? 0) : 0
clauseFrame[i] = frameIndex.get(c.frameRef) ?? 0
clauseState[i] = stateIndex.get(c.stateRef) ?? 0
clauseTimeline[i] = timelineIndex.get(c.timelineRef) ?? 0
clauseTolerance[i] = toleranceIndex.get(c.toleranceRef) ?? 0
clauseEnvGuard[i] = guardIndex.get(c.envGuardRef) ?? 0
clauseOrigin[i] = i // origin index same as clause index for now
// Retrieve flags via cast
const flags = (c as unknown as Record<string, unknown>).flags as number | undefined
clauseFlags[i] = flags || 0
// Pack bounds into arg0/arg1 where applicable
if (c.bounds.minGap && c.bounds.maxGap) {
clauseArg0[i] = c.bounds.minGap.value
clauseArg1[i] = c.bounds.maxGap.value
} else if (c.bounds.minGap) {
clauseArg0[i] = c.bounds.minGap.value
clauseArg1[i] = Number.POSITIVE_INFINITY
} else if (c.bounds.maxGap) {
clauseArg0[i] = Number.NEGATIVE_INFINITY
clauseArg1[i] = c.bounds.maxGap.value
} else {
clauseArg0[i] = Number.NEGATIVE_INFINITY
clauseArg1[i] = Number.POSITIVE_INFINITY
}
}
return {
clauseCount: count,
clauseType,
clauseSubject,
clauseReference,
clauseFrame,
clauseState,
clauseTimeline,
clauseTolerance,
clauseEnvGuard,
clauseArg0,
clauseArg1,
clauseFlags,
clauseOrigin,
}
}
// ---- FOL Formula Compilation ----
const subjectVar: VariableRef = { type: 'VariableRef', name: '$subject' }
const referenceVar: VariableRef = { type: 'VariableRef', name: '$reference' }
function makeDomain(selector: string): DomainRef {
return { type: 'DomainRef', domain: 'elements', selector }
}
function makeBinding(varName: string, selector: string): TupleBinding {
return {
type: 'TupleBinding',
variables: [varName],
domain: makeDomain(selector),
}
}
function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion | TopologyAssertion): Record<string, unknown> {
const options: Record<string, unknown> = {}
if (assertion.type === 'RelationAssertion') {
const opts = assertion.options as unknown as Record<string, unknown>
if (opts?.minGap !== undefined) {
const v = normalizeOptionValue(opts.minGap)
if (v !== undefined) options.minGap = v
}
if (opts?.maxGap !== undefined) {
const v = normalizeOptionValue(opts.maxGap)
if (v !== undefined) options.maxGap = v
}
if (opts?.tolerance !== undefined) {
const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
if (tol) {
options.tolerance = tol.value
options.toleranceUnit = tol.unit
}
}
if (opts?.axis !== undefined) options.axis = opts.axis
if (opts?.inStackingContext === true) options.inStackingContext = true
}
if (assertion.type === 'SizeAssertion') {
const bounds = assertion.bounds as unknown as Record<string, unknown>
if (bounds?.min && typeof (bounds.min as any).value === 'number') {
options.min = (bounds.min as any).value
options.value = (bounds.min as any).value
}
if (bounds?.max && typeof (bounds.max as any).value === 'number') {
options.max = (bounds.max as any).value
}
if (bounds?.exact && typeof (bounds.exact as any).value === 'number') {
options.value = (bounds.exact as any).value
}
if (assertion.property) {
options.dimension = assertion.property
}
}
if (assertion.type === 'TopologyAssertion') {
const opts = assertion.options as unknown as Record<string, unknown>
if (opts?.tolerance !== undefined) {
const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
if (tol) {
options.tolerance = tol.value
options.toleranceUnit = tol.unit
}
}
}
return options
}
function compileSimpleAssertionToFormula(
assertion: RelationAssertion | SizeAssertion | TopologyAssertion,
): FormulaNode {
const options = buildOptionsFromAssertion(assertion)
const isUnary = assertion.type === 'SizeAssertion'
|| (assertion.type === 'RelationAssertion'
&& (assertion.relation === 'atLeast' || assertion.relation === 'atMost'))
let predicateName: string
let args: VariableRef[]
if (assertion.type === 'RelationAssertion') {
predicateName = assertion.relation
args = isUnary ? [subjectVar] : [subjectVar, referenceVar]
} else if (assertion.type === 'SizeAssertion') {
if (assertion.property === 'aspectRatio') {
predicateName = 'aspectRatio'
} else if (assertion.bounds.min && assertion.bounds.max) {
predicateName = 'between'
} else if (assertion.bounds.max) {
predicateName = 'atMost'
} else if (assertion.bounds.min) {
predicateName = 'atLeast'
} else {
predicateName = 'atLeast'
}
args = [subjectVar]
} else {
predicateName = assertion.predicate
args = assertion.reference ? [subjectVar, referenceVar] : [subjectVar]
}
let body: FormulaNode = {
type: 'FormulaNode',
kind: 'predicate',
predicate: predicateName,
args,
...(Object.keys(options).length > 0 ? { options } : {}),
} as FormulaNode
// Negation
if ((assertion as any).negated) {
body = {
type: 'FormulaNode',
kind: 'not',
operand: body,
}
}
// Bindings
const subjectBinding = makeBinding('$subject', assertion.subject.value)
const bindings: TupleBinding[] = [subjectBinding]
if (!isUnary && assertion.type !== 'TopologyAssertion') {
const ref = (assertion as RelationAssertion).reference
if (ref?.value) {
bindings.push(makeBinding('$reference', ref.value))
}
}
if (assertion.type === 'TopologyAssertion' && assertion.reference?.value) {
bindings.push(makeBinding('$reference', assertion.reference.value))
}
return {
type: 'FormulaNode',
kind: 'forall',
bindings,
body,
} as FormulaNode
}
function compileQuantifierToFormula(
assertion: CompoundAssertion,
body: FormulaNode,
): FormulaNode {
const quantifier = assertion.quantifier?.kind
const subject = (assertion.children[0] as any).subject as SelectorRef
const reference = (assertion.children[0] as any).reference as SelectorRef | undefined
const subjectBinding = makeBinding('$subject', subject.value)
if (quantifier === 'any') {
const refBinding = reference?.value
? makeBinding('$reference', reference.value)
: null
return {
type: 'FormulaNode',
kind: 'exists',
bindings: [subjectBinding],
body: refBinding
? {
type: 'FormulaNode',
kind: 'forall',
bindings: [refBinding],
body,
}
: body,
} as FormulaNode
}
if (quantifier === 'none') {
const refBinding = reference?.value
? makeBinding('$reference', reference.value)
: null
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [subjectBinding],
body: {
type: 'FormulaNode',
kind: 'not',
operand: refBinding
? {
type: 'FormulaNode',
kind: 'exists',
bindings: [refBinding],
body,
}
: body,
},
} as FormulaNode
}
// Default / 'all'
const bindings: TupleBinding[] = [subjectBinding]
if (reference?.value) {
bindings.push(makeBinding('$reference', reference.value))
}
return {
type: 'FormulaNode',
kind: 'forall',
bindings,
body,
} as FormulaNode
}
/**
* Compile a single DSL assertion AST node to a FOL FormulaNode.
* Handles RelationAssertion, SizeAssertion, TopologyAssertion,
* and CompoundAssertion (with operators and/or quantifiers).
*/
export function compileToFormula(assertion: AssertionNode): FormulaNode | null {
if (!assertion) return null
// Compound assertion: operator (and/or) or quantifier wrapper
if (assertion.type === 'CompoundAssertion') {
const compound = assertion as CompoundAssertion
if (compound.operator) {
// Logical compound: compile children and wrap in and/or
const children = (compound.children || [])
.map((child) => compileToFormula(child))
.filter((f): f is FormulaNode => f !== null)
if (children.length === 0) return null
if (children.length === 1) return children[0]
let result = children[0]
for (let i = 1; i < children.length; i++) {
result = {
type: 'FormulaNode',
kind: compound.operator,
left: result,
right: children[i],
} as FormulaNode
}
// If quantifier is present, wrap the whole compound
if (compound.quantifier) {
return compileQuantifierToFormula(compound, result)
}
return result
}
// Quantifier-only compound
const childFormulas = (compound.children || [])
.map((child) => compileToFormula(child))
.filter((f): f is FormulaNode => f !== null)
if (childFormulas.length === 0) return null
const body = childFormulas.length === 1
? childFormulas[0]
: childFormulas.reduce((left, right) => ({
type: 'FormulaNode',
kind: 'and',
left,
right,
} as FormulaNode))
return compileQuantifierToFormula(compound, body)
}
// Simple assertions
if (
assertion.type === 'RelationAssertion'
|| assertion.type === 'SizeAssertion'
|| assertion.type === 'TopologyAssertion'
) {
return compileSimpleAssertionToFormula(assertion as RelationAssertion | SizeAssertion | TopologyAssertion)
}
return null
}
// ---- Dense DSL FOL Compilation (bypasses canonical lowering) ----
/**
* Convert a DSL grammar FOL formula AST node to a solver FormulaNode.
*
* Dense DSL forall/exists formulas use a distinct AST shape from the solver's
* FormulaNode (e.g. type: 'ForAll' vs type: 'FormulaNode' kind: 'forall').
* This function bridges the two representations so that parsed dense FOL specs
* can be evaluated directly by evaluateLogic() without going through canonical
* clause descriptors.
*
* String arguments inside PredicateCall are lifted into implicit forall
* bindings because the solver only accepts VariableRef and AccessorTerm
* as predicate arguments.
*/
export function compileDenseFOLToFormula(dslFormula: DslFormulaNode): FormulaNode {
let freshVarCounter = 0
function makeFreshVar(): string {
freshVarCounter += 1
return `$str_${freshVarCounter}`
}
function compile(node: DslFormulaNode): FormulaNode {
switch (node.type) {
case 'ForAll': {
const fa = node as DslForAll
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [makeTupleBinding(fa.variable, fa.domain)],
body: compile(fa.body),
} as FormulaNode
}
case 'Exists': {
const ex = node as DslExists
return {
type: 'FormulaNode',
kind: 'exists',
bindings: [makeTupleBinding(ex.variable, ex.domain)],
body: compile(ex.body),
} as FormulaNode
}
case 'And': {
const a = node as DslAnd
return {
type: 'FormulaNode',
kind: 'and',
left: compile(a.left),
right: compile(a.right),
} as FormulaNode
}
case 'Or': {
const o = node as DslOr
return {
type: 'FormulaNode',
kind: 'or',
left: compile(o.left),
right: compile(o.right),
} as FormulaNode
}
case 'Not': {
const n = node as DslNot
return {
type: 'FormulaNode',
kind: 'not',
operand: compile(n.operand),
} as FormulaNode
}
case 'Implies': {
const imp = node as DslImplies
return {
type: 'FormulaNode',
kind: 'implies',
antecedent: compile(imp.left),
consequent: compile(imp.right),
} as FormulaNode
}
case 'PredicateCall': {
const pc = node as DslPredicateCall
const implicitBindings: TupleBinding[] = []
const compiledArgs: VariableRef[] = []
for (const arg of pc.args) {
if (typeof arg === 'string') {
const varName = makeFreshVar()
implicitBindings.push({
type: 'TupleBinding',
variables: [varName],
domain: { type: 'DomainRef', domain: 'elements', selector: arg },
})
compiledArgs.push({ type: 'VariableRef', name: varName })
} else if (typeof arg === 'number') {
// Numbers are not supported as predicate args in the solver.
// Skip them — the caller should have encoded numeric values
// in predicate options instead.
continue
} else {
compiledArgs.push(convertTerm(arg) as VariableRef)
}
}
// Size predicates with comparison operators: width($x) >= 44 → size.atLeast
let predicateBody: FormulaNode
if (['width', 'height', 'size'].includes(pc.name) && pc.operator && pc.right !== undefined) {
const options: Record<string, unknown> = {}
if (pc.name === 'width') options.dimension = 'width'
else if (pc.name === 'height') options.dimension = 'height'
else options.dimension = 'width' // size defaults to width
let predicateName: string
switch (pc.operator) {
case '>=':
predicateName = 'atLeast'
options.min = pc.right
break
case '<=':
predicateName = 'atMost'
options.max = pc.right
break
case '==':
predicateName = 'between'
options.min = pc.right
options.max = pc.right
break
case '!=':
// Not-equal is compiled as negated equality
predicateBody = {
type: 'FormulaNode',
kind: 'not',
operand: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'between',
args: compiledArgs,
options: { ...options, min: pc.right, max: pc.right },
} as FormulaNode,
} as FormulaNode
// Wrap implicit bindings if any
return wrapInForAll(implicitBindings, predicateBody)
case '>':
predicateName = 'atLeast'
options.min = pc.right
break
case '<':
predicateName = 'atMost'
options.max = pc.right
break
default:
predicateName = pc.name
}
predicateBody = {
type: 'FormulaNode',
kind: 'predicate',
predicate: predicateName,
args: compiledArgs,
...(Object.keys(options).length > 0 ? { options } : {}),
} as FormulaNode
} else {
predicateBody = {
type: 'FormulaNode',
kind: 'predicate',
predicate: pc.name,
args: compiledArgs,
} as FormulaNode
}
// Wrap implicit forall bindings around the predicate body.
// String literals in predicate args are treated as universally
// quantified domains (same semantics as canonical clause compilation).
return wrapInForAll(implicitBindings, predicateBody)
}
case 'VariableRef':
case 'DomainRef':
// VariableRef and DomainRef are terms, not formulas. They should only
// appear as arguments inside PredicateCall, never as top-level formulas.
throw new Error(`DSL ${node.type} is a term, not a formula, and cannot be compiled standalone`)
default:
throw new Error(`Unknown DSL formula node type: ${(node as any).type}`)
}
}
return compile(dslFormula)
}
function wrapInForAll(bindings: TupleBinding[], body: FormulaNode): FormulaNode {
if (bindings.length === 0) return body
let result = body
// Wrap from last binding to first so that the outermost forall
// corresponds to the leftmost argument.
for (let i = bindings.length - 1; i >= 0; i--) {
result = {
type: 'FormulaNode',
kind: 'forall',
bindings: [bindings[i]],
body: result,
} as FormulaNode
}
return result
}
function makeTupleBinding(variable: DslVariableRef, domain: DslDomainRef): TupleBinding {
return {
type: 'TupleBinding',
variables: [variable.name],
domain: convertDomain(domain),
}
}
function convertDomain(domain: DslDomainRef): DomainRef {
const selectorFromVar = domain.variableSelector
? `$${domain.variableSelector.name}`
: undefined
const extraArgFromVar = domain.variableExtraArg
? `$${domain.variableExtraArg.name}`
: undefined
// Descendant domains use the first argument as parent and second as filter:
// descendants($card, '.title') => parentVar: '$card', selector: '.title'
if (domain.kind === 'descendants') {
const parentVar = selectorFromVar ?? domain.selector
const selector = extraArgFromVar ?? (domain as any).extraArg
return {
type: 'DomainRef',
domain: domain.kind,
selector,
parentVar,
}
}
// Default mapping for non-descendant domains.
const selector = selectorFromVar ?? domain.selector
const parentVar = extraArgFromVar ?? (domain as any).extraArg
return {
type: 'DomainRef',
domain: domain.kind,
selector,
parentVar,
}
}
function convertTerm(term: DslVariableRef | string | number): VariableRef | string | number {
if (typeof term === 'object' && term !== null && term.type === 'VariableRef') {
return { type: 'VariableRef', name: (term as DslVariableRef).name }
}
return term
}
// ---- Public Compiler ----
export interface CompileResult {
ast: { type: 'Program'; children: RelationAssertion[] }
semanticIr: SemanticIr
executionIr: ExecutionIr
diagnostics: Diagnostic[]
}
export function compile(
ast: RelationAssertion[],
options: CompilerOptions = {},
): CompileResult {
const programNode = {
type: 'Program' as const,
children: ast,
}
const { semanticIr, diagnostics } = toSemanticIr(ast, options)
const executionIr = toExecutionIr(semanticIr)
return {
ast: programNode,
semanticIr,
executionIr,
diagnostics,
}
}
+51
View File
@@ -0,0 +1,51 @@
// Global configuration and project defaults for Imhotep
// Uses dependency injection patterns: configure() sets runtime globals,
// project() sets persistent project-level metadata.
export interface ImhotepConfig {
defaultTolerance?: { value: number; unit: 'px' | 'jnd' }
defaultFrame?: string
perceptualModel?: string
[key: string]: unknown
}
let globalConfig: ImhotepConfig = {}
/** Sets or updates global Imhotep runtime configuration. */
export function configure(config: ImhotepConfig): void {
globalConfig = { ...globalConfig, ...config }
}
/** Returns a shallow copy of the current global config. */
export function getConfig(): ImhotepConfig {
return { ...globalConfig }
}
export interface ProjectConfig {
name?: string
fontCorpus?: string
adapters?: string[]
environments?: unknown[]
[key: string]: unknown
}
let projectConfig: ProjectConfig = {}
/** Sets or updates project-level defaults, font corpora, and adapters. */
export function project(config: ProjectConfig): void {
projectConfig = { ...projectConfig, ...config }
}
/** Returns a shallow copy of the current project config. */
export function getProjectConfig(): ProjectConfig {
return { ...projectConfig }
}
/** Environment guard for responsive contracts.
* Only executes the callback when the condition is truthy.
*/
export function when(condition: unknown, fn: () => void): void {
if (condition) {
fn()
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Tests for FOL fluent builder safety.
*
* Verifies:
* - forAll/exists callbacks collect returned assertions
* - Empty assertion quantifiers throw instead of vacuously passing
* - Assertions are properly lowered to formula AST
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
expect,
FluentAssertion,
FluentBinding,
FluentQuantifier,
} from './fluent.js'
describe('FOL fluent builder', () => {
it('forAll collects returned FluentRelation', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) =>
btn.expect().to.be.above('.label'),
)
assert.strictEqual(quantifier.kind, 'forAll')
assert.strictEqual(quantifier.bindings.length, 1)
const assertions = quantifier.bindings[0].getAssertions()
assert.strictEqual(assertions.length, 1)
assert.strictEqual(assertions[0].relation, 'above')
assert.strictEqual(assertions[0].subject.value, '.button')
assert.strictEqual(assertions[0].reference.value, '.label')
})
it('forAll collects multiple returned assertions via addAssertion', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) => {
const rel1 = btn.expect().to.be.above('.label')
btn.addAssertion(rel1.toAst())
const rel2 = btn.expect().to.be.leftOf('.icon')
btn.addAssertion(rel2.toAst())
})
const assertions = quantifier.bindings[0].getAssertions()
assert.strictEqual(assertions.length, 2)
assert.strictEqual(assertions[0].relation, 'above')
assert.strictEqual(assertions[1].relation, 'leftOf')
})
it('forAll with no assertions throws on toFormula', () => {
const quantifier = FluentAssertion.forAll('.button', (_btn) => {
// Intentionally empty — simulates builder misuse
})
assert.throws(() => quantifier.toFormula(), /produced no assertions/)
})
it('exists collects returned FluentRelation', () => {
const quantifier = FluentAssertion.exists('.tooltip', (tip) =>
tip.expect().to.be.inside('.container'),
)
assert.strictEqual(quantifier.kind, 'exists')
assert.strictEqual(quantifier.bindings.length, 1)
const assertions = quantifier.bindings[0].getAssertions()
assert.strictEqual(assertions.length, 1)
assert.strictEqual(assertions[0].relation, 'inside')
})
it('exists with no assertions throws on toFormula', () => {
const quantifier = FluentAssertion.exists('.tooltip', (_tip) => {
// Intentionally empty
})
assert.throws(() => quantifier.toFormula(), /produced no assertions/)
})
it('forAll over multiple selectors creates multiple bindings', () => {
const quantifier = FluentAssertion.forAll(['.a', '.b'], (el) =>
el.expect().to.be.leftOf('.c'),
)
assert.strictEqual(quantifier.bindings.length, 2)
assert.strictEqual(quantifier.bindings[0].selector, '.a')
assert.strictEqual(quantifier.bindings[1].selector, '.b')
})
it('forAll lowers to ForAll formula AST', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) =>
btn.expect().to.be.above('.label'),
)
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'ForAll')
assert.strictEqual((formula as any).variable.name, '$0')
assert.strictEqual((formula as any).domain.selector, '.button')
// Binary relations are wrapped in a reference forall
assert.strictEqual((formula as any).body.type, 'ForAll')
assert.strictEqual((formula as any).body.variable.name, '$ref')
assert.strictEqual((formula as any).body.body.type, 'PredicateCall')
assert.strictEqual((formula as any).body.body.name, 'above')
})
it('exists lowers to Exists formula AST', () => {
const quantifier = FluentAssertion.exists('.modal', (modal) =>
modal.expect().to.be.centeredWithin('.viewport'),
)
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'Exists')
// Binary relations are wrapped in a reference forall
assert.strictEqual((formula as any).body.type, 'ForAll')
assert.strictEqual((formula as any).body.body.type, 'PredicateCall')
assert.strictEqual((formula as any).body.body.name, 'centeredWithin')
})
it('forAll with multiple assertions lowers to conjunction', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) => {
const rel1 = btn.expect().to.be.above('.label')
btn.addAssertion(rel1.toAst())
const rel2 = btn.expect().to.be.leftOf('.icon')
btn.addAssertion(rel2.toAst())
return undefined
})
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'ForAll')
const body = (formula as any).body
assert.strictEqual(body.type, 'And')
// Each predicate is wrapped in a reference forall
assert.strictEqual(body.left.type, 'ForAll')
assert.strictEqual(body.left.body.type, 'PredicateCall')
assert.strictEqual(body.right.type, 'ForAll')
assert.strictEqual(body.right.body.type, 'PredicateCall')
})
})
+421
View File
@@ -0,0 +1,421 @@
// Unit tests for imhotep-dsl fluent API and compiler
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
expect,
assert as imhotepAssert,
frame,
state,
compile,
validateRelation,
validateProgram,
parseSpec,
parseTolerance,
parseGap,
} from './index.js'
// ---- Fluent Builder Tests ----
describe('fluent builder', () => {
it('creates a basic leftOf relation', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
assert.strictEqual(chain.assertion.getSubject(), '.a')
assert.strictEqual(chain.relation, 'leftOf')
assert.strictEqual(chain.referenceSelector, '.b')
assert.strictEqual(chain.options.minGap, 16)
})
it('chains frame attachment', () => {
const chain = expect('.tooltip')
.in(frame.nearestPositionedAncestor('.trigger'))
.to.be.rightOf('.trigger', { minGap: 8, maxGap: 16 })
assert.strictEqual(chain.assertion.getSubject(), '.tooltip')
assert.strictEqual(chain.assertion.getFrame()?.kind, 'nearestPositionedAncestor')
assert.strictEqual(chain.assertion.getFrame()?.selector, '.trigger')
assert.strictEqual(chain.relation, 'rightOf')
assert.deepStrictEqual(chain.options, { minGap: 8, maxGap: 16 })
})
it('chains state attachment', () => {
const chain = expect('.button')
.inState(state.hover())
.to.be.above('.label')
assert.strictEqual(chain.assertion.getState()?.kind, 'hover')
})
it('chains tolerance option', () => {
const chain = expect('.dialog').to.be.centeredWithin('.viewport', {
tolerance: 1,
})
assert.strictEqual(chain.options.tolerance, 1)
})
it('chains perceptual tolerance in jnd', () => {
const chain = expect('.icon').to.be.alignedWith('.label', {
tolerance: '0.5jnd',
})
assert.strictEqual(chain.options.tolerance, '0.5jnd')
})
it('chains quantifiers', () => {
const allChain = expect('.toolbar button').all.to.be.leftOf('.sidebar')
assert.strictEqual(allChain.assertion.getQuantifier(), 'all')
const anyChain = expect('.menu').any.to.be.below('.trigger')
assert.strictEqual(anyChain.assertion.getQuantifier(), 'any')
const noneChain = expect('.errors').none.to.be.above('.footer')
assert.strictEqual(noneChain.assertion.getQuantifier(), 'none')
})
it('chains environment guard', () => {
const chain = expect('.sidebar')
.when('width >= 768')
.to.be.leftOf('.content', { minGap: 24 })
assert.strictEqual(chain.assertion.getEnvGuard(), 'width >= 768')
})
it('produces a relation assertion AST node', () => {
const chain = expect('.nav-toggle')
.in(frame.viewport())
.to.be.leftOf('.search', { minGap: 16, maxGap: 20 })
const ast = chain.toAst()
assert.strictEqual(ast.type, 'RelationAssertion')
assert.strictEqual(ast.relation, 'leftOf')
assert.strictEqual(ast.subject.value, '.nav-toggle')
assert.strictEqual(ast.reference.value, '.search')
// Access extended properties via cast
const extended = ast as unknown as Record<string, unknown>
assert.strictEqual((extended.frame as { kind: string }).kind, 'viewport')
const opts = ast.options as unknown as Record<string, unknown>
assert.strictEqual(opts.minGap, 16)
assert.strictEqual(opts.maxGap, 20)
})
it('produces AST with source span', () => {
const span = {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 10, offset: 9 },
}
const chain = expect('.a', span).to.be.leftOf('.b')
const ast = chain.toAst()
assert.deepStrictEqual(ast.position, span)
})
it('supports lower-level assert helper', () => {
const chain = imhotepAssert('leftOf', '.a', '.b', { minGap: 8 }, frame.viewport(), state.default())
assert.strictEqual(chain.relation, 'leftOf')
assert.strictEqual(chain.assertion.getFrame()?.kind, 'viewport')
assert.strictEqual(chain.assertion.getState()?.kind, 'default')
})
// ---- Size Assertion Overloads ----
it('atLeast(44, "width") returns FluentRelation directly', () => {
const chain = expect('.box').to.be.atLeast(44, 'width')
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, 44)
assert.strictEqual(chain.options.mode, 'atLeast')
})
it('atLeast({ width: 44 }) returns FluentRelation directly', () => {
const chain = expect('.box').to.be.atLeast({ width: 44 })
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, 44)
assert.strictEqual(chain.options.mode, 'atLeast')
})
it('atLeast({ height: 88 }) returns height FluentRelation', () => {
const chain = expect('.box').to.be.atLeast({ height: 88 })
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.value, 88)
})
it('atMost(100, "height") returns FluentRelation directly', () => {
const chain = expect('.box').to.be.atMost(100, 'height')
assert.strictEqual(chain.relation, 'size.atMost')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.value, 100)
assert.strictEqual(chain.options.mode, 'atMost')
})
it('atMost({ width: 200 }) returns FluentRelation directly', () => {
const chain = expect('.box').to.be.atMost({ width: 200 })
assert.strictEqual(chain.relation, 'size.atMost')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, 200)
})
it('between(50, 100, "width") returns FluentRelation directly', () => {
const chain = expect('.box').to.be.between(50, 100, 'width')
assert.strictEqual(chain.relation, 'size.between')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.min, 50)
assert.strictEqual(chain.options.max, 100)
assert.strictEqual(chain.options.mode, 'between')
})
it('between({ width: [50, 100] }) returns FluentRelation directly', () => {
const chain = expect('.box').to.be.between({ width: [50, 100] })
assert.strictEqual(chain.relation, 'size.between')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.min, 50)
assert.strictEqual(chain.options.max, 100)
})
it('between({ height: [20, 80] }) returns height FluentRelation', () => {
const chain = expect('.box').to.be.between({ height: [20, 80] })
assert.strictEqual(chain.relation, 'size.between')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.min, 20)
assert.strictEqual(chain.options.max, 80)
})
// ---- Backward Compatibility ----
it('atLeast("44px").wide still returns SizeProxy (old syntax)', () => {
const proxy = expect('.box').to.be.atLeast('44px')
assert.ok(proxy)
assert.strictEqual(typeof proxy.wide, 'object')
const chain = proxy.wide
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, '44px')
})
it('atLeast("44px").tall still works (old syntax)', () => {
const chain = expect('.box').to.be.atLeast('44px').tall
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.value, '44px')
})
it('atMost("100px").wide still works (old syntax)', () => {
const chain = expect('.box').to.be.atMost('100px').wide
assert.strictEqual(chain.relation, 'size.atMost')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, '100px')
})
it('between("50px", "100px").tall still works (old syntax)', () => {
const chain = expect('.box').to.be.between('50px', '100px').tall
assert.strictEqual(chain.relation, 'size.between')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.min, '50px')
assert.strictEqual(chain.options.max, '100px')
})
it('new overloads produce correct AST via toAst()', () => {
const chain = expect('.box').to.be.atLeast(44, 'width')
const ast = chain.toAst()
assert.strictEqual(ast.relation, 'size.atLeast')
const opts = ast.options as unknown as Record<string, unknown>
assert.strictEqual(opts.dimension, 'width')
assert.strictEqual(opts.value, 44)
assert.strictEqual(opts.mode, 'atLeast')
})
})
// ---- Validator Tests ----
describe('validator', () => {
it('accepts a valid leftOf with minGap', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, true)
assert.strictEqual(result.diagnostics.length, 0)
})
it('rejects unknown relation', () => {
// Manually construct an invalid AST
const ast = expect('.a').to.be.leftOf('.b').toAst()
ast.relation = 'unknownRelation'
const result = validateRelation(ast)
assert.strictEqual(result.valid, false)
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_ILLEGAL_RELATION_OPTION')
})
it('rejects invalid option for relation', () => {
const chain = expect('.a').to.be.leftOf('.b', { axis: 'centerY' })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, false)
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_ILLEGAL_RELATION_OPTION')
})
it('rejects contradictory gap bounds', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 30, maxGap: 10 })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, false)
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_CONTRADICTION')
})
it('rejects negative gap', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: -5 })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, false)
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_INVALID_UNIT')
})
it('accepts valid tolerance in px', () => {
const chain = expect('.a').to.be.centeredWithin('.b', { tolerance: 2 })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, true)
})
it('accepts valid tolerance in jnd', () => {
const chain = expect('.a').to.be.centeredWithin('.b', { tolerance: '1jnd' })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, true)
})
it('parses tolerance correctly', () => {
assert.deepStrictEqual(parseTolerance(1), { value: 1, unit: 'px' })
assert.deepStrictEqual(parseTolerance('0.5jnd'), { value: 0.5, unit: 'jnd' })
assert.strictEqual(parseTolerance('bad'), null)
assert.strictEqual(parseTolerance(undefined), null)
})
it('parses gap correctly', () => {
assert.deepStrictEqual(parseGap(16), { value: 16, unit: 'px' })
assert.deepStrictEqual(parseGap('24'), { value: 24, unit: 'px' })
assert.strictEqual(parseGap(-1)?.value, -1)
})
it('validates batch of assertions', () => {
const assertions = [
expect('.a').to.be.leftOf('.b', { minGap: 16 }).toAst(),
expect('.c').to.be.above('.d', { maxGap: 10 }).toAst(),
]
const result = validateProgram(assertions)
assert.strictEqual(result.valid, true)
})
})
// ---- Compiler Tests ----
describe('compiler', () => {
it('compiles a single relation to semantic IR', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
const result = compile([chain.toAst()])
assert.strictEqual(result.ast.type, 'Program')
assert.strictEqual(result.semanticIr.clauses.size, 1)
assert.strictEqual(result.diagnostics.length, 0)
const clause = Array.from(result.semanticIr.clauses.values())[0]
assert.strictEqual(clause.relation, 'leftOf')
assert.strictEqual(clause.bounds.minGap?.value, 16)
assert.strictEqual(clause.bounds.minGap?.unit, 'px')
assert.ok(clause.frameRef)
})
it('compiles frame attachment into semantic IR', () => {
const chain = expect('.tooltip')
.in(frame.nearestPositionedAncestor('.trigger'))
.to.be.rightOf('.trigger', { minGap: 8 })
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const frameDef = result.semanticIr.frames.get(clause.frameRef)
assert.strictEqual(frameDef?.kind, 'nearestPositionedAncestor')
assert.strictEqual(frameDef?.selector, '.trigger')
})
it('compiles state attachment into semantic IR', () => {
const chain = expect('.button')
.inState(state.hover())
.to.be.above('.label')
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const stateDef = result.semanticIr.states.get(clause.stateRef)
assert.strictEqual(stateDef?.kind, 'hover')
})
it('compiles quantifier into flags', () => {
const chain = expect('.toolbar button').all.to.be.leftOf('.sidebar')
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const flags = (clause as unknown as Record<string, unknown>).flags as number
assert.ok(flags & 1) // all flag
})
it('compiles tolerance into semantic IR', () => {
const chain = expect('.dialog').to.be.centeredWithin('.viewport', {
tolerance: '1jnd',
})
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const tolDef = result.semanticIr.tolerances.get(clause.toleranceRef)
assert.strictEqual(tolDef?.value, 1)
assert.strictEqual(tolDef?.unit, 'jnd')
})
it('compiles environment guard into semantic IR', () => {
const chain = expect('.sidebar')
.when('width >= 768')
.to.be.leftOf('.content')
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const guardDef = result.semanticIr.envGuards.get(clause.envGuardRef)
assert.strictEqual(guardDef?.expression, 'width >= 768')
})
it('lowers semantic IR to execution IR', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16, maxGap: 20 })
const result = compile([chain.toAst()])
assert.strictEqual(result.executionIr.clauseCount, 1)
assert.strictEqual(result.executionIr.clauseType[0], 1) // leftOf code
assert.strictEqual(result.executionIr.clauseArg0[0], 16)
assert.strictEqual(result.executionIr.clauseArg1[0], 20)
assert.strictEqual(result.executionIr.clauseFlags[0], 0)
})
it('handles empty assertion list', () => {
const result = compile([])
assert.strictEqual(result.semanticIr.clauses.size, 0)
assert.strictEqual(result.executionIr.clauseCount, 0)
})
it('attaches source spans in semantic IR', () => {
const span = {
start: { line: 2, column: 5, offset: 17 },
end: { line: 2, column: 51, offset: 63 },
}
const chain = expect('.a', span).to.be.leftOf('.b')
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
assert.deepStrictEqual(clause.origin.positions[0], span)
})
})
// ---- Parser Placeholder Tests ----
describe('parser placeholder', () => {
it('returns empty AST for empty input', () => {
const result = parseSpec('')
assert.strictEqual(result.ast.children.length, 0)
assert.strictEqual(result.diagnostics.length, 0)
})
it('parses dense DSL input', () => {
const result = parseSpec(`in viewport:\n '.a' leftOf '.b'`)
assert.strictEqual(result.ast.children.length, 1)
assert.strictEqual(result.diagnostics.length, 0)
const block = result.ast.children[0] as any
assert.strictEqual(block.type, 'FrameBlock')
assert.strictEqual(block.children[0].type, 'RelationAssertion')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,465 @@
// FOL Dense DSL Combination Tests
// Systematically tests every First-Order Logic combination in dense DSL syntax
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { parseSpec } from './parser.js'
import { tokenize } from './lexer.js'
import { parseGrammar } from './grammar.js'
import { lowerFormula, lowerProgram } from './logic-compiler.js'
import { validateFormula, validateProgram } from './logic-validator.js'
import { compileDenseFOLToFormula } from './compiler.js'
import { extractFOLFormulas } from './lower-to-canonical.js'
import type { FormulaNode } from './logic-fluent.js'
// ---------------------------------------------------------------------------
// Test Helpers
// ---------------------------------------------------------------------------
function parseDense(source: string) {
const tokens = tokenize(source)
const ast = parseGrammar(tokens)
return ast
}
function getFormula(ast: any): FormulaNode | null {
const formulas = ast.children.filter(
(child: any) => child.type === 'ForAll' || child.type === 'Exists' || child.type === 'And' || child.type === 'Or' || child.type === 'Not' || child.type === 'Implies'
)
return formulas.length > 0 ? formulas[0] : null
}
function testParses(name: string, source: string) {
it(`parses: ${name}`, () => {
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
const formula = getFormula(result.ast)
assert.ok(formula, `Expected formula node in AST, got children: ${result.ast.children.map((c: any) => c.type).join(', ')}`)
})
}
function testLowers(name: string, source: string, expectedType: string) {
it(`lowers: ${name}`, () => {
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula, 'Expected formula')
const ir = lowerFormula(formula!)
assert.strictEqual(ir.type, expectedType, `Expected ${expectedType} but got ${ir.type}`)
})
}
function testValidates(name: string, source: string, expectedValid: boolean) {
it(`validates: ${name}`, () => {
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula, 'Expected formula')
const validation = validateFormula(formula!)
assert.strictEqual(validation.valid, expectedValid, `Expected valid=${expectedValid} but got: ${validation.diagnostics.map(d => d.message).join(', ')}`)
})
}
// ---------------------------------------------------------------------------
// Dense DSL FOL Combination Tests
// ---------------------------------------------------------------------------
describe('FOL Dense DSL - Simple Quantifiers', () => {
testParses('forall with simple predicate',
`forall $btn in elements('.button'):\n width($btn) >= 44`)
testParses('exists with simple predicate',
`exists $title in elements('.title'):\n above($title, '.content')`)
testParses('forall with elements domain',
`forall $btn in elements('.button'):\n leftOf($btn, '.label')`)
testParses('forall with descendants domain',
`forall $item in descendants('.card', '.item'):\n inside($item, '.container')`)
testParses('exists with lineBoxes domain',
`exists $box in lineBoxes('.text'):\n overlaps($box, '.highlight')`)
})
describe('FOL Dense DSL - Boolean Connectives', () => {
testParses('forall + and with spatial predicates',
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') and above($item, '.footer')`)
testParses('forall + or',
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') or above($item, '.footer')`)
testParses('forall + implies',
`forall $modal in elements('.modal'):\n visible($modal) implies centeredWithin($modal, 'viewport')`)
testParses('exists + and with spatial predicates',
`exists $item in elements('.item'):\n leftOf($item, '.sidebar') and above($item, '.footer')`)
testParses('exists + or',
`exists $item in elements('.item'):\n leftOf($item, '.sidebar') or above($item, '.footer')`)
testParses('forall + nested and',
`forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c') and inside($a, '.d')`)
testParses('forall + nested or',
`forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c') or inside($a, '.d')`)
it('forall + and with size predicates (width/height) parses successfully', () => {
// width/height are now recognized as predicate keywords
const source = `forall $btn in elements('.button'):\n width($btn) >= 44 and height($btn) >= 44`
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
const formula = getFormula(result.ast)
assert.ok(formula, 'Expected formula')
assert.strictEqual(formula?.type, 'ForAll')
})
})
describe('FOL Dense DSL - Negation', () => {
testParses('forall + not prefix',
`forall $el in elements('.error'):\n not overlaps($el, '.success')`)
testParses('exists + not prefix',
`exists $el in elements('.error'):\n not overlaps($el, '.success')`)
testParses('not + forall',
`not (forall $btn in elements('.button'):\n leftOf($btn, '.label'))`)
testParses('not + exists',
`not (exists $btn in elements('.button'):\n leftOf($btn, '.label'))`)
testParses('forall + not + and',
`forall $a in elements('.a'):\n not leftOf($a, '.b') and not above($a, '.c')`)
testParses('not + forall + and',
`not (forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c'))`)
})
describe('FOL Dense DSL - Nested Quantifiers', () => {
testParses('forall + exists with string selectors',
`forall $card in elements('.card'):\n exists $title in descendants('.card', '.title'):\n above($title, '.content')`)
testParses('exists + forall with string selectors',
`exists $container in elements('.container'):\n forall $item in descendants('.container', '.item'):\n inside($item, '.container')`)
testParses('forall + forall',
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n leftOf($a, $b)`)
testParses('exists + exists',
`exists $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`)
testParses('triple nesting',
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n exists $c in elements('.c'):\n leftOf($a, $c) and above($b, $c)`)
it('descendants($var, selector) syntax is supported', () => {
// Variable as first arg to descendants now supported
const source = `forall $card in elements('.card'):\n exists $title in descendants($card, '.title'):\n above($title, '.content')`
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
const formula = getFormula(result.ast)
assert.ok(formula, 'Expected formula')
// Verify nested quantifier with variable domain is present
assert.strictEqual(formula?.type, 'ForAll')
})
it('GAP: variable reference as second arg in predicate not supported', () => {
// Using $container as second arg
const source = `exists $container in elements('.container'):\n forall $item in elements('.item'):\n inside($item, $container)`
const result = parseSpec(source)
// This might parse or might fail depending on grammar
// Document actual behavior
const formula = getFormula(result.ast)
if (!formula) {
assert.ok(result.diagnostics.length > 0)
}
})
})
describe('FOL Dense DSL - Complex Combinations', () => {
testParses('forall + exists + and + or',
`forall $a in elements('.a'):\n exists $b in elements('.b'):\n (leftOf($a, $b) or above($a, $b)) and not overlaps($a, $b)`)
testParses('forall + implies + exists',
`forall $btn in elements('.button'):\n visible($btn) implies exists $label in elements('.label'):\n above($label, $btn)`)
testParses('forall + or + not',
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') or not overlaps($item, '.overlay')`)
testParses('exists + and + implies',
`exists $modal in elements('.modal'):\n visible($modal) and (focused($modal) implies centeredWithin($modal, 'viewport'))`)
testParses('forall + complex nested',
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n (leftOf($a, $b) and above($a, $b)) implies not overlaps($a, $b)`)
testParses('not + exists + and',
`not (exists $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c'))`)
})
describe('FOL Dense DSL - Lowering Verification', () => {
testLowers('forall lowers to LogicForAll',
`forall $btn in elements('.button'):\n leftOf($btn, '.label')`,
'LogicForAll')
testLowers('exists lowers to LogicExists',
`exists $title in elements('.title'):\n above($title, '.content')`,
'LogicExists')
testLowers('and lowers to LogicAnd',
`forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c')`,
'LogicForAll')
testLowers('or lowers to LogicOr (inside body)',
`forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c')`,
'LogicForAll')
testLowers('not lowers to LogicNot',
`forall $a in elements('.a'):\n not leftOf($a, '.b')`,
'LogicForAll')
testLowers('implies lowers to LogicImplies',
`forall $a in elements('.a'):\n leftOf($a, '.b') implies above($a, '.c')`,
'LogicForAll')
testLowers('nested forall+exists',
`forall $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`,
'LogicForAll')
})
describe('FOL Dense DSL - Validation Verification', () => {
it('validates: closed forall formula', () => {
const source = `forall $btn in elements('.button'):\n leftOf($btn, '.label')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const validation = validateFormula(formula)
assert.strictEqual(validation.valid, true)
})
it('validates: free variable detected', () => {
// forall $btn but uses $bad inside
const source = `forall $btn in elements('.button'):\n leftOf($bad, '.label')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const validation = validateFormula(formula)
assert.strictEqual(validation.valid, false)
assert.ok(validation.diagnostics.some(d => d.message.includes('Free variable') || d.message.includes('free variable')))
})
it('validates: nested quantifiers with correct scoping', () => {
const source = `forall $card in elements('.card'):\n exists $title in descendants('.card', '.title'):\n above($title, '.content')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const validation = validateFormula(formula)
assert.strictEqual(validation.valid, true)
})
it('validates: nested quantifiers with variable capture', () => {
// $card used in inner scope where it should be accessible (capture is correct)
const source = `forall $card in elements('.card'):\n exists $title in elements('.title'):\n above($title, $card)`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const validation = validateFormula(formula)
assert.strictEqual(validation.valid, true)
})
})
describe('FOL Dense DSL - Edge Cases', () => {
it('parses: forall with predicate taking no arguments', () => {
const source = `forall $btn in elements('.button'):\n focused()`
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula)
})
it('parses: forall with predicate taking string argument', () => {
const source = `forall $btn in elements('.button'):\n matchesSelector($btn, '.primary')`
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula)
})
it('parses: forall with predicate taking numeric argument', () => {
const source = `forall $btn in elements('.button'):\n minWidth($btn, 44)`
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula)
})
it('parses: multiple independent quantifiers in program', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b')\n\nexists $c in elements('.c'):\n above($c, '.d')`
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0)
const formulas = result.ast.children.filter(
(child: any) => child.type === 'ForAll' || child.type === 'Exists'
)
assert.strictEqual(formulas.length, 2)
})
})
// ---------------------------------------------------------------------------
// End-to-End Lowering: DSL AST -> Solver FormulaNode
// ---------------------------------------------------------------------------
describe('FOL Dense DSL - Solver Formula Lowering', () => {
it('compileDenseFOLToFormula: forall -> solver forall', () => {
const source = `forall $btn in elements('.button'):\n leftOf($btn, '.label')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual(solverFormula.type, 'FormulaNode')
assert.strictEqual(solverFormula.kind, 'forall')
assert.ok(Array.isArray(solverFormula.bindings))
assert.strictEqual(solverFormula.bindings.length, 1)
assert.strictEqual(solverFormula.bindings[0].type, 'TupleBinding')
assert.deepStrictEqual(solverFormula.bindings[0].variables, ['btn'])
assert.strictEqual(solverFormula.bindings[0].domain.type, 'DomainRef')
assert.strictEqual(solverFormula.bindings[0].domain.domain, 'elements')
assert.strictEqual(solverFormula.bindings[0].domain.selector, '.button')
})
it('compileDenseFOLToFormula: exists -> solver exists', () => {
const source = `exists $title in elements('.title'):\n above($title, '.content')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual(solverFormula.type, 'FormulaNode')
assert.strictEqual(solverFormula.kind, 'exists')
assert.strictEqual(solverFormula.bindings[0].variables[0], 'title')
})
it('compileDenseFOLToFormula: and -> solver and', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual(solverFormula.type, 'FormulaNode')
assert.strictEqual(solverFormula.kind, 'forall')
const andBody = (solverFormula as any).body
assert.strictEqual(andBody.type, 'FormulaNode')
assert.strictEqual(andBody.kind, 'and')
})
it('compileDenseFOLToFormula: or -> solver or', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual((solverFormula as any).body.kind, 'or')
})
it('compileDenseFOLToFormula: not -> solver not', () => {
const source = `forall $a in elements('.a'):\n not leftOf($a, '.b')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual((solverFormula as any).body.kind, 'not')
})
it('compileDenseFOLToFormula: implies -> solver implies', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') implies above($a, '.c')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
const impliesBody = (solverFormula as any).body
assert.strictEqual(impliesBody.kind, 'implies')
assert.ok(impliesBody.antecedent)
assert.ok(impliesBody.consequent)
})
it('compileDenseFOLToFormula: nested forall+exists', () => {
const source = `forall $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual(solverFormula.kind, 'forall')
const existsBody = (solverFormula as any).body
assert.strictEqual(existsBody.kind, 'exists')
const predBody = existsBody.body
assert.strictEqual(predBody.kind, 'predicate')
assert.strictEqual(predBody.predicate, 'leftOf')
assert.strictEqual(predBody.args.length, 2)
assert.strictEqual(predBody.args[0].type, 'VariableRef')
assert.strictEqual(predBody.args[0].name, 'a')
assert.strictEqual(predBody.args[1].type, 'VariableRef')
assert.strictEqual(predBody.args[1].name, 'b')
})
it('compileDenseFOLToFormula: descendants($var, selector) maps selector + parentVar correctly', () => {
const source = `forall $card in elements('.card'):\n exists $title in descendants($card, '.title'):\n above($title, '.content')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula) as any
assert.strictEqual(solverFormula.kind, 'forall')
const existsBody = solverFormula.body
assert.strictEqual(existsBody.kind, 'exists')
assert.strictEqual(existsBody.bindings[0].domain.domain, 'descendants')
assert.strictEqual(existsBody.bindings[0].domain.selector, '.title')
assert.strictEqual(existsBody.bindings[0].domain.parentVar, '$card')
})
it('extractFOLFormulas returns formulas from mixed AST', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b')\n\n'[data-testid="x"]' above '[data-testid="y"]'`
const result = parseSpec(source)
const formulas = extractFOLFormulas(result.ast)
assert.strictEqual(formulas.length, 1)
assert.strictEqual(formulas[0].type, 'ForAll')
})
it('extractFOLFormulas ignores non-FOL assertions', () => {
const source = `'[data-testid="x"]' above '[data-testid="y"]'`
const result = parseSpec(source)
const formulas = extractFOLFormulas(result.ast)
assert.strictEqual(formulas.length, 0)
})
it('extractFOLFormulas finds formulas nested in frame blocks', () => {
const source = `in viewport:\n forall $cat in elements('.showroom-category'):\n width($cat) >= 200`
const result = parseSpec(source)
const formulas = extractFOLFormulas(result.ast)
assert.strictEqual(formulas.length, 1)
assert.strictEqual(formulas[0].type, 'ForAll')
})
})
// ---------------------------------------------------------------------------
// Gap Reporting Summary
// ---------------------------------------------------------------------------
describe('FOL Dense DSL - Gap Detection', () => {
// These tests document known gaps in the dense DSL FOL support
it('GAP: dense DSL does not support size between assertions', () => {
// between is only in fluent API, not dense DSL grammar
const source = `forall $btn in elements('.button'):\n between($btn, 44, 100)`
const result = parseSpec(source)
// May parse as predicate call or fail
assert.ok(result.diagnostics.length > 0 || result.ast.children.length === 0,
'Expected parse failure or no formula for between in dense DSL')
})
it('GAP: dense DSL does not support separatedFrom relation', () => {
const source = `forall $a in elements('.a'):\n separatedFrom($a, '.b')`
const result = parseSpec(source)
// separatedFrom may not be in keyword list
assert.ok(result.diagnostics.length >= 0)
})
it('GAP: dense DSL does not support contains relation', () => {
// contains exists in fluent but may not parse in dense
const source = `forall $a in elements('.a'):\n contains($a, '.b')`
const result = parseSpec(source)
// This might parse as generic predicate
const formula = getFormula(result.ast)
if (formula) {
assert.ok(true, 'contains parsed as generic predicate')
}
})
})
@@ -0,0 +1,311 @@
// FOL Equivalence Property Tests
// Verifies that fluent FOL API and string FOL DSL lower to equivalent logic IR.
import { describe, it } from 'node:test'
import assert from 'node:assert'
import fc from 'fast-check'
import { forAll, exists, predicate, domain } from './logic-fluent.js'
import { parseSpec } from './parser.js'
import { lowerFormula, type LogicIrNode } from './logic-compiler.js'
// ---------------------------------------------------------------------------
// Generators
// ---------------------------------------------------------------------------
const varNameArb = fc.constantFrom('x', 'y', 'z', 'btn', 'card', 'item')
const selectorArb = fc
.string({ minLength: 1, maxLength: 20 })
.map((s) => s.replace(/['"\n\r]/g, '').trim())
.filter((s) => s.length > 0 && /[a-zA-Z0-9_-]/.test(s))
const predicateNameArb = fc.constantFrom(
'leftOf',
'rightOf',
'above',
'below',
'inside',
'overlaps',
'alignedWith',
'centeredWithin',
)
// Note: 'custom' domain kind is excluded because fluent API and string DSL
// have divergent semantics for custom domains.
// Fluent: domain.custom(name, selector?) -> kind=name, selector=selector
// String: custom('selector') -> kind='custom', selector='selector'
// These cannot be made equivalent without API changes.
const domainKindArb = fc.constantFrom('elements', 'lineBoxes')
// Build a fluent FOL formula from generated parameters
function buildFluentFormula(
varName: string,
domKind: string,
selector: string,
predName: string,
refSelector: string,
) {
const dom =
domKind === 'lineBoxes'
? domain.lineBoxes(selector)
: domKind === 'custom'
? domain.custom(selector)
: domain.elements(selector)
return forAll(varName, dom, () => predicate(predName, { type: 'VariableRef', name: varName }, refSelector))
}
// Build string FOL source from generated parameters
function buildStringFol(
varName: string,
domKind: string,
selector: string,
predName: string,
refSelector: string,
): string {
return `forall $${varName} in ${domKind}('${selector}'):\n ${predName}($${varName}, '${refSelector}')`
}
// Compare logic IR nodes, ignoring origin field
function logicIrEquivalent(a: LogicIrNode, b: LogicIrNode): boolean {
if (a.type !== b.type) return false
const bAny = b as any
switch (a.type) {
case 'LogicForAll':
case 'LogicExists': {
const aAny = a as any
return (
logicIrEquivalent(aAny.variable, bAny.variable) &&
logicIrEquivalent(aAny.domain, bAny.domain) &&
logicIrEquivalent(aAny.body, bAny.body)
)
}
case 'LogicAnd':
case 'LogicOr':
case 'LogicImplies': {
const aAny = a as any
return logicIrEquivalent(aAny.left, bAny.left) && logicIrEquivalent(aAny.right, bAny.right)
}
case 'LogicNot': {
const aAny = a as any
return logicIrEquivalent(aAny.operand, bAny.operand)
}
case 'LogicPredicate': {
const aAny = a as any
if (aAny.name !== bAny.name) return false
if (aAny.args.length !== bAny.args.length) return false
for (let i = 0; i < aAny.args.length; i++) {
if (typeof aAny.args[i] === 'object') {
if (!logicIrEquivalent(aAny.args[i], bAny.args[i])) return false
} else {
if (aAny.args[i] !== bAny.args[i]) return false
}
}
return true
}
case 'LogicVariable': {
const aAny = a as any
return aAny.name === bAny.name
}
case 'LogicDomain': {
const aAny = a as any
return aAny.kind === bAny.kind && aAny.selector === bAny.selector
}
default:
return false
}
}
// ---------------------------------------------------------------------------
// Properties
// ---------------------------------------------------------------------------
describe('FOL equivalence property tests', () => {
it('property: fluent FOL and string FOL lower to equivalent logic IR', () => {
fc.assert(
fc.property(
varNameArb,
domainKindArb,
selectorArb,
predicateNameArb,
selectorArb,
(varName, domKind, selector, predName, refSelector) => {
const fluentFormula = buildFluentFormula(varName, domKind, selector, predName, refSelector)
const stringResult = parseSpec(buildStringFol(varName, domKind, selector, predName, refSelector))
if (stringResult.diagnostics.length > 0) {
return true
}
// Extract formula from parsed program
const formulaNodes = stringResult.ast.children.filter(
(child: any) => child.type === 'ForAll' || child.type === 'Exists',
)
if (formulaNodes.length === 0) {
return true
}
const fluentIr = lowerFormula(fluentFormula.node, { origin: 'fluent' })
const stringIr = lowerFormula(formulaNodes[0] as any, { origin: 'dense' })
return logicIrEquivalent(fluentIr, stringIr)
},
),
{ numRuns: 150, seed: 55 },
)
})
it('property: exists quantifier lowers equivalently', () => {
fc.assert(
fc.property(
varNameArb,
domainKindArb,
selectorArb,
predicateNameArb,
selectorArb,
(varName, domKind, selector, predName, refSelector) => {
const dom =
domKind === 'lineBoxes'
? domain.lineBoxes(selector)
: domain.elements(selector)
const fluentFormula = exists(varName, dom, () =>
predicate(predName, { type: 'VariableRef', name: varName }, refSelector),
)
const source = `exists $${varName} in ${domKind}('${selector}'):\n ${predName}($${varName}, '${refSelector}')`
const stringResult = parseSpec(source)
if (stringResult.diagnostics.length > 0) {
return true
}
const formulaNodes = stringResult.ast.children.filter(
(child: any) => child.type === 'ForAll' || child.type === 'Exists',
)
if (formulaNodes.length === 0) {
return true
}
const fluentIr = lowerFormula(fluentFormula.node, { origin: 'fluent' })
const stringIr = lowerFormula(formulaNodes[0] as any, { origin: 'dense' })
return logicIrEquivalent(fluentIr, stringIr)
},
),
{ numRuns: 100, seed: 66 },
)
})
it('property: boolean connectives lower equivalently', () => {
fc.assert(
fc.property(
varNameArb,
domainKindArb,
selectorArb,
predicateNameArb,
selectorArb,
predicateNameArb,
selectorArb,
(varName, domKind, selector, pred1, ref1, pred2, ref2) => {
const dom =
domKind === 'lineBoxes'
? domain.lineBoxes(selector)
: domain.elements(selector)
const fluentFormula = forAll(varName, dom, () =>
predicate(pred1, { type: 'VariableRef', name: varName }, ref1).and(
predicate(pred2, { type: 'VariableRef', name: varName }, ref2),
),
)
const source =
`forall $${varName} in ${domKind}('${selector}'):\n` +
` ${pred1}($${varName}, '${ref1}') and ${pred2}($${varName}, '${ref2}')`
const stringResult = parseSpec(source)
if (stringResult.diagnostics.length > 0) {
return true
}
const formulaNodes = stringResult.ast.children.filter(
(child: any) => child.type === 'ForAll' || child.type === 'Exists',
)
if (formulaNodes.length === 0) {
return true
}
const fluentIr = lowerFormula(fluentFormula.node, { origin: 'fluent' })
const stringIr = lowerFormula(formulaNodes[0] as any, { origin: 'dense' })
return logicIrEquivalent(fluentIr, stringIr)
},
),
{ numRuns: 100, seed: 77 },
)
})
it('property: nested quantifiers lower equivalently', () => {
fc.assert(
fc.property(
varNameArb,
varNameArb,
selectorArb,
selectorArb,
predicateNameArb,
(v1, v2, sel1, sel2, pred) => {
const fluentFormula = forAll(v1, domain.elements(sel1), () =>
exists(v2, domain.elements(sel2), () =>
predicate(pred, { type: 'VariableRef', name: v1 }, { type: 'VariableRef', name: v2 }),
),
)
const source =
`forall $${v1} in elements('${sel1}'):\n` +
` exists $${v2} in elements('${sel2}'):\n` +
` ${pred}($${v1}, $${v2})`
const stringResult = parseSpec(source)
if (stringResult.diagnostics.length > 0) {
return true
}
const formulaNodes = stringResult.ast.children.filter(
(child: any) => child.type === 'ForAll' || child.type === 'Exists',
)
if (formulaNodes.length === 0) {
return true
}
const fluentIr = lowerFormula(fluentFormula.node, { origin: 'fluent' })
const stringIr = lowerFormula(formulaNodes[0] as any, { origin: 'dense' })
return logicIrEquivalent(fluentIr, stringIr)
},
),
{ numRuns: 100, seed: 88 },
)
})
it('property: logic IR lowering is deterministic', () => {
fc.assert(
fc.property(
varNameArb,
domainKindArb,
selectorArb,
predicateNameArb,
selectorArb,
(varName, domKind, selector, predName, refSelector) => {
const fluentFormula = buildFluentFormula(varName, domKind, selector, predName, refSelector)
const ir1 = lowerFormula(fluentFormula.node, { origin: 'fluent' })
const ir2 = lowerFormula(fluentFormula.node, { origin: 'fluent' })
return logicIrEquivalent(ir1, ir2)
},
),
{ numRuns: 100, seed: 99 },
)
})
})
@@ -0,0 +1,508 @@
// FOL Fluent API Combination Tests
// Systematically tests every First-Order Logic combination in fluent API syntax
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
expect,
FluentAssertion,
FluentBinding,
FluentQuantifier,
FluentRelation,
} from './fluent.js'
import {
forAll,
exists,
predicate,
domain,
FluentFormula,
} from './logic-fluent.js'
import { lowerFormula } from './logic-compiler.js'
import { validateFormula } from './logic-validator.js'
// ---------------------------------------------------------------------------
// Test Helpers
// ---------------------------------------------------------------------------
function testBuilds(name: string, buildFn: () => FluentFormula | FluentQuantifier | null) {
it(`builds: ${name}`, () => {
const result = buildFn()
assert.ok(result, `Expected result but got null/undefined`)
})
}
function testLowers(name: string, buildFn: () => FluentFormula, expectedType: string) {
it(`lowers: ${name}`, () => {
const formula = buildFn()
const ir = lowerFormula(formula.node)
assert.strictEqual(ir.type, expectedType, `Expected ${expectedType} but got ${ir.type}`)
})
}
function testValidates(name: string, buildFn: () => FluentFormula, expectedValid: boolean) {
it(`validates: ${name}`, () => {
const formula = buildFn()
const validation = validateFormula(formula.node)
assert.strictEqual(validation.valid, expectedValid, `Expected valid=${expectedValid} but got: ${validation.diagnostics.map(d => d.message).join(', ')}`)
})
}
// ---------------------------------------------------------------------------
// Fluent API - Simple Quantifiers (FluentAssertion static methods)
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Simple Quantifiers (FluentAssertion)', () => {
it('forAll builds with single assertion', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) =>
btn.expect().to.be.above('.label'),
)
assert.strictEqual(quantifier.kind, 'forAll')
assert.strictEqual(quantifier.bindings.length, 1)
})
it('forAll builds with multiple assertions', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) => {
const rel1 = btn.expect().to.be.above('.label')
btn.addAssertion(rel1.toAst())
const rel2 = btn.expect().to.be.leftOf('.icon')
btn.addAssertion(rel2.toAst())
})
const assertions = quantifier.bindings[0].getAssertions()
assert.strictEqual(assertions.length, 2)
})
it('exists builds with single assertion', () => {
const quantifier = FluentAssertion.exists('.tooltip', (tip) =>
tip.expect().to.be.inside('.container'),
)
assert.strictEqual(quantifier.kind, 'exists')
assert.strictEqual(quantifier.bindings.length, 1)
})
it('forAll over multiple selectors', () => {
const quantifier = FluentAssertion.forAll(['.a', '.b'], (el) =>
el.expect().to.be.leftOf('.c'),
)
assert.strictEqual(quantifier.bindings.length, 2)
})
it('forAll lowers to ForAll AST', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) =>
btn.expect().to.be.above('.label'),
)
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'ForAll')
})
it('exists lowers to Exists AST', () => {
const quantifier = FluentAssertion.exists('.modal', (modal) =>
modal.expect().to.be.centeredWithin('.viewport'),
)
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'Exists')
})
})
// ---------------------------------------------------------------------------
// Fluent API - Quantifiers with Boolean Connectives (logic-fluent)
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Quantifiers with Connectives', () => {
it('forAll with conjunction via multiple assertions', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) => {
const rel1 = btn.expect().to.be.atLeast(44, 'width')
btn.addAssertion(rel1.toAst())
const rel2 = btn.expect().to.be.atLeast(44, 'height')
btn.addAssertion(rel2.toAst())
return undefined
})
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'ForAll')
assert.strictEqual((formula as any).body.type, 'And')
})
it('forAll with conjunction via logic-fluent .and()', () => {
const formula = forAll('btn', domain.elements('.button'), (btn) =>
predicate('width', btn, 44).and(
predicate('height', btn, 44)
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'And')
})
it('forAll with disjunction via logic-fluent .or()', () => {
const formula = forAll('item', domain.elements('.item'), (item) =>
predicate('leftOf', item, '.sidebar').or(
predicate('above', item, '.footer')
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'Or')
})
it('forAll with implication via logic-fluent .implies()', () => {
const formula = forAll('modal', domain.elements('.modal'), (modal) =>
predicate('visible', modal).implies(
predicate('centeredWithin', modal, 'viewport')
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'Implies')
})
it('forAll with negation via logic-fluent .not()', () => {
const formula = forAll('el', domain.elements('.error'), (el) =>
predicate('overlaps', el, '.success').not()
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'Not')
})
})
// ---------------------------------------------------------------------------
// Fluent API - Nested Quantifiers
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Nested Quantifiers', () => {
it('forall + exists nesting', () => {
const formula = forAll('card', domain.elements('.card'), (card) =>
exists('title', domain.descendants(card, '.title'), (title) =>
predicate('above', title, '.content')
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'Exists')
})
it('exists + forall nesting', () => {
const formula = exists('container', domain.elements('.container'), (container) =>
forAll('item', domain.descendants(container, '.item'), (item) =>
predicate('inside', item, container)
)
)
assert.strictEqual(formula.node.type, 'Exists')
assert.strictEqual((formula.node as any).body.type, 'ForAll')
})
it('forall + forall nesting', () => {
const formula = forAll('a', domain.elements('.a'), (a) =>
forAll('b', domain.elements('.b'), (b) =>
predicate('leftOf', a, b)
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'ForAll')
})
it('exists + exists nesting', () => {
const formula = exists('a', domain.elements('.a'), (a) =>
exists('b', domain.elements('.b'), (b) =>
predicate('leftOf', a, b)
)
)
assert.strictEqual(formula.node.type, 'Exists')
assert.strictEqual((formula.node as any).body.type, 'Exists')
})
it('triple nesting', () => {
const formula = forAll('a', domain.elements('.a'), (a) =>
forAll('b', domain.elements('.b'), (b) =>
exists('c', domain.elements('.c'), (c) =>
predicate('leftOf', a, c).and(
predicate('above', b, c)
)
)
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'ForAll')
assert.strictEqual((formula.node as any).body.body.type, 'Exists')
})
})
// ---------------------------------------------------------------------------
// Fluent API - Complex Combinations
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Complex Combinations', () => {
it('forall + exists + and + or', () => {
const formula = forAll('a', domain.elements('.a'), (a) =>
exists('b', domain.elements('.b'), (b) =>
predicate('leftOf', a, b).or(
predicate('above', a, b)
).and(
predicate('overlaps', a, b).not()
)
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'Exists')
})
it('forall + implies + exists', () => {
const formula = forAll('btn', domain.elements('.button'), (btn) =>
predicate('visible', btn).implies(
exists('label', domain.elements('.label'), (label) =>
predicate('above', label, btn)
)
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'Implies')
assert.strictEqual((formula.node as any).body.right.type, 'Exists')
})
it('forall + or + not', () => {
const formula = forAll('item', domain.elements('.item'), (item) =>
predicate('leftOf', item, '.sidebar').or(
predicate('overlaps', item, '.overlay').not()
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'Or')
})
it('not + forall', () => {
const inner = forAll('btn', domain.elements('.button'), (btn) =>
predicate('width', btn, 44)
)
const formula = inner.not()
assert.strictEqual(formula.node.type, 'Not')
assert.strictEqual((formula.node as any).operand.type, 'ForAll')
})
it('not + exists', () => {
const inner = exists('btn', domain.elements('.button'), (btn) =>
predicate('width', btn, 44)
)
const formula = inner.not()
assert.strictEqual(formula.node.type, 'Not')
assert.strictEqual((formula.node as any).operand.type, 'Exists')
})
it('forall + complex nested connectives', () => {
const formula = forAll('a', domain.elements('.a'), (a) =>
forAll('b', domain.elements('.b'), (b) =>
predicate('leftOf', a, b).and(
predicate('above', a, b)
).implies(
predicate('overlaps', a, b).not()
)
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'ForAll')
})
it('not + exists + and', () => {
const inner = exists('a', domain.elements('.a'), (a) =>
predicate('leftOf', a, '.b').and(
predicate('above', a, '.c')
)
)
const formula = inner.not()
assert.strictEqual(formula.node.type, 'Not')
assert.strictEqual((formula.node as any).operand.type, 'Exists')
})
})
// ---------------------------------------------------------------------------
// Fluent API - Lowering Verification
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Lowering Verification', () => {
testLowers('forAll lowers to LogicForAll', () =>
forAll('btn', domain.elements('.button'), (btn) =>
predicate('width', btn, 44)
), 'LogicForAll')
testLowers('exists lowers to LogicExists', () =>
exists('title', domain.elements('.title'), (title) =>
predicate('above', title, '.content')
), 'LogicExists')
testLowers('and lowers to LogicAnd', () =>
forAll('a', domain.elements('.a'), (a) =>
predicate('leftOf', a, '.b').and(
predicate('above', a, '.c')
)
), 'LogicForAll')
testLowers('or lowers to LogicOr', () =>
forAll('a', domain.elements('.a'), (a) =>
predicate('leftOf', a, '.b').or(
predicate('above', a, '.c')
)
), 'LogicForAll')
testLowers('not lowers to LogicNot', () =>
forAll('a', domain.elements('.a'), (a) =>
predicate('leftOf', a, '.b').not()
), 'LogicForAll')
testLowers('implies lowers to LogicImplies', () =>
forAll('a', domain.elements('.a'), (a) =>
predicate('leftOf', a, '.b').implies(
predicate('above', a, '.c')
)
), 'LogicForAll')
testLowers('nested forall+exists lowers correctly', () =>
forAll('a', domain.elements('.a'), (a) =>
exists('b', domain.elements('.b'), (b) =>
predicate('leftOf', a, b)
)
), 'LogicForAll')
})
// ---------------------------------------------------------------------------
// Fluent API - Validation Verification
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Validation Verification', () => {
testValidates('closed forAll is valid', () =>
forAll('btn', domain.elements('.button'), (btn) =>
predicate('width', btn, 44)
), true)
testValidates('free variable detected', () => {
// Use wrong variable name in body
const wrongVar: any = { type: 'VariableRef', name: 'bad' }
return forAll('btn', domain.elements('.button'), (_btn) =>
predicate('width', wrongVar, 44)
)
}, false)
testValidates('nested quantifiers with correct binding', () =>
forAll('card', domain.elements('.card'), (card) =>
exists('title', domain.descendants(card, '.title'), (title) =>
predicate('above', title, '.content')
)
), true)
testValidates('variable capture across nested scopes', () =>
forAll('card', domain.elements('.card'), (card) =>
exists('title', domain.descendants(card, '.title'), (title) =>
predicate('above', title, card)
)
), true)
})
// ---------------------------------------------------------------------------
// Fluent API - Edge Cases
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Edge Cases', () => {
it('predicate with no arguments', () => {
const formula = predicate('true')
assert.strictEqual(formula.node.type, 'PredicateCall')
assert.strictEqual((formula.node as any).args.length, 0)
})
it('predicate with string argument', () => {
const formula = predicate('matchesSelector', '.primary')
assert.strictEqual(formula.node.type, 'PredicateCall')
assert.strictEqual((formula.node as any).args[0], '.primary')
})
it('predicate with numeric argument', () => {
const formula = predicate('minWidth', 44)
assert.strictEqual(formula.node.type, 'PredicateCall')
assert.strictEqual((formula.node as any).args[0], 44)
})
it('predicate with mixed arguments', () => {
const btn = { type: 'VariableRef' as const, name: 'btn' }
const formula = predicate('minWidth', btn, 44)
assert.strictEqual(formula.node.type, 'PredicateCall')
assert.strictEqual((formula.node as any).args.length, 2)
})
it('empty forAll throws on toFormula', () => {
const quantifier = FluentAssertion.forAll('.button', (_btn) => {
// Intentionally empty
})
assert.throws(() => quantifier.toFormula(), /produced no assertions/)
})
it('empty exists throws on toFormula', () => {
const quantifier = FluentAssertion.exists('.tooltip', (_tip) => {
// Intentionally empty
})
assert.throws(() => quantifier.toFormula(), /produced no assertions/)
})
})
// ---------------------------------------------------------------------------
// Fluent API - Inline Quantifier Chains (all/any/none)
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Inline Quantifier Chains', () => {
it('all quantifier on expect()', () => {
// expect().all returns FluentAssertion, then .to.be... returns FluentRelation
const assertion = expect('.button')
assertion.all
const rel = assertion.to.be.atLeast(44, 'width')
// rel is FluentRelation, not FluentAssertion
assert.ok(rel instanceof FluentRelation)
})
it('any quantifier on expect()', () => {
const assertion = expect('.item')
assertion.any
const rel = assertion.to.be.above('.footer')
assert.ok(rel instanceof FluentRelation)
})
it('none quantifier on expect()', () => {
const assertion = expect('.error')
assertion.none
const rel = assertion.to.be.overlaps('.success')
assert.ok(rel instanceof FluentRelation)
})
})
// ---------------------------------------------------------------------------
// Gap Detection Summary
// ---------------------------------------------------------------------------
describe('FOL Fluent API - Gap Detection', () => {
it('GAP: FluentAssertion.forAll does not support direct logic-fluent connectives', () => {
// FluentAssertion.forAll collects assertions, not formula nodes
// You cannot use .and/.or/.implies inside the callback directly
const quantifier = FluentAssertion.forAll('.button', (btn) =>
btn.expect().to.be.above('.label')
)
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'ForAll')
// Body is wrapped in a reference forall, then PredicateCall
assert.strictEqual((formula as any).body.type, 'ForAll')
assert.strictEqual((formula as any).body.body.type, 'PredicateCall')
})
it('GAP: FluentAssertion.exists does not support disjunction in single callback', () => {
// To build OR, you must use logic-fluent API separately
const quantifier = FluentAssertion.exists('.item', (item) =>
item.expect().to.be.leftOf('.sidebar')
)
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'Exists')
// Body is wrapped in a reference forall, then PredicateCall
assert.strictEqual((formula as any).body.type, 'ForAll')
assert.strictEqual((formula as any).body.body.type, 'PredicateCall')
})
it('WORKAROUND: Use logic-fluent for complex connectives', () => {
const formula = forAll('item', domain.elements('.item'), (item) =>
predicate('leftOf', item, '.sidebar').or(
predicate('above', item, '.footer')
)
)
assert.strictEqual(formula.node.type, 'ForAll')
assert.strictEqual((formula.node as any).body.type, 'Or')
})
})
+328
View File
@@ -0,0 +1,328 @@
// FOL parser property-based tests
// Validates that parse -> lower -> meaning is stable and equivalent
// between fluent and dense DSL.
import { describe, it } from 'node:test'
import assert from 'node:assert'
import fc from 'fast-check'
import { parseSpec } from './parser.js'
import { tokenize } from './lexer.js'
import { parseGrammar, type FormulaNode } from './grammar.js'
import { lowerFormula, lowerProgram } from './logic-compiler.js'
import { validateFormula, validateProgram } from './logic-validator.js'
import { forAll, exists, predicate, domain } from './logic-fluent.js'
// ---------------------------------------------------------------------------
// Arbitraries
// ---------------------------------------------------------------------------
const identifierArb = fc.string({ minLength: 1, maxLength: 10 }).map(s => s.replace(/[^a-zA-Z0-9_]/g, '')).filter(s => s.length > 0)
const selectorArb = fc.string({ minLength: 1, maxLength: 20 }).map(s => s.trim()).filter(s => s.length > 0 && /[a-zA-Z0-9_]/.test(s))
const variableArb = fc.string({ minLength: 1, maxLength: 5 }).map(s => s.replace(/[^a-zA-Z0-9_]/g, '')).filter(s => s.length > 0)
const numberArgArb = fc.integer({ min: 0, max: 1000 })
const predicateNameArb = fc.constantFrom('width', 'height', 'leftOf', 'above', 'inside', 'overlaps')
const domainKindArb = fc.constantFrom('elements', 'descendants', 'lineBoxes')
const atomicFormulaArb: fc.Arbitrary<FormulaNode> = fc
.tuple(predicateNameArb, fc.array(fc.oneof(variableArb, selectorArb, numberArgArb), { minLength: 0, maxLength: 2 }))
.map(([name, args]) => {
return {
type: 'PredicateCall' as const,
name,
args: args.map((arg) => {
if (typeof arg === 'number') return arg
if (arg.startsWith('$')) return { type: 'VariableRef' as const, name: arg.slice(1) }
// Heuristic: short strings are variables, long ones are selectors
if (arg.length <= 3) return { type: 'VariableRef' as const, name: arg }
return arg
}),
}
})
const formulaArb = fc.letrec((tie) => ({
formula: fc.oneof(
{ weight: 5, arbitrary: atomicFormulaArb },
{ weight: 2, arbitrary: fc.tuple(variableArb, domainKindArb, fc.option(selectorArb), tie('formula')).map(([v, kind, sel, body]) => ({
type: 'ForAll' as const,
variable: { type: 'VariableRef' as const, name: v },
domain: { type: 'DomainRef' as const, kind, selector: sel || undefined },
body,
})) },
{ weight: 2, arbitrary: fc.tuple(variableArb, domainKindArb, fc.option(selectorArb), tie('formula')).map(([v, kind, sel, body]) => ({
type: 'Exists' as const,
variable: { type: 'VariableRef' as const, name: v },
domain: { type: 'DomainRef' as const, kind, selector: sel || undefined },
body,
})) },
{ weight: 2, arbitrary: fc.tuple(tie('formula'), tie('formula')).map(([l, r]) => ({
type: 'And' as const,
left: l,
right: r,
})) },
{ weight: 2, arbitrary: fc.tuple(tie('formula'), tie('formula')).map(([l, r]) => ({
type: 'Or' as const,
left: l,
right: r,
})) },
{ weight: 1, arbitrary: tie('formula').map((operand) => ({
type: 'Not' as const,
operand,
})) },
{ weight: 1, arbitrary: fc.tuple(tie('formula'), tie('formula')).map(([l, r]) => ({
type: 'Implies' as const,
left: l,
right: r,
})) },
),
})).formula as fc.Arbitrary<FormulaNode>
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formulaToDense(formula: FormulaNode): string {
switch (formula.type) {
case 'ForAll':
return `forall $${formula.variable.name} in ${formula.domain.kind}${formula.domain.selector ? `('${formula.domain.selector}')` : ''}:\n ${formulaToDense(formula.body)}`
case 'Exists':
return `exists $${formula.variable.name} in ${formula.domain.kind}${formula.domain.selector ? `('${formula.domain.selector}')` : ''}:\n ${formulaToDense(formula.body)}`
case 'And':
return `${formulaToDense(formula.left)} and ${formulaToDense(formula.right)}`
case 'Or':
return `${formulaToDense(formula.left)} or ${formulaToDense(formula.right)}`
case 'Not':
return `not ${formulaToDense(formula.operand)}`
case 'Implies':
return `${formulaToDense(formula.left)} implies ${formulaToDense(formula.right)}`
case 'PredicateCall':
return `${formula.name}(${formula.args.map(a => typeof a === 'object' && a !== null && a.type === 'VariableRef' ? `$${a.name}` : String(a)).join(', ')})`
default:
return ''
}
}
function formulaToFluent(formula: FormulaNode): FormulaNode {
// Fluent and dense AST should be isomorphic for the same formula
return formula
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('fol parser property tests', () => {
it('property: generated formulas tokenize without crashing', () => {
fc.assert(
fc.property(formulaArb, (formula) => {
const dense = formulaToDense(formula)
const tokens = tokenize(dense)
assert.ok(tokens.length > 0)
assert.strictEqual(tokens[tokens.length - 1].kind, 'eof')
}),
{ numRuns: 200, seed: 42 }
)
})
it('property: generated formulas parse without crashing', () => {
fc.assert(
fc.property(formulaArb, (formula) => {
const dense = formulaToDense(formula)
const result = parseSpec(dense)
// Should either parse or emit diagnostics, never throw
assert.ok(result.ast != null)
assert.ok(Array.isArray(result.diagnostics))
}),
{ numRuns: 200, seed: 123 }
)
})
it('property: valid closed formulas pass validation', () => {
fc.assert(
fc.property(
fc.tuple(variableArb, domainKindArb, fc.option(selectorArb), predicateNameArb).map(([v, kind, sel, pred]) => {
// Build a closed formula: forall v in domain: pred(v)
const formula: FormulaNode = {
type: 'ForAll',
variable: { type: 'VariableRef', name: v },
domain: { type: 'DomainRef', kind, selector: sel || undefined },
body: {
type: 'PredicateCall',
name: pred,
args: [{ type: 'VariableRef', name: v }],
},
}
return formula
}),
(formula) => {
const result = validateFormula(formula)
assert.strictEqual(result.valid, true, `Expected valid but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
assert.strictEqual(result.diagnostics.length, 0)
}
),
{ numRuns: 200, seed: 77 }
)
})
it('property: free variables are detected', () => {
fc.assert(
fc.property(
fc.tuple(variableArb, variableArb, predicateNameArb).filter(([a, b]) => a !== b).map(([bound, free, pred]) => {
// forall bound in elements: pred(free) — free is unbound
const formula: FormulaNode = {
type: 'ForAll',
variable: { type: 'VariableRef', name: bound },
domain: { type: 'DomainRef', kind: 'elements', selector: '.test' },
body: {
type: 'PredicateCall',
name: pred,
args: [{ type: 'VariableRef', name: free }],
},
}
return formula
}),
(formula) => {
const result = validateFormula(formula)
assert.strictEqual(result.valid, false)
assert.ok(result.diagnostics.some(d => d.code === 'IMH_LOGIC_FREE_VARIABLE'))
}
),
{ numRuns: 200, seed: 88 }
)
})
it('property: lowering preserves node type structure', () => {
fc.assert(
fc.property(formulaArb, (formula) => {
const denseIr = lowerFormula(formula, { origin: 'dense' })
const fluentIr = lowerFormula(formulaToFluent(formula), { origin: 'fluent' })
// Both should have the same structural type, differing only in origin
assert.strictEqual(denseIr.type, fluentIr.type)
// Origins should differ
assert.strictEqual(denseIr.origin, 'dense')
assert.strictEqual(fluentIr.origin, 'fluent')
}),
{ numRuns: 200, seed: 99 }
)
})
it('property: lowering is idempotent on structure', () => {
fc.assert(
fc.property(formulaArb, (formula) => {
const once = lowerFormula(formula)
const twice = lowerFormula(formula)
// Structural equality (deep compare by serializing)
assert.deepStrictEqual(once, twice)
}),
{ numRuns: 200, seed: 55 }
)
})
it('property: forall + exists quantifiers nest correctly', () => {
fc.assert(
fc.property(
fc.tuple(variableArb, variableArb, predicateNameArb).filter(([a, b]) => a !== b).map(([v1, v2, pred]) => {
const formula: FormulaNode = {
type: 'ForAll',
variable: { type: 'VariableRef', name: v1 },
domain: { type: 'DomainRef', kind: 'elements', selector: '.a' },
body: {
type: 'Exists',
variable: { type: 'VariableRef', name: v2 },
domain: { type: 'DomainRef', kind: 'descendants', selector: '.b' },
body: {
type: 'PredicateCall',
name: pred,
args: [{ type: 'VariableRef', name: v1 }, { type: 'VariableRef', name: v2 }],
},
},
}
return formula
}),
(formula) => {
const result = validateFormula(formula)
assert.strictEqual(result.valid, true)
const ir = lowerFormula(formula)
assert.strictEqual(ir.type, 'LogicForAll')
}
),
{ numRuns: 200, seed: 33 }
)
})
})
// ---------------------------------------------------------------------------
// Unit Tests for Specific Parsing Cases
// ---------------------------------------------------------------------------
describe('fol parser unit tests', () => {
it('parses forall formula', () => {
const source = `forall $btn in elements('.button'):\n width($btn) >= 44`
const tokens = tokenize(source)
const ast = parseGrammar(tokens)
assert.strictEqual(ast.children.length, 1)
const formula = ast.children[0] as any
assert.strictEqual(formula.type, 'ForAll')
assert.strictEqual(formula.variable.name, 'btn')
assert.strictEqual(formula.domain.kind, 'elements')
assert.strictEqual(formula.domain.selector, '.button')
})
it('parses exists formula', () => {
const source = `exists $title in descendants('.card', '.title'):\n above($title, '.content')`
const tokens = tokenize(source)
const ast = parseGrammar(tokens)
assert.strictEqual(ast.children.length, 1)
const formula = ast.children[0] as any
assert.strictEqual(formula.type, 'Exists')
assert.strictEqual(formula.variable.name, 'title')
})
it('parses boolean connectives', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c')`
const tokens = tokenize(source)
const ast = parseGrammar(tokens)
const formula = ast.children[0] as any
assert.strictEqual(formula.body.type, 'And')
assert.strictEqual(formula.body.left.type, 'PredicateCall')
assert.strictEqual(formula.body.right.type, 'PredicateCall')
})
it('parses nested quantifiers', () => {
const source = `forall $card in elements('.card'):\n exists $title in descendants('.title'):\n above($title, '.content')`
const tokens = tokenize(source)
const ast = parseGrammar(tokens)
const outer = ast.children[0] as any
assert.strictEqual(outer.type, 'ForAll')
assert.strictEqual(outer.body.type, 'Exists')
})
it('detects free variables in validation', () => {
const formula: FormulaNode = {
type: 'ForAll',
variable: { type: 'VariableRef', name: 'x' },
domain: { type: 'DomainRef', kind: 'elements', selector: '.x' },
body: {
type: 'PredicateCall',
name: 'width',
args: [{ type: 'VariableRef', name: 'y' }],
},
}
const result = validateFormula(formula)
assert.strictEqual(result.valid, false)
assert.ok(result.diagnostics.some(d => d.message.includes('Free variable')))
})
})
+451
View File
@@ -0,0 +1,451 @@
// Grammar tests for new dense DSL syntax additions
// Covers: between size assertions, contains, separatedFrom, vw/vh units, negation
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { parseSpec } from './parser.js'
import { lowerToCanonical } from './lower-to-canonical.js'
function parse(source: string) {
return parseSpec(source)
}
function firstRelation(result: ReturnType<typeof parse>) {
const block = result.ast.children[0] as any
return block.children[0] as any
}
// ---------------------------------------------------------------------------
// Between Size Assertion Tests
// ---------------------------------------------------------------------------
describe('between size assertions', () => {
it('parses between width', () => {
const result = parse(`in viewport:\n '.button' between 44px and 48px wide`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.type, 'SizeAssertion')
assert.strictEqual(assertion.property, 'width')
assert.strictEqual(assertion.bounds.min.value, 44)
assert.strictEqual(assertion.bounds.min.unit, 'px')
assert.strictEqual(assertion.bounds.max.value, 48)
assert.strictEqual(assertion.bounds.max.unit, 'px')
})
it('parses between height', () => {
const result = parse(`in viewport:\n '.button' between 20px and 40px tall`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.type, 'SizeAssertion')
assert.strictEqual(assertion.property, 'height')
assert.strictEqual(assertion.bounds.min.value, 20)
assert.strictEqual(assertion.bounds.max.value, 40)
})
it('parses between with vw units', () => {
const result = parse(`in viewport:\n '.hero' between 10vw and 20vw wide`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.bounds.min.unit, 'vw')
assert.strictEqual(assertion.bounds.max.unit, 'vw')
})
it('parses between with vh units', () => {
const result = parse(`in viewport:\n '.header' between 5vh and 10vh tall`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.bounds.min.unit, 'vh')
assert.strictEqual(assertion.bounds.max.unit, 'vh')
})
})
// ---------------------------------------------------------------------------
// Contains Relation Tests
// ---------------------------------------------------------------------------
describe('contains relation', () => {
it('parses contains', () => {
const result = parse(`in viewport:\n '.container' contains '.child'`)
const rel = firstRelation(result)
assert.strictEqual(rel.type, 'RelationAssertion')
assert.strictEqual(rel.relation, 'contains')
assert.strictEqual(rel.subject.value, '.container')
assert.strictEqual(rel.reference.value, '.child')
})
it('parses contains with tolerance', () => {
const result = parse(`in viewport:\n '.container' contains '.child' tolerance 2px`)
const rel = firstRelation(result)
assert.strictEqual(rel.relation, 'contains')
assert.strictEqual(rel.options.tolerance.value, 2)
assert.strictEqual(rel.options.tolerance.unit, 'px')
})
})
// ---------------------------------------------------------------------------
// SeparatedFrom Relation Tests
// ---------------------------------------------------------------------------
describe('separatedFrom relation', () => {
it('parses separatedFrom', () => {
const result = parse(`in viewport:\n '.a' separatedFrom '.b'`)
const rel = firstRelation(result)
assert.strictEqual(rel.type, 'RelationAssertion')
assert.strictEqual(rel.relation, 'separatedFrom')
assert.strictEqual(rel.subject.value, '.a')
assert.strictEqual(rel.reference.value, '.b')
})
it('parses separatedFrom with gap range', () => {
const result = parse(`in viewport:\n '.a' separatedFrom '.b' gap 8px..16px`)
const rel = firstRelation(result)
assert.strictEqual(rel.relation, 'separatedFrom')
assert.strictEqual(rel.options.minGap.value.min.value, 8)
assert.strictEqual(rel.options.minGap.value.max.value, 16)
})
})
// ---------------------------------------------------------------------------
// Viewport Unit Tests
// ---------------------------------------------------------------------------
describe('viewport units', () => {
it('parses vw in atLeast width', () => {
const result = parse(`in viewport:\n '.hero' atLeast 50vw wide`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.bounds.min.value, 50)
assert.strictEqual(assertion.bounds.min.unit, 'vw')
})
it('parses vh in atMost height', () => {
const result = parse(`in viewport:\n '.header' atMost 20vh tall`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.bounds.max.value, 20)
assert.strictEqual(assertion.bounds.max.unit, 'vh')
})
it('parses vw in gap option', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' gap 2vw`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.minGap.value, 2)
assert.strictEqual(rel.options.minGap.unit, 'vw')
})
it('parses vh in gap option', () => {
const result = parse(`in viewport:\n '.a' above '.b' gap 3vh`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.minGap.value, 3)
assert.strictEqual(rel.options.minGap.unit, 'vh')
})
it('parses ch in atMost width', () => {
const result = parse(`in viewport:\n '.copy' atMost 60ch wide`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.bounds.max.value, 60)
assert.strictEqual(assertion.bounds.max.unit, 'ch')
})
it('preserves unit metadata through canonical lowering', () => {
const result = parse(`in viewport:\n '.chat' atMost 85vw wide`)
const clause = lowerToCanonical(result.ast)[0] as any
assert.deepStrictEqual(clause.options.max, { value: 85, unit: 'vw' })
})
})
// ---------------------------------------------------------------------------
// Negation Tests
// ---------------------------------------------------------------------------
describe('negation', () => {
it('parses prefix negation before subject', () => {
const result = parse(`in viewport:\n not '.a' leftOf '.b'`)
const rel = firstRelation(result)
assert.strictEqual(rel.type, 'RelationAssertion')
assert.strictEqual(rel.relation, 'leftOf')
assert.strictEqual(rel.negated, true)
assert.strictEqual(rel.subject.value, '.a')
assert.strictEqual(rel.reference.value, '.b')
})
it('parses infix negation before relation', () => {
const result = parse(`in viewport:\n '.a' not leftOf '.b'`)
const rel = firstRelation(result)
assert.strictEqual(rel.relation, 'leftOf')
assert.strictEqual(rel.negated, true)
})
it('parses negation with size assertion', () => {
const result = parse(`in viewport:\n '.button' not atLeast 44px wide`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.type, 'SizeAssertion')
assert.strictEqual(assertion.negated, true)
})
it('parses negation with between', () => {
const result = parse(`in viewport:\n '.button' not between 44px and 48px wide`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.type, 'SizeAssertion')
assert.strictEqual(assertion.negated, true)
assert.strictEqual(assertion.bounds.min.value, 44)
assert.strictEqual(assertion.bounds.max.value, 48)
})
it('parses negation with quantifier', () => {
const result = parse(`in viewport:\n all not '.a' leftOf '.b'`)
const compound = firstRelation(result)
assert.strictEqual(compound.type, 'CompoundAssertion')
assert.strictEqual(compound.quantifier.kind, 'all')
assert.strictEqual(compound.children[0].negated, true)
})
})
// ---------------------------------------------------------------------------
// Combined Features
// ---------------------------------------------------------------------------
describe('combined features', () => {
it('parses negated contains with tolerance', () => {
const result = parse(`in viewport:\n '.container' not contains '.child' tolerance 2px`)
const rel = firstRelation(result)
assert.strictEqual(rel.relation, 'contains')
assert.strictEqual(rel.negated, true)
assert.strictEqual(rel.options.tolerance.value, 2)
})
it('parses separatedFrom with vw gap', () => {
const result = parse(`in viewport:\n '.a' separatedFrom '.b' gap 2vw`)
const rel = firstRelation(result)
assert.strictEqual(rel.relation, 'separatedFrom')
assert.strictEqual(rel.options.minGap.value, 2)
assert.strictEqual(rel.options.minGap.unit, 'vw')
})
})
// ---------------------------------------------------------------------------
// Compound Assertion Tests
// ---------------------------------------------------------------------------
describe('compound assertions', () => {
it('parses and of two relation assertions', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' and above '.c'`)
const compound = firstRelation(result)
assert.strictEqual(compound.type, 'CompoundAssertion')
assert.strictEqual(compound.operator, 'and')
assert.strictEqual(compound.children.length, 2)
assert.strictEqual(compound.children[0].type, 'RelationAssertion')
assert.strictEqual(compound.children[0].relation, 'leftOf')
assert.strictEqual(compound.children[1].type, 'RelationAssertion')
assert.strictEqual(compound.children[1].relation, 'above')
})
it('parses or of two relation assertions', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' or above '.c'`)
const compound = firstRelation(result)
assert.strictEqual(compound.type, 'CompoundAssertion')
assert.strictEqual(compound.operator, 'or')
assert.strictEqual(compound.children.length, 2)
assert.strictEqual(compound.children[0].relation, 'leftOf')
assert.strictEqual(compound.children[1].relation, 'above')
})
it('parses left-associative chain: a and b and c', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' and above '.c' and below '.d'`)
const compound = firstRelation(result)
assert.strictEqual(compound.type, 'CompoundAssertion')
assert.strictEqual(compound.operator, 'and')
// Left-associative: ((a and b) and c)
assert.strictEqual(compound.children[0].type, 'CompoundAssertion')
assert.strictEqual(compound.children[0].operator, 'and')
assert.strictEqual(compound.children[0].children[0].relation, 'leftOf')
assert.strictEqual(compound.children[0].children[1].relation, 'above')
assert.strictEqual(compound.children[1].relation, 'below')
})
it('parses mixed operators left-associatively', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' and above '.c' or below '.d'`)
const compound = firstRelation(result)
assert.strictEqual(compound.type, 'CompoundAssertion')
assert.strictEqual(compound.operator, 'or')
// Left-associative: ((a and b) or c)
assert.strictEqual(compound.children[0].type, 'CompoundAssertion')
assert.strictEqual(compound.children[0].operator, 'and')
assert.strictEqual(compound.children[1].relation, 'below')
})
it('parses compound with quantifier', () => {
const result = parse(`in viewport:\n all '.a' leftOf '.b' and above '.c'`)
const compound = firstRelation(result)
assert.strictEqual(compound.type, 'CompoundAssertion')
assert.strictEqual(compound.quantifier.kind, 'all')
assert.strictEqual(compound.operator, 'and')
assert.strictEqual(compound.children.length, 2)
})
it('parses compound with negated sub-assertion', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' and not above '.c'`)
const compound = firstRelation(result)
assert.strictEqual(compound.operator, 'and')
assert.strictEqual(compound.children[1].negated, true)
assert.strictEqual(compound.children[1].relation, 'above')
})
it('parses compound with size assertion', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' and atLeast 44px wide`)
const compound = firstRelation(result)
assert.strictEqual(compound.operator, 'and')
assert.strictEqual(compound.children[0].type, 'RelationAssertion')
assert.strictEqual(compound.children[1].type, 'SizeAssertion')
assert.strictEqual(compound.children[1].property, 'width')
})
it('parses compound with topology assertion', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' and clippedBy '.c'`)
const compound = firstRelation(result)
assert.strictEqual(compound.operator, 'and')
assert.strictEqual(compound.children[0].type, 'RelationAssertion')
assert.strictEqual(compound.children[1].type, 'TopologyAssertion')
assert.strictEqual(compound.children[1].predicate, 'clippedBy')
})
})
// ---------------------------------------------------------------------------
// Size Predicate Tests (FOL formula context)
// ---------------------------------------------------------------------------
describe('size predicates in FOL formulas', () => {
it('parses width predicate with >= operator', () => {
const result = parse(`forall $btn in elements('.button'):\n width($btn) >= 44`)
const formula = (result.ast.children[0] as any).body
assert.strictEqual(formula.type, 'PredicateCall')
assert.strictEqual(formula.name, 'width')
assert.strictEqual(formula.operator, '>=')
assert.strictEqual(formula.right, 44)
})
it('parses height predicate with <= operator', () => {
const result = parse(`forall $card in elements('.card'):\n height($card) <= 200`)
const formula = (result.ast.children[0] as any).body
assert.strictEqual(formula.type, 'PredicateCall')
assert.strictEqual(formula.name, 'height')
assert.strictEqual(formula.operator, '<=')
assert.strictEqual(formula.right, 200)
})
it('parses width predicate with == operator', () => {
const result = parse(`forall $btn in elements('.button'):\n width($btn) == 100`)
const formula = (result.ast.children[0] as any).body
assert.strictEqual(formula.type, 'PredicateCall')
assert.strictEqual(formula.name, 'width')
assert.strictEqual(formula.operator, '==')
assert.strictEqual(formula.right, 100)
})
it('parses width predicate with != operator', () => {
const result = parse(`forall $btn in elements('.button'):\n width($btn) != 0`)
const formula = (result.ast.children[0] as any).body
assert.strictEqual(formula.type, 'PredicateCall')
assert.strictEqual(formula.name, 'width')
assert.strictEqual(formula.operator, '!=')
assert.strictEqual(formula.right, 0)
})
it('parses forall nested under frame block', () => {
const result = parse(`in viewport:\n forall $cat in elements('.showroom-category'):\n width($cat) >= 200`)
assert.strictEqual(result.diagnostics.length, 0)
const frame = result.ast.children[0] as any
assert.strictEqual(frame.type, 'FrameBlock')
assert.strictEqual(frame.children.length, 1)
assert.strictEqual(frame.children[0].type, 'ForAll')
})
})
// ---------------------------------------------------------------------------
// Domain Constructor with Variable Reference Tests
// ---------------------------------------------------------------------------
describe('domain constructors with variable references', () => {
it('parses descendants with variable as first arg', () => {
const result = parse(`forall $card in elements('.card'):\n exists $title in descendants($card, '.title'):\n above($title, '.content')`)
assert.strictEqual(result.diagnostics.length, 0)
const outer = result.ast.children[0] as any
assert.strictEqual(outer.type, 'ForAll')
const inner = outer.body
assert.strictEqual(inner.type, 'Exists')
assert.strictEqual(inner.domain.kind, 'descendants')
assert.ok(inner.domain.variableSelector, 'Expected variableSelector')
assert.strictEqual(inner.domain.variableSelector.name, 'card')
assert.strictEqual(inner.domain.extraArg, '.title')
})
it('parses children with variable as first arg', () => {
const result = parse(`forall $parent in elements('.parent'):\n forall $child in children($parent):\n inside($child, $parent)`)
assert.strictEqual(result.diagnostics.length, 0)
const outer = result.ast.children[0] as any
const inner = outer.body
assert.strictEqual(inner.domain.kind, 'children')
assert.ok(inner.domain.variableSelector)
assert.strictEqual(inner.domain.variableSelector.name, 'parent')
})
})
describe('multi-variable quantifier syntax', () => {
it('parses forall with comma-separated variables as nested quantifiers', () => {
const result = parse(`forall $a, $b in elements('.item'):\n leftOf($a, $b)`)
assert.strictEqual(result.diagnostics.length, 0)
const outer = result.ast.children[0] as any
assert.strictEqual(outer.type, 'ForAll')
assert.strictEqual(outer.variable.name, 'a')
assert.strictEqual(outer.domain.selector, '.item')
const inner = outer.body
assert.strictEqual(inner.type, 'ForAll')
assert.strictEqual(inner.variable.name, 'b')
assert.strictEqual(inner.domain.selector, '.item')
})
it('parses exists with comma-separated variables as nested quantifiers', () => {
const result = parse(`exists $x, $y in elements('.item'):\n overlaps($x, $y)`)
assert.strictEqual(result.diagnostics.length, 0)
const outer = result.ast.children[0] as any
assert.strictEqual(outer.type, 'Exists')
assert.strictEqual(outer.variable.name, 'x')
const inner = outer.body
assert.strictEqual(inner.type, 'Exists')
assert.strictEqual(inner.variable.name, 'y')
})
})
describe('inline state attachments', () => {
it('parses relation assertion with disabled state', () => {
const result = parse(`in viewport:\n '.submit' leftOf '.cancel' in disabled`)
assert.strictEqual(result.diagnostics.length, 0)
const rel = firstRelation(result)
assert.strictEqual(rel.type, 'RelationAssertion')
assert.strictEqual(rel.relation, 'leftOf')
assert.ok(rel.state)
assert.strictEqual(rel.state.type, 'StateSelector')
assert.strictEqual(rel.state.kind, 'disabled')
})
it('parses size assertion with checked state', () => {
const result = parse(`in viewport:\n '.toggle' atLeast 44px wide in checked`)
assert.strictEqual(result.diagnostics.length, 0)
const size = firstRelation(result)
assert.strictEqual(size.type, 'SizeAssertion')
assert.ok(size.state)
assert.strictEqual(size.state.kind, 'checked')
})
it('parses topology assertion with expanded state', () => {
const result = parse(`in viewport:\n '.menu' inStackingContext '.overlay' in expanded`)
assert.strictEqual(result.diagnostics.length, 0)
const top = firstRelation(result)
assert.strictEqual(top.type, 'TopologyAssertion')
assert.ok(top.state)
assert.strictEqual(top.state.kind, 'expanded')
})
})
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
// Package entry point for imhotep-dsl
// Exports fluent API, compiler, validator, and parser surface
export {
expect,
assert,
FluentAssertion,
FluentRelation,
FluentBinding,
FluentQuantifier,
FluentCardinality,
ToProxy,
BeProxy,
HaveProxy,
SizeProxy,
frame,
state,
type RelationOptions,
type FrameDescriptor,
type StateDescriptor,
type Quantifier,
} from './fluent.js'
export {
compile,
compileToFormula,
compileDenseFOLToFormula,
type CompilerOptions,
type CompileResult,
} from './compiler.js'
export {
validateRelation,
validateAssertion,
validateProgram,
parseTolerance,
parseGap,
type ValidationResult,
} from './validator.js'
export {
parseSpec,
type ParseResult,
type ParserOptions,
} from './parser.js'
// Lexer and grammar internals (for tooling and transforms)
export {
tokenize,
Lexer,
type Token,
type TokenKind,
} from './lexer.js'
export {
parseGrammar,
GrammarParser,
ParseError,
} from './grammar.js'
// Dense DSL entry point alias
export { parseSpec as spec } from './parser.js'
// Timeline constructors
export {
timeline,
type TimelineDescriptor,
} from './timeline.js'
// Configuration and environment guards
export {
configure,
project,
when,
getConfig,
getProjectConfig,
type ImhotepConfig,
type ProjectConfig,
} from './config.js'
// Quantifier combinators
export {
all,
any,
none,
type ComposableAssertion,
type QuantifiedGroup,
} from './quantifiers.js'
// FOL fluent builders
export {
forAll,
exists,
predicate,
domain,
FluentFormula,
FluentDomain,
type FormulaNode,
type ForAllFormula,
type ExistsFormula,
type AndFormula,
type OrFormula,
type NotFormula,
type ImpliesFormula,
type PredicateCall,
type VariableRef,
type DomainRef,
} from './logic-fluent.js'
// Logic compiler (lowering)
export {
lowerFormula,
lowerProgram,
type LogicIrNode,
type LogicForAll,
type LogicExists,
type LogicAnd,
type LogicOr,
type LogicNot,
type LogicImplies,
type LogicPredicate,
type LogicVariable,
type LogicDomain,
type LogicCompilerOptions,
type LowerResult,
} from './logic-compiler.js'
// Logic validator
export {
validateFormula as validateLogicFormula,
validateProgram as validateLogicProgram,
type LogicValidationResult,
type LogicDiagnostic,
type PredicateDescriptor,
type PredicateRegistry,
} from './logic-validator.js'
// Property-run fluent builders
export {
component,
enumerate,
PropertyRunBuilder,
PropertyScene,
PropertyContext,
PropertyAssertionBuilder,
type InputDomain,
type RenderCase,
type PropertyRunDescriptor,
type PropertyAssertion,
} from './property-fluent.js'
// Shared canonical lowering for IR equivalence testing
export {
lowerToCanonical,
extractFOLFormulas,
areEquivalent,
areSemanticallyEquivalent,
reconstructFromCanonical,
type CanonicalClauseDescriptor,
} from './lower-to-canonical.js'
@@ -0,0 +1,537 @@
// IR Equivalence Property Tests
// Verifies that fluent API and string DSL lower to the same canonical IR.
// Uses fast-check for property-based generation with fixed seeds for determinism.
import { describe, it } from 'node:test'
import assert from 'node:assert'
import fc from 'fast-check'
import { expect, frame, state } from './fluent.js'
import { parseSpec } from './parser.js'
import { compile } from './compiler.js'
import type { RelationAssertion } from 'imhotep-core'
import {
lowerToCanonical,
areEquivalent,
areSemanticallyEquivalent,
reconstructFromCanonical,
type CanonicalClauseDescriptor,
} from './lower-to-canonical.js'
// ---------------------------------------------------------------------------
// Generators
// ---------------------------------------------------------------------------
// Selectors that work in both fluent and string DSL.
// String DSL requires quoting for CSS selectors containing dots,
// so we generate simple identifiers or quoted strings.
const selectorArb = fc
.string({ minLength: 1, maxLength: 20 })
.map((s) => s.replace(/['"\n\r]/g, '').trim())
.filter((s) => s.length > 0 && /[a-zA-Z0-9_-]/.test(s))
// Relations supported by both fluent API and string parser
const sharedRelationArb = fc.constantFrom(
'leftOf',
'rightOf',
'above',
'below',
'alignedWith',
'centeredWithin',
'inside',
'overlaps',
)
const gapArb = fc.integer({ min: 0, max: 1000 })
const toleranceArb = fc.integer({ min: 0, max: 100 })
// Options supported by both paths
interface SharedOptions {
minGap?: number
maxGap?: number
tolerance?: number
axis?: 'centerX' | 'centerY'
inStackingContext?: boolean
}
// Note: String DSL gap syntax always sets minGap (range sets both).
// maxGap-only cannot be expressed in string DSL, so we exclude it.
const sharedOptionsArb: fc.Arbitrary<SharedOptions> = fc.oneof(
fc.constant({}),
fc.record({ minGap: gapArb }),
fc.record({ tolerance: toleranceArb }),
fc.record({ axis: fc.constantFrom('centerX', 'centerY') }),
fc.record({ inStackingContext: fc.boolean() }),
// Combined options
fc.record({ minGap: gapArb, tolerance: toleranceArb }),
fc.record({ minGap: gapArb, maxGap: gapArb }),
fc.record({ minGap: gapArb, maxGap: gapArb, tolerance: toleranceArb }),
)
// Build a fluent AST from generated parameters
function buildFluentAst(
subject: string,
relation: string,
reference: string,
opts: SharedOptions,
): RelationAssertion {
const chain = (expect(subject).to.be as any)[relation](reference, {
minGap: opts.minGap,
maxGap: opts.maxGap,
tolerance: opts.tolerance,
axis: opts.axis,
inStackingContext: opts.inStackingContext,
})
return chain.toAst() as RelationAssertion
}
// Build string DSL source from generated parameters
function buildStringDsl(
subject: string,
relation: string,
reference: string,
opts: SharedOptions,
): string {
let source = `'${subject}' ${relation} '${reference}'`
if (opts.minGap !== undefined && opts.maxGap !== undefined) {
source += ` gap ${opts.minGap}px..${opts.maxGap}px`
} else if (opts.minGap !== undefined) {
source += ` gap ${opts.minGap}px`
} else if (opts.maxGap !== undefined) {
// String DSL gap without min is unusual; represent as range with 0 min
source += ` gap 0px..${opts.maxGap}px`
}
if (opts.tolerance !== undefined) {
source += ` tolerance ${opts.tolerance}px`
}
if (opts.axis !== undefined) {
source += ` axis ${opts.axis}`
}
if (opts.inStackingContext === true) {
source += ' inStackingContext true'
} else if (opts.inStackingContext === false) {
source += ' inStackingContext false'
}
return source
}
// Normalize options so minGap <= maxGap.
// Also filters out maxGap-only since string DSL cannot express it.
function normalizeOpts(opts: SharedOptions): SharedOptions {
const normalized = { ...opts }
// String DSL gap syntax always sets minGap; maxGap-only is unrepresentable.
if (normalized.maxGap !== undefined && normalized.minGap === undefined) {
delete normalized.maxGap
}
if (
normalized.minGap !== undefined &&
normalized.maxGap !== undefined &&
normalized.minGap > normalized.maxGap
) {
const tmp = normalized.minGap
normalized.minGap = normalized.maxGap
normalized.maxGap = tmp
}
return normalized
}
// ---------------------------------------------------------------------------
// Property: Simple relation equivalence
// ---------------------------------------------------------------------------
describe('IR equivalence property tests', () => {
it('property: fluent and string produce equivalent canonical IR for simple relations', () => {
fc.assert(
fc.property(
selectorArb,
selectorArb,
sharedRelationArb,
sharedOptionsArb,
(subj, ref, rel, opts) => {
const normalizedOpts = normalizeOpts(opts)
const fluentAst = buildFluentAst(subj, rel, ref, normalizedOpts)
const stringResult = parseSpec(buildStringDsl(subj, rel, ref, normalizedOpts))
if (stringResult.diagnostics.length > 0) {
// String parse failure means the generator produced invalid syntax.
// Filter this case rather than failing the property.
return true
}
const fluentCanonical = lowerToCanonical(fluentAst)
const stringCanonical = lowerToCanonical(stringResult.ast)
if (!areEquivalent(fluentCanonical, stringCanonical)) {
// Return false so fast-check reports the counterexample
return false
}
return true
},
),
{ numRuns: 200, seed: 42 },
)
})
// ---------------------------------------------------------------------------
// Property: Frame equivalence
// ---------------------------------------------------------------------------
it('property: fluent and string with frames produce equivalent canonical IR', () => {
const frameKindArb = fc.constantFrom(
'viewport',
'document',
'nearestPositionedAncestor',
'containingBlock',
'scrollContainer',
'namedGridArea',
'landmark',
)
const frameArgArb = fc.string({ minLength: 1, maxLength: 15 }).filter((s) => s.length > 0)
fc.assert(
fc.property(
selectorArb,
selectorArb,
sharedRelationArb,
sharedOptionsArb,
frameKindArb,
frameArgArb,
(subj, ref, rel, opts, frameKind, frameArg) => {
const normalizedOpts = normalizeOpts(opts)
// Fluent: attach frame via .in()
const fluentBuilder = expect(subj).in(
frameKind === 'namedGridArea'
? frame.namedGridArea(frameArg)
: frameKind === 'landmark'
? frame.landmark(frameArg)
: frameKind === 'viewport'
? frame.viewport()
: frameKind === 'document'
? frame.document()
: (frame as any)[frameKind](frameArg),
)
const fluentAst = (fluentBuilder.to.be as any)[rel](ref, normalizedOpts).toAst()
// String: wrap in frame block
let frameDecl: string
if (frameKind === 'viewport' || frameKind === 'document') {
frameDecl = `in ${frameKind}:`
} else if (frameKind === 'namedGridArea' || frameKind === 'landmark') {
frameDecl = `in ${frameKind}('${frameArg}'):`
} else {
frameDecl = `in ${frameKind}('${frameArg}'):`
}
const inner = buildStringDsl(subj, rel, ref, normalizedOpts)
const source = `${frameDecl}\n ${inner}`
const stringResult = parseSpec(source)
if (stringResult.diagnostics.length > 0) {
return true
}
const fluentCanonical = lowerToCanonical(fluentAst)
const stringCanonical = lowerToCanonical(stringResult.ast)
return areEquivalent(fluentCanonical, stringCanonical)
},
),
{ numRuns: 150, seed: 77 },
)
})
// ---------------------------------------------------------------------------
// Property: Quantifier equivalence
// ---------------------------------------------------------------------------
it('property: fluent and string with quantifiers produce equivalent canonical IR', () => {
const quantifierArb = fc.constantFrom('all', 'any', 'none')
fc.assert(
fc.property(
selectorArb,
selectorArb,
sharedRelationArb,
sharedOptionsArb,
quantifierArb,
(subj, ref, rel, opts, quantifier) => {
const normalizedOpts = normalizeOpts(opts)
// Fluent: attach quantifier
const fluentBuilder = (expect(subj) as any)[quantifier]
const fluentAst = (fluentBuilder.to.be as any)[rel](ref, normalizedOpts).toAst()
// String: prepend quantifier
const source = `${quantifier} ${buildStringDsl(subj, rel, ref, normalizedOpts)}`
const stringResult = parseSpec(source)
if (stringResult.diagnostics.length > 0) {
return true
}
const fluentCanonical = lowerToCanonical(fluentAst)
const stringCanonical = lowerToCanonical(stringResult.ast)
return areEquivalent(fluentCanonical, stringCanonical)
},
),
{ numRuns: 150, seed: 88 },
)
})
// ---------------------------------------------------------------------------
// Property: State equivalence
// ---------------------------------------------------------------------------
it('property: fluent and string with states produce equivalent canonical IR', () => {
const stateKindArb = fc.constantFrom('default', 'hover', 'focus', 'focusVisible', 'active')
fc.assert(
fc.property(
selectorArb,
selectorArb,
sharedRelationArb,
sharedOptionsArb,
stateKindArb,
(subj, ref, rel, opts, stateKind) => {
const normalizedOpts = normalizeOpts(opts)
// Fluent: attach state
const fluentBuilder = expect(subj).inState(
stateKind === 'default'
? state.default()
: stateKind === 'hover'
? state.hover()
: stateKind === 'focus'
? state.focus()
: stateKind === 'focusVisible'
? state.focusVisible()
: state.active(),
)
const fluentAst = (fluentBuilder.to.be as any)[rel](ref, normalizedOpts).toAst()
// Note: String DSL does not have inline state syntax for bare assertions.
// States in string DSL are typically handled via block structure or
// are not directly equivalent. For this property we test fluent vs
// fluent-with-state to ensure the lowering captures state correctly.
const withoutState = buildFluentAst(subj, rel, ref, normalizedOpts)
const withStateCanonical = lowerToCanonical(fluentAst)
const withoutStateCanonical = lowerToCanonical(withoutState)
// The state should be the only difference
return (
withStateCanonical.length === 1 &&
withoutStateCanonical.length === 1 &&
withStateCanonical[0].state?.kind === stateKind &&
withoutStateCanonical[0].state === undefined
)
},
),
{ numRuns: 100, seed: 99 },
)
})
// ---------------------------------------------------------------------------
// Property: AST → IR → AST round-trip preserves semantics
// ---------------------------------------------------------------------------
it('property: AST → canonical IR → AST preserves semantic meaning', () => {
fc.assert(
fc.property(
selectorArb,
selectorArb,
sharedRelationArb,
sharedOptionsArb,
(subj, ref, rel, opts) => {
const normalizedOpts = normalizeOpts(opts)
const originalAst = buildFluentAst(subj, rel, ref, normalizedOpts)
const canonical = lowerToCanonical(originalAst)
const recovered = reconstructFromCanonical(canonical)
if (recovered.length !== 1) {
return false
}
return areSemanticallyEquivalent(originalAst, recovered[0])
},
),
{ numRuns: 200, seed: 123 },
)
})
// ---------------------------------------------------------------------------
// Property: Empty selectors edge case
// ---------------------------------------------------------------------------
it('property: empty selectors lower consistently', () => {
fc.assert(
fc.property(sharedRelationArb, sharedOptionsArb, (rel, opts) => {
const normalizedOpts = normalizeOpts(opts)
const fluentAst = buildFluentAst('', rel, '.ref', normalizedOpts)
const stringResult = parseSpec(buildStringDsl('', rel, '.ref', normalizedOpts))
const fluentCanonical = lowerToCanonical(fluentAst)
if (stringResult.diagnostics.length > 0) {
// Empty selector may fail string parse
return true
}
const stringCanonical = lowerToCanonical(stringResult.ast)
return areEquivalent(fluentCanonical, stringCanonical)
}),
{ numRuns: 50, seed: 11 },
)
})
// ---------------------------------------------------------------------------
// Property: Unicode selectors
// ---------------------------------------------------------------------------
it('property: unicode selectors lower consistently', () => {
const unicodeSelectorArb = fc
.string({ minLength: 1, maxLength: 10 })
.map((s) => s.replace(/['"\n\r]/g, ''))
.filter((s) => s.length > 0)
fc.assert(
fc.property(
unicodeSelectorArb,
unicodeSelectorArb,
sharedRelationArb,
fc.record({ minGap: gapArb }),
(subj, ref, rel, opts) => {
const fluentAst = buildFluentAst(subj, rel, ref, opts)
const stringResult = parseSpec(buildStringDsl(subj, rel, ref, opts))
if (stringResult.diagnostics.length > 0) {
return true
}
const fluentCanonical = lowerToCanonical(fluentAst)
const stringCanonical = lowerToCanonical(stringResult.ast)
return areEquivalent(fluentCanonical, stringCanonical)
},
),
{ numRuns: 100, seed: 222 },
)
})
// ---------------------------------------------------------------------------
// Property: Multiple assertions in program equivalence
// ---------------------------------------------------------------------------
it('property: programs with multiple assertions lower consistently', () => {
const assertionArb = fc.tuple(selectorArb, selectorArb, sharedRelationArb, sharedOptionsArb).map(
([subj, ref, rel, opts]) => ({
subj,
ref,
rel,
opts: normalizeOpts(opts),
}),
)
fc.assert(
fc.property(
fc.array(assertionArb, { minLength: 1, maxLength: 5 }),
(assertions) => {
const fluentAsts = assertions.map((a) => buildFluentAst(a.subj, a.rel, a.ref, a.opts))
const stringSource = assertions.map((a) => buildStringDsl(a.subj, a.rel, a.ref, a.opts)).join('\n')
const stringResult = parseSpec(stringSource)
if (stringResult.diagnostics.length > 0) {
return true
}
const fluentCanonical = fluentAsts.flatMap((ast) => lowerToCanonical(ast))
const stringCanonical = lowerToCanonical(stringResult.ast)
return areEquivalent(fluentCanonical, stringCanonical)
},
),
{ numRuns: 100, seed: 333 },
)
})
// ---------------------------------------------------------------------------
// Property: Compiler integration equivalence
// ---------------------------------------------------------------------------
it('property: both paths compile to equivalent execution IR structure', () => {
fc.assert(
fc.property(
selectorArb,
selectorArb,
sharedRelationArb,
sharedOptionsArb,
(subj, ref, rel, opts) => {
const normalizedOpts = normalizeOpts(opts)
const fluentAst = buildFluentAst(subj, rel, ref, normalizedOpts)
const stringResult = parseSpec(buildStringDsl(subj, rel, ref, normalizedOpts))
if (stringResult.diagnostics.length > 0) {
return true
}
// Extract relation assertions from string AST (may be wrapped in blocks)
const stringAssertions: RelationAssertion[] = []
for (const child of stringResult.ast.children as any[]) {
if (child.type === 'RelationAssertion') {
stringAssertions.push(child as RelationAssertion)
} else if (child.children) {
for (const inner of child.children as any[]) {
if (inner.type === 'RelationAssertion') {
stringAssertions.push(inner as RelationAssertion)
}
}
}
}
if (stringAssertions.length === 0) {
return true
}
// Compile both
const fluentCompile = compile([fluentAst])
const stringCompile = compile(stringAssertions)
// Compare execution IR structure (clause count, relation codes, arg packing)
const fluentExec = fluentCompile.executionIr
const stringExec = stringCompile.executionIr
if (fluentExec.clauseCount !== stringExec.clauseCount) {
return false
}
if (fluentExec.clauseCount === 0) {
return true
}
return (
fluentExec.clauseType[0] === stringExec.clauseType[0] &&
fluentExec.clauseFlags[0] === stringExec.clauseFlags[0] &&
fluentExec.clauseArg0[0] === stringExec.clauseArg0[0] &&
fluentExec.clauseArg1[0] === stringExec.clauseArg1[0]
)
},
),
{ numRuns: 150, seed: 444 },
)
})
})
+601
View File
@@ -0,0 +1,601 @@
// Lexer for the Imhotep dense declarative DSL
// Tokenizes DSL source into a stream of typed tokens with source positions
import type { Point } from 'imhotep-core'
// ---------------------------------------------------------------------------
// Token Types
// ---------------------------------------------------------------------------
export type TokenKind =
// Keywords
| 'in'
| 'across'
| 'always'
| 'when'
| 'and'
| 'or'
| 'not'
| 'implies'
| 'forall'
| 'exists'
| 'is'
| 'atLeast'
| 'atMost'
| 'aspectRatio'
| 'clippedBy'
| 'attachedToScrollContainer'
| 'inStackingContext'
| 'escapeClippingChainOf'
| 'gap'
| 'tolerance'
| 'axis'
| 'space'
| 'between'
// State keywords
| 'default'
| 'hover'
| 'focus'
| 'focusVisible'
| 'active'
| 'disabled'
| 'checked'
| 'expanded'
| 'collapsed'
| 'selected'
| 'pressed'
| 'visited'
| 'true'
| 'false'
// Size predicates
| 'width'
| 'height'
| 'size'
// Relations
| 'leftOf'
| 'rightOf'
| 'above'
| 'below'
| 'alignedWith'
| 'centeredWithin'
| 'inside'
| 'contains'
| 'overlaps'
| 'separatedFrom'
// Spatial aliases
| 'beside'
| 'nextTo'
| 'adjacent'
| 'touching'
| 'near'
| 'under'
| 'within'
// Frame kinds
| 'viewport'
| 'document'
| 'nearestPositionedAncestor'
| 'containingBlock'
| 'scrollContainer'
| 'namedGridArea'
| 'landmark'
// Units
| 'px'
| 'em'
| 'rem'
| 'percent' // % is special in lexer
| 'jnd'
| 'ch'
| 'vw'
| 'vh'
// Dimensions
| 'wide'
| 'tall'
// Quantifiers
| 'all'
| 'any'
| 'none'
// Literals
| 'number'
| 'string'
| 'identifier'
// Punctuation
| 'colon'
| 'dotDot'
| 'lbracket'
| 'rbracket'
| 'lparen'
| 'rparen'
| 'lbrace'
| 'rbrace'
| 'comma'
| 'dollar'
// Comparators
| 'eq'
| 'ne'
| 'lt'
| 'gt'
| 'le'
| 'ge'
// Special
| 'newline'
| 'indent'
| 'dedent'
| 'eof'
| 'unknown'
export interface Token {
kind: TokenKind
value: string
start: Point
end: Point
/** For string tokens: which quote delimiter was used. */
quoteStyle?: 'single' | 'double'
}
// ---------------------------------------------------------------------------
// Keywords Map
// ---------------------------------------------------------------------------
const KEYWORDS: Record<string, TokenKind> = {
in: 'in',
across: 'across',
always: 'always',
when: 'when',
and: 'and',
or: 'or',
not: 'not',
implies: 'implies',
forall: 'forall',
exists: 'exists',
is: 'is',
atLeast: 'atLeast',
atMost: 'atMost',
aspectRatio: 'aspectRatio',
clippedBy: 'clippedBy',
attachedToScrollContainer: 'attachedToScrollContainer',
inStackingContext: 'inStackingContext',
escapeClippingChainOf: 'escapeClippingChainOf',
gap: 'gap',
tolerance: 'tolerance',
axis: 'axis',
space: 'space',
between: 'between',
// State keywords
default: 'default',
hover: 'hover',
focus: 'focus',
focusVisible: 'focusVisible',
active: 'active',
disabled: 'disabled',
checked: 'checked',
expanded: 'expanded',
collapsed: 'collapsed',
selected: 'selected',
pressed: 'pressed',
visited: 'visited',
true: 'true',
false: 'false',
// Size predicates
width: 'width',
height: 'height',
size: 'size',
// Relations
leftOf: 'leftOf',
rightOf: 'rightOf',
above: 'above',
below: 'below',
alignedWith: 'alignedWith',
centeredWithin: 'centeredWithin',
inside: 'inside',
contains: 'contains',
overlaps: 'overlaps',
separatedFrom: 'separatedFrom',
// Spatial aliases
beside: 'beside',
nextTo: 'nextTo',
adjacent: 'adjacent',
touching: 'touching',
near: 'near',
under: 'under',
within: 'within',
// Frame kinds
viewport: 'viewport',
document: 'document',
nearestPositionedAncestor: 'nearestPositionedAncestor',
containingBlock: 'containingBlock',
scrollContainer: 'scrollContainer',
namedGridArea: 'namedGridArea',
landmark: 'landmark',
// Units
px: 'px',
em: 'em',
rem: 'rem',
jnd: 'jnd',
ch: 'ch',
vw: 'vw',
vh: 'vh',
// Dimensions
wide: 'wide',
tall: 'tall',
// Quantifiers
all: 'all',
any: 'any',
none: 'none',
}
// ---------------------------------------------------------------------------
// Lexer State
// ---------------------------------------------------------------------------
export class Lexer {
private source: string
private offset: number = 0
private line: number = 1
private column: number = 1
private tokens: Token[] = []
private indentStack: number[] = [0]
constructor(source: string) {
this.source = source
}
tokenize(): Token[] {
this.tokens = []
this.offset = 0
this.line = 1
this.column = 1
this.indentStack = [0]
while (!this.isAtEnd()) {
// Track current position for token start
const startPoint = this.currentPoint()
// Handle indentation at start of line
if (this.isAtLineStart()) {
this.handleIndentation()
}
if (this.isAtEnd()) break
const char = this.peek()
// Skip whitespace (but not newlines)
if (char === ' ' || char === '\t' || char === '\r') {
this.advance()
continue
}
// Newlines
if (char === '\n') {
this.tokens.push(this.makeToken('newline', '\n', startPoint))
this.advance()
continue
}
// Comments
if (char === '/' && this.peekNext() === '/') {
this.skipComment()
continue
}
// Strings
if (char === "'" || char === '"') {
this.tokens.push(this.readString(char, startPoint))
continue
}
// Numbers
if (this.isDigit(char)) {
this.tokens.push(this.readNumber(startPoint))
continue
}
// Multi-char operators and punctuation
if (char === '.' && this.peekNext() === '.') {
this.tokens.push(this.makeToken('dotDot', '..', startPoint))
this.advance()
this.advance()
continue
}
if (char === '=' && this.peekNext() === '=') {
this.tokens.push(this.makeToken('eq', '==', startPoint))
this.advance()
this.advance()
continue
}
if (char === '!' && this.peekNext() === '=') {
this.tokens.push(this.makeToken('ne', '!=', startPoint))
this.advance()
this.advance()
continue
}
if (char === '<' && this.peekNext() === '=') {
this.tokens.push(this.makeToken('le', '<=', startPoint))
this.advance()
this.advance()
continue
}
if (char === '>' && this.peekNext() === '=') {
this.tokens.push(this.makeToken('ge', '>=', startPoint))
this.advance()
this.advance()
continue
}
// Single-char punctuation
if (char === ':') {
this.tokens.push(this.makeToken('colon', ':', startPoint))
this.advance()
continue
}
if (char === '[') {
this.tokens.push(this.makeToken('lbracket', '[', startPoint))
this.advance()
continue
}
if (char === ']') {
this.tokens.push(this.makeToken('rbracket', ']', startPoint))
this.advance()
continue
}
if (char === '(') {
this.tokens.push(this.makeToken('lparen', '(', startPoint))
this.advance()
continue
}
if (char === ')') {
this.tokens.push(this.makeToken('rparen', ')', startPoint))
this.advance()
continue
}
if (char === '{') {
this.tokens.push(this.makeToken('lbrace', '{', startPoint))
this.advance()
continue
}
if (char === '}') {
this.tokens.push(this.makeToken('rbrace', '}', startPoint))
this.advance()
continue
}
if (char === ',') {
this.tokens.push(this.makeToken('comma', ',', startPoint))
this.advance()
continue
}
if (char === '$') {
this.tokens.push(this.makeToken('dollar', '$', startPoint))
this.advance()
continue
}
if (char === '<') {
this.tokens.push(this.makeToken('lt', '<', startPoint))
this.advance()
continue
}
if (char === '>') {
this.tokens.push(this.makeToken('gt', '>', startPoint))
this.advance()
continue
}
if (char === '%') {
this.tokens.push(this.makeToken('percent', '%', startPoint))
this.advance()
continue
}
// Identifiers and keywords
if (this.isAlpha(char) || char === '_') {
this.tokens.push(this.readIdentifier(startPoint))
continue
}
// Unknown character
this.tokens.push(this.makeToken('unknown', char, startPoint))
this.advance()
}
// Emit dedents to close all open indentation levels
while (this.indentStack.length > 1) {
this.indentStack.pop()
this.tokens.push(this.makeToken('dedent', '', this.currentPoint()))
}
this.tokens.push(this.makeToken('eof', '', this.currentPoint()))
return this.tokens
}
// -------------------------------------------------------------------------
// Indentation Handling
// -------------------------------------------------------------------------
private isAtLineStart(): boolean {
// Check if we're at the very start, or right after a newline
if (this.offset === 0) return true
const prev = this.source[this.offset - 1]
return prev === '\n'
}
private handleIndentation(): void {
// Skip blank lines
let lookAhead = this.offset
while (lookAhead < this.source.length) {
const c = this.source[lookAhead]
if (c === ' ' || c === '\t') {
lookAhead++
} else if (c === '\n') {
// Blank line, skip it and keep looking
this.offset = lookAhead + 1
this.line++
this.column = 1
lookAhead = this.offset
} else if (c === '/' && lookAhead + 1 < this.source.length && this.source[lookAhead + 1] === '/') {
// Comment line, skip to end
while (lookAhead < this.source.length && this.source[lookAhead] !== '\n') {
lookAhead++
}
this.offset = lookAhead
this.line++
this.column = 1
lookAhead = this.offset
} else {
break
}
}
if (this.isAtEnd()) return
// Count indentation
let indent = 0
const indentStart = this.currentPoint()
while (!this.isAtEnd() && (this.peek() === ' ' || this.peek() === '\t')) {
indent++
this.advance()
}
// If line is empty or comment, don't process indent
if (this.isAtEnd() || this.peek() === '\n' || (this.peek() === '/' && this.peekNext() === '/')) {
return
}
const currentIndent = this.indentStack[this.indentStack.length - 1]
if (indent > currentIndent) {
this.indentStack.push(indent)
this.tokens.push(this.makeToken('indent', '', indentStart))
} else if (indent < currentIndent) {
while (this.indentStack.length > 1 && this.indentStack[this.indentStack.length - 1] > indent) {
this.indentStack.pop()
this.tokens.push(this.makeToken('dedent', '', indentStart))
}
// If dedented to a level that doesn't exist, it's an error but we emit dedent anyway
if (this.indentStack[this.indentStack.length - 1] !== indent && indent !== 0) {
// Mismatched indent - will be caught by parser
}
}
}
// -------------------------------------------------------------------------
// Token Readers
// -------------------------------------------------------------------------
private readString(quote: string, start: Point): Token {
this.advance() // consume opening quote
let value = ''
while (!this.isAtEnd() && this.peek() !== quote) {
value += this.advance()
}
const quoteStyle = quote === '"' ? 'double' : 'single'
if (this.isAtEnd()) {
// Unterminated string - return what we have
return this.makeToken('string', value, start, quoteStyle)
}
this.advance() // consume closing quote
return this.makeToken('string', value, start, quoteStyle)
}
private readNumber(start: Point): Token {
let value = ''
while (!this.isAtEnd() && this.isDigit(this.peek())) {
value += this.advance()
}
if (!this.isAtEnd() && this.peek() === '.' && this.isDigit(this.peekNext())) {
value += this.advance() // consume '.'
while (!this.isAtEnd() && this.isDigit(this.peek())) {
value += this.advance()
}
}
return this.makeToken('number', value, start)
}
private readIdentifier(start: Point): Token {
let value = ''
while (!this.isAtEnd() && (this.isAlphaNumeric(this.peek()) || this.peek() === '_')) {
value += this.advance()
}
const kind = KEYWORDS[value] || 'identifier'
return this.makeToken(kind, value, start)
}
private skipComment(): void {
while (!this.isAtEnd() && this.peek() !== '\n') {
this.advance()
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private currentPoint(): Point {
return { line: this.line, column: this.column, offset: this.offset }
}
private makeToken(kind: TokenKind, value: string, start: Point, quoteStyle?: 'single' | 'double'): Token {
const end = this.currentPoint()
const token: Token = { kind, value, start, end }
if (quoteStyle !== undefined) token.quoteStyle = quoteStyle
return token
}
private peek(): string {
return this.source[this.offset] || '\0'
}
private peekNext(): string {
if (this.offset + 1 >= this.source.length) return '\0'
return this.source[this.offset + 1]
}
private advance(): string {
const char = this.source[this.offset]
this.offset++
if (char === '\n') {
this.line++
this.column = 1
} else {
this.column++
}
return char
}
private isAtEnd(): boolean {
return this.offset >= this.source.length
}
private isDigit(char: string): boolean {
return char >= '0' && char <= '9'
}
private isAlpha(char: string): boolean {
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}
private isAlphaNumeric(char: string): boolean {
return this.isAlpha(char) || this.isDigit(char)
}
}
// ---------------------------------------------------------------------------
// Convenience Export
// ---------------------------------------------------------------------------
export function tokenize(source: string): Token[] {
return new Lexer(source).tokenize()
}
+261
View File
@@ -0,0 +1,261 @@
// Logic compiler: lowers FOL AST (from Stream 1 contracts) to logic IR.
// Assumes Stream 1 types and creates a stable lowering that preserves meaning.
import type {
FormulaNode,
ForAllFormula,
ExistsFormula,
AndFormula,
OrFormula,
NotFormula,
ImpliesFormula,
PredicateCall,
VariableRef,
DomainRef,
} from './logic-fluent.js'
// ---------------------------------------------------------------------------
// Logic IR Types (matches migration plan Section 1)
// ---------------------------------------------------------------------------
export type LogicIrNode =
| LogicForAll
| LogicExists
| LogicAnd
| LogicOr
| LogicNot
| LogicImplies
| LogicPredicate
| LogicVariable
| LogicDomain
export interface LogicForAll {
type: 'LogicForAll'
variable: LogicVariable
domain: LogicDomain
body: LogicIrNode
origin?: string // 'fluent' | 'dense'
}
export interface LogicExists {
type: 'LogicExists'
variable: LogicVariable
domain: LogicDomain
body: LogicIrNode
origin?: string
}
export interface LogicAnd {
type: 'LogicAnd'
left: LogicIrNode
right: LogicIrNode
origin?: string
}
export interface LogicOr {
type: 'LogicOr'
left: LogicIrNode
right: LogicIrNode
origin?: string
}
export interface LogicNot {
type: 'LogicNot'
operand: LogicIrNode
origin?: string
}
export interface LogicImplies {
type: 'LogicImplies'
left: LogicIrNode
right: LogicIrNode
origin?: string
}
export interface LogicPredicate {
type: 'LogicPredicate'
name: string
args: (LogicVariable | string | number)[]
origin?: string
}
export interface LogicVariable {
type: 'LogicVariable'
name: string
origin?: string
}
export interface LogicDomain {
type: 'LogicDomain'
kind: string
selector?: string
origin?: string
}
// ---------------------------------------------------------------------------
// Lowering Options
// ---------------------------------------------------------------------------
export interface LogicCompilerOptions {
// Origin metadata to distinguish fluent from dense DSL lowering
origin?: 'fluent' | 'dense'
}
// ---------------------------------------------------------------------------
// Lowering Function
// ---------------------------------------------------------------------------
export function lowerFormula(
ast: FormulaNode,
opts: LogicCompilerOptions = {},
): LogicIrNode {
const origin = opts.origin || 'dense'
switch (ast.type) {
case 'ForAll':
return lowerForAll(ast, origin)
case 'Exists':
return lowerExists(ast, origin)
case 'And':
return lowerAnd(ast, origin)
case 'Or':
return lowerOr(ast, origin)
case 'Not':
return lowerNot(ast, origin)
case 'Implies':
return lowerImplies(ast, origin)
case 'PredicateCall':
return lowerPredicate(ast, origin)
case 'VariableRef':
return lowerVariable(ast, origin)
case 'DomainRef':
return lowerDomain(ast, origin)
default:
throw new Error(`Unknown formula node type: ${(ast as any).type}`)
}
}
function lowerForAll(ast: ForAllFormula, origin: string): LogicForAll {
return {
type: 'LogicForAll',
variable: lowerVariable(ast.variable, origin) as LogicVariable,
domain: lowerDomain(ast.domain, origin) as LogicDomain,
body: lowerFormula(ast.body, { origin: origin as 'dense' | 'fluent' }),
origin: origin as 'dense' | 'fluent',
}
}
function lowerExists(ast: ExistsFormula, origin: string): LogicExists {
return {
type: 'LogicExists',
variable: lowerVariable(ast.variable, origin) as LogicVariable,
domain: lowerDomain(ast.domain, origin) as LogicDomain,
body: lowerFormula(ast.body, { origin: origin as 'dense' | 'fluent' }),
origin: origin as 'dense' | 'fluent',
}
}
function lowerAnd(ast: AndFormula, origin: string): LogicAnd {
return {
type: 'LogicAnd',
left: lowerFormula(ast.left, { origin: origin as 'dense' | 'fluent' }),
right: lowerFormula(ast.right, { origin: origin as 'dense' | 'fluent' }),
origin: origin as 'dense' | 'fluent',
}
}
function lowerOr(ast: OrFormula, origin: string): LogicOr {
return {
type: 'LogicOr',
left: lowerFormula(ast.left, { origin: origin as 'dense' | 'fluent' }),
right: lowerFormula(ast.right, { origin: origin as 'dense' | 'fluent' }),
origin: origin as 'dense' | 'fluent',
}
}
function lowerNot(ast: NotFormula, origin: string): LogicNot {
return {
type: 'LogicNot',
operand: lowerFormula(ast.operand, { origin: origin as 'dense' | 'fluent' }),
origin: origin as 'dense' | 'fluent',
}
}
function lowerImplies(ast: ImpliesFormula, origin: string): LogicImplies {
return {
type: 'LogicImplies',
left: lowerFormula(ast.left, { origin: origin as 'dense' | 'fluent' }),
right: lowerFormula(ast.right, { origin: origin as 'dense' | 'fluent' }),
origin: origin as 'dense' | 'fluent',
}
}
function lowerPredicate(ast: PredicateCall, origin: string): LogicPredicate {
return {
type: 'LogicPredicate',
name: ast.name,
args: ast.args.map((arg) => {
if (typeof arg === 'object' && arg !== null && arg.type === 'VariableRef') {
return lowerVariable(arg as VariableRef, origin) as LogicVariable
}
return arg as string | number
}),
origin,
}
}
function lowerVariable(ast: VariableRef, origin: string): LogicVariable {
return {
type: 'LogicVariable',
name: ast.name,
origin,
}
}
function lowerDomain(ast: DomainRef, origin: string): LogicDomain {
// Encode variable references as $name strings for the solver
const selector = ast.variableSelector
? `$${ast.variableSelector.name}`
: ast.selector
return {
type: 'LogicDomain',
kind: ast.kind,
selector,
origin,
}
}
// ---------------------------------------------------------------------------
// Batch Lowering
// ---------------------------------------------------------------------------
export interface LowerResult {
logicIr: LogicIrNode
diagnostics: string[]
}
export function lowerProgram(
formulas: FormulaNode[],
opts: LogicCompilerOptions = {},
): LowerResult {
const diagnostics: string[] = []
if (formulas.length === 0) {
return {
logicIr: { type: 'LogicPredicate', name: 'true', args: [], origin: opts.origin },
diagnostics,
}
}
let combined: LogicIrNode = lowerFormula(formulas[0], opts)
for (let i = 1; i < formulas.length; i++) {
combined = {
type: 'LogicAnd',
left: combined,
right: lowerFormula(formulas[i], opts),
origin: opts.origin,
}
}
return { logicIr: combined, diagnostics }
}
+220
View File
@@ -0,0 +1,220 @@
// Standalone FOL fluent builders for Imhotep
// Provides first-order logic quantifiers and connectives as a fluent API
// that can be used independently of the main assertion builder.
import type { Position } from 'imhotep-core'
// ---------------------------------------------------------------------------
// FOL AST Node Types (local copies matching migration plan until Stream 1)
// ---------------------------------------------------------------------------
export type FormulaNode =
| ForAllFormula
| ExistsFormula
| AndFormula
| OrFormula
| NotFormula
| ImpliesFormula
| PredicateCall
| VariableRef
| DomainRef
export interface ForAllFormula {
type: 'ForAll'
variable: VariableRef
domain: DomainRef
body: FormulaNode
position?: Position
}
export interface ExistsFormula {
type: 'Exists'
variable: VariableRef
domain: DomainRef
body: FormulaNode
position?: Position
}
export interface AndFormula {
type: 'And'
left: FormulaNode
right: FormulaNode
position?: Position
}
export interface OrFormula {
type: 'Or'
left: FormulaNode
right: FormulaNode
position?: Position
}
export interface NotFormula {
type: 'Not'
operand: FormulaNode
position?: Position
}
export interface ImpliesFormula {
type: 'Implies'
left: FormulaNode
right: FormulaNode
position?: Position
}
export interface PredicateCall {
type: 'PredicateCall'
name: string
args: (VariableRef | string | number)[]
/** Comparison operator for size predicates: width($x) >= 44 */
operator?: string
/** Right-hand side of comparison operator */
right?: number | string
position?: Position
}
export interface VariableRef {
type: 'VariableRef'
name: string
position?: Position
}
export interface DomainRef {
type: 'DomainRef'
kind: string
selector?: string
extraArg?: string
/** Variable reference used as selector (e.g. descendants($card, '.title')) */
variableSelector?: VariableRef
/** Variable reference used as extra argument */
variableExtraArg?: VariableRef
position?: Position
}
// ---------------------------------------------------------------------------
// Fluent Domain Builder
// ---------------------------------------------------------------------------
export class FluentDomain {
private _kind: string
private _selector?: string
constructor(kind: string, selector?: string) {
this._kind = kind
this._selector = selector
}
toRef(): DomainRef {
return {
type: 'DomainRef',
kind: this._kind,
selector: this._selector,
}
}
}
// ---------------------------------------------------------------------------
// Fluent Formula Builder
// ---------------------------------------------------------------------------
export class FluentFormula {
private _node: FormulaNode
constructor(node: FormulaNode) {
this._node = node
}
get node(): FormulaNode {
return this._node
}
and(other: FluentFormula): FluentFormula {
return new FluentFormula({
type: 'And',
left: this._node,
right: other._node,
})
}
or(other: FluentFormula): FluentFormula {
return new FluentFormula({
type: 'Or',
left: this._node,
right: other._node,
})
}
implies(other: FluentFormula): FluentFormula {
return new FluentFormula({
type: 'Implies',
left: this._node,
right: other._node,
})
}
not(): FluentFormula {
return new FluentFormula({
type: 'Not',
operand: this._node,
})
}
}
// ---------------------------------------------------------------------------
// Quantifier Builders
// ---------------------------------------------------------------------------
export function forAll(variableName: string, domain: FluentDomain, body: (v: VariableRef) => FluentFormula): FluentFormula {
const varRef: VariableRef = { type: 'VariableRef', name: variableName }
const bodyFormula = body(varRef)
return new FluentFormula({
type: 'ForAll',
variable: varRef,
domain: domain.toRef(),
body: bodyFormula.node,
})
}
export function exists(variableName: string, domain: FluentDomain, body: (v: VariableRef) => FluentFormula): FluentFormula {
const varRef: VariableRef = { type: 'VariableRef', name: variableName }
const bodyFormula = body(varRef)
return new FluentFormula({
type: 'Exists',
variable: varRef,
domain: domain.toRef(),
body: bodyFormula.node,
})
}
// ---------------------------------------------------------------------------
// Predicate Builder
// ---------------------------------------------------------------------------
export function predicate(name: string, ...args: (VariableRef | string | number)[]): FluentFormula {
return new FluentFormula({
type: 'PredicateCall',
name,
args,
})
}
// ---------------------------------------------------------------------------
// Domain Constructors
// ---------------------------------------------------------------------------
export const domain = {
elements(selector: string): FluentDomain {
return new FluentDomain('elements', selector)
},
descendants(parent: VariableRef | string, selector: string): FluentDomain {
return new FluentDomain('descendants', selector)
},
lineBoxes(text: VariableRef | string): FluentDomain {
// Store the selector argument to match string DSL semantics.
const selector = typeof text === 'string' ? text : text.name
return new FluentDomain('lineBoxes', selector)
},
custom(kind: string, selector?: string): FluentDomain {
return new FluentDomain(kind, selector)
},
}
+231
View File
@@ -0,0 +1,231 @@
// Logic validator: validates FOL AST for well-formedness,
// closed formulas (no free variables), and domain compatibility.
import type {
FormulaNode,
ForAllFormula,
ExistsFormula,
AndFormula,
OrFormula,
NotFormula,
ImpliesFormula,
PredicateCall,
VariableRef,
DomainRef,
} from './logic-fluent.js'
// ---------------------------------------------------------------------------
// Validation Result
// ---------------------------------------------------------------------------
export interface LogicValidationResult {
valid: boolean
diagnostics: LogicDiagnostic[]
}
export interface LogicDiagnostic {
code: string
message: string
severity: 'error' | 'warning'
}
// ---------------------------------------------------------------------------
// Predicate Registry (contract from migration plan Section 4)
// ---------------------------------------------------------------------------
export interface PredicateDescriptor {
name: string
arity: number
domains: string[]
}
export interface PredicateRegistry {
lookup(name: string): PredicateDescriptor | undefined
}
// ---------------------------------------------------------------------------
// Validation Context
// ---------------------------------------------------------------------------
interface ValidationContext {
boundVars: Set<string>
registry?: PredicateRegistry
diagnostics: LogicDiagnostic[]
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function validateFormula(
formula: FormulaNode,
registry?: PredicateRegistry,
): LogicValidationResult {
const ctx: ValidationContext = {
boundVars: new Set(),
registry,
diagnostics: [],
}
validateNode(formula, ctx)
return {
valid: ctx.diagnostics.length === 0,
diagnostics: ctx.diagnostics,
}
}
export function validateProgram(
formulas: FormulaNode[],
registry?: PredicateRegistry,
): LogicValidationResult {
const allDiagnostics: LogicDiagnostic[] = []
for (const formula of formulas) {
const result = validateFormula(formula, registry)
allDiagnostics.push(...result.diagnostics)
}
return {
valid: allDiagnostics.length === 0,
diagnostics: allDiagnostics,
}
}
// ---------------------------------------------------------------------------
// Node Validation
// ---------------------------------------------------------------------------
function validateNode(node: FormulaNode, ctx: ValidationContext): void {
switch (node.type) {
case 'ForAll':
validateForAll(node, ctx)
break
case 'Exists':
validateExists(node, ctx)
break
case 'And':
validateAnd(node, ctx)
break
case 'Or':
validateOr(node, ctx)
break
case 'Not':
validateNot(node, ctx)
break
case 'Implies':
validateImplies(node, ctx)
break
case 'PredicateCall':
validatePredicateCall(node, ctx)
break
case 'VariableRef':
validateVariableRef(node, ctx)
break
case 'DomainRef':
validateDomainRef(node, ctx)
break
default:
ctx.diagnostics.push({
code: 'IMH_LOGIC_UNKNOWN_NODE',
message: `Unknown formula node type: ${(node as any).type}`,
severity: 'error',
})
}
}
function validateForAll(node: ForAllFormula, ctx: ValidationContext): void {
validateDomainRef(node.domain, ctx)
const childCtx: ValidationContext = {
boundVars: new Set(ctx.boundVars),
registry: ctx.registry,
diagnostics: ctx.diagnostics,
}
childCtx.boundVars.add(node.variable.name)
validateNode(node.body, childCtx)
}
function validateExists(node: ExistsFormula, ctx: ValidationContext): void {
validateDomainRef(node.domain, ctx)
const childCtx: ValidationContext = {
boundVars: new Set(ctx.boundVars),
registry: ctx.registry,
diagnostics: ctx.diagnostics,
}
childCtx.boundVars.add(node.variable.name)
validateNode(node.body, childCtx)
}
function validateAnd(node: AndFormula, ctx: ValidationContext): void {
validateNode(node.left, ctx)
validateNode(node.right, ctx)
}
function validateOr(node: OrFormula, ctx: ValidationContext): void {
validateNode(node.left, ctx)
validateNode(node.right, ctx)
}
function validateNot(node: NotFormula, ctx: ValidationContext): void {
validateNode(node.operand, ctx)
}
function validateImplies(node: ImpliesFormula, ctx: ValidationContext): void {
validateNode(node.left, ctx)
validateNode(node.right, ctx)
}
function validatePredicateCall(node: PredicateCall, ctx: ValidationContext): void {
// Check predicate arity if registry is provided
if (ctx.registry) {
const descriptor = ctx.registry.lookup(node.name)
if (descriptor) {
if (node.args.length !== descriptor.arity) {
ctx.diagnostics.push({
code: 'IMH_LOGIC_ARITY_MISMATCH',
message: `Predicate "${node.name}" expects ${descriptor.arity} arguments, got ${node.args.length}.`,
severity: 'error',
})
}
} else {
// Unknown predicate — warn but don't error (extensibility)
ctx.diagnostics.push({
code: 'IMH_LOGIC_UNKNOWN_PREDICATE',
message: `Unknown predicate "${node.name}".`,
severity: 'warning',
})
}
}
// Validate that all variable references are bound
for (const arg of node.args) {
if (typeof arg === 'object' && arg !== null && arg.type === 'VariableRef') {
validateVariableRef(arg as VariableRef, ctx)
}
}
}
function validateVariableRef(node: VariableRef, ctx: ValidationContext): void {
if (!ctx.boundVars.has(node.name)) {
ctx.diagnostics.push({
code: 'IMH_LOGIC_FREE_VARIABLE',
message: `Free variable "${node.name}" detected in a closed formula context.`,
severity: 'error',
})
}
}
function validateDomainRef(node: DomainRef, ctx: ValidationContext): void {
// Basic well-formedness: domain kind must be non-empty
if (!node.kind || node.kind.trim().length === 0) {
ctx.diagnostics.push({
code: 'IMH_LOGIC_EMPTY_DOMAIN',
message: 'Domain kind must not be empty.',
severity: 'error',
})
}
}
@@ -0,0 +1,236 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import fc from 'fast-check';
import {
forAll,
exists,
predicate,
domain,
FluentFormula,
type FormulaNode,
} from './logic-fluent.js';
import {
lowerFormula,
lowerProgram,
type LogicIrNode,
} from './logic-compiler.js';
// ---- Arbitraries for FOL generation ----
const identifierArb = fc.string({ minLength: 1, maxLength: 10 }).filter(s => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s));
const predicateNameArb = fc.constantFrom(
'width', 'height', 'leftOf', 'rightOf', 'above', 'below',
'inside', 'overlaps', 'alignedWith', 'centeredWithin', 'atLeast', 'atMost'
);
const domainKindArb = fc.constantFrom('elements', 'lineBoxes', 'custom');
const selectorArb = fc.string({ minLength: 1, maxLength: 20 }).filter(s => /^[.#]?[a-zA-Z0-9_-]+$/.test(s));
// Generate a domain reference (only kinds that take a single selector argument)
const domainRefArb = fc.tuple(domainKindArb, fc.option(selectorArb, { nil: undefined }))
.map(([kind, sel]) => {
if (kind === 'custom') {
return domain.custom(sel ?? 'default');
}
return domain[kind](sel ?? '');
});
// Generate a variable name
const varNameArb = fc.constantFrom('x', 'y', 'z', 'btn', 'card', 'item', 'container');
// Generate a predicate argument: either a variable ref or a literal
const argArb: fc.Arbitrary<any> = fc.oneof(
varNameArb.map(name => ({ type: 'VariableRef', name })),
fc.integer({ min: 0, max: 100 }),
fc.string({ minLength: 1, maxLength: 10 })
);
// Generate a predicate call
const predicateCallArb: fc.Arbitrary<FluentFormula> = fc.tuple(predicateNameArb, fc.array(argArb, { minLength: 1, maxLength: 2 }))
.map(([name, args]) => predicate(name, ...args));
// Generate a FOL formula with configurable depth
function formulaArb(maxDepth: number): fc.Arbitrary<FluentFormula> {
if (maxDepth <= 0) {
return predicateCallArb;
}
const base = predicateCallArb;
const quantified = fc.tuple(varNameArb, domainRefArb, formulaArb(maxDepth - 1))
.map(([v, dom, body]) => {
const choice = fc.sample(fc.constantFrom('forall', 'exists'), 1)[0];
if (choice === 'forall') {
return forAll(v, dom, () => body);
}
return exists(v, dom, () => body);
});
const composite = fc.tuple(formulaArb(maxDepth - 1), formulaArb(maxDepth - 1))
.map(([left, right]) => {
const choice = fc.sample(fc.constantFrom('and', 'or', 'implies'), 1)[0];
if (choice === 'and') return left.and(right);
if (choice === 'or') return left.or(right);
return left.implies(right);
});
const negated = formulaArb(maxDepth - 1).map(f => f.not());
return fc.oneof(
{ weight: 4, arbitrary: base },
{ weight: 2, arbitrary: quantified },
{ weight: 2, arbitrary: composite },
{ weight: 1, arbitrary: negated }
);
}
// ---- Property Tests ----
describe('dsl logic property tests', () => {
it('property: fluent FOL lowers to valid logic IR', () => {
fc.assert(
fc.property(formulaArb(3), (fluentFormula) => {
const ast = fluentFormula.node;
const result = lowerFormula(ast, { origin: 'fluent' });
assert.ok(result);
// IR must have a recognized type
assert.ok([
'LogicForAll', 'LogicExists', 'LogicAnd', 'LogicOr',
'LogicNot', 'LogicImplies', 'LogicPredicate', 'LogicVariable', 'LogicDomain'
].includes(result.type));
// Origin must be preserved
assert.strictEqual(result.origin, 'fluent');
}),
{ numRuns: 200, seed: 42 }
);
});
it('property: lowering is deterministic (same AST = same IR)', () => {
fc.assert(
fc.property(formulaArb(2), (fluentFormula) => {
const ast = fluentFormula.node;
const ir1 = lowerFormula(ast, { origin: 'fluent' });
const ir2 = lowerFormula(ast, { origin: 'fluent' });
assert.deepStrictEqual(ir1, ir2);
}),
{ numRuns: 100, seed: 99 }
);
});
it('property: nested quantifiers produce nested IR', () => {
fc.assert(
fc.property(
fc.tuple(varNameArb, domainRefArb, varNameArb, domainRefArb, predicateCallArb),
([v1, dom1, v2, dom2, inner]) => {
const formula = forAll(v1, dom1, () =>
exists(v2, dom2, () => inner)
);
const ir = lowerFormula(formula.node, { origin: 'fluent' });
assert.strictEqual(ir.type, 'LogicForAll');
const forallIr = ir as any;
assert.strictEqual(forallIr.body.type, 'LogicExists');
const existsIr = forallIr.body as any;
assert.strictEqual(existsIr.body.type, 'LogicPredicate');
}
),
{ numRuns: 100, seed: 77 }
);
});
it('property: boolean connectives lower correctly', () => {
fc.assert(
fc.property(
fc.tuple(predicateCallArb, predicateCallArb),
([left, right]) => {
const andIr = lowerFormula(left.and(right).node, { origin: 'fluent' });
assert.strictEqual(andIr.type, 'LogicAnd');
assert.strictEqual((andIr as any).left.type, 'LogicPredicate');
assert.strictEqual((andIr as any).right.type, 'LogicPredicate');
const orIr = lowerFormula(left.or(right).node, { origin: 'fluent' });
assert.strictEqual(orIr.type, 'LogicOr');
const notIr = lowerFormula(left.not().node, { origin: 'fluent' });
assert.strictEqual(notIr.type, 'LogicNot');
assert.strictEqual((notIr as any).operand.type, 'LogicPredicate');
const impliesIr = lowerFormula(left.implies(right).node, { origin: 'fluent' });
assert.strictEqual(impliesIr.type, 'LogicImplies');
}
),
{ numRuns: 100, seed: 88 }
);
});
it('property: program lowering combines formulas with And', () => {
fc.assert(
fc.property(
fc.array(predicateCallArb, { minLength: 1, maxLength: 5 }),
(formulas) => {
const asts = formulas.map(f => f.node);
const result = lowerProgram(asts, { origin: 'fluent' });
assert.strictEqual(result.diagnostics.length, 0);
// Single formula should lower directly
if (asts.length === 1) {
assert.strictEqual(result.logicIr.type, 'LogicPredicate');
} else {
// Multiple formulas should be joined with And
assert.strictEqual(result.logicIr.type, 'LogicAnd');
}
}
),
{ numRuns: 100, seed: 55 }
);
});
it('property: empty program lowers to true predicate', () => {
const result = lowerProgram([], { origin: 'fluent' });
assert.strictEqual(result.logicIr.type, 'LogicPredicate');
assert.strictEqual((result.logicIr as any).name, 'true');
});
it('property: predicate calls preserve name and args', () => {
fc.assert(
fc.property(
fc.tuple(predicateNameArb, fc.array(argArb, { minLength: 1, maxLength: 2 })),
([name, args]) => {
const formula = predicate(name, ...args);
const ir = lowerFormula(formula.node, { origin: 'fluent' });
assert.strictEqual(ir.type, 'LogicPredicate');
const predIr = ir as any;
assert.strictEqual(predIr.name, name);
assert.strictEqual(predIr.args.length, args.length);
}
),
{ numRuns: 200, seed: 33 }
);
});
it('property: domain references preserve kind and selector', () => {
fc.assert(
fc.property(
fc.tuple(varNameArb, domainKindArb, fc.option(selectorArb, { nil: undefined })),
([v, kind, sel]) => {
const dom = kind === 'lineBoxes'
? domain.lineBoxes('')
: kind === 'custom'
? domain.custom(kind, sel ?? '')
: domain[kind as 'elements'](sel ?? '');
const formula = forAll(v, dom, () => predicate('width', { type: 'VariableRef', name: v }));
const ir = lowerFormula(formula.node, { origin: 'fluent' });
assert.strictEqual(ir.type, 'LogicForAll');
const forallIr = ir as any;
assert.strictEqual(forallIr.domain.type, 'LogicDomain');
assert.strictEqual(forallIr.domain.kind, kind);
if (sel && kind !== 'lineBoxes') {
assert.strictEqual(forallIr.domain.selector, sel);
}
}
),
{ numRuns: 100, seed: 111 }
);
});
});
@@ -0,0 +1,697 @@
// Shared canonical lowering for Imhotep DSL assertions
// Normalizes both fluent AST and string-parser AST into comparable descriptors.
// Handles the option-format divergence: fluent stores raw numbers,
// string parser stores LiteralNode / ToleranceLiteralNode AST wrappers.
//
// Context propagation: parent blocks (FrameBlock, CompoundAssertion) push
// context (frame, quantifier) down to child assertions. This mirrors the
// string DSL structure where frames and quantifiers wrap assertions.
//
// Known non-equivalences (documented inline, not in .md files):
// 1. maxGap-only: String DSL gap syntax always sets minGap. maxGap without
// minGap cannot be expressed in string DSL. Property tests avoid this case.
// 2. States: String DSL has no inline state syntax for bare assertions.
// State equivalence is tested as fluent-internal property only.
// 3. FOL custom domains: Fluent API uses domain.custom(name, selector?) where
// name becomes the kind, but string DSL parses custom('selector') as
// kind='custom', selector='selector'. These are fundamentally divergent.
// Property tests exclude custom domains from FOL equivalence.
import type { RelationAssertion, Program, Position } from 'imhotep-core'
import type { FormulaNode as DslFormulaNode } from './logic-fluent.js'
// ---------------------------------------------------------------------------
// Canonical Descriptor
// ---------------------------------------------------------------------------
export interface CanonicalClauseDescriptor {
relation: string
subject: string
reference?: string
space?: 'layout' | 'visual'
options?: Record<string, unknown>
frame?: { kind: string; selector?: string; name?: string }
state?: { kind: string; name?: string }
quantifier?: 'all' | 'any' | 'none'
envGuard?: string
tolerance?: { value: number; unit: string }
bounds?: { minGap?: number; maxGap?: number }
flags: number
/** Whether this clause is negated (not assertion) */
negated?: boolean
/** Source position from the original AST node, if available */
position?: Position
/** Compound grouping metadata for logical operators */
compoundOperator?: 'and' | 'or'
compoundGroupId?: number
}
// ---------------------------------------------------------------------------
// Value Extractors
// ---------------------------------------------------------------------------
function extractTolerance(raw: unknown): { value: number; unit: string } | undefined {
if (raw === undefined || raw === null) return undefined
// Fluent path: raw number
if (typeof raw === 'number') {
return { value: raw, unit: 'px' }
}
// Fluent path: raw string like "5px" or "2jnd"
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (trimmed.endsWith('jnd')) {
const num = parseFloat(trimmed.slice(0, -3))
if (!Number.isNaN(num)) return { value: num, unit: 'jnd' }
}
const num = parseFloat(trimmed)
if (!Number.isNaN(num)) return { value: num, unit: 'px' }
return undefined
}
// String-parser path: ToleranceLiteralNode { type: 'ToleranceLiteral', value, unit }
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>
if ('value' in obj && typeof obj.value === 'number') {
const unit = (obj.unit as string) || 'px'
return { value: obj.value, unit }
}
}
return undefined
}
function extractGap(raw: unknown): { value: number; unit: string } | undefined {
if (raw === undefined || raw === null) return undefined
// Fluent path: raw number
if (typeof raw === 'number') {
return { value: raw, unit: 'px' }
}
// Fluent path: raw string like "10px"
if (typeof raw === 'string') {
const num = parseFloat(raw.trim())
if (!Number.isNaN(num)) return { value: num, unit: 'px' }
return undefined
}
// String-parser path: LiteralNode { type: 'Literal', kind: 'length', value, unit }
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>
if ('value' in obj && typeof obj.value === 'number') {
const unit = (obj.unit as string) || 'px'
return { value: obj.value, unit }
}
}
return undefined
}
function extractRangeGap(raw: unknown): { minGap?: number; maxGap?: number } | undefined {
if (raw === undefined || raw === null) return undefined
// String-parser path: range LiteralNode
// { type: 'Literal', kind: 'range', value: { min: { value, unit }, max: { value, unit } } }
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>
if (obj.kind === 'range' && obj.value && typeof obj.value === 'object') {
const range = obj.value as Record<string, unknown>
const result: { minGap?: number; maxGap?: number } = {}
if (range.min && typeof range.min === 'object') {
const minObj = range.min as Record<string, unknown>
if (typeof minObj.value === 'number') {
result.minGap = minObj.value
}
}
if (range.max && typeof range.max === 'object') {
const maxObj = range.max as Record<string, unknown>
if (typeof maxObj.value === 'number') {
result.maxGap = maxObj.value
}
}
if (result.minGap !== undefined || result.maxGap !== undefined) {
return result
}
}
}
return undefined
}
// ---------------------------------------------------------------------------
// Lowering with context propagation
// ---------------------------------------------------------------------------
export function lowerToCanonical(ast: RelationAssertion | Program): CanonicalClauseDescriptor[] {
const descriptors: CanonicalClauseDescriptor[] = []
let compoundGroupId = 0
if (ast.type === 'Program') {
for (const child of ast.children) {
lowerBlock(child as any, descriptors, {}, undefined, () => { compoundGroupId += 1; return compoundGroupId })
}
} else {
descriptors.push(lowerAssertionToCanonical(ast, {}))
}
return descriptors
}
// ---------------------------------------------------------------------------
// FOL Formula Extraction (bypasses canonical lowering)
// ---------------------------------------------------------------------------
// Dense DSL forall/exists formulas have their own AST shape and are compiled
// directly to solver FormulaNode via compileDenseFOLToFormula in compiler.ts.
// They do NOT go through canonical clause descriptors.
const FOL_NODE_TYPES = new Set([
'ForAll',
'Exists',
'And',
'Or',
'Not',
'Implies',
'PredicateCall',
])
/**
* Extract top-level FOL formula nodes from a Program AST.
* These formulas bypass canonical lowering and are evaluated directly
* by the logic engine.
*/
export function extractFOLFormulas(ast: Program): DslFormulaNode[] {
const formulas: DslFormulaNode[] = []
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object') return
const typed = node as { type?: string; children?: unknown[]; body?: unknown }
if (typed.type && FOL_NODE_TYPES.has(typed.type)) {
formulas.push(node as DslFormulaNode)
return
}
if (Array.isArray(typed.children)) {
for (const child of typed.children) {
walk(child)
}
}
if (typed.body) {
walk(typed.body)
}
}
for (const child of ast.children) {
walk(child)
}
return formulas
}
function lowerBlock(
node: any,
out: CanonicalClauseDescriptor[],
context: Partial<CanonicalClauseDescriptor>,
compoundOperator?: 'and' | 'or',
nextGroupId?: () => number,
): void {
if (!node) return
// Terminal: relation assertion
if (node.type === 'RelationAssertion') {
const desc = lowerAssertionToCanonical(node, context)
if (compoundOperator && nextGroupId) {
desc.compoundOperator = compoundOperator
desc.compoundGroupId = nextGroupId()
}
out.push(desc)
return
}
// Size assertion: convert to canonical descriptor
if (node.type === 'SizeAssertion') {
const desc = lowerSizeAssertionToCanonical(node, context)
if (compoundOperator && nextGroupId) {
desc.compoundOperator = compoundOperator
desc.compoundGroupId = nextGroupId()
}
out.push(desc)
return
}
// Quantifier wrapper or logical compound: propagate to children
if (node.type === 'CompoundAssertion') {
const quantifier = node.quantifier?.kind as 'all' | 'any' | 'none' | undefined
const operator = node.operator as 'and' | 'or' | undefined
if (operator) {
// Logical compound: assign a single group ID shared across all children
const groupId = nextGroupId ? nextGroupId() : undefined
for (const child of node.children || []) {
lowerBlock(child, out, { ...context, quantifier }, operator, () => groupId as number)
}
} else {
for (const child of node.children || []) {
lowerBlock(child, out, { ...context, quantifier }, compoundOperator, nextGroupId)
}
}
return
}
// Frame wrapper: propagate frame to children
if (node.type === 'FrameBlock') {
const frameNode = node.frame
const frame = frameNode
? {
kind: frameNode.kind,
selector: frameNode.selector,
name: frameNode.name,
}
: undefined
for (const child of node.children || []) {
lowerBlock(child, out, { ...context, frame }, compoundOperator, nextGroupId)
}
return
}
// Environment guard wrapper: propagate envGuard to children
if (node.type === 'WhenBlock') {
const guard = node.guard
let envGuard: string | undefined
if (guard) {
// Serialize guard expression back to a string for canonical comparison
if (guard.right !== undefined) {
envGuard = `${guard.left} ${guard.op} ${guard.right}`
} else {
envGuard = `${guard.left} ${guard.op}`
}
}
for (const child of node.children || []) {
lowerBlock(child, out, { ...context, envGuard }, compoundOperator, nextGroupId)
}
return
}
// Other block containers: recurse without adding context
if (node.type === 'AlwaysBlock' || node.type === 'AcrossBlock') {
for (const child of node.children || []) {
lowerBlock(child, out, context, compoundOperator, nextGroupId)
}
return
}
// FOL formulas (ForAll, Exists, And, Or, Not, Implies, PredicateCall)
// are intentionally NOT lowered to canonical descriptors. They bypass
// canonical lowering entirely and are compiled directly to solver
// FormulaNode via compileDenseFOLToFormula in compiler.ts.
}
function lowerAssertionToCanonical(
assertion: RelationAssertion,
context: Partial<CanonicalClauseDescriptor>,
): CanonicalClauseDescriptor {
// Extended properties are attached via cast in fluent.ts
const extended = assertion as unknown as Record<string, unknown>
const descriptor: CanonicalClauseDescriptor = {
relation: assertion.relation,
subject: assertion.subject.value,
flags: 0,
}
if (assertion.reference.value) {
descriptor.reference = assertion.reference.value
}
// Preserve source position from AST for traceability
if (assertion.position) {
descriptor.position = assertion.position
}
// Frame: assertion-level overrides context-level
const frameNode = extended.frame as { kind: string; selector?: string; name?: string } | undefined
if (frameNode) {
descriptor.frame = {
kind: frameNode.kind,
selector: frameNode.selector,
name: frameNode.name,
}
} else if (context.frame) {
descriptor.frame = context.frame
}
// State: only on assertion (string DSL has no state blocks)
const stateNode = extended.state as { kind: string; name?: string } | undefined
if (stateNode) {
descriptor.state = {
kind: stateNode.kind,
name: stateNode.name,
}
}
// Quantifier: assertion-level overrides context-level
const quantifier = extended.quantifier as 'all' | 'any' | 'none' | undefined
if (quantifier) {
descriptor.quantifier = quantifier
} else if (context.quantifier) {
descriptor.quantifier = context.quantifier
}
// Environment guard: assertion-level overrides context-level
const envGuard = extended.envGuard as { condition: string } | undefined
if (envGuard) {
descriptor.envGuard = envGuard.condition
} else if (context.envGuard) {
descriptor.envGuard = context.envGuard
}
// Options
const rawOpts = assertion.options as unknown as Record<string, unknown> | undefined
// Tolerance
if (rawOpts?.tolerance !== undefined) {
const tol = extractTolerance(rawOpts.tolerance)
if (tol) {
descriptor.tolerance = tol
}
}
// Bounds
const bounds: { minGap?: number; maxGap?: number } = {}
if (rawOpts?.minGap !== undefined) {
const rangeResult = extractRangeGap(rawOpts.minGap)
if (rangeResult) {
if (rangeResult.minGap !== undefined) bounds.minGap = rangeResult.minGap
if (rangeResult.maxGap !== undefined) bounds.maxGap = rangeResult.maxGap
} else {
const parsed = extractGap(rawOpts.minGap)
if (parsed) {
bounds.minGap = parsed.value
}
}
}
if (rawOpts?.maxGap !== undefined) {
// If maxGap is a separate non-range value and we haven't already set it from range
const rangeResult = extractRangeGap(rawOpts.maxGap)
if (rangeResult) {
if (bounds.maxGap === undefined && rangeResult.maxGap !== undefined) {
bounds.maxGap = rangeResult.maxGap
}
} else if (bounds.maxGap === undefined) {
const parsed = extractGap(rawOpts.maxGap)
if (parsed) {
bounds.maxGap = parsed.value
}
}
}
if (Object.keys(bounds).length > 0) {
descriptor.bounds = bounds
}
const options: Record<string, unknown> = {}
if (rawOpts?.axis !== undefined) options.axis = rawOpts.axis
if (rawOpts?.dimension !== undefined) options.dimension = rawOpts.dimension
if (rawOpts?.mode !== undefined) options.mode = rawOpts.mode
if (rawOpts?.value !== undefined) options.value = normalizeOptionNumber(rawOpts.value)
if (rawOpts?.min !== undefined) options.min = normalizeOptionNumber(rawOpts.min)
if (rawOpts?.max !== undefined) options.max = normalizeOptionNumber(rawOpts.max)
if (rawOpts?.space === 'layout' || rawOpts?.space === 'visual') {
descriptor.space = rawOpts.space
options.space = rawOpts.space
}
if (Object.keys(options).length > 0) {
descriptor.options = options
}
// Negation: propagate from AST node
const astNegated = (assertion as any).negated as boolean | undefined
if (astNegated) {
descriptor.negated = true
}
// Flags (bitmask matching compiler.ts)
const effectiveQuantifier = descriptor.quantifier
if (effectiveQuantifier === 'all') descriptor.flags |= 1
if (effectiveQuantifier === 'any') descriptor.flags |= 2
if (effectiveQuantifier === 'none') descriptor.flags |= 4
if (rawOpts?.inStackingContext === true) descriptor.flags |= 8
return descriptor
}
function lowerSizeAssertionToCanonical(
assertion: any,
context: Partial<CanonicalClauseDescriptor>,
): CanonicalClauseDescriptor {
const descriptor: CanonicalClauseDescriptor = {
relation: 'atLeast',
subject: assertion.subject.value,
flags: 0,
}
if (context.quantifier) {
descriptor.quantifier = context.quantifier
}
const bounds = assertion.bounds as any
const options: Record<string, unknown> = {
dimension: assertion.property,
}
const normalizeLengthBound = (raw: any): number | { value: number; unit: string } | undefined => {
if (!raw || typeof raw !== 'object') return undefined
const value = typeof raw.value === 'number' ? raw.value : undefined
if (value === undefined) return undefined
const unit = typeof raw.unit === 'string' ? raw.unit.toLowerCase() : 'px'
return unit === 'px' ? value : { value, unit }
}
const minBound = normalizeLengthBound(bounds?.min)
if (minBound !== undefined) {
options.value = minBound
options.min = minBound
}
const maxBound = normalizeLengthBound(bounds?.max)
if (maxBound !== undefined) {
options.max = maxBound
}
const exactBound = normalizeLengthBound(bounds?.exact)
if (exactBound !== undefined) {
options.value = exactBound
}
// Determine relation from bounds structure
if (bounds?.min && bounds?.max) {
descriptor.relation = 'between'
options.mode = 'between'
} else if (bounds?.max) {
descriptor.relation = 'atMost'
options.mode = 'atMost'
} else if (bounds?.min) {
descriptor.relation = 'atLeast'
options.mode = 'atLeast'
} else if (bounds?.exact) {
descriptor.relation = 'exact'
options.mode = 'exact'
}
// AspectRatio is special: keep the relation as aspectRatio when property is aspectRatio
// and both min/max are present (dense spec aspectRatio between X and Y)
if (assertion.property === 'aspectRatio') {
descriptor.relation = 'aspectRatio'
}
descriptor.options = options
// Propagate negation from AST node
const astNegated = (assertion as any).negated as boolean | undefined
if (astNegated) {
descriptor.negated = true
}
return descriptor
}
// ---------------------------------------------------------------------------
// Equivalence
// ---------------------------------------------------------------------------
export function areEquivalent(a: CanonicalClauseDescriptor[], b: CanonicalClauseDescriptor[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (!clauseEquivalent(a[i], b[i])) {
return false
}
}
return true
}
export function clauseEquivalent(a: CanonicalClauseDescriptor, b: CanonicalClauseDescriptor): boolean {
return (
a.relation === b.relation &&
a.subject === b.subject &&
(a.reference || '') === (b.reference || '') &&
a.space === b.space &&
JSON.stringify(a.options ?? {}) === JSON.stringify(b.options ?? {}) &&
a.flags === b.flags &&
a.quantifier === b.quantifier &&
a.envGuard === b.envGuard &&
frameEquivalent(a.frame, b.frame) &&
stateEquivalent(a.state, b.state) &&
toleranceEquivalent(a.tolerance, b.tolerance) &&
boundsEquivalent(a.bounds, b.bounds)
)
}
function frameEquivalent(
a: CanonicalClauseDescriptor['frame'],
b: CanonicalClauseDescriptor['frame'],
): boolean {
if (a === b) return true
if (!a || !b) return a === b
return a.kind === b.kind && a.selector === b.selector && a.name === b.name
}
function stateEquivalent(
a: CanonicalClauseDescriptor['state'],
b: CanonicalClauseDescriptor['state'],
): boolean {
if (a === b) return true
if (!a || !b) return a === b
return a.kind === b.kind && a.name === b.name
}
function toleranceEquivalent(
a: CanonicalClauseDescriptor['tolerance'],
b: CanonicalClauseDescriptor['tolerance'],
): boolean {
if (a === b) return true
if (!a || !b) return a === b
return a.value === b.value && a.unit === b.unit
}
function boundsEquivalent(
a: CanonicalClauseDescriptor['bounds'],
b: CanonicalClauseDescriptor['bounds'],
): boolean {
if (a === b) return true
if (!a || !b) return a === b
return a.minGap === b.minGap && a.maxGap === b.maxGap
}
// ---------------------------------------------------------------------------
// Reconstruction (for round-trip tests)
// ---------------------------------------------------------------------------
export function reconstructFromCanonical(clauses: CanonicalClauseDescriptor[]): RelationAssertion[] {
return clauses.map((clause) => {
const assertion: RelationAssertion = {
type: 'RelationAssertion',
relation: clause.relation,
subject: { type: 'SelectorRef', value: clause.subject },
reference: { type: 'SelectorRef', value: clause.reference || '' },
options: { type: 'RelationOptions' },
}
const extended = assertion as unknown as Record<string, unknown>
if (clause.frame) {
extended.frame = {
type: 'FrameRef',
kind: clause.frame.kind,
selector: clause.frame.selector,
name: clause.frame.name,
}
}
if (clause.state) {
extended.state = {
type: 'StateSelector',
kind: clause.state.kind,
name: clause.state.name,
}
}
if (clause.quantifier) {
extended.quantifier = clause.quantifier
}
if (clause.envGuard) {
extended.envGuard = {
type: 'EnvGuard',
condition: clause.envGuard,
}
}
const opts = assertion.options as unknown as Record<string, unknown>
if (clause.tolerance) {
opts.tolerance = clause.tolerance.value
}
if (clause.bounds) {
if (clause.bounds.minGap !== undefined) {
opts.minGap = clause.bounds.minGap
}
if (clause.bounds.maxGap !== undefined) {
opts.maxGap = clause.bounds.maxGap
}
}
if (clause.space) {
opts.space = clause.space
}
if (clause.options) {
for (const [k, v] of Object.entries(clause.options)) {
opts[k] = v
}
}
if (clause.flags & 8) {
opts.inStackingContext = true
}
return assertion
})
}
function normalizeOptionNumber(raw: unknown): number | { value: number; unit: string } | undefined {
if (typeof raw === 'number') return raw
if (typeof raw === 'string') {
const trimmed = raw.trim()
const m = trimmed.match(/^(-?\d*\.?\d+)\s*([a-z%]+)?$/i)
if (!m) return undefined
const n = Number.parseFloat(m[1] ?? '')
if (Number.isNaN(n)) return undefined
const unit = (m[2] ?? 'px').toLowerCase()
return unit === 'px' ? n : { value: n, unit }
}
if (typeof raw === 'object' && raw !== null) {
const o = raw as Record<string, unknown>
if (typeof o.value === 'number') {
const unit = typeof o.unit === 'string' ? o.unit.toLowerCase() : 'px'
return unit === 'px' ? o.value : { value: o.value, unit }
}
}
return undefined
}
export function areSemanticallyEquivalent(
original: RelationAssertion,
recovered: RelationAssertion,
): boolean {
const a = lowerAssertionToCanonical(original, {})
const b = lowerAssertionToCanonical(recovered, {})
return clauseEquivalent(a, b)
}
+467
View File
@@ -0,0 +1,467 @@
// Comprehensive tests for the Imhotep dense DSL parser
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { parseSpec } from './parser.js'
import { expect, frame } from './fluent.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parse(source: string) {
return parseSpec(source)
}
function firstRelation(result: ReturnType<typeof parse>) {
const block = result.ast.children[0] as any
return block.children[0] as any
}
// ---------------------------------------------------------------------------
// Relation Tests
// ---------------------------------------------------------------------------
describe('relations', () => {
it('parses leftOf', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b'`)
const rel = firstRelation(result)
assert.strictEqual(rel.type, 'RelationAssertion')
assert.strictEqual(rel.relation, 'leftOf')
assert.strictEqual(rel.subject.value, '.a')
assert.strictEqual(rel.reference.value, '.b')
})
it('parses rightOf', () => {
const result = parse(`in viewport:\n '.a' rightOf '.b'`)
assert.strictEqual(firstRelation(result).relation, 'rightOf')
})
it('parses above', () => {
const result = parse(`in viewport:\n '.a' above '.b'`)
assert.strictEqual(firstRelation(result).relation, 'above')
})
it('parses below', () => {
const result = parse(`in viewport:\n '.a' below '.b'`)
assert.strictEqual(firstRelation(result).relation, 'below')
})
it('parses alignedWith', () => {
const result = parse(`in viewport:\n '.a' alignedWith '.b'`)
assert.strictEqual(firstRelation(result).relation, 'alignedWith')
})
it('parses centeredWithin', () => {
const result = parse(`in viewport:\n '.a' centeredWithin '.b'`)
assert.strictEqual(firstRelation(result).relation, 'centeredWithin')
})
it('parses inside', () => {
const result = parse(`in viewport:\n '.a' inside '.b'`)
assert.strictEqual(firstRelation(result).relation, 'inside')
})
it('parses overlaps', () => {
const result = parse(`in viewport:\n '.a' overlaps '.b'`)
assert.strictEqual(firstRelation(result).relation, 'overlaps')
})
})
// ---------------------------------------------------------------------------
// Option Tests
// ---------------------------------------------------------------------------
describe('options', () => {
it('parses gap as single value', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' gap 16px`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.minGap.type, 'Literal')
assert.strictEqual(rel.options.minGap.kind, 'length')
assert.strictEqual(rel.options.minGap.value, 16)
assert.strictEqual(rel.options.minGap.unit, 'px')
})
it('parses gap as range', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' gap 8px..16px`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.minGap.kind, 'range')
})
it('parses tolerance in px', () => {
const result = parse(`in viewport:\n '.a' centeredWithin '.b' tolerance 2px`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.tolerance.type, 'ToleranceLiteral')
assert.strictEqual(rel.options.tolerance.value, 2)
assert.strictEqual(rel.options.tolerance.unit, 'px')
})
it('parses tolerance in jnd', () => {
const result = parse(`in viewport:\n '.a' centeredWithin '.b' tolerance 1jnd`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.tolerance.type, 'ToleranceLiteral')
assert.strictEqual(rel.options.tolerance.value, 1)
assert.strictEqual(rel.options.tolerance.unit, 'jnd')
})
it('parses axis option', () => {
const result = parse(`in viewport:\n '.a' alignedWith '.b' axis centerX`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.axis, 'centerX')
})
it('parses inStackingContext true', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' inStackingContext true`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.inStackingContext, true)
})
it('parses inStackingContext false', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b' inStackingContext false`)
const rel = firstRelation(result)
assert.strictEqual(rel.options.inStackingContext, false)
})
})
// ---------------------------------------------------------------------------
// Frame Block Tests
// ---------------------------------------------------------------------------
describe('frame blocks', () => {
it('parses viewport frame', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.type, 'FrameBlock')
assert.strictEqual(block.frame.kind, 'viewport')
})
it('parses document frame', () => {
const result = parse(`in document:\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.frame.kind, 'document')
})
it('parses nearestPositionedAncestor frame', () => {
const result = parse(`in nearestPositionedAncestor('.trigger'):\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.frame.kind, 'nearestPositionedAncestor')
assert.strictEqual(block.frame.selector, '.trigger')
})
it('parses containingBlock frame', () => {
const result = parse(`in containingBlock('.parent'):\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.frame.kind, 'containingBlock')
assert.strictEqual(block.frame.selector, '.parent')
})
it('parses scrollContainer frame', () => {
const result = parse(`in scrollContainer('.scroller'):\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.frame.kind, 'scrollContainer')
assert.strictEqual(block.frame.selector, '.scroller')
})
it('parses namedGridArea frame', () => {
const result = parse(`in namedGridArea('header'):\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.frame.kind, 'namedGridArea')
assert.strictEqual(block.frame.name, 'header')
})
it('parses landmark frame', () => {
const result = parse(`in landmark('main'):\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.frame.kind, 'landmark')
assert.strictEqual(block.frame.name, 'main')
})
})
// ---------------------------------------------------------------------------
// Across Block Tests
// ---------------------------------------------------------------------------
describe('across blocks', () => {
it('parses across with single axis', () => {
const result = parse(`across width [320, 768, 1440]:\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.type, 'AcrossBlock')
assert.strictEqual(block.axes.length, 1)
assert.strictEqual(block.axes[0].name, 'width')
assert.deepStrictEqual(block.axes[0].values, [320, 768, 1440])
})
it('parses across with multiple axes', () => {
const result = parse(`across width [320, 768] colorScheme [light, dark]:\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.axes.length, 2)
assert.strictEqual(block.axes[1].name, 'colorScheme')
assert.deepStrictEqual(block.axes[1].values, ['light', 'dark'])
})
it('parses always block inside across', () => {
const result = parse(`across width [320, 768]:\n always:\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.children[0].type, 'AlwaysBlock')
assert.strictEqual(block.children[0].children[0].type, 'RelationAssertion')
})
it('parses when block inside across', () => {
const result = parse(`across width [320, 768]:\n when width < 768:\n '.a' below '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.children[0].type, 'WhenBlock')
assert.strictEqual(block.children[0].guard.op, '<')
assert.strictEqual(block.children[0].guard.left, 'width')
assert.strictEqual(block.children[0].guard.right, 768)
assert.strictEqual(block.children[0].children[0].relation, 'below')
})
it('parses frame block inside across', () => {
const result = parse(`across width [320, 768]:\n in viewport:\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.strictEqual(block.children[0].type, 'FrameBlock')
})
})
// ---------------------------------------------------------------------------
// Size Assertion Tests
// ---------------------------------------------------------------------------
describe('size assertions', () => {
it('parses atLeast width', () => {
const result = parse(`in viewport:\n '.a' atLeast 44px wide`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.type, 'SizeAssertion')
assert.strictEqual(assertion.property, 'width')
assert.strictEqual(assertion.bounds.min.value, 44)
assert.strictEqual(assertion.bounds.min.unit, 'px')
})
it('parses atMost height', () => {
const result = parse(`in viewport:\n '.a' atMost 100px tall`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.property, 'height')
assert.strictEqual(assertion.bounds.max.value, 100)
})
it('parses aspectRatio exact', () => {
const result = parse(`in viewport:\n '.a' aspectRatio 1.5`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.property, 'aspectRatio')
assert.strictEqual(assertion.bounds.exact.value, 1.5)
})
it('parses aspectRatio between', () => {
const result = parse(`in viewport:\n '.a' aspectRatio between 1 and 2`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.property, 'aspectRatio')
assert.strictEqual(assertion.bounds.min.value, 1)
assert.strictEqual(assertion.bounds.max.value, 2)
})
})
// ---------------------------------------------------------------------------
// Topology Assertion Tests
// ---------------------------------------------------------------------------
describe('topology assertions', () => {
it('parses clippedBy', () => {
const result = parse(`in viewport:\n '.a' clippedBy '.parent'`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.type, 'TopologyAssertion')
assert.strictEqual(assertion.predicate, 'clippedBy')
assert.strictEqual(assertion.reference.value, '.parent')
})
it('parses attachedToScrollContainer', () => {
const result = parse(`in viewport:\n '.a' attachedToScrollContainer '.scroller'`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.predicate, 'attachedToScrollContainer')
})
it('parses escapeClippingChainOf', () => {
const result = parse(`in viewport:\n '.a' escapeClippingChainOf '.ancestor'`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.predicate, 'escapeClippingChainOf')
})
it('parses inStackingContext without reference', () => {
const result = parse(`in viewport:\n '.a' inStackingContext`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.type, 'TopologyAssertion')
assert.strictEqual(assertion.predicate, 'inStackingContext')
assert.strictEqual(assertion.reference, undefined)
})
})
// ---------------------------------------------------------------------------
// Quantifier Tests
// ---------------------------------------------------------------------------
describe('quantifiers', () => {
it('parses all quantifier', () => {
const result = parse(`in viewport:\n all '.a' leftOf '.b'`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.type, 'CompoundAssertion')
assert.strictEqual(assertion.quantifier.kind, 'all')
assert.strictEqual(assertion.children[0].type, 'RelationAssertion')
})
it('parses any quantifier', () => {
const result = parse(`in viewport:\n any '.a' leftOf '.b'`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.quantifier.kind, 'any')
})
it('parses none quantifier', () => {
const result = parse(`in viewport:\n none '.a' leftOf '.b'`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.quantifier.kind, 'none')
})
})
// ---------------------------------------------------------------------------
// Error Tests
// ---------------------------------------------------------------------------
describe('parse errors', () => {
it('reports error for unknown relation', () => {
const result = parse(`in viewport:\n '.a' unknownRelation '.b'`)
assert.strictEqual(result.ast.children.length, 0)
assert.strictEqual(result.diagnostics.length, 1)
assert.strictEqual(result.diagnostics[0].category, 'parse-error')
assert.ok(result.diagnostics[0].position)
})
it('reports error for missing colon after frame', () => {
const result = parse(`in viewport\n '.a' leftOf '.b'`)
assert.strictEqual(result.diagnostics.length, 1)
assert.ok(result.diagnostics[0].message.includes('Expected colon'))
})
it('reports error for unterminated string', () => {
const result = parse(`in viewport:\n '.a leftOf '.b'`)
assert.strictEqual(result.diagnostics.length, 1)
})
it('reports error with line and column', () => {
const result = parse(`in viewport:\n '.a' unknownRelation '.b'`)
const diag = result.diagnostics[0]
assert.ok(diag.position)
assert.strictEqual(diag.position.start.line, 2)
})
})
// ---------------------------------------------------------------------------
// Equivalence Tests: DSL AST should match Fluent AST
// ---------------------------------------------------------------------------
describe('dsl-fluent equivalence', () => {
it('leftOf with gap matches fluent AST', () => {
const dslResult = parse(`in viewport:\n '.a' leftOf '.b' gap 16px`)
const dslAst = (dslResult.ast.children[0] as any).children[0]
const fluentAst = expect('.a')
.in(frame.viewport())
.to.be.leftOf('.b', { minGap: 16 })
.toAst()
assert.strictEqual(dslAst.type, fluentAst.type)
assert.strictEqual(dslAst.relation, fluentAst.relation)
assert.strictEqual(dslAst.subject.value, fluentAst.subject.value)
assert.strictEqual(dslAst.reference.value, fluentAst.reference.value)
})
it('centeredWithin with tolerance matches fluent AST', () => {
const dslResult = parse(`in viewport:\n '.a' centeredWithin '.b' tolerance 1jnd`)
const dslAst = (dslResult.ast.children[0] as any).children[0]
const fluentAst = expect('.a')
.in(frame.viewport())
.to.be.centeredWithin('.b', { tolerance: '1jnd' })
.toAst()
assert.strictEqual(dslAst.relation, fluentAst.relation)
assert.strictEqual(dslAst.options.tolerance.unit, 'jnd')
})
it('frame attachment matches fluent AST', () => {
const dslResult = parse(`in nearestPositionedAncestor('.trigger'):\n '.tooltip' rightOf '.trigger' gap 8px`)
const dslAst = (dslResult.ast.children[0] as any).children[0]
const dslFrame = (dslResult.ast.children[0] as any).frame
const fluentAst = expect('.tooltip')
.in(frame.nearestPositionedAncestor('.trigger'))
.to.be.rightOf('.trigger', { minGap: 8 })
.toAst()
const fluentFrame = (fluentAst as any).frame
assert.strictEqual(dslFrame.kind, fluentFrame.kind)
assert.strictEqual(dslFrame.selector, fluentFrame.selector)
assert.strictEqual(dslAst.relation, fluentAst.relation)
assert.strictEqual(dslAst.subject.value, fluentAst.subject.value)
})
})
// ---------------------------------------------------------------------------
// Source Position Tests
// ---------------------------------------------------------------------------
describe('source positions', () => {
it('attaches position to Program', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b'`)
assert.ok(result.ast.position)
assert.strictEqual(result.ast.position.start.line, 1)
})
it('attaches position to FrameBlock', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b'`)
const block = result.ast.children[0] as any
assert.ok(block.position)
assert.strictEqual(block.position.start.line, 1)
})
it('attaches position to RelationAssertion', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b'`)
const rel = firstRelation(result)
assert.ok(rel.position)
assert.strictEqual(rel.position.start.line, 2)
})
})
// ---------------------------------------------------------------------------
// Edge Cases
// ---------------------------------------------------------------------------
describe('edge cases', () => {
it('parses empty input', () => {
const result = parse('')
assert.strictEqual(result.ast.children.length, 0)
assert.strictEqual(result.diagnostics.length, 0)
})
it('parses input with only whitespace', () => {
const result = parse(' \n\n ')
assert.strictEqual(result.ast.children.length, 0)
assert.strictEqual(result.diagnostics.length, 0)
})
it('parses input with comments', () => {
const result = parse(`// comment\nin viewport:\n '.a' leftOf '.b'`)
assert.strictEqual(result.ast.children.length, 1)
})
it('parses multiple top-level blocks', () => {
const result = parse(`in viewport:\n '.a' leftOf '.b'\n\nin document:\n '.c' above '.d'`)
assert.strictEqual(result.ast.children.length, 2)
assert.strictEqual(result.ast.children[0].type, 'FrameBlock')
assert.strictEqual(result.ast.children[1].type, 'FrameBlock')
})
it('parses decimal numbers', () => {
const result = parse(`in viewport:\n '.a' atLeast 44.5px wide`)
const assertion = firstRelation(result)
assert.strictEqual(assertion.bounds.min.value, 44.5)
})
})
+210
View File
@@ -0,0 +1,210 @@
// Dense DSL parser for Imhotep
// Tokenizes source text and builds a unist-style AST
import type {
Program,
Diagnostic,
DiagnosticCode,
DiagnosticCategory,
Position,
} from 'imhotep-core'
// Inline minimal diagnostic factory to avoid pulling in the full
// imhotep-core index during test runs when core may have build issues.
function createDiagnostic(
code: DiagnosticCode,
category: DiagnosticCategory,
severity: 'error' | 'warning' | 'info',
message: string,
opts?: Partial<Omit<Diagnostic, 'code' | 'category' | 'severity' | 'message'>>,
): Diagnostic {
return {
code,
severity,
category,
message,
source: opts?.source ?? 'imhotep-core',
related: opts?.related ?? [],
fixHints: opts?.fixHints ?? [],
metrics: opts?.metrics ?? {},
sourceRef: opts?.sourceRef ?? {},
...opts,
}
}
import { tokenize } from './lexer.js'
import { parseGrammar, ParseError } from './grammar.js'
import type { SuggestedFix } from 'imhotep-core'
// ---------------------------------------------------------------------------
// Parse Result
// ---------------------------------------------------------------------------
export interface ParseResult {
ast: Program
diagnostics: Diagnostic[]
}
export interface ParserOptions {
sourcePath?: string
}
// ---------------------------------------------------------------------------
// Rewrite Suggestion Engine
// ---------------------------------------------------------------------------
function buildSuggestedFix(err: ParseError, source: string): SuggestedFix | undefined {
switch (err.code) {
case 'IMH_PARSE_IS_KEYWORD': {
return {
action: 'remove',
target: 'is',
rationale: 'The keyword "is" is not used in dense spec syntax. Write the relation directly after the selector.',
}
}
case 'IMH_PARSE_MISSING_QUOTES': {
// Extract the bare selector name from the error message
const match = err.message.match(/Selector '(.+)' must be wrapped/)
const selector = match ? match[1] : 'selector'
return {
action: 'wrap',
target: selector,
value: `'${selector}'`,
rationale: 'Selectors must be wrapped in single quotes.',
}
}
case 'IMH_PARSE_WRONG_QUOTE_STYLE': {
const match = err.message.match(/Use single quotes: '(.+)' instead/)
const selector = match ? match[1] : 'selector'
return {
action: 'replace',
target: `"${selector}"`,
value: `'${selector}'`,
rationale: 'Dense specs require single quotes around selectors, not double quotes.',
}
}
case 'IMH_PARSE_MISSING_UNIT': {
return {
action: 'replace',
target: 'gap <number>',
value: 'gap <number>px',
rationale: 'Gap values require a unit (px, em, rem, %, jnd). The default suggestion is px.',
}
}
default:
return undefined
}
}
function buildFixHints(err: ParseError): string[] {
const hints: string[] = []
switch (err.code) {
case 'IMH_PARSE_IS_KEYWORD':
hints.push("Remove 'is' and write the relation directly.")
hints.push("Example: '.a' leftOf '.b'")
break
case 'IMH_PARSE_MISSING_QUOTES':
hints.push("Wrap selectors in single quotes.")
hints.push("Example: '.button' leftOf '.icon'")
break
case 'IMH_PARSE_WRONG_QUOTE_STYLE':
hints.push("Use single quotes instead of double quotes.")
hints.push("Example: '.a' leftOf '.b'")
break
case 'IMH_PARSE_MISSING_UNIT':
hints.push("Add a unit to gap values.")
hints.push("Example: gap 8px or gap 8px..16px")
break
default:
hints.push('Selectors must be single-quoted strings, e.g. \'[data-testid="x"]\'.')
hints.push('Use relation keywords directly without "is": e.g. \'a\' leftOf \'b\'.')
hints.push('Gap values require units: e.g. gap 8px.')
}
return hints
}
function mapErrorCode(err: ParseError): DiagnosticCode {
switch (err.code) {
case 'IMH_PARSE_IS_KEYWORD':
return 'IMH_PARSE_INVALID_SYNTAX' as DiagnosticCode
case 'IMH_PARSE_MISSING_QUOTES':
return 'IMH_PARSE_INVALID_SYNTAX' as DiagnosticCode
case 'IMH_PARSE_WRONG_QUOTE_STYLE':
return 'IMH_PARSE_INVALID_SYNTAX' as DiagnosticCode
case 'IMH_PARSE_MISSING_UNIT':
return 'IMH_PARSE_INVALID_SYNTAX' as DiagnosticCode
default:
return 'IMH_PARSE_ERROR' as DiagnosticCode
}
}
// ---------------------------------------------------------------------------
// Parse Spec
// ---------------------------------------------------------------------------
export function parseSpec(source: string, opts: ParserOptions = {}): ParseResult {
const diagnostics: Diagnostic[] = []
if (source.trim().length === 0) {
return {
ast: {
type: 'Program',
children: [],
},
diagnostics,
}
}
try {
const tokens = tokenize(source)
const ast = parseGrammar(tokens)
return { ast, diagnostics }
} catch (err) {
if (err instanceof ParseError) {
const suggestedFix = buildSuggestedFix(err, source)
diagnostics.push(
createDiagnostic(
mapErrorCode(err),
'parse-error' as DiagnosticCategory,
'error',
err.message,
{
source: opts.sourcePath || 'imhotep-dsl',
position: err.position,
fixHints: buildFixHints(err),
suggestedFix,
},
),
)
} else {
const position: Position = {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 1, offset: 0 },
}
diagnostics.push(
createDiagnostic(
'IMH_PARSE_ERROR' as DiagnosticCode,
'parse-error' as DiagnosticCategory,
'error',
err instanceof Error ? err.message : String(err),
{
source: opts.sourcePath || 'imhotep-dsl',
position,
fixHints: [
'Selectors must be single-quoted strings, e.g. \'[data-testid="x"]\'.',
'Use relation keywords directly without "is": e.g. \'a\' leftOf \'b\'.',
'Gap values require units: e.g. gap 8px.',
],
},
),
)
}
return {
ast: {
type: 'Program',
children: [],
},
diagnostics,
}
}
}
@@ -0,0 +1,155 @@
// Tests for property-run fluent API
// Mocks the scene target and renderer to verify fluent chains compile correctly.
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
component,
enumerate,
PropertyRunBuilder,
PropertyScene,
PropertyContext,
} from './property-fluent.js'
// ---------------------------------------------------------------------------
// Mock Renderer and Component
// ---------------------------------------------------------------------------
const mockRenderer = 'react'
const mockComponent = 'Button'
// ---------------------------------------------------------------------------
// Unit Tests
// ---------------------------------------------------------------------------
describe('property-fluent API', () => {
it('creates a component run builder', () => {
const builder = component(mockComponent, { renderer: mockRenderer })
assert.ok(builder instanceof PropertyRunBuilder)
})
it('builds forAllProps run descriptor', () => {
const descriptor = component(mockComponent, {
renderer: mockRenderer,
props: enumerate({
size: ['sm', 'md', 'lg'],
disabled: [true, false],
}),
}).forAllProps((scene, ctx) => {
scene.expect('.button').to.be.atLeast('44px').wide
scene.expect('.button').to.be.atLeast('44px').tall
})
assert.strictEqual(descriptor.type, 'PropertyRun')
assert.strictEqual(descriptor.mode, 'forAllProps')
assert.strictEqual(descriptor.component, mockComponent)
assert.strictEqual(descriptor.renderer, mockRenderer)
assert.strictEqual(descriptor.assertions.length, 2)
assert.strictEqual(descriptor.assertions[0].selector, '.button')
assert.strictEqual(descriptor.assertions[0].relation, 'sizeBound')
assert.strictEqual(descriptor.assertions[0].options?.property, 'width')
})
it('builds exhaustivelyForAllProps run descriptor', () => {
const descriptor = component(mockComponent, {
renderer: mockRenderer,
props: enumerate({
size: ['sm', 'md'],
}),
}).exhaustivelyForAllProps((scene) => {
scene.expect('.button').to.be.inside('.viewport')
})
assert.strictEqual(descriptor.type, 'PropertyRun')
assert.strictEqual(descriptor.mode, 'exhaustivelyForAllProps')
assert.strictEqual(descriptor.assertions.length, 1)
assert.strictEqual(descriptor.assertions[0].relation, 'inside')
assert.strictEqual(descriptor.assertions[0].reference, '.viewport')
})
it('supports relation assertions', () => {
const descriptor = component(mockComponent).forAllProps((scene) => {
scene.expect('.a').to.be.leftOf('.b')
scene.expect('.a').to.be.above('.c')
scene.expect('.a').to.be.centeredWithin('.d')
})
assert.strictEqual(descriptor.assertions[0].relation, 'leftOf')
assert.strictEqual(descriptor.assertions[1].relation, 'above')
assert.strictEqual(descriptor.assertions[2].relation, 'centeredWithin')
})
it('supports size assertions with atLeast', () => {
const descriptor = component(mockComponent).forAllProps((scene) => {
scene.expect('.button').to.be.atLeast(44).wide
})
const assertion = descriptor.assertions[0]
assert.strictEqual(assertion.relation, 'sizeBound')
assert.strictEqual(assertion.options?.property, 'width')
assert.strictEqual(assertion.options?.value, 44)
assert.strictEqual(assertion.options?.mode, 'atLeast')
})
it('supports size assertions with atMost', () => {
const descriptor = component(mockComponent).forAllProps((scene) => {
scene.expect('.button').is.atMost(100).tall
})
const assertion = descriptor.assertions[0]
assert.strictEqual(assertion.options?.mode, 'atMost')
assert.strictEqual(assertion.options?.property, 'height')
})
it('supports context seed setting', () => {
const descriptor = component(mockComponent).forAllProps((scene, ctx) => {
ctx.setSeed(42)
ctx.setNumRuns(100)
scene.expect('.button').to.be.inside('.viewport')
})
// Descriptor should be built successfully
assert.strictEqual(descriptor.assertions.length, 1)
})
it('enumerate generates cartesian product', () => {
const domain = enumerate({
size: ['sm', 'md'],
disabled: [true, false],
})
assert.strictEqual(domain.mode, 'enumerated')
const values = (domain as { mode: 'enumerated'; values: Record<string, unknown>[] }).values
assert.strictEqual(values.length, 4)
assert.ok(values.some(v => v.size === 'sm' && v.disabled === true))
assert.ok(values.some(v => v.size === 'sm' && v.disabled === false))
assert.ok(values.some(v => v.size === 'md' && v.disabled === true))
assert.ok(values.some(v => v.size === 'md' && v.disabled === false))
})
it('enumerate handles single key', () => {
const domain = enumerate({
size: ['sm', 'md', 'lg'],
})
const values = (domain as { mode: 'enumerated'; values: Record<string, unknown>[] }).values
assert.strictEqual(values.length, 3)
})
it('enumerate handles empty input', () => {
const domain = enumerate({})
const values = (domain as { mode: 'enumerated'; values: Record<string, unknown>[] }).values
assert.strictEqual(values.length, 1)
assert.deepStrictEqual(values[0], {})
})
it('preserves options through fluent chain', () => {
const descriptor = component(mockComponent).forAllProps((scene) => {
scene.expect('.a').to.be.leftOf('.b', { minGap: 8 })
})
const assertion = descriptor.assertions[0]
assert.strictEqual(assertion.options?.minGap, 8)
})
})
+353
View File
@@ -0,0 +1,353 @@
// Property-run fluent builders for Imhotep
// Supports sampled and exhaustive property-based testing over render inputs.
import type { Position } from 'imhotep-core'
// ---------------------------------------------------------------------------
// Property-Run AST Node Types
// ---------------------------------------------------------------------------
export type InputDomain =
| { mode: 'enumerated'; values: unknown[] }
| { mode: 'generated'; arbitrary: unknown; seed?: number; numRuns?: number }
export interface RenderCase {
caseId: string
input: unknown
env?: Record<string, unknown>
metadata?: Record<string, unknown>
}
export interface PropertyRunDescriptor {
type: 'PropertyRun'
mode: 'forAllProps' | 'exhaustivelyForAllProps'
component?: string
renderer?: string
props?: InputDomain
assertions: PropertyAssertion[]
position?: Position
}
export interface PropertyAssertion {
type: 'PropertyAssertion'
selector: string
relation: string
reference?: string
options?: Record<string, unknown>
position?: Position
}
// ---------------------------------------------------------------------------
// Property-Run Fluent Builder
// ---------------------------------------------------------------------------
export class PropertyRunBuilder {
private _component?: string
private _renderer?: string
private _props?: InputDomain
private _mode: 'forAllProps' | 'exhaustivelyForAllProps' | null = null
constructor(component?: string, renderer?: string) {
this._component = component
this._renderer = renderer
}
props(domain: InputDomain): this {
this._props = domain
return this
}
forAllProps(callback: (scene: PropertyScene, ctx: PropertyContext) => void): PropertyRunDescriptor {
this._mode = 'forAllProps'
const scene = new PropertyScene()
const ctx = new PropertyContext()
callback(scene, ctx)
return {
type: 'PropertyRun',
mode: 'forAllProps',
component: this._component,
renderer: this._renderer,
props: this._props,
assertions: scene.getAssertions(),
}
}
exhaustivelyForAllProps(callback: (scene: PropertyScene) => void): PropertyRunDescriptor {
this._mode = 'exhaustivelyForAllProps'
const scene = new PropertyScene()
callback(scene)
return {
type: 'PropertyRun',
mode: 'exhaustivelyForAllProps',
component: this._component,
renderer: this._renderer,
props: this._props,
assertions: scene.getAssertions(),
}
}
}
// ---------------------------------------------------------------------------
// Property Scene (inside the callback)
// ---------------------------------------------------------------------------
export class PropertyScene {
private _assertions: PropertyAssertion[] = []
expect(selector: string): PropertyAssertionBuilder {
return new PropertyAssertionBuilder(this, selector)
}
getAssertions(): PropertyAssertion[] {
return this._assertions
}
_addAssertion(assertion: PropertyAssertion): void {
this._assertions.push(assertion)
}
}
// ---------------------------------------------------------------------------
// Property Context (seed/replay metadata)
// ---------------------------------------------------------------------------
export class PropertyContext {
private _seed?: number
private _numRuns?: number
setSeed(seed: number): this {
this._seed = seed
return this
}
setNumRuns(numRuns: number): this {
this._numRuns = numRuns
return this
}
getSeed(): number | undefined {
return this._seed
}
getNumRuns(): number | undefined {
return this._numRuns
}
}
// ---------------------------------------------------------------------------
// Property Assertion Builder
// ---------------------------------------------------------------------------
export class PropertyAssertionBuilder {
private _scene: PropertyScene
private _selector: string
private _relation: string = ''
private _reference?: string
private _options: Record<string, unknown> = {}
constructor(scene: PropertyScene, selector: string) {
this._scene = scene
this._selector = selector
}
get to(): ToProxy {
return new ToProxy(this)
}
get be(): BeProxy {
return new BeProxy(this)
}
get is(): IsProxy {
return new IsProxy(this)
}
leftOf(reference: string, options?: Record<string, unknown>): this {
return this._finalize('leftOf', reference, options)
}
rightOf(reference: string, options?: Record<string, unknown>): this {
return this._finalize('rightOf', reference, options)
}
above(reference: string, options?: Record<string, unknown>): this {
return this._finalize('above', reference, options)
}
below(reference: string, options?: Record<string, unknown>): this {
return this._finalize('below', reference, options)
}
inside(reference: string, options?: Record<string, unknown>): this {
return this._finalize('inside', reference, options)
}
overlaps(reference: string, options?: Record<string, unknown>): this {
return this._finalize('overlaps', reference, options)
}
centeredWithin(reference: string, options?: Record<string, unknown>): this {
return this._finalize('centeredWithin', reference, options)
}
alignedWith(reference: string, options?: Record<string, unknown>): this {
return this._finalize('alignedWith', reference, options)
}
atLeast(value: string | number): SizeProxy {
return new SizeProxy(this, value, 'atLeast')
}
atMost(value: string | number): SizeProxy {
return new SizeProxy(this, value, 'atMost')
}
_finalize(relation: string, reference?: string, options?: Record<string, unknown>): this {
this._relation = relation
this._reference = reference
if (options) this._options = { ...this._options, ...options }
this._scene._addAssertion({
type: 'PropertyAssertion',
selector: this._selector,
relation: this._relation,
reference: this._reference,
options: this._options,
})
return this
}
_finalizeSize(property: 'width' | 'height', value: string | number, mode: 'atLeast' | 'atMost' | 'exact'): this {
this._relation = mode === 'exact' ? 'sizeExact' : 'sizeBound'
this._options = { ...this._options, property, value, mode }
this._scene._addAssertion({
type: 'PropertyAssertion',
selector: this._selector,
relation: this._relation,
reference: this._reference,
options: this._options,
})
return this
}
}
// ---------------------------------------------------------------------------
// Proxy Chains for Natural Language
// ---------------------------------------------------------------------------
export class ToProxy {
private _builder: PropertyAssertionBuilder
constructor(builder: PropertyAssertionBuilder) {
this._builder = builder
}
get be(): BeProxy {
return new BeProxy(this._builder)
}
}
export class BeProxy {
private _builder: PropertyAssertionBuilder
constructor(builder: PropertyAssertionBuilder) {
this._builder = builder
}
leftOf(reference: string, options?: Record<string, unknown>): PropertyAssertionBuilder {
return this._builder.leftOf(reference, options)
}
rightOf(reference: string, options?: Record<string, unknown>): PropertyAssertionBuilder {
return this._builder.rightOf(reference, options)
}
above(reference: string, options?: Record<string, unknown>): PropertyAssertionBuilder {
return this._builder.above(reference, options)
}
below(reference: string, options?: Record<string, unknown>): PropertyAssertionBuilder {
return this._builder.below(reference, options)
}
inside(reference: string, options?: Record<string, unknown>): PropertyAssertionBuilder {
return this._builder.inside(reference, options)
}
overlaps(reference: string, options?: Record<string, unknown>): PropertyAssertionBuilder {
return this._builder.overlaps(reference, options)
}
centeredWithin(reference: string, options?: Record<string, unknown>): PropertyAssertionBuilder {
return this._builder.centeredWithin(reference, options)
}
alignedWith(reference: string, options?: Record<string, unknown>): PropertyAssertionBuilder {
return this._builder.alignedWith(reference, options)
}
atLeast(value: string | number): SizeProxy {
return this._builder.atLeast(value)
}
atMost(value: string | number): SizeProxy {
return this._builder.atMost(value)
}
}
export class IsProxy {
private _builder: PropertyAssertionBuilder
constructor(builder: PropertyAssertionBuilder) {
this._builder = builder
}
atLeast(value: string | number): SizeProxy {
return this._builder.atLeast(value)
}
atMost(value: string | number): SizeProxy {
return this._builder.atMost(value)
}
}
export class SizeProxy {
private _builder: PropertyAssertionBuilder
private _value: string | number
private _mode: 'atLeast' | 'atMost' | 'exact'
constructor(builder: PropertyAssertionBuilder, value: string | number, mode: 'atLeast' | 'atMost' | 'exact') {
this._builder = builder
this._value = value
this._mode = mode
}
get wide(): PropertyAssertionBuilder {
return this._builder._finalizeSize('width', this._value, this._mode)
}
get tall(): PropertyAssertionBuilder {
return this._builder._finalizeSize('height', this._value, this._mode)
}
}
// ---------------------------------------------------------------------------
// Entry Point
// ---------------------------------------------------------------------------
export function component(Component: string, options?: { renderer?: string; props?: InputDomain }): PropertyRunBuilder {
return new PropertyRunBuilder(Component, options?.renderer).props(options?.props || { mode: 'enumerated', values: [] })
}
export function enumerate(props: Record<string, unknown[]>): InputDomain {
// Generate Cartesian product of all prop values
const keys = Object.keys(props)
const arrays = keys.map(k => props[k])
if (arrays.length === 0) {
return { mode: 'enumerated', values: [{}] }
}
// Iterative cartesian product to keep key mapping simple
let result: Record<string, unknown>[] = [{}]
for (let i = 0; i < arrays.length; i++) {
const key = keys[i]
const nextResult: Record<string, unknown>[] = []
for (const combo of result) {
for (const val of arrays[i]) {
nextResult.push({ ...combo, [key]: val })
}
}
result = nextResult
}
return {
mode: 'enumerated',
values: result,
}
}
+202
View File
@@ -0,0 +1,202 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import fc from 'fast-check';
import {
expect,
assert as imhotepAssert,
frame,
state,
compile,
validateRelation,
validateProgram,
parseSpec,
} from './index.js';
// ---- Arbitraries for DSL generation ----
// Valid CSS-like selectors: alphanumeric, hyphens, underscores, dots, hashes.
// Rejects standalone punctuation to avoid invalid selectors like "!".
const selectorArb = fc.string({ minLength: 1, maxLength: 30 }).map(s => s.trim()).filter(s => {
if (s.length === 0) return false;
// Must contain at least one valid identifier character; not be pure punctuation
return /[a-zA-Z0-9_]/.test(s);
});
const relationArb = fc.constantFrom(
'leftOf', 'rightOf', 'above', 'below',
'centeredWithin', 'alignedWith', 'inside', 'contains', 'overlaps', 'separatedFrom'
);
const gapArb = fc.integer({ min: 0, max: 1000 });
// Relation-specific valid options to ensure validator always accepts
function optionsForRelation(relation: string): fc.Arbitrary<Record<string, unknown>> {
const minGapRelations = new Set(['leftOf', 'rightOf', 'above', 'below', 'inside', 'separatedFrom']);
const maxGapRelations = new Set(['leftOf', 'rightOf', 'above', 'below', 'separatedFrom']);
const toleranceRelations = new Set(['leftOf', 'rightOf', 'above', 'below', 'alignedWith', 'centeredWithin', 'inside', 'contains', 'overlaps', 'separatedFrom']);
const axisRelations = new Set(['alignedWith']);
const arbitraries: fc.Arbitrary<Record<string, unknown>>[] = [fc.constant({})];
if (minGapRelations.has(relation)) {
arbitraries.push(fc.record({ minGap: gapArb }));
}
if (maxGapRelations.has(relation)) {
arbitraries.push(fc.record({ maxGap: gapArb }));
}
// Only generate both minGap and maxGap together with valid ordering
if (minGapRelations.has(relation) && maxGapRelations.has(relation)) {
arbitraries.push(
fc.tuple(gapArb, gapArb).filter(([min, max]) => min <= max).map(([minGap, maxGap]) => ({ minGap, maxGap }))
);
}
if (toleranceRelations.has(relation)) {
arbitraries.push(fc.record({ tolerance: fc.integer({ min: 0, max: 100 }) }));
}
if (axisRelations.has(relation)) {
arbitraries.push(fc.record({ axis: fc.constantFrom('centerX', 'centerY') }));
}
return fc.oneof(...arbitraries);
}
const validAssertionArb = fc
.tuple(selectorArb, relationArb, selectorArb)
.chain(([subject, relation, reference]) =>
optionsForRelation(relation).map((opts) => {
const chain = (expect(subject).to.be as any)[relation](reference, opts);
return chain.toAst();
})
);
const invalidAssertionArb = fc.oneof(
fc.constant(() => {
const ast = expect('').to.be.leftOf('.b').toAst();
return ast;
}),
fc.constant(() => {
const ast = expect('.a').to.be.leftOf('').toAst();
return ast;
}),
fc.constant(() => {
const ast = expect('.a').to.be.leftOf('.b').toAst();
(ast as any).relation = 'unknownRelation';
return ast;
}),
fc.constant(() => {
const ast = expect('.a').to.be.leftOf('.b', { axis: 'centerY' } as any).toAst();
return ast;
}),
fc.constant(() => {
const ast = expect('.a').to.be.leftOf('.b', { minGap: 50, maxGap: 10 }).toAst();
return ast;
}),
fc.constant(() => {
const ast = expect('.a').to.be.leftOf('.b', { minGap: -5 }).toAst();
return ast;
})
);
// ---- Property Tests ----
describe('dsl property tests', () => {
it('property: fluent assertions lower to valid execution IR', () => {
fc.assert(
fc.property(validAssertionArb, (ast) => {
const result = compile([ast]);
assert.strictEqual(result.diagnostics.length, 0);
assert.strictEqual(result.executionIr.clauseCount, 1);
assert.ok(result.semanticIr.clauses.size > 0);
}),
{ numRuns: 200, seed: 42 }
);
});
it('property: equivalent assertions produce equivalent IR structure', () => {
fc.assert(
fc.property(
fc.tuple(selectorArb, selectorArb, gapArb),
([subj, ref, gap]) => {
const fluentAst = expect(subj).to.be.leftOf(ref, { minGap: gap }).toAst();
const denseAst = imhotepAssert('leftOf', subj, ref, { minGap: gap }).toAst();
const fluentResult = compile([fluentAst]);
const denseResult = compile([denseAst]);
assert.strictEqual(fluentResult.executionIr.clauseCount, denseResult.executionIr.clauseCount);
assert.strictEqual(fluentResult.executionIr.clauseType[0], denseResult.executionIr.clauseType[0]);
assert.strictEqual(fluentResult.executionIr.clauseArg0[0], denseResult.executionIr.clauseArg0[0]);
}
),
{ numRuns: 200, seed: 123 }
);
});
it('property: parser round-trip preserves meaning for empty input', () => {
fc.assert(
fc.property(fc.constant(''), (source) => {
const result = parseSpec(source);
assert.strictEqual(result.ast.children.length, 0);
assert.strictEqual(result.diagnostics.length, 0);
}),
{ numRuns: 100, seed: 7 }
);
});
it('property: parser produces AST or diagnostics for any input', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
(source) => {
const result = parseSpec(source);
// Parser should either produce an AST or emit diagnostics, never crash
assert.ok(result.ast != null);
assert.ok(Array.isArray(result.diagnostics));
}
),
{ numRuns: 200, seed: 99 }
);
});
it('property: invalid programs are rejected by validator', () => {
fc.assert(
fc.property(invalidAssertionArb, (makeInvalid) => {
const ast = makeInvalid();
const result = validateRelation(ast);
assert.strictEqual(result.valid, false);
assert.ok(result.diagnostics.length > 0);
}),
{ numRuns: 200, seed: 55 }
);
});
it('property: validator accepts all generated valid assertions', () => {
fc.assert(
fc.property(validAssertionArb, (ast) => {
const result = validateRelation(ast);
assert.strictEqual(result.valid, true);
assert.strictEqual(result.diagnostics.length, 0);
}),
{ numRuns: 200, seed: 88 }
);
});
it('property: batch validation aggregates all diagnostics', () => {
fc.assert(
fc.property(
fc.array(validAssertionArb, { minLength: 1, maxLength: 10 }),
(asts) => {
const result = validateProgram(asts);
assert.strictEqual(result.valid, true);
assert.strictEqual(result.diagnostics.length, 0);
}
),
{ numRuns: 100, seed: 33 }
);
});
});
+26
View File
@@ -0,0 +1,26 @@
// Quantifier combinators for compositional expectations
// all(), any(), none() wrap multiple assertions into quantified groups.
import type { FluentAssertion, FluentRelation } from './fluent.js'
export type ComposableAssertion = FluentAssertion | FluentRelation
export interface QuantifiedGroup {
kind: 'all' | 'any' | 'none'
assertions: ComposableAssertion[]
}
/** Every assertion in the group must hold. */
export function all(...assertions: ComposableAssertion[]): QuantifiedGroup {
return { kind: 'all', assertions }
}
/** At least one assertion in the group must hold. */
export function any(...assertions: ComposableAssertion[]): QuantifiedGroup {
return { kind: 'any', assertions }
}
/** No assertion in the group may hold. */
export function none(...assertions: ComposableAssertion[]): QuantifiedGroup {
return { kind: 'none', assertions }
}
+36
View File
@@ -0,0 +1,36 @@
// Timeline constructors for temporal assertions in Imhotep
// Provides range, keyframe, static, and transition sampling descriptors
export interface TimelineDescriptor {
mode: 'static' | 'keyframes' | 'range' | 'transition'
samples?: number[]
start?: number
end?: number
name?: string
}
export const timeline = {
/** Creates a time range from start to end (in milliseconds). */
from(start: number) {
return {
to(end: number): TimelineDescriptor {
return { mode: 'range', start, end }
}
}
},
/** Creates a keyframe timeline from sample timestamps. */
keyframes(samples: number[]): TimelineDescriptor {
return { mode: 'keyframes', samples }
},
/** Creates a single-point static timeline. */
at(time: number): TimelineDescriptor {
return { mode: 'static', samples: [time] }
},
/** Creates a transition timeline by name. */
transition(name: string): TimelineDescriptor {
return { mode: 'transition', name, samples: [] }
}
}
+403
View File
@@ -0,0 +1,403 @@
// 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',
'separatedFrom',
// 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']),
separatedFrom: 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.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',
'separatedFrom',
'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,
}
}