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:
@@ -91,7 +91,7 @@ export class CDPSessionManager {
|
|||||||
return this.documentRootNodeId
|
return this.documentRootNodeId
|
||||||
}
|
}
|
||||||
const s = await this.attach()
|
const s = await this.attach()
|
||||||
const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument')
|
const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument', { depth: -1 })
|
||||||
this.documentRootNodeId = result.root.nodeId
|
this.documentRootNodeId = result.root.nodeId
|
||||||
return this.documentRootNodeId
|
return this.documentRootNodeId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export async function extractTopology(
|
|||||||
if (style.clipPath !== 'none') return true
|
if (style.clipPath !== 'none') return true
|
||||||
if (style.isolation === 'isolate') return true
|
if (style.isolation === 'isolate') return true
|
||||||
if (style.willChange.includes('transform') || style.willChange.includes('opacity')) return true
|
if (style.willChange.includes('transform') || style.willChange.includes('opacity')) return true
|
||||||
|
const contain = style.contain
|
||||||
|
if (contain !== 'none' && (contain.includes('paint') || contain.includes('layout'))) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ function toExecutionIr(semanticIr: SemanticIr): ExecutionIr {
|
|||||||
separatedFrom: 14,
|
separatedFrom: 14,
|
||||||
aspectRatioBetween: 15,
|
aspectRatioBetween: 15,
|
||||||
between: 16,
|
between: 16,
|
||||||
|
intersects: 17,
|
||||||
|
touches: 18,
|
||||||
|
hasGap: 19,
|
||||||
}
|
}
|
||||||
|
|
||||||
const clauseType = new Uint16Array(count)
|
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')
|
'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 source = `forall $a in elements('.a'):\n separatedFrom($a, '.b')`
|
||||||
const result = parseSpec(source)
|
const result = parseSpec(source)
|
||||||
// separatedFrom may not be in keyword list
|
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
|
||||||
assert.ok(result.diagnostics.length >= 0)
|
const formula = getFormula(result.ast)
|
||||||
|
assert.ok(formula, 'Expected formula for separatedFrom in dense DSL')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('GAP: dense DSL does not support contains relation', () => {
|
it('contains parses as relation predicate in dense DSL', () => {
|
||||||
// contains exists in fluent but may not parse in dense
|
|
||||||
const source = `forall $a in elements('.a'):\n contains($a, '.b')`
|
const source = `forall $a in elements('.a'):\n contains($a, '.b')`
|
||||||
const result = parseSpec(source)
|
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)
|
const formula = getFormula(result.ast)
|
||||||
if (formula) {
|
assert.ok(formula, 'Expected formula for contains in dense DSL')
|
||||||
assert.ok(true, 'contains parsed as generic predicate')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -660,7 +660,10 @@ export class GrammarParser {
|
|||||||
const kind = this.currentToken().kind
|
const kind = this.currentToken().kind
|
||||||
const predicateKinds: Token['kind'][] = [
|
const predicateKinds: Token['kind'][] = [
|
||||||
'leftOf', 'rightOf', 'above', 'below',
|
'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
|
// Size predicates that can appear in FOL formula bodies
|
||||||
'width', 'height', 'size',
|
'width', 'height', 'size',
|
||||||
]
|
]
|
||||||
@@ -1140,7 +1143,7 @@ export class GrammarParser {
|
|||||||
const relationKinds: Array<Token['kind']> = [
|
const relationKinds: Array<Token['kind']> = [
|
||||||
'leftOf', 'rightOf', 'above', 'below',
|
'leftOf', 'rightOf', 'above', 'below',
|
||||||
'alignedWith', 'leftAlignedWith', 'rightAlignedWith', 'topAlignedWith', 'bottomAlignedWith',
|
'alignedWith', 'leftAlignedWith', 'rightAlignedWith', 'topAlignedWith', 'bottomAlignedWith',
|
||||||
'centeredWithin', 'inside', 'contains', 'overlaps', 'separatedFrom',
|
'centeredWithin', 'inside', 'contains', 'overlaps', 'intersects', 'touches', 'separatedFrom', 'hasGap',
|
||||||
// Spatial aliases
|
// Spatial aliases
|
||||||
'beside', 'nextTo', 'adjacent', 'touching', 'near', 'under', 'within',
|
'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'
|
| 'inside'
|
||||||
| 'contains'
|
| 'contains'
|
||||||
| 'overlaps'
|
| 'overlaps'
|
||||||
|
| 'intersects'
|
||||||
|
| 'touches'
|
||||||
| 'separatedFrom'
|
| 'separatedFrom'
|
||||||
|
| 'hasGap'
|
||||||
| 'leftAlignedWith'
|
| 'leftAlignedWith'
|
||||||
| 'rightAlignedWith'
|
| 'rightAlignedWith'
|
||||||
| 'topAlignedWith'
|
| 'topAlignedWith'
|
||||||
@@ -193,7 +196,10 @@ const KEYWORDS: Record<string, TokenKind> = {
|
|||||||
inside: 'inside',
|
inside: 'inside',
|
||||||
contains: 'contains',
|
contains: 'contains',
|
||||||
overlaps: 'overlaps',
|
overlaps: 'overlaps',
|
||||||
|
intersects: 'intersects',
|
||||||
|
touches: 'touches',
|
||||||
separatedFrom: 'separatedFrom',
|
separatedFrom: 'separatedFrom',
|
||||||
|
hasGap: 'hasGap',
|
||||||
leftAlignedWith: 'leftAlignedWith',
|
leftAlignedWith: 'leftAlignedWith',
|
||||||
rightAlignedWith: 'rightAlignedWith',
|
rightAlignedWith: 'rightAlignedWith',
|
||||||
topAlignedWith: 'topAlignedWith',
|
topAlignedWith: 'topAlignedWith',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
// kind='custom', selector='selector'. These are fundamentally divergent.
|
// kind='custom', selector='selector'. These are fundamentally divergent.
|
||||||
// Property tests exclude custom domains from FOL equivalence.
|
// 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'
|
import type { FormulaNode as DslFormulaNode } from './logic-fluent.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -243,6 +243,17 @@ function lowerBlock(
|
|||||||
return
|
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
|
// Quantifier wrapper or logical compound: propagate to children
|
||||||
if (node.type === 'CompoundAssertion') {
|
if (node.type === 'CompoundAssertion') {
|
||||||
const quantifier = node.quantifier?.kind as 'all' | 'any' | 'none' | undefined
|
const quantifier = node.quantifier?.kind as 'all' | 'any' | 'none' | undefined
|
||||||
@@ -520,6 +531,65 @@ function lowerSizeAssertionToCanonical(
|
|||||||
return descriptor
|
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
|
// Equivalence
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ const SPATIAL_RELATIONS = new Set([
|
|||||||
'inside',
|
'inside',
|
||||||
'contains',
|
'contains',
|
||||||
'overlaps',
|
'overlaps',
|
||||||
|
'intersects',
|
||||||
|
'touches',
|
||||||
'separatedFrom',
|
'separatedFrom',
|
||||||
|
'hasGap',
|
||||||
// Spatial aliases
|
// Spatial aliases
|
||||||
'beside',
|
'beside',
|
||||||
'nextTo',
|
'nextTo',
|
||||||
@@ -73,7 +76,10 @@ const VALID_OPTIONS: Record<string, Set<string>> = {
|
|||||||
inside: new Set(['tolerance', 'minGap', 'space']),
|
inside: new Set(['tolerance', 'minGap', 'space']),
|
||||||
contains: new Set(['tolerance', 'space']),
|
contains: new Set(['tolerance', 'space']),
|
||||||
overlaps: 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']),
|
separatedFrom: new Set(['minGap', 'maxGap', 'tolerance', 'space']),
|
||||||
|
hasGap: new Set(['minGap', 'maxGap', 'tolerance', 'space']),
|
||||||
// Spatial alias options
|
// Spatial alias options
|
||||||
beside: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
beside: new Set(['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space']),
|
||||||
nextTo: 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',
|
'inside',
|
||||||
'contains',
|
'contains',
|
||||||
'overlaps',
|
'overlaps',
|
||||||
|
'intersects',
|
||||||
|
'touches',
|
||||||
'separatedFrom',
|
'separatedFrom',
|
||||||
|
'hasGap',
|
||||||
'beside',
|
'beside',
|
||||||
'nextTo',
|
'nextTo',
|
||||||
'adjacent',
|
'adjacent',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"imhotep-core": "^1.0.0",
|
"imhotep-core": "^1.0.0",
|
||||||
"imhotep-dsl": "^1.0.0",
|
"imhotep-dsl": "^1.0.0",
|
||||||
"imhotep-solver": "^1.0.0",
|
"imhotep-solver": "^1.0.0",
|
||||||
|
"imhotep-state": "^1.0.0",
|
||||||
"imhotep-reporter": "^1.0.0"
|
"imhotep-reporter": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export function computeRequiredFacts(formulas: FormulaNode[]): {
|
|||||||
fragments: boolean
|
fragments: boolean
|
||||||
domAncestry: boolean
|
domAncestry: boolean
|
||||||
} {
|
} {
|
||||||
|
registerDefaultPredicates()
|
||||||
const facts = new Set<string>()
|
const facts = new Set<string>()
|
||||||
for (const formula of formulas) {
|
for (const formula of formulas) {
|
||||||
const predicates = collectPredicates(formula)
|
const predicates = collectPredicates(formula)
|
||||||
@@ -222,9 +223,11 @@ export function computeRequiredFacts(formulas: FormulaNode[]): {
|
|||||||
return found
|
return found
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const needsTopology = Array.from(facts).some((f) => f.startsWith('topology.') || f.endsWith('.clipChain'))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
geometry: true, // Always required for subject resolution
|
geometry: true, // Always required for subject resolution
|
||||||
topology: facts.has('topology.stackingContextOf') || facts.has('subject.clipChain'),
|
topology: needsTopology,
|
||||||
styles: facts.has('styles') || facts.has('computedStyle') || needsCssLengthMetrics,
|
styles: facts.has('styles') || facts.has('computedStyle') || needsCssLengthMetrics,
|
||||||
fragments: facts.has('subject.fragmentCount') || facts.has('subject.firstFragmentId'),
|
fragments: facts.has('subject.fragmentCount') || facts.has('subject.firstFragmentId'),
|
||||||
domAncestry: needsDomAncestry,
|
domAncestry: needsDomAncestry,
|
||||||
@@ -732,6 +735,40 @@ export async function extractWorldFastGeometry(
|
|||||||
// CDP Extraction
|
// CDP Extraction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function remapTopologyIds(world: GeometryWorld): void {
|
||||||
|
const nSubjects = world.subjects.ids.length
|
||||||
|
if (nSubjects === 0) return
|
||||||
|
|
||||||
|
const backendToSolver = new Map<number, number>()
|
||||||
|
for (let i = 0; i < nSubjects; i++) {
|
||||||
|
backendToSolver.set(world.subjects.domNodeId[i], world.subjects.ids[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
const remap = (src: ArrayLike<number>): number[] => {
|
||||||
|
const out = new Array<number>(src.length)
|
||||||
|
for (let i = 0; i < src.length; i++) {
|
||||||
|
const backendId = src[i]
|
||||||
|
out[i] = backendId > 0 ? (backendToSolver.get(backendId) ?? 0) : 0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = world.topology
|
||||||
|
t.containingBlockOf = remap(t.containingBlockOf)
|
||||||
|
t.nearestPositionedAncestorOf = remap(t.nearestPositionedAncestorOf)
|
||||||
|
t.scrollContainerOf = remap(t.scrollContainerOf)
|
||||||
|
t.stackingContextOf = remap(t.stackingContextOf)
|
||||||
|
t.formattingContextOf = remap(t.formattingContextOf)
|
||||||
|
t.clippingRootOf = remap(t.clippingRootOf)
|
||||||
|
|
||||||
|
if (world.clipping) {
|
||||||
|
world.clipping.subjectId = remap(world.clipping.subjectId)
|
||||||
|
}
|
||||||
|
if (world.scroll) {
|
||||||
|
world.scroll.containerId = remap(world.scroll.containerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function extractWorldCdp(
|
export async function extractWorldCdp(
|
||||||
playwrightPage: Page,
|
playwrightPage: Page,
|
||||||
selectors: string[],
|
selectors: string[],
|
||||||
@@ -751,15 +788,13 @@ export async function extractWorldCdp(
|
|||||||
try {
|
try {
|
||||||
await sessionManager.enableDomain('DOM')
|
await sessionManager.enableDomain('DOM')
|
||||||
|
|
||||||
const session = sessionManager.getSession()
|
|
||||||
await session.send('DOM.getDocument', { depth: -1 })
|
|
||||||
|
|
||||||
const allQueries = selectorPlans.flatMap((plan) =>
|
const allQueries = selectorPlans.flatMap((plan) =>
|
||||||
plan.queries.map((query) => ({ key: plan.key, query })),
|
plan.queries.map((query) => ({ key: plan.key, query })),
|
||||||
)
|
)
|
||||||
const allMatches = await Promise.all(
|
const allMatches: Awaited<ReturnType<typeof resolveSelector>>[] = []
|
||||||
allQueries.map(({ query }) => resolveSelector(sessionManager, query)),
|
for (const { query } of allQueries) {
|
||||||
)
|
allMatches.push(await resolveSelector(sessionManager, query))
|
||||||
|
}
|
||||||
const resolvedSubjects: Array<{ id: string; selector: string; nodeId: number; backendNodeId: number }> = []
|
const resolvedSubjects: Array<{ id: string; selector: string; nodeId: number; backendNodeId: number }> = []
|
||||||
for (let i = 0; i < allQueries.length; i++) {
|
for (let i = 0; i < allQueries.length; i++) {
|
||||||
const { key, query } = allQueries[i]
|
const { key, query } = allQueries[i]
|
||||||
@@ -830,6 +865,8 @@ export async function extractWorldCdp(
|
|||||||
fontWeight: Array.from(canonical.styles.fontWeight),
|
fontWeight: Array.from(canonical.styles.fontWeight),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remapTopologyIds(world)
|
||||||
|
|
||||||
const selectorToIds = new Map<string, number[]>()
|
const selectorToIds = new Map<string, number[]>()
|
||||||
for (const [selectorKey, nodeIds] of selectorToNodeIds) {
|
for (const [selectorKey, nodeIds] of selectorToNodeIds) {
|
||||||
const idSet = new Set(nodeIds)
|
const idSet = new Set(nodeIds)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import assert from 'node:assert'
|
|||||||
import { imhotep, ImhotepUi, imhotepComponent, imhotepStory, imhotepFixture } from './public.js'
|
import { imhotep, ImhotepUi, imhotepComponent, imhotepStory, imhotepFixture } from './public.js'
|
||||||
import { defaultEnvironment } from './fixture.js'
|
import { defaultEnvironment } from './fixture.js'
|
||||||
import { react, storybook } from './renderers.js'
|
import { react, storybook } from './renderers.js'
|
||||||
import { SelectorDomainResolver, buildAncestorIndex } from './extraction.js'
|
import { SelectorDomainResolver, buildAncestorIndex, computeRequiredFacts } from './extraction.js'
|
||||||
import { BindingEnv } from 'imhotep-solver'
|
import { BindingEnv } from 'imhotep-solver'
|
||||||
import type { GeometryWorld } from 'imhotep-solver'
|
import type { GeometryWorld } from 'imhotep-solver'
|
||||||
|
|
||||||
@@ -1356,3 +1356,32 @@ describe('buildAncestorIndex', () => {
|
|||||||
assert.strictEqual(index.size, 0)
|
assert.strictEqual(index.size, 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('computeRequiredFacts topology gating', () => {
|
||||||
|
it('requires topology extraction for scroll-container predicates', () => {
|
||||||
|
const facts = computeRequiredFacts([
|
||||||
|
{
|
||||||
|
type: 'FormulaNode',
|
||||||
|
kind: 'predicate',
|
||||||
|
predicate: 'attachedToScrollContainer',
|
||||||
|
args: [{ type: 'VariableRef', name: '$subject' }],
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
assert.strictEqual(facts.topology, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires topology extraction for clipping-chain escape predicates', () => {
|
||||||
|
const facts = computeRequiredFacts([
|
||||||
|
{
|
||||||
|
type: 'FormulaNode',
|
||||||
|
kind: 'predicate',
|
||||||
|
predicate: 'escapeClippingChainOf',
|
||||||
|
args: [
|
||||||
|
{ type: 'VariableRef', name: '$subject' },
|
||||||
|
{ type: 'VariableRef', name: '$reference' },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
assert.strictEqual(facts.topology, true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type { DomainValue } from 'imhotep-core';
|
|||||||
// Join Spec
|
// Join Spec
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const MAX_CARTESIAN_TUPLES = 100_000;
|
||||||
|
|
||||||
export interface JoinSpec {
|
export interface JoinSpec {
|
||||||
variableName: string;
|
variableName: string;
|
||||||
domain: DomainValue;
|
domain: DomainValue;
|
||||||
@@ -53,6 +55,13 @@ export function createCartesianIterator(specs: JoinSpec[]): TupleIterator {
|
|||||||
const domainSizes = specs.map((s) => s.domain.subjectIds.length);
|
const domainSizes = specs.map((s) => s.domain.subjectIds.length);
|
||||||
const totalTuples = domainSizes.reduce((a, b) => a * b, 1);
|
const totalTuples = domainSizes.reduce((a, b) => a * b, 1);
|
||||||
|
|
||||||
|
if (totalTuples > MAX_CARTESIAN_TUPLES) {
|
||||||
|
throw new Error(
|
||||||
|
`Cartesian product exceeds safety limit: ${totalTuples} tuples (max ${MAX_CARTESIAN_TUPLES}). ` +
|
||||||
|
`Domain sizes: [${domainSizes.join(', ')}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-allocate reusable buffers.
|
// Pre-allocate reusable buffers.
|
||||||
const tuple = new Uint32Array(n);
|
const tuple = new Uint32Array(n);
|
||||||
const indices = new Uint32Array(n);
|
const indices = new Uint32Array(n);
|
||||||
|
|||||||
@@ -597,5 +597,90 @@ describe('spatial alias predicates', () => {
|
|||||||
assert.strictEqual(result.truth, 'true');
|
assert.strictEqual(result.truth, 'true');
|
||||||
assert.strictEqual(result.metrics?.clipKind, 2); // 2=overflow in predicate encoding
|
assert.strictEqual(result.metrics?.clipKind, 2); // 2=overflow in predicate encoding
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('escapeClippingChainOf passes when subject overflows an ancestor clip but is not clipped by it', () => {
|
||||||
|
const evaluator = getPredicateEvaluator('escapeClippingChainOf')!;
|
||||||
|
const world = makeWorld({
|
||||||
|
subjects: { ids: [1, 2], domNodeId: [10, 20], subjectKind: [1, 1], primaryBoxId: [100, 200], firstFragmentId: [0, 0], fragmentCount: [0, 0] },
|
||||||
|
dom: { nodeId: [10, 20], parentNodeId: [20, 0], childCount: [0, 1], tagNameStringId: [0, 0] },
|
||||||
|
boxes: {
|
||||||
|
boxId: [100, 200], subjectId: [1, 2], frameId: [1, 1],
|
||||||
|
borderLeft: [10, 0], borderTop: [10, 0], borderRight: [150, 100], borderBottom: [50, 100],
|
||||||
|
paddingLeft: [0, 0], paddingTop: [0, 0], paddingRight: [0, 0], paddingBottom: [0, 0],
|
||||||
|
contentLeft: [10, 0], contentTop: [10, 0], contentRight: [150, 100], contentBottom: [50, 100],
|
||||||
|
},
|
||||||
|
clipping: {
|
||||||
|
clipNodeId: [0], subjectId: [2], clipKind: [1],
|
||||||
|
clipLeft: [0], clipTop: [0], clipRight: [100], clipBottom: [100], parentClipNodeId: [0],
|
||||||
|
},
|
||||||
|
topology: { clippingRootOf: [0, 0], stackingContextOf: [], scrollContainerOf: [], containingBlockOf: [], formattingContextOf: [], nearestPositionedAncestorOf: [], paintOrderBucket: [], paintOrderIndex: [] },
|
||||||
|
});
|
||||||
|
const result = evaluator.evaluateTuple(world, [1, 2]);
|
||||||
|
assert.strictEqual(result.truth, 'true');
|
||||||
|
assert.strictEqual(result.metrics?.overflowRight, 50);
|
||||||
|
assert.strictEqual(result.metrics?.clippedByReference, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapeClippingChainOf fails when the subject is clipped by the reference', () => {
|
||||||
|
const evaluator = getPredicateEvaluator('escapeClippingChainOf')!;
|
||||||
|
const world = makeWorld({
|
||||||
|
subjects: { ids: [1, 2], domNodeId: [10, 20], subjectKind: [1, 1], primaryBoxId: [100, 200], firstFragmentId: [0, 0], fragmentCount: [0, 0] },
|
||||||
|
dom: { nodeId: [10, 20], parentNodeId: [20, 0], childCount: [0, 1], tagNameStringId: [0, 0] },
|
||||||
|
boxes: {
|
||||||
|
boxId: [100, 200], subjectId: [1, 2], frameId: [1, 1],
|
||||||
|
borderLeft: [10, 0], borderTop: [10, 0], borderRight: [150, 100], borderBottom: [50, 100],
|
||||||
|
paddingLeft: [0, 0], paddingTop: [0, 0], paddingRight: [0, 0], paddingBottom: [0, 0],
|
||||||
|
contentLeft: [10, 0], contentTop: [10, 0], contentRight: [150, 100], contentBottom: [50, 100],
|
||||||
|
},
|
||||||
|
clipping: {
|
||||||
|
clipNodeId: [0], subjectId: [2], clipKind: [1],
|
||||||
|
clipLeft: [0], clipTop: [0], clipRight: [100], clipBottom: [100], parentClipNodeId: [0],
|
||||||
|
},
|
||||||
|
topology: { clippingRootOf: [2, 0], stackingContextOf: [], scrollContainerOf: [], containingBlockOf: [], formattingContextOf: [], nearestPositionedAncestorOf: [], paintOrderBucket: [], paintOrderIndex: [] },
|
||||||
|
});
|
||||||
|
const result = evaluator.evaluateTuple(world, [1, 2]);
|
||||||
|
assert.strictEqual(result.truth, 'false');
|
||||||
|
assert.strictEqual(result.metrics?.clippedByReference, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('core geometric predicates', () => {
|
||||||
|
it('intersects includes boundary contact', () => {
|
||||||
|
const evaluator = getPredicateEvaluator('intersects')!;
|
||||||
|
const world = makeWorld({
|
||||||
|
boxes: {
|
||||||
|
boxId: [100, 200, 300, 400], subjectId: [1, 2, 3, 4], frameId: [1, 1, 1, 1],
|
||||||
|
borderLeft: [0, 100, 50, 50], borderTop: [0, 0, 0, 150], borderRight: [100, 200, 150, 150], borderBottom: [40, 40, 100, 190],
|
||||||
|
paddingLeft: [0, 0, 0, 0], paddingTop: [0, 0, 0, 0], paddingRight: [0, 0, 0, 0], paddingBottom: [0, 0, 0, 0],
|
||||||
|
contentLeft: [0, 100, 50, 50], contentTop: [0, 0, 0, 150], contentRight: [100, 200, 150, 150], contentBottom: [40, 40, 100, 190],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = evaluator.evaluateTuple(world, [1, 2]);
|
||||||
|
assert.strictEqual(result.truth, 'true');
|
||||||
|
assert.strictEqual(result.metrics?.overlapArea, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('touches passes for boundary contact and fails for positive overlap', () => {
|
||||||
|
const evaluator = getPredicateEvaluator('touches')!;
|
||||||
|
const touchingWorld = makeWorld({
|
||||||
|
boxes: {
|
||||||
|
boxId: [100, 200, 300, 400], subjectId: [1, 2, 3, 4], frameId: [1, 1, 1, 1],
|
||||||
|
borderLeft: [0, 100, 50, 50], borderTop: [0, 0, 0, 150], borderRight: [100, 200, 150, 150], borderBottom: [40, 40, 100, 190],
|
||||||
|
paddingLeft: [0, 0, 0, 0], paddingTop: [0, 0, 0, 0], paddingRight: [0, 0, 0, 0], paddingBottom: [0, 0, 0, 0],
|
||||||
|
contentLeft: [0, 100, 50, 50], contentTop: [0, 0, 0, 150], contentRight: [100, 200, 150, 150], contentBottom: [40, 40, 100, 190],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.strictEqual(evaluator.evaluateTuple(touchingWorld, [1, 2]).truth, 'true');
|
||||||
|
assert.strictEqual(evaluator.evaluateTuple(makeWorld(), [1, 3]).truth, 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasGap measures axis-aligned and diagonal separation', () => {
|
||||||
|
const evaluator = getPredicateEvaluator('hasGap')!;
|
||||||
|
const result = evaluator.evaluateTuple(makeWorld(), [1, 2], { minGap: 10, maxGap: 20 });
|
||||||
|
assert.strictEqual(result.truth, 'true');
|
||||||
|
assert.strictEqual(result.metrics?.gapX, 10);
|
||||||
|
assert.strictEqual(result.metrics?.gapY, 10);
|
||||||
|
assert.ok(Math.abs((result.metrics?.gap ?? 0) - Math.sqrt(200)) < 0.0001);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ export const BUILTIN_PREDICATES: PredicateDescriptor[] = [
|
|||||||
{ name: 'atMost', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
{ name: 'atMost', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
||||||
{ name: 'between', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
{ name: 'between', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
||||||
{ name: 'clippedBy', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.clipChain', 'reference.clipChain'] },
|
{ name: 'clippedBy', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.clipChain', 'reference.clipChain'] },
|
||||||
{ name: 'attachedToScrollContainer', arity: 1, domains: ['element'], requiredFacts: ['topology.scrollContainerOf'] },
|
{ name: 'attachedToScrollContainer', arity: 2, domains: ['element', 'element'], requiredFacts: ['topology.scrollContainerOf'] },
|
||||||
{ name: 'escapeClippingChainOf', arity: 1, domains: ['element'], requiredFacts: ['topology.stackingContextOf', 'subject.clipChain'] },
|
{ name: 'escapeClippingChainOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.clipChain', 'topology.clippingRootOf'] },
|
||||||
{ name: 'inStackingContext', arity: 2, domains: ['element', 'element'], requiredFacts: ['topology.stackingContextOf'] },
|
{ name: 'inStackingContext', arity: 2, domains: ['element', 'element'], requiredFacts: ['topology.stackingContextOf'] },
|
||||||
{ name: 'separatedFrom', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
{ name: 'separatedFrom', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
{ name: 'aspectRatio', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
{ name: 'aspectRatio', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
||||||
@@ -93,6 +93,13 @@ export const BUILTIN_PREDICATES: PredicateDescriptor[] = [
|
|||||||
{ name: 'near', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
{ name: 'near', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
{ name: 'under', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
{ name: 'under', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
{ name: 'within', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
{ name: 'within', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
|
{ name: 'leftAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
|
{ name: 'rightAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
|
{ name: 'topAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
|
{ name: 'bottomAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
|
{ name: 'intersects', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
|
{ name: 'touches', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
|
{ name: 'hasGap', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -183,6 +190,69 @@ function getBorderRect(world: GeometryWorld, subjectId: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSubjectIndex(world: GeometryWorld, subjectId: number): number {
|
||||||
|
return world.subjects.ids.indexOf(subjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopologyValueBySubject(world: GeometryWorld, subjectId: number, values: number[]): number {
|
||||||
|
const idx = getSubjectIndex(world, subjectId);
|
||||||
|
return idx >= 0 ? values[idx] ?? 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubjectDomNodeId(world: GeometryWorld, subjectId: number): number {
|
||||||
|
const idx = getSubjectIndex(world, subjectId);
|
||||||
|
return idx >= 0 ? world.subjects.domNodeId[idx] ?? 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDomAncestor(world: GeometryWorld, ancestorSubjectId: number, descendantSubjectId: number): boolean | undefined {
|
||||||
|
const ancestorNodeId = getSubjectDomNodeId(world, ancestorSubjectId);
|
||||||
|
let current = getSubjectDomNodeId(world, descendantSubjectId);
|
||||||
|
if (ancestorNodeId <= 0 || current <= 0 || world.dom.nodeId.length === 0) return undefined;
|
||||||
|
|
||||||
|
while (current > 0) {
|
||||||
|
const pos = world.dom.nodeId.indexOf(current);
|
||||||
|
if (pos < 0) return undefined;
|
||||||
|
const parent = world.dom.parentNodeId[pos] ?? 0;
|
||||||
|
if (parent === ancestorNodeId) return true;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClipRectForSubject(world: GeometryWorld, subjectId: number) {
|
||||||
|
const clipping = world.clipping;
|
||||||
|
for (let i = 0; i < clipping.subjectId.length; i++) {
|
||||||
|
if (clipping.subjectId[i] !== subjectId) continue;
|
||||||
|
return {
|
||||||
|
left: clipping.clipLeft[i],
|
||||||
|
top: clipping.clipTop[i],
|
||||||
|
right: clipping.clipRight[i],
|
||||||
|
bottom: clipping.clipBottom[i],
|
||||||
|
kind: clipping.clipKind[i] ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectOverlapMetrics(sRect: ReturnType<typeof getBorderRect> & {}, rRect: ReturnType<typeof getBorderRect> & {}) {
|
||||||
|
const overlapLeft = Math.max(sRect.left, rRect.left);
|
||||||
|
const overlapTop = Math.max(sRect.top, rRect.top);
|
||||||
|
const overlapRight = Math.min(sRect.right, rRect.right);
|
||||||
|
const overlapBottom = Math.min(sRect.bottom, rRect.bottom);
|
||||||
|
const overlapWidth = Math.max(0, overlapRight - overlapLeft);
|
||||||
|
const overlapHeight = Math.max(0, overlapBottom - overlapTop);
|
||||||
|
const gapX = Math.max(rRect.left - sRect.right, sRect.left - rRect.right, 0);
|
||||||
|
const gapY = Math.max(rRect.top - sRect.bottom, sRect.top - rRect.bottom, 0);
|
||||||
|
return {
|
||||||
|
overlapWidth,
|
||||||
|
overlapHeight,
|
||||||
|
overlapArea: overlapWidth * overlapHeight,
|
||||||
|
gapX,
|
||||||
|
gapY,
|
||||||
|
gap: Math.hypot(gapX, gapY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makePredicateResult(
|
function makePredicateResult(
|
||||||
truth: PredicateTruth,
|
truth: PredicateTruth,
|
||||||
metrics?: Record<string, number>,
|
metrics?: Record<string, number>,
|
||||||
@@ -712,7 +782,7 @@ export const clippedByPredicate: PredicateEvaluator = {
|
|||||||
return makePredicateResult('indeterminate');
|
return makePredicateResult('indeterminate');
|
||||||
}
|
}
|
||||||
// Simplified: check if subject's clipping root is the reference
|
// Simplified: check if subject's clipping root is the reference
|
||||||
const clipRoot = world.topology.clippingRootOf[subjectId - 1] ?? 0;
|
const clipRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf);
|
||||||
const pass = clipRoot === referenceId;
|
const pass = clipRoot === referenceId;
|
||||||
// Determine clip kind from the clipping table entry for the reference.
|
// Determine clip kind from the clipping table entry for the reference.
|
||||||
// Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto
|
// Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto
|
||||||
@@ -737,12 +807,12 @@ export const inStackingContextPredicate: PredicateEvaluator = {
|
|||||||
if (subjectId === undefined) {
|
if (subjectId === undefined) {
|
||||||
return makePredicateResult('indeterminate');
|
return makePredicateResult('indeterminate');
|
||||||
}
|
}
|
||||||
const sc = world.topology.stackingContextOf[subjectId - 1] ?? 0;
|
const sc = getTopologyValueBySubject(world, subjectId, world.topology.stackingContextOf);
|
||||||
const subjectHasSC = sc > 0;
|
const subjectHasSC = sc > 0;
|
||||||
|
|
||||||
if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) {
|
if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) {
|
||||||
const referenceId = tuple[1];
|
const referenceId = tuple[1];
|
||||||
const refSC = world.topology.stackingContextOf[referenceId - 1] ?? 0;
|
const refSC = getTopologyValueBySubject(world, referenceId, world.topology.stackingContextOf);
|
||||||
const pass = subjectHasSC && refSC > 0 && sc === refSC;
|
const pass = subjectHasSC && refSC > 0 && sc === refSC;
|
||||||
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]);
|
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]);
|
||||||
}
|
}
|
||||||
@@ -755,30 +825,51 @@ export const inStackingContextPredicate: PredicateEvaluator = {
|
|||||||
export const attachedToScrollContainerPredicate: PredicateEvaluator = {
|
export const attachedToScrollContainerPredicate: PredicateEvaluator = {
|
||||||
descriptor: BUILTIN_PREDICATES[15]!,
|
descriptor: BUILTIN_PREDICATES[15]!,
|
||||||
evaluateTuple(world, tuple) {
|
evaluateTuple(world, tuple) {
|
||||||
const subjectId = tuple[0];
|
const [subjectId, referenceId] = tuple;
|
||||||
if (subjectId === undefined) {
|
if (subjectId === undefined || referenceId === undefined) {
|
||||||
return makePredicateResult('indeterminate');
|
return makePredicateResult('indeterminate');
|
||||||
}
|
}
|
||||||
const scrollContainer = world.topology.scrollContainerOf[subjectId - 1] ?? 0;
|
const scrollContainer = getTopologyValueBySubject(world, subjectId, world.topology.scrollContainerOf);
|
||||||
const pass = scrollContainer > 0;
|
const pass = scrollContainer > 0 && scrollContainer === referenceId;
|
||||||
return makePredicateResult(pass ? 'true' : 'false', { scrollContainer }, [subjectId]);
|
return makePredicateResult(pass ? 'true' : 'false', { scrollContainer, referenceId }, [subjectId]);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const escapeClippingChainOfPredicate: PredicateEvaluator = {
|
export const escapeClippingChainOfPredicate: PredicateEvaluator = {
|
||||||
descriptor: BUILTIN_PREDICATES[16]!,
|
descriptor: BUILTIN_PREDICATES[16]!,
|
||||||
evaluateTuple(_world, tuple) {
|
evaluateTuple(world, tuple) {
|
||||||
const subjectId = tuple[0];
|
const [subjectId, referenceId] = tuple;
|
||||||
if (subjectId === undefined) {
|
if (subjectId === undefined || referenceId === undefined) {
|
||||||
return makePredicateResult('indeterminate');
|
return makePredicateResult('indeterminate');
|
||||||
}
|
}
|
||||||
return makePredicateResult('indeterminate', {}, [subjectId], [
|
const sRect = getBorderRect(world, subjectId);
|
||||||
{
|
const clipRect = getClipRectForSubject(world, referenceId);
|
||||||
code: 'IMH_TOPOLOGY_UNSUPPORTED',
|
if (!sRect || !clipRect) {
|
||||||
severity: 'warning',
|
return makePredicateResult('indeterminate', { hasClipRect: clipRect ? 1 : 0 }, [subjectId, referenceId]);
|
||||||
message: `escapeClippingChainOf is not yet implemented. The predicate parses but the evaluator returns indeterminate — visual clipping chain escape geometry requires fragment-level bounds analysis. Use clippedBy as an alternative for overflow containment checks.`,
|
}
|
||||||
},
|
|
||||||
]);
|
const ancestry = isDomAncestor(world, referenceId, subjectId);
|
||||||
|
const overflowLeft = Math.max(0, clipRect.left - sRect.left);
|
||||||
|
const overflowTop = Math.max(0, clipRect.top - sRect.top);
|
||||||
|
const overflowRight = Math.max(0, sRect.right - clipRect.right);
|
||||||
|
const overflowBottom = Math.max(0, sRect.bottom - clipRect.bottom);
|
||||||
|
const overflow = overflowLeft + overflowTop + overflowRight + overflowBottom;
|
||||||
|
const clippingRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf);
|
||||||
|
const clippedByReference = clippingRoot === referenceId;
|
||||||
|
const pass = overflow > 0 && !clippedByReference && ancestry !== false;
|
||||||
|
const metrics: Record<string, number> = {
|
||||||
|
overflowLeft,
|
||||||
|
overflowTop,
|
||||||
|
overflowRight,
|
||||||
|
overflowBottom,
|
||||||
|
overflow,
|
||||||
|
clipKind: clipRect.kind,
|
||||||
|
clippingRoot,
|
||||||
|
clippedByReference: clippedByReference ? 1 : 0,
|
||||||
|
referenceIsAncestor: ancestry === undefined ? -1 : ancestry ? 1 : 0,
|
||||||
|
};
|
||||||
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('escapeClippingChainOf', metrics, [subjectId, referenceId]);
|
||||||
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1021,33 +1112,86 @@ export const separatedFromPredicate: PredicateEvaluator = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const leftAlignedWithPredicate: PredicateEvaluator = {
|
export const leftAlignedWithPredicate: PredicateEvaluator = {
|
||||||
descriptor: { name: 'leftAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
descriptor: BUILTIN_PREDICATES[27]!,
|
||||||
evaluateTuple(world, tuple, options) {
|
evaluateTuple(world, tuple, options) {
|
||||||
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'left' });
|
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'left' });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const rightAlignedWithPredicate: PredicateEvaluator = {
|
export const rightAlignedWithPredicate: PredicateEvaluator = {
|
||||||
descriptor: { name: 'rightAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
descriptor: BUILTIN_PREDICATES[28]!,
|
||||||
evaluateTuple(world, tuple, options) {
|
evaluateTuple(world, tuple, options) {
|
||||||
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'right' });
|
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'right' });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const topAlignedWithPredicate: PredicateEvaluator = {
|
export const topAlignedWithPredicate: PredicateEvaluator = {
|
||||||
descriptor: { name: 'topAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
descriptor: BUILTIN_PREDICATES[29]!,
|
||||||
evaluateTuple(world, tuple, options) {
|
evaluateTuple(world, tuple, options) {
|
||||||
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'top' });
|
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'top' });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bottomAlignedWithPredicate: PredicateEvaluator = {
|
export const bottomAlignedWithPredicate: PredicateEvaluator = {
|
||||||
descriptor: { name: 'bottomAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
descriptor: BUILTIN_PREDICATES[30]!,
|
||||||
evaluateTuple(world, tuple, options) {
|
evaluateTuple(world, tuple, options) {
|
||||||
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'bottom' });
|
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'bottom' });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const intersectsPredicate: PredicateEvaluator = {
|
||||||
|
descriptor: BUILTIN_PREDICATES[31]!,
|
||||||
|
evaluateTuple(world, tuple) {
|
||||||
|
const [subjectId, referenceId] = tuple;
|
||||||
|
if (subjectId === undefined || referenceId === undefined) return makePredicateResult('indeterminate');
|
||||||
|
const sRect = getBorderRect(world, subjectId);
|
||||||
|
const rRect = getBorderRect(world, referenceId);
|
||||||
|
if (!sRect || !rRect) return makePredicateResult('indeterminate');
|
||||||
|
const metrics = rectOverlapMetrics(sRect, rRect);
|
||||||
|
const pass = sRect.left <= rRect.right && sRect.right >= rRect.left
|
||||||
|
&& sRect.top <= rRect.bottom && sRect.bottom >= rRect.top;
|
||||||
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('intersects', metrics, [subjectId, referenceId]);
|
||||||
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const touchesPredicate: PredicateEvaluator = {
|
||||||
|
descriptor: BUILTIN_PREDICATES[32]!,
|
||||||
|
evaluateTuple(world, tuple) {
|
||||||
|
const [subjectId, referenceId] = tuple;
|
||||||
|
if (subjectId === undefined || referenceId === undefined) return makePredicateResult('indeterminate');
|
||||||
|
const sRect = getBorderRect(world, subjectId);
|
||||||
|
const rRect = getBorderRect(world, referenceId);
|
||||||
|
if (!sRect || !rRect) return makePredicateResult('indeterminate');
|
||||||
|
const metrics = rectOverlapMetrics(sRect, rRect);
|
||||||
|
const intersects = sRect.left <= rRect.right && sRect.right >= rRect.left
|
||||||
|
&& sRect.top <= rRect.bottom && sRect.bottom >= rRect.top;
|
||||||
|
const overlapsArea = metrics.overlapArea > 0;
|
||||||
|
const pass = intersects && !overlapsArea;
|
||||||
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('touches', metrics, [subjectId, referenceId]);
|
||||||
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasGapPredicate: PredicateEvaluator = {
|
||||||
|
descriptor: BUILTIN_PREDICATES[33]!,
|
||||||
|
evaluateTuple(world, tuple, options) {
|
||||||
|
const [subjectId, referenceId] = tuple;
|
||||||
|
if (subjectId === undefined || referenceId === undefined) return makePredicateResult('indeterminate');
|
||||||
|
const sRect = getBorderRect(world, subjectId);
|
||||||
|
const rRect = getBorderRect(world, referenceId);
|
||||||
|
if (!sRect || !rRect) return makePredicateResult('indeterminate');
|
||||||
|
const metrics = rectOverlapMetrics(sRect, rRect);
|
||||||
|
const minGap = (options?.minGap as number | undefined) ?? 0;
|
||||||
|
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
|
||||||
|
const separated = metrics.gap > 0;
|
||||||
|
const pass = separated && metrics.gap >= minGap && metrics.gap <= maxGap;
|
||||||
|
const resultMetrics = { ...metrics, minGap, maxGap };
|
||||||
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('hasGap', resultMetrics, [subjectId, referenceId]);
|
||||||
|
return makePredicateResult(pass ? 'true' : 'false', resultMetrics, [subjectId, referenceId], diagnostics);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Register Defaults
|
// Register Defaults
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1081,6 +1225,9 @@ export function registerDefaultPredicates(): void {
|
|||||||
registerPredicate(rightAlignedWithPredicate);
|
registerPredicate(rightAlignedWithPredicate);
|
||||||
registerPredicate(topAlignedWithPredicate);
|
registerPredicate(topAlignedWithPredicate);
|
||||||
registerPredicate(bottomAlignedWithPredicate);
|
registerPredicate(bottomAlignedWithPredicate);
|
||||||
|
registerPredicate(intersectsPredicate);
|
||||||
|
registerPredicate(touchesPredicate);
|
||||||
|
registerPredicate(hasGapPredicate);
|
||||||
registerPredicate(besidePredicate);
|
registerPredicate(besidePredicate);
|
||||||
registerPredicate(nextToPredicate);
|
registerPredicate(nextToPredicate);
|
||||||
registerPredicate(adjacentPredicate);
|
registerPredicate(adjacentPredicate);
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function evaluateInStackingContext(
|
|||||||
clause: ClauseDescriptor,
|
clause: ClauseDescriptor,
|
||||||
): ClauseResult {
|
): ClauseResult {
|
||||||
const { subjectRef, referenceRef, clauseId } = clause;
|
const { subjectRef, referenceRef, clauseId } = clause;
|
||||||
if (subjectRef === undefined || referenceRef === undefined) {
|
if (subjectRef === undefined) {
|
||||||
return result(clauseId, 'error', 'indeterminate', undefined, {
|
return result(clauseId, 'error', 'indeterminate', undefined, {
|
||||||
subjectRef,
|
subjectRef,
|
||||||
referenceRef,
|
referenceRef,
|
||||||
@@ -140,6 +140,18 @@ export function evaluateInStackingContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sCtx = getTopologyValue(world.topology.stackingContextOf, subjectRef);
|
const sCtx = getTopologyValue(world.topology.stackingContextOf, subjectRef);
|
||||||
|
|
||||||
|
if (referenceRef === undefined) {
|
||||||
|
const pass = sCtx !== 0;
|
||||||
|
return result(
|
||||||
|
clauseId,
|
||||||
|
pass ? 'pass' : 'fail',
|
||||||
|
'determinate',
|
||||||
|
{ subjectStackingContext: sCtx },
|
||||||
|
{ subjectId: subjectRef },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rCtx = getTopologyValue(world.topology.stackingContextOf, referenceRef);
|
const rCtx = getTopologyValue(world.topology.stackingContextOf, referenceRef);
|
||||||
const pass = sCtx !== 0 && sCtx === rCtx;
|
const pass = sCtx !== 0 && sCtx === rCtx;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user