v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "imhotep-dsl",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/anomalyco/imhotep.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --test dist/**/*.test.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"imhotep-core": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')))
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: [] }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema":"../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
"packageManager":"npm",
|
||||
"reporters":["html","clear-text","progress"],
|
||||
"testRunner":"command",
|
||||
"commandRunner":{
|
||||
"command":"npm run build && npm test"
|
||||
},
|
||||
"coverageAnalysis":"off",
|
||||
"mutate":["src/**/*.ts","!src/**/*.test.ts"],
|
||||
"thresholds":{
|
||||
"high":80,
|
||||
"low":60,
|
||||
"break":50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {},
|
||||
"composite": false,
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user