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:
@@ -80,8 +80,8 @@ export const BUILTIN_PREDICATES: PredicateDescriptor[] = [
|
||||
{ name: 'atMost', 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: 'attachedToScrollContainer', arity: 1, domains: ['element'], requiredFacts: ['topology.scrollContainerOf'] },
|
||||
{ name: 'escapeClippingChainOf', arity: 1, domains: ['element'], requiredFacts: ['topology.stackingContextOf', 'subject.clipChain'] },
|
||||
{ name: 'attachedToScrollContainer', arity: 2, domains: ['element', 'element'], requiredFacts: ['topology.scrollContainerOf'] },
|
||||
{ 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: 'separatedFrom', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.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: 'under', 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(
|
||||
truth: PredicateTruth,
|
||||
metrics?: Record<string, number>,
|
||||
@@ -712,7 +782,7 @@ export const clippedByPredicate: PredicateEvaluator = {
|
||||
return makePredicateResult('indeterminate');
|
||||
}
|
||||
// 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;
|
||||
// Determine clip kind from the clipping table entry for the reference.
|
||||
// Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto
|
||||
@@ -737,12 +807,12 @@ export const inStackingContextPredicate: PredicateEvaluator = {
|
||||
if (subjectId === undefined) {
|
||||
return makePredicateResult('indeterminate');
|
||||
}
|
||||
const sc = world.topology.stackingContextOf[subjectId - 1] ?? 0;
|
||||
const sc = getTopologyValueBySubject(world, subjectId, world.topology.stackingContextOf);
|
||||
const subjectHasSC = sc > 0;
|
||||
|
||||
if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) {
|
||||
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;
|
||||
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]);
|
||||
}
|
||||
@@ -755,30 +825,51 @@ export const inStackingContextPredicate: PredicateEvaluator = {
|
||||
export const attachedToScrollContainerPredicate: PredicateEvaluator = {
|
||||
descriptor: BUILTIN_PREDICATES[15]!,
|
||||
evaluateTuple(world, tuple) {
|
||||
const subjectId = tuple[0];
|
||||
if (subjectId === undefined) {
|
||||
const [subjectId, referenceId] = tuple;
|
||||
if (subjectId === undefined || referenceId === undefined) {
|
||||
return makePredicateResult('indeterminate');
|
||||
}
|
||||
const scrollContainer = world.topology.scrollContainerOf[subjectId - 1] ?? 0;
|
||||
const pass = scrollContainer > 0;
|
||||
return makePredicateResult(pass ? 'true' : 'false', { scrollContainer }, [subjectId]);
|
||||
const scrollContainer = getTopologyValueBySubject(world, subjectId, world.topology.scrollContainerOf);
|
||||
const pass = scrollContainer > 0 && scrollContainer === referenceId;
|
||||
return makePredicateResult(pass ? 'true' : 'false', { scrollContainer, referenceId }, [subjectId]);
|
||||
},
|
||||
};
|
||||
|
||||
export const escapeClippingChainOfPredicate: PredicateEvaluator = {
|
||||
descriptor: BUILTIN_PREDICATES[16]!,
|
||||
evaluateTuple(_world, tuple) {
|
||||
const subjectId = tuple[0];
|
||||
if (subjectId === undefined) {
|
||||
evaluateTuple(world, tuple) {
|
||||
const [subjectId, referenceId] = tuple;
|
||||
if (subjectId === undefined || referenceId === undefined) {
|
||||
return makePredicateResult('indeterminate');
|
||||
}
|
||||
return makePredicateResult('indeterminate', {}, [subjectId], [
|
||||
{
|
||||
code: 'IMH_TOPOLOGY_UNSUPPORTED',
|
||||
severity: 'warning',
|
||||
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 sRect = getBorderRect(world, subjectId);
|
||||
const clipRect = getClipRectForSubject(world, referenceId);
|
||||
if (!sRect || !clipRect) {
|
||||
return makePredicateResult('indeterminate', { hasClipRect: clipRect ? 1 : 0 }, [subjectId, referenceId]);
|
||||
}
|
||||
|
||||
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 = {
|
||||
descriptor: { name: 'leftAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
||||
descriptor: BUILTIN_PREDICATES[27]!,
|
||||
evaluateTuple(world, tuple, options) {
|
||||
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'left' });
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'right' });
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'top' });
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1081,6 +1225,9 @@ export function registerDefaultPredicates(): void {
|
||||
registerPredicate(rightAlignedWithPredicate);
|
||||
registerPredicate(topAlignedWithPredicate);
|
||||
registerPredicate(bottomAlignedWithPredicate);
|
||||
registerPredicate(intersectsPredicate);
|
||||
registerPredicate(touchesPredicate);
|
||||
registerPredicate(hasGapPredicate);
|
||||
registerPredicate(besidePredicate);
|
||||
registerPredicate(nextToPredicate);
|
||||
registerPredicate(adjacentPredicate);
|
||||
|
||||
Reference in New Issue
Block a user