fix: CDP extraction concurrency + topology ID remapping + predicate completeness
CDP protocol fixes:
- session.ts: getDocumentRootNodeId() now pushes {depth:-1} full DOM tree
(was shallow default, causing querySelectorAll to find zero nodes)
- extraction.ts: serialize resolveSelector calls (was concurrent Promise.all,
CDP DOM.querySelectorAll is not safe for concurrent calls on same session)
Topology ID mismatch fixes (critical — backendNodeId vs solver subject ID):
- extraction.ts: add remapTopologyIds() — builds Map<backendNodeId,solverId>
from world.subjects.domNodeId/ids, remaps all 6 topology arrays +
clipping.subjectId + scroll.containerId
- topology.ts: createsStackingContext() now checks contain:paint|layout
(both create stacking contexts per CSS spec, was missing)
- topology.ts: evaluateInStackingContext() handles unary (no reference)
Predicate completeness:
- predicates.ts: 3 new evaluators — intersects, touches, hasGap (indices 31-33)
- predicates.ts: escapeClippingChainOf replaced indeterminate stub with
deterministic 2-ary evaluator (overflow + clip chain + ancestry checks)
- predicates.ts: attachedToScrollContainer arity 1→2, checks reference match
- predicates.ts: 7 aliases + 4 alignment aliases promoted to BUILTIN_PREDICATES
DSL grammar fixes:
- grammar.ts: isKeywordThatCanBePredicate() extended with all missing keywords
- lower-to-canonical.ts: add TopologyAssertion lowering (was silently dropped)
- compiler.ts: add intersects/touches/hasGap relation codes
- validator.ts: add option sets for new predicates
- lexer.ts: add intersects/touches/hasGap token kinds
Safety + infrastructure:
- joins.ts: MAX_CARTESIAN_TUPLES=100,000 guard with descriptive error
- extraction.ts: computeRequiredFacts() registers defaults + broadens topology gate
- package.json: add missing imhotep-state dependency to imhotep-playwright
Tests:
- 9 new predicate tests (intersects, touches, hasGap, escapeClippingChainOf)
- Convert stale separatedFrom/contains GAP tests to working assertions
- Add computeRequiredFacts topology gating tests (public.test.ts)
This commit is contained in:
@@ -379,6 +379,9 @@ function toExecutionIr(semanticIr: SemanticIr): ExecutionIr {
|
||||
separatedFrom: 14,
|
||||
aspectRatioBetween: 15,
|
||||
between: 16,
|
||||
intersects: 17,
|
||||
touches: 18,
|
||||
hasGap: 19,
|
||||
}
|
||||
|
||||
const clauseType = new Uint16Array(count)
|
||||
|
||||
@@ -445,21 +445,19 @@ describe('FOL Dense DSL - Gap Detection', () => {
|
||||
'Expected parse failure or no formula for between in dense DSL')
|
||||
})
|
||||
|
||||
it('GAP: dense DSL does not support separatedFrom relation', () => {
|
||||
it('separatedFrom parses as relation predicate in dense DSL', () => {
|
||||
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)
|
||||
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 for separatedFrom in dense DSL')
|
||||
})
|
||||
|
||||
it('GAP: dense DSL does not support contains relation', () => {
|
||||
// contains exists in fluent but may not parse in dense
|
||||
it('contains parses as relation predicate in dense DSL', () => {
|
||||
const source = `forall $a in elements('.a'):\n contains($a, '.b')`
|
||||
const result = parseSpec(source)
|
||||
// This might parse as generic predicate
|
||||
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
|
||||
const formula = getFormula(result.ast)
|
||||
if (formula) {
|
||||
assert.ok(true, 'contains parsed as generic predicate')
|
||||
}
|
||||
assert.ok(formula, 'Expected formula for contains in dense DSL')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -660,7 +660,10 @@ export class GrammarParser {
|
||||
const kind = this.currentToken().kind
|
||||
const predicateKinds: Token['kind'][] = [
|
||||
'leftOf', 'rightOf', 'above', 'below',
|
||||
'alignedWith', 'centeredWithin', 'inside', 'contains', 'overlaps', 'separatedFrom',
|
||||
'alignedWith', 'leftAlignedWith', 'rightAlignedWith', 'topAlignedWith', 'bottomAlignedWith',
|
||||
'centeredWithin', 'inside', 'contains', 'overlaps', 'intersects', 'touches', 'separatedFrom', 'hasGap',
|
||||
// Spatial aliases
|
||||
'beside', 'nextTo', 'adjacent', 'touching', 'near', 'under', 'within',
|
||||
// Size predicates that can appear in FOL formula bodies
|
||||
'width', 'height', 'size',
|
||||
]
|
||||
@@ -1140,7 +1143,7 @@ export class GrammarParser {
|
||||
const relationKinds: Array<Token['kind']> = [
|
||||
'leftOf', 'rightOf', 'above', 'below',
|
||||
'alignedWith', 'leftAlignedWith', 'rightAlignedWith', 'topAlignedWith', 'bottomAlignedWith',
|
||||
'centeredWithin', 'inside', 'contains', 'overlaps', 'separatedFrom',
|
||||
'centeredWithin', 'inside', 'contains', 'overlaps', 'intersects', 'touches', 'separatedFrom', 'hasGap',
|
||||
// Spatial aliases
|
||||
'beside', 'nextTo', 'adjacent', 'touching', 'near', 'under', 'within',
|
||||
]
|
||||
@@ -1151,7 +1154,7 @@ export class GrammarParser {
|
||||
}
|
||||
}
|
||||
|
||||
throw this.error(`Expected relation (leftOf, rightOf, above, below, alignedWith, centeredWithin, inside, contains, overlaps, separatedFrom, beside, nextTo, adjacent, touching, near, under, within)`)
|
||||
throw this.error(`Expected relation (leftOf, rightOf, above, below, alignedWith, centeredWithin, inside, contains, overlaps, intersects, touches, separatedFrom, hasGap, beside, nextTo, adjacent, touching, near, under, within)`)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -61,7 +61,10 @@ export type TokenKind =
|
||||
| 'inside'
|
||||
| 'contains'
|
||||
| 'overlaps'
|
||||
| 'intersects'
|
||||
| 'touches'
|
||||
| 'separatedFrom'
|
||||
| 'hasGap'
|
||||
| 'leftAlignedWith'
|
||||
| 'rightAlignedWith'
|
||||
| 'topAlignedWith'
|
||||
@@ -193,7 +196,10 @@ const KEYWORDS: Record<string, TokenKind> = {
|
||||
inside: 'inside',
|
||||
contains: 'contains',
|
||||
overlaps: 'overlaps',
|
||||
intersects: 'intersects',
|
||||
touches: 'touches',
|
||||
separatedFrom: 'separatedFrom',
|
||||
hasGap: 'hasGap',
|
||||
leftAlignedWith: 'leftAlignedWith',
|
||||
rightAlignedWith: 'rightAlignedWith',
|
||||
topAlignedWith: 'topAlignedWith',
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// 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 { RelationAssertion, TopologyAssertion, Program, Position } from 'imhotep-core'
|
||||
import type { FormulaNode as DslFormulaNode } from './logic-fluent.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -243,6 +243,17 @@ function lowerBlock(
|
||||
return
|
||||
}
|
||||
|
||||
// Topology assertion: clippedBy, attachedToScrollContainer, escapeClippingChainOf, inStackingContext
|
||||
if (node.type === 'TopologyAssertion') {
|
||||
const desc = lowerTopologyAssertionToCanonical(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
|
||||
@@ -520,6 +531,65 @@ function lowerSizeAssertionToCanonical(
|
||||
return descriptor
|
||||
}
|
||||
|
||||
function lowerTopologyAssertionToCanonical(
|
||||
assertion: TopologyAssertion,
|
||||
context: Partial<CanonicalClauseDescriptor>,
|
||||
): CanonicalClauseDescriptor {
|
||||
const descriptor: CanonicalClauseDescriptor = {
|
||||
relation: assertion.predicate,
|
||||
subject: assertion.subject.value,
|
||||
flags: 0,
|
||||
}
|
||||
if (assertion.reference?.value) {
|
||||
descriptor.reference = assertion.reference.value
|
||||
}
|
||||
|
||||
if (assertion.position) {
|
||||
descriptor.position = assertion.position
|
||||
}
|
||||
|
||||
if (context.quantifier) {
|
||||
descriptor.quantifier = context.quantifier
|
||||
}
|
||||
|
||||
const rawOpts = assertion.options as unknown as Record<string, unknown> | undefined
|
||||
if (rawOpts) {
|
||||
if (rawOpts.tolerance !== undefined) {
|
||||
const tol = extractTolerance(rawOpts.tolerance)
|
||||
if (tol) descriptor.tolerance = tol
|
||||
}
|
||||
|
||||
const bounds: { minGap?: number; maxGap?: number } = {}
|
||||
if (rawOpts.minGap !== undefined) {
|
||||
const parsed = extractGap(rawOpts.minGap)
|
||||
if (parsed) bounds.minGap = parsed.value
|
||||
}
|
||||
if (rawOpts.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.inStackingContext === true) {
|
||||
options.inStackingContext = true
|
||||
descriptor.flags |= 8
|
||||
}
|
||||
if (Object.keys(options).length > 0) {
|
||||
descriptor.options = options
|
||||
}
|
||||
}
|
||||
|
||||
const astNegated = (assertion as any).negated as boolean | undefined
|
||||
if (astNegated) descriptor.negated = true
|
||||
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Equivalence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -33,7 +33,10 @@ const SPATIAL_RELATIONS = new Set([
|
||||
'inside',
|
||||
'contains',
|
||||
'overlaps',
|
||||
'intersects',
|
||||
'touches',
|
||||
'separatedFrom',
|
||||
'hasGap',
|
||||
// Spatial aliases
|
||||
'beside',
|
||||
'nextTo',
|
||||
@@ -73,7 +76,10 @@ const VALID_OPTIONS: Record<string, Set<string>> = {
|
||||
inside: new Set(['tolerance', 'minGap', 'space']),
|
||||
contains: new Set(['tolerance', 'space']),
|
||||
overlaps: new Set(['tolerance', 'space']),
|
||||
intersects: new Set(['tolerance', 'space']),
|
||||
touches: new Set(['tolerance', 'inStackingContext', 'space']),
|
||||
separatedFrom: new Set(['minGap', 'maxGap', 'tolerance', 'space']),
|
||||
hasGap: new Set(['minGap', 'maxGap', 'tolerance', 'space']),
|
||||
// Spatial alias options
|
||||
beside: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
||||
nextTo: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
||||
@@ -101,7 +107,10 @@ const QUANTIFIER_COMPATIBLE = new Set([
|
||||
'inside',
|
||||
'contains',
|
||||
'overlaps',
|
||||
'intersects',
|
||||
'touches',
|
||||
'separatedFrom',
|
||||
'hasGap',
|
||||
'beside',
|
||||
'nextTo',
|
||||
'adjacent',
|
||||
|
||||
Reference in New Issue
Block a user