From 45b5575e53fb9624b65cd7c77ca00f06973c3875 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 22 May 2026 13:55:11 -0700 Subject: [PATCH] fix: prevent silent passing on missing topology data + visible cleanup failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit predicates.ts (missing-fact discipline): - getTopologyValueBySubject: return -1 sentinel instead of ?? 0 when topology data is missing/unset. NaN and negative values also treated as missing. Previously returned 0 which was indistinguishable from root subject ID. - clippedByPredicate: return 'indeterminate' when clippingRoot < 0 - inStackingContextPredicate: return 'indeterminate' when sc < 0 or refSC < 0 (both subject and reference). Previously treated missing data as false — a silent wrong answer. - attachedToScrollContainerPredicate: return 'indeterminate' when scrollContainer < 0 - escapeClippingChainOfPredicate: return 'indeterminate' when clippingRoot < 0 extraction.ts (cleanup visibility): - Promote fast-geometry and CDP cleanup failures from console.debug (invisible during test execution) to console.warn. Contaminated pages are now diagnosable without debug-log inspection. 658 tests pass. --- packages/imhotep-playwright/src/extraction.ts | 6 ++++-- packages/imhotep-solver/src/predicates.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/imhotep-playwright/src/extraction.ts b/packages/imhotep-playwright/src/extraction.ts index 3e25a64..905bd1d 100644 --- a/packages/imhotep-playwright/src/extraction.ts +++ b/packages/imhotep-playwright/src/extraction.ts @@ -747,7 +747,8 @@ export async function extractWorldFastGeometry( el.removeAttribute('data-imhotep-runtime-id') } }).catch((err) => { - console.debug('[imhotep-playwright] fast-geometry cleanup evaluate failed:', err instanceof Error ? err.message : err) + // eslint-disable-next-line no-console + console.warn('[imhotep-playwright] fast-geometry cleanup failed:', err instanceof Error ? err.message : err) }) } } @@ -933,7 +934,8 @@ export async function extractWorldCdp( el.removeAttribute('data-imhotep-runtime-id') } }).catch((err) => { - console.debug('[imhotep-playwright] CDP cleanup evaluate failed:', err instanceof Error ? err.message : err) + // eslint-disable-next-line no-console + console.warn('[imhotep-playwright] CDP cleanup failed:', err instanceof Error ? err.message : err) }) await sessionManager.detach() } diff --git a/packages/imhotep-solver/src/predicates.ts b/packages/imhotep-solver/src/predicates.ts index df79a44..d402d38 100644 --- a/packages/imhotep-solver/src/predicates.ts +++ b/packages/imhotep-solver/src/predicates.ts @@ -195,7 +195,10 @@ function getSubjectIndex(world: GeometryWorld, subjectId: number): number { function getTopologyValueBySubject(world: GeometryWorld, subjectId: number, values: number[]): number { const idx = getSubjectIndex(world, subjectId); - return idx >= 0 ? values[idx] ?? 0 : 0; + if (idx < 0 || idx >= values.length) return -1; + const v = values[idx]; + // NaN or negative values indicate unset topology; treat as missing. + return (v === undefined || Number.isNaN(v) || v < 0) ? -1 : v; } function getSubjectDomNodeId(world: GeometryWorld, subjectId: number): number { @@ -782,6 +785,7 @@ export const clippedByPredicate: PredicateEvaluator = { } // Simplified: check if subject's clipping root is the reference const clipRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf); + if (clipRoot < 0) return makePredicateResult('indeterminate'); const pass = clipRoot === referenceId; // Determine clip kind from the clipping table entry for the reference. // Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto @@ -807,11 +811,13 @@ export const inStackingContextPredicate: PredicateEvaluator = { return makePredicateResult('indeterminate'); } const sc = getTopologyValueBySubject(world, subjectId, world.topology.stackingContextOf); + if (sc < 0) return makePredicateResult('indeterminate'); const subjectHasSC = sc > 0; if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) { const referenceId = tuple[1]; const refSC = getTopologyValueBySubject(world, referenceId, world.topology.stackingContextOf); + if (refSC < 0) return makePredicateResult('indeterminate'); const pass = subjectHasSC && refSC > 0 && sc === refSC; return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]); } @@ -829,6 +835,7 @@ export const attachedToScrollContainerPredicate: PredicateEvaluator = { return makePredicateResult('indeterminate'); } const scrollContainer = getTopologyValueBySubject(world, subjectId, world.topology.scrollContainerOf); + if (scrollContainer < 0) return makePredicateResult('indeterminate'); const pass = scrollContainer > 0 && scrollContainer === referenceId; return makePredicateResult(pass ? 'true' : 'false', { scrollContainer, referenceId }, [subjectId]); }, @@ -854,6 +861,7 @@ export const escapeClippingChainOfPredicate: PredicateEvaluator = { const overflowBottom = Math.max(0, sRect.bottom - clipRect.bottom); const overflow = overflowLeft + overflowTop + overflowRight + overflowBottom; const clippingRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf); + if (clippingRoot < 0) return makePredicateResult('indeterminate', { hasClippingRoot: 0 }, [subjectId, referenceId]); const clippedByReference = clippingRoot === referenceId; const pass = overflow > 0 && !clippedByReference && ancestry !== false; const metrics: Record = {