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:
John Dvorak
2026-05-22 10:20:06 -07:00
parent 654becabc6
commit 1bc92e1f7d
15 changed files with 458 additions and 47 deletions
+3
View File
@@ -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')
})
})
+6 -3
View File
@@ -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)`)
}
// -------------------------------------------------------------------------
+6
View File
@@ -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',
+71 -1
View File
@@ -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
// ---------------------------------------------------------------------------
+9
View File
@@ -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',