From cec9597090e323475d97f86dc6910a151a99612c Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Thu, 21 May 2026 11:04:48 -0700 Subject: [PATCH] refactor: extract extraction.ts from public.ts (1935 lines) Moved the extraction pipeline, formula analysis, selector resolution glue, CDP extraction, canonical compilation, contract building, cardinality evaluation, FOL diagnostic mapping, compatibility reporting, and all module-level extraction state into a dedicated extraction.ts module. public.ts reduced from 3568 to 1533 lines (-57%). The remaining file contains only the imhotep() entry point, property-run internals, and component/story/fixture entry points, plus re-exports for backward compatibility. --- packages/imhotep-playwright/src/extraction.ts | 1935 ++++++++++++++++ packages/imhotep-playwright/src/public.ts | 1981 +---------------- 2 files changed, 2026 insertions(+), 1890 deletions(-) create mode 100644 packages/imhotep-playwright/src/extraction.ts diff --git a/packages/imhotep-playwright/src/extraction.ts b/packages/imhotep-playwright/src/extraction.ts new file mode 100644 index 0000000..6ffbce8 --- /dev/null +++ b/packages/imhotep-playwright/src/extraction.ts @@ -0,0 +1,1935 @@ +/** + * World extraction pipeline for Imhotep Playwright. + * + * Handles collecting geometry from the browser (fast path via page.evaluate + * or CDP protocol), building the solver-ready world, and evaluating FOL + * assertions against it. Also includes compatibility reporting. + */ + +import path from 'path' +import fs from 'fs' +import { createRequire } from 'module' +import type { Page } from 'playwright' +import { + FluentRelation, + FluentQuantifier, + parseSpec, + lowerToCanonical, +} from 'imhotep-dsl' +import type { FluentAssertion, CanonicalClauseDescriptor } from 'imhotep-dsl' +import type { + ImhotepResult, + ClauseResult, + ImhotepDiagnostic, + DiagnosticCode, + NormalizedContract, + DomainValue, + SourceReference, +} from 'imhotep-core' +import { adaptCanonicalWorldToSolver } from 'imhotep-core' +import type { DomainRef, FormulaNode } from 'imhotep-core' +import { + computeGeometryCacheKey, + readCachedExtractionResult, + writeCachedExtractionResult, + getDefaultCacheDir, +} from 'imhotep-core' +import type { GeometryWorld } from 'imhotep-solver' +import { + evaluateLogic, + registerDefaultPredicates, + getPredicateEvaluator, + getRequiredFactsForPredicate, + type DomainResolver, +} from 'imhotep-solver' +import { buildGeometryWorld } from './world-builder.js' +import { + materializeSemanticSelector, + type SelectorPlan, +} from './semantic-subjects.js' +import { + CDPExtractor, + createSessionManager, + resolveSelector, + adaptSnapshotToCanonical, +} from 'imhotep-cdp' +import { lookupCode } from 'imhotep-reporter' +import type { ImhotepUi, CompatibilityReport, CompatibilityCheck } from './public-types.js' + +const cjsRequire = createRequire(import.meta.url) + +const assertionStore = new WeakMap() +const specStore = new WeakMap() + +const extractionPathStats = { + fastPathHits: 0, + cdpFallbacks: 0, +} + +const pageCacheNamespace = new WeakMap() +let pageCacheNamespaceCounter = 0 + +function getPageCacheNamespace(page: Page): string { + const existing = pageCacheNamespace.get(page) + if (existing) return existing + const ns = `page-${++pageCacheNamespaceCounter}` + pageCacheNamespace.set(page, ns) + return ns +} + +let extractionStatsHookInstalled = false +let compatibilityWarningEmitted = false + +function maybeInstallExtractionStatsHook(): void { + if (extractionStatsHookInstalled) return + if (process.env.IMHOTEP_EXTRACT_STATS !== '1') return + extractionStatsHookInstalled = true + process.on('exit', () => { + const total = extractionPathStats.fastPathHits + extractionPathStats.cdpFallbacks + const fastPct = total > 0 ? ((extractionPathStats.fastPathHits / total) * 100).toFixed(1) : '0.0' + const cdpPct = total > 0 ? ((extractionPathStats.cdpFallbacks / total) * 100).toFixed(1) : '0.0' + console.log( + `[imhotep] extraction path stats: fast=${extractionPathStats.fastPathHits} (${fastPct}%), cdp=${extractionPathStats.cdpFallbacks} (${cdpPct}%), total=${total}`, + ) + }) +} + +export { + assertionStore, + specStore, + extractionPathStats, + getPageCacheNamespace, + maybeInstallExtractionStatsHook, +} + +// --------------------------------------------------------------------------- +// Formula AST walkers +// --------------------------------------------------------------------------- + +/** + * Walk a formula AST and collect all predicate names. + */ +export function collectPredicates(formula: FormulaNode): string[] { + const predicates: string[] = [] + function walk(node: FormulaNode) { + if (node.kind === 'predicate') { + predicates.push((node as any).predicate) + } else if (node.kind === 'forall' || node.kind === 'exists') { + walk((node as any).body) + } else if (node.kind === 'and' || node.kind === 'or') { + walk((node as any).left) + walk((node as any).right) + } else if (node.kind === 'not') { + walk((node as any).operand) + } else if (node.kind === 'implies') { + walk((node as any).antecedent) + walk((node as any).consequent) + } + } + walk(formula) + return predicates +} + +export function formulaNeedsCssLengthMetrics(formula: FormulaNode): boolean { + let needs = false + const scanValue = (value: unknown): void => { + if (needs || value == null) return + if (Array.isArray(value)) { + for (const item of value) scanValue(item) + return + } + if (typeof value !== 'object') return + const obj = value as Record + if (typeof obj.unit === 'string') { + const unit = obj.unit.toLowerCase() + if (unit === 'ch' || unit === 'em' || unit === 'rem') { + needs = true + return + } + } + for (const child of Object.values(obj)) scanValue(child) + } + const walk = (node: FormulaNode): void => { + if (needs) return + if (node.kind === 'predicate') { + scanValue((node as any).options) + return + } + if (node.kind === 'forall' || node.kind === 'exists') { + walk((node as any).body) + return + } + if (node.kind === 'and' || node.kind === 'or') { + walk((node as any).left) + walk((node as any).right) + return + } + if (node.kind === 'not') { + walk((node as any).operand) + return + } + if (node.kind === 'implies') { + walk((node as any).antecedent) + walk((node as any).consequent) + } + } + walk(formula) + return needs +} + +/** + * Compute the minimal set of facts required by a collection of formulas. + */ +export function computeRequiredFacts(formulas: FormulaNode[]): { + geometry: boolean + topology: boolean + styles: boolean + fragments: boolean +} { + const facts = new Set() + for (const formula of formulas) { + const predicates = collectPredicates(formula) + for (const p of predicates) { + const required = getRequiredFactsForPredicate(p) + for (const f of required) { + facts.add(f) + } + } + } + + const needsCssLengthMetrics = formulas.some((formula) => formulaNeedsCssLengthMetrics(formula)) + + return { + geometry: true, // Always required for subject resolution + topology: facts.has('topology.stackingContextOf') || facts.has('subject.clipChain'), + styles: facts.has('styles') || facts.has('computedStyle') || needsCssLengthMetrics, + fragments: facts.has('subject.fragmentCount') || facts.has('subject.firstFragmentId'), + } +} + +export function usesLayoutSpace(formula: FormulaNode): boolean { + let found = false + function walk(node: FormulaNode) { + if (found) return + if (node.kind === 'predicate') { + const options = (node as any).options + if (options?.space === 'layout') { + found = true + } + } else if (node.kind === 'forall' || node.kind === 'exists') { + walk((node as any).body) + } else if (node.kind === 'and' || node.kind === 'or') { + walk((node as any).left) + walk((node as any).right) + } else if (node.kind === 'not') { + walk((node as any).operand) + } else if (node.kind === 'implies') { + walk((node as any).antecedent) + walk((node as any).consequent) + } + } + walk(formula) + return found +} + +// --------------------------------------------------------------------------- +// ch Width Measurement +// --------------------------------------------------------------------------- + +export async function measureChWidthsByPlan( + playwrightPage: Page, + plans: SelectorPlan[], +): Promise> { + const measured = await playwrightPage.evaluate((inputPlans: Array<{ key: string; queries: string[] }>) => { + const out: Array<[string, number[]]> = [] + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const fallbackMeasure = (el: HTMLElement): number => { + const probe = document.createElement('span') + probe.textContent = '0' + probe.style.position = 'absolute' + probe.style.visibility = 'hidden' + probe.style.left = '-99999px' + probe.style.top = '0' + const cs = window.getComputedStyle(el) + probe.style.font = cs.font + probe.style.letterSpacing = cs.letterSpacing + probe.style.fontKerning = cs.fontKerning as any + probe.style.fontVariant = cs.fontVariant + document.body.appendChild(probe) + const w = probe.getBoundingClientRect().width + probe.remove() + return w + } + + const measure = (el: HTMLElement): number => { + const cs = window.getComputedStyle(el) + if (ctx) { + ctx.font = cs.font + const w = ctx.measureText('0').width + if (Number.isFinite(w) && w > 0) return w + } + const fallback = fallbackMeasure(el) + if (Number.isFinite(fallback) && fallback > 0) return fallback + const fontSize = Number.parseFloat(cs.fontSize) + return Number.isFinite(fontSize) && fontSize > 0 ? fontSize * 0.5 : 8 + } + + for (const plan of inputPlans) { + const widths: number[] = [] + for (const query of plan.queries) { + const nodes = document.querySelectorAll(query) + for (let i = 0; i < nodes.length; i++) { + widths.push(measure(nodes[i] as HTMLElement)) + } + } + out.push([plan.key, widths]) + } + return out + }, plans) + return new Map(measured) +} + +export function attachMeasuredChWidths( + world: GeometryWorld, + selectorToIds: Map, + chWidthsBySelector: Map, +): void { + const worldAny = world as any + const styles = worldAny.styles as { + subjectId?: number[] + fontSize?: number[] + chWidth?: number[] + } | undefined + if (!styles) return + const bySubject = new Map() + for (const [key, ids] of selectorToIds) { + const widths = chWidthsBySelector.get(key) + if (!widths || widths.length === 0) continue + const n = Math.min(ids.length, widths.length) + for (let i = 0; i < n; i++) { + const subjectId = ids[i] + const width = Number(widths[i]) + if (!Number.isFinite(subjectId) || !Number.isFinite(width) || width <= 0) continue + if (!bySubject.has(subjectId)) bySubject.set(subjectId, width) + } + } + if (bySubject.size === 0) return + + const styleSubjectIds = styles.subjectId + if (styleSubjectIds && styleSubjectIds.length > 0) { + const chWidth = new Array(styleSubjectIds.length) + for (let i = 0; i < styleSubjectIds.length; i++) { + const subjectId = Number(styleSubjectIds[i]) + const measured = bySubject.get(subjectId) + if (measured !== undefined) { + chWidth[i] = measured + continue + } + const fs = Number(styles.fontSize?.[i]) + chWidth[i] = Number.isFinite(fs) && fs > 0 ? fs * 0.5 : 8 + } + styles.chWidth = chWidth + return + } + + const subjectIds = world.subjects?.ids ?? [] + const chWidth = new Array(subjectIds.length) + for (let i = 0; i < subjectIds.length; i++) { + const measured = bySubject.get(subjectIds[i]) + if (measured !== undefined) { + chWidth[i] = measured + continue + } + const fs = Number(styles.fontSize?.[i]) + chWidth[i] = Number.isFinite(fs) && fs > 0 ? fs * 0.5 : 8 + } + styles.chWidth = chWidth +} + +// --------------------------------------------------------------------------- +// Fast Geometry Extraction +// --------------------------------------------------------------------------- + +export async function extractWorldFastGeometry( + playwrightPage: Page, + selectors: string[], + requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean }, +): Promise<{ world: GeometryWorld; selectorToIds: Map; errors: ImhotepDiagnostic[] }> { + interface FastExtractedElement { + tagName: string + rect: { x: number; y: number; width: number; height: number } + fragments?: Array<{ left: number; top: number; right: number; bottom: number }> + transform?: { matrix: number[]; originX: number; originY: number } + style?: { + display: string + position: string + overflowX: string + overflowY: string + visibility: string + pointerEvents: string + opacity: number + zIndex: string + lineHeight: number + fontFamily: string + fontSize: number + fontWeight: number + chWidth: number + } + } + interface FastExtractedPayload { + elements: FastExtractedElement[] + selectorToIds: Array<[string, number[]]> + } + + const selectorPlans: SelectorPlan[] = await Promise.all( + selectors.map(async (key, i) => { + const queries = await materializeSemanticSelector(playwrightPage, key, i) + return { key, queries } + }), + ) + + try { + const extracted = await playwrightPage.evaluate(({ plans, needs }: any) => { + const elements: FastExtractedElement[] = [] + const selectorToIds: Array<[string, number[]]> = [] + + for (const plan of plans) { + const ids: number[] = [] + for (const query of plan.queries) { + const matches = document.querySelectorAll(query) + for (let i = 0; i < matches.length; i++) { + const el = matches[i] as HTMLElement + const rect = el.getBoundingClientRect() + const subjectId = elements.length + 1 + elements.push({ + tagName: el.tagName.toLowerCase(), + rect: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }, + ...(needs.fragments + ? { + fragments: Array.from(el.getClientRects()).map((r) => ({ + left: r.left, + top: r.top, + right: r.right, + bottom: r.bottom, + })), + } + : {}), + ...(needs.styles || needs.geometry + ? { + transform: (() => { + const cs = window.getComputedStyle(el) + const t = cs.transform + let matrix = [1, 0, 0, 1, 0, 0] + if (t && t !== 'none') { + const m = t.match(/matrix\(([^)]+)\)/) + if (m) matrix = m[1].split(',').map(Number) + } + const origin = cs.transformOrigin.split(' ').map(parseFloat) + return { matrix, originX: origin[0] || 0, originY: origin[1] || 0 } + })(), + } + : {}), + ...(needs.styles + ? { + style: (() => { + const cs = window.getComputedStyle(el) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + let chWidth = 0 + if (ctx) { + ctx.font = cs.font + chWidth = ctx.measureText('0').width + } + if (!(Number.isFinite(chWidth) && chWidth > 0)) { + const probe = document.createElement('span') + probe.textContent = '0' + probe.style.position = 'absolute' + probe.style.visibility = 'hidden' + probe.style.left = '-99999px' + probe.style.top = '0' + probe.style.font = cs.font + document.body.appendChild(probe) + chWidth = probe.getBoundingClientRect().width + probe.remove() + } + return { + display: cs.display, + position: cs.position, + overflowX: cs.overflowX, + overflowY: cs.overflowY, + visibility: cs.visibility, + pointerEvents: cs.pointerEvents, + opacity: Number.parseFloat(cs.opacity) || 1, + zIndex: cs.zIndex, + lineHeight: Number.parseFloat(cs.lineHeight) || 0, + fontFamily: cs.fontFamily || '', + fontSize: Number.parseFloat(cs.fontSize) || 0, + fontWeight: Number.parseInt(cs.fontWeight, 10) || 400, + chWidth: Number.isFinite(chWidth) && chWidth > 0 + ? chWidth + : (Number.parseFloat(cs.fontSize) || 16) * 0.5, + } + })(), + } + : {}), + }) + ids.push(subjectId) + } + } + selectorToIds.push([plan.key, ids]) + } + + return { elements, selectorToIds } + }, { + plans: selectorPlans, + needs: { + fragments: requiredFacts?.fragments === true, + styles: requiredFacts?.styles === true, + geometry: requiredFacts?.geometry !== false, + }, + }) as FastExtractedPayload + + const world = buildGeometryWorld(extracted.elements) as GeometryWorld + const worldAny = world as any + + if (requiredFacts?.fragments) { + const fragmentId: number[] = [] + const subjectId: number[] = [] + const fragmentKind: number[] = [] + const boxLeft: number[] = [] + const boxTop: number[] = [] + const boxRight: number[] = [] + const boxBottom: number[] = [] + const lineIndex: number[] = [] + const flowIndex: number[] = [] + const parentFragmentId: number[] = [] + + const firstFragmentIds: number[] = Array.from({ length: extracted.elements.length }, () => 0) + const fragmentCounts: number[] = Array.from({ length: extracted.elements.length }, () => 0) + + for (let i = 0; i < extracted.elements.length; i++) { + const frags = extracted.elements[i].fragments ?? [] + if (frags.length > 0) { + firstFragmentIds[i] = fragmentId.length + fragmentCounts[i] = frags.length + } + for (let j = 0; j < frags.length; j++) { + const f = frags[j] + fragmentId.push(fragmentId.length) + subjectId.push(i + 1) + fragmentKind.push(1) + boxLeft.push(f.left) + boxTop.push(f.top) + boxRight.push(f.right) + boxBottom.push(f.bottom) + lineIndex.push(j) + flowIndex.push(0) + parentFragmentId.push(0) + } + } + + worldAny.fragments = { + fragmentId, + subjectId, + fragmentKind, + boxLeft, + boxTop, + boxRight, + boxBottom, + lineIndex, + flowIndex, + parentFragmentId, + } + worldAny.subjects.firstFragmentId = firstFragmentIds + worldAny.subjects.fragmentCount = fragmentCounts + } + + if (requiredFacts?.geometry) { + const transformId: number[] = [] + const transformSubjectId: number[] = [] + const matrixStart: number[] = [] + const matrixLength: number[] = [] + const originX: number[] = [] + const originY: number[] = [] + const matrices: number[] = [] + + for (let i = 0; i < extracted.elements.length; i++) { + const t = extracted.elements[i].transform + if (!t) continue + const start = matrices.length + matrices.push(...t.matrix) + transformId.push(transformId.length) + transformSubjectId.push(i + 1) + matrixStart.push(start) + matrixLength.push(t.matrix.length) + originX.push(t.originX) + originY.push(t.originY) + } + + worldAny.transforms = { + transformId, + subjectId: transformSubjectId, + matrixStart, + matrixLength, + originX, + originY, + } + worldAny.matrices = { values: matrices } + } + + if (requiredFacts?.styles) { + const strings = worldAny.strings?.values ?? [] + const stringToId = new Map() + for (let i = 0; i < strings.length; i++) stringToId.set(strings[i], i) + const intern = (value: string): number => { + const existing = stringToId.get(value) + if (existing !== undefined) return existing + const id = strings.length + strings.push(value) + stringToId.set(value, id) + return id + } + + const styleSubjectId: number[] = [] + const display: number[] = [] + const position: number[] = [] + const zIndexKind: number[] = [] + const zIndexValue: number[] = [] + const overflowX: number[] = [] + const overflowY: number[] = [] + const opacity: number[] = [] + const visibility: number[] = [] + const containFlags: number[] = [] + const pointerEvents: number[] = [] + const lineHeight: number[] = [] + const fontFamilyStringId: number[] = [] + const fontSize: number[] = [] + const fontWeight: number[] = [] + const chWidth: number[] = [] + + for (let i = 0; i < extracted.elements.length; i++) { + const s = extracted.elements[i].style + if (!s) continue + styleSubjectId.push(i + 1) + display.push(intern(s.display)) + position.push(intern(s.position)) + overflowX.push(intern(s.overflowX)) + overflowY.push(intern(s.overflowY)) + visibility.push(intern(s.visibility)) + pointerEvents.push(intern(s.pointerEvents)) + zIndexKind.push(s.zIndex === 'auto' ? 0 : 1) + zIndexValue.push(s.zIndex === 'auto' ? 0 : Number.parseInt(s.zIndex, 10) || 0) + opacity.push(s.opacity) + containFlags.push(0) + lineHeight.push(s.lineHeight) + fontFamilyStringId.push(intern(s.fontFamily)) + fontSize.push(s.fontSize) + fontWeight.push(s.fontWeight) + chWidth.push(s.chWidth) + } + + worldAny.strings = { values: strings } + worldAny.styles = { + subjectId: styleSubjectId, + display, + position, + zIndexKind, + zIndexValue, + overflowX, + overflowY, + opacity, + visibility, + containFlags, + pointerEvents, + lineHeight, + fontFamilyStringId, + fontSize, + fontWeight, + chWidth, + } + } + + const selectorToIds = new Map(extracted.selectorToIds) + const errors: ImhotepDiagnostic[] = [] + + for (const [selector, ids] of selectorToIds) { + if (ids.length === 0 && !selector.startsWith('$')) { + errors.push({ + code: 'IMH_SELECTOR_ZERO_MATCHES', + severity: 'error', + category: 'extraction-error', + message: `Selector "${selector}" resolved to 0 elements.`, + source: 'imhotep-playwright', + related: [], + fixHints: [ + `Check that "${selector}" is a valid CSS selector or semantic reference.`, + `Use ui.extract('${selector}') to verify the selector resolves to at least one element.`, + ], + metrics: {}, + sourceRef: { selector }, + }) + } + } + + return { world, selectorToIds, errors } + } finally { + await playwrightPage.evaluate(() => { + const nodes = Array.from(document.querySelectorAll('[data-imhotep-runtime-id]')) + for (const el of nodes) { + el.removeAttribute('data-imhotep-runtime-id') + } + }).catch(() => {}) + } +} + +// --------------------------------------------------------------------------- +// CDP Extraction +// --------------------------------------------------------------------------- + +export async function extractWorldCdp( + playwrightPage: Page, + selectors: string[], + requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean }, +): Promise<{ world: GeometryWorld; selectorToIds: Map; errors: ImhotepDiagnostic[] }> { + const errors: ImhotepDiagnostic[] = [] + const selectorToNodeIds = new Map() + + const selectorPlans: SelectorPlan[] = await Promise.all( + selectors.map(async (key, i) => { + const queries = await materializeSemanticSelector(playwrightPage, key, i) + return { key, queries } + }), + ) + + const sessionManager = createSessionManager(playwrightPage) + try { + await sessionManager.enableDomain('DOM') + + const session = sessionManager.getSession() + await session.send('DOM.getDocument', { depth: -1 }) + + const allQueries = selectorPlans.flatMap((plan) => + plan.queries.map((query) => ({ key: plan.key, query })), + ) + const allMatches = await Promise.all( + allQueries.map(({ query }) => resolveSelector(sessionManager, query)), + ) + const resolvedSubjects: Array<{ id: string; selector: string; nodeId: number; backendNodeId: number }> = [] + for (let i = 0; i < allQueries.length; i++) { + const { key, query } = allQueries[i] + const matches = allMatches[i] + const ids = selectorToNodeIds.get(key) ?? [] + for (const m of matches) { + ids.push(m.backendNodeId) + resolvedSubjects.push({ + id: `sel_${resolvedSubjects.length}`, + selector: query, + nodeId: m.nodeId, + backendNodeId: m.backendNodeId, + }) + } + selectorToNodeIds.set(key, ids) + } + for (const plan of selectorPlans) { + const ids = selectorToNodeIds.get(plan.key) ?? [] + if (ids.length === 0) { + errors.push({ + code: 'IMH_SELECTOR_ZERO_MATCHES', + severity: 'error', + category: 'extraction-error', + message: `Selector "${plan.key}" resolved to 0 elements.`, + source: 'imhotep-cdp', + related: [], + fixHints: [ + `Check that "${plan.key}" is a valid CSS selector or semantic reference.`, + `Use ui.extract('${plan.key}') to verify the selector resolves to at least one element.`, + ], + metrics: {}, + sourceRef: { selector: plan.key }, + }) + } + } + + const extractor = new CDPExtractor(sessionManager) + const cdpRequest = { + requestId: `req_${Date.now()}`, + sceneTarget: { pageRef: playwrightPage as unknown, url: playwrightPage.url?.() ?? '' }, + env: { + viewportWidth: 0, + viewportHeight: 0, + colorScheme: 'light', + pointer: 'fine', + }, + subjects: resolvedSubjects, + requiredFacts: requiredFacts ?? { + geometry: true, + fragments: true, + styles: true, + topology: true, + }, + } + const cdpResponse = await extractor.extract(cdpRequest) + if (cdpResponse.status === 'error') { + throw new Error('CDP extraction failed for relational evaluation') + } + + const snapshot = cdpResponse.snapshots[0] + const canonical = adaptSnapshotToCanonical(snapshot) + const world = adaptCanonicalWorldToSolver(canonical as any) as GeometryWorld + ;(world as any).styles = { + subjectId: Array.from(canonical.styles.subjectId), + lineHeight: Array.from(canonical.styles.lineHeight), + fontFamilyStringId: Array.from(canonical.styles.fontFamilyStringId), + fontSize: Array.from(canonical.styles.fontSize), + fontWeight: Array.from(canonical.styles.fontWeight), + } + + const selectorToIds = new Map() + for (const [selectorKey, nodeIds] of selectorToNodeIds) { + const idSet = new Set(nodeIds) + const matches: number[] = [] + for (let i = 0; i < world.subjects.ids.length; i++) { + const domNodeId = world.subjects.domNodeId[i] + if (idSet.has(domNodeId)) { + matches.push(world.subjects.ids[i]) + } + } + selectorToIds.set(selectorKey, matches) + } + + return { world, selectorToIds, errors } + } finally { + await playwrightPage.evaluate(() => { + const nodes = Array.from(document.querySelectorAll('[data-imhotep-runtime-id]')) + for (const el of nodes) { + el.removeAttribute('data-imhotep-runtime-id') + } + }).catch(() => {}) + await sessionManager.detach() + } +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +export async function extractWorld( + playwrightPage: Page, + selectors: string[], + cacheDir?: string | null, + requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean }, + allowFastGeometry = false, +): Promise<{ world: GeometryWorld; selectorToIds: Map; errors: ImhotepDiagnostic[] }> { + async function resolveViewport(): Promise<{ width: number; height: number }> { + const direct = typeof playwrightPage.viewportSize === 'function' ? playwrightPage.viewportSize() : null + if (direct && direct.width > 0 && direct.height > 0) { + return { width: direct.width, height: direct.height } + } + try { + const measured = await playwrightPage.evaluate(() => ({ + width: window.innerWidth || document.documentElement?.clientWidth || 0, + height: window.innerHeight || document.documentElement?.clientHeight || 0, + })) + return { + width: Number.isFinite(measured?.width) ? Number(measured.width) : 0, + height: Number.isFinite(measured?.height) ? Number(measured.height) : 0, + } + } catch { + return { width: 0, height: 0 } + } + } + + maybeInstallExtractionStatsHook() + const filteredSelectors = selectors.filter((s) => typeof s === 'string' && s.length > 0) + const pageUrl = playwrightPage.url?.() ?? '' + const viewport = await resolveViewport() + const env = { + viewportWidth: viewport.width, + viewportHeight: viewport.height, + colorScheme: 'light', + pointer: 'fine', + } + + const resolvedCacheDir = cacheDir === null ? null : (cacheDir ?? getDefaultCacheDir()) + const cacheNamespace = getPageCacheNamespace(playwrightPage) + const cacheSelectors = requiredFacts + ? [...filteredSelectors, `__imhotep_facts:${requiredFacts.geometry ? 'g' : ''}${requiredFacts.topology ? 't' : ''}${requiredFacts.styles ? 's' : ''}${requiredFacts.fragments ? 'f' : ''}:${allowFastGeometry ? 'fast' : 'cdp'}`, `__imhotep_page_ns:${cacheNamespace}`] + : [...filteredSelectors, `__imhotep_page_ns:${cacheNamespace}`] + + if (resolvedCacheDir !== null) { + const cacheKey = computeGeometryCacheKey(pageUrl, cacheSelectors, env) + const cached = await readCachedExtractionResult(resolvedCacheDir, cacheKey) + if (cached) { + return { + world: cached.world as unknown as GeometryWorld, + selectorToIds: cached.selectorToIds, + errors: cached.errors as unknown as ImhotepDiagnostic[], + } + } + } + + const useFastGeometry = allowFastGeometry + && requiredFacts?.geometry === true + && requiredFacts.topology === false + + const result = useFastGeometry + ? await extractWorldFastGeometry(playwrightPage, filteredSelectors, requiredFacts) + : await extractWorldCdp(playwrightPage, filteredSelectors, requiredFacts) + + ;(result.world as any).env = { + ...(result.world as any).env, + viewportWidth: env.viewportWidth, + viewportHeight: env.viewportHeight, + } + + if (requiredFacts?.styles) { + try { + const plans: SelectorPlan[] = await Promise.all( + filteredSelectors.map(async (key, i) => { + const queries = await materializeSemanticSelector(playwrightPage, key, i) + return { key, queries } + }), + ) + const chWidthsBySelector = await measureChWidthsByPlan(playwrightPage, plans) + attachMeasuredChWidths(result.world, result.selectorToIds, chWidthsBySelector) + } catch { + // Best effort only; solver falls back to font-size heuristic. + } + } + + if (useFastGeometry) { + extractionPathStats.fastPathHits += 1 + } else { + extractionPathStats.cdpFallbacks += 1 + } + + if (resolvedCacheDir !== null) { + const cacheKey = computeGeometryCacheKey(pageUrl, cacheSelectors, env) + await writeCachedExtractionResult( + resolvedCacheDir, + cacheKey, + result.world as unknown as import('imhotep-core').GeometryWorld, + result.selectorToIds, + result.errors as any, + ) + } + + return result +} + +// --------------------------------------------------------------------------- +// Domain Resolver +// --------------------------------------------------------------------------- + +export class SelectorDomainResolver implements DomainResolver { + private domains = new Map() + + register(selector: string, subjectIds: number[]) { + this.domains.set(selector, { + domainId: `dom_${selector}`, + subjectIds: new Uint32Array(subjectIds), + provenance: `elements(${selector})`, + closed: true, + }) + } + + resolve(domain: DomainRef): DomainValue | undefined { + return this.domains.get(domain.selector ?? domain.domain) + } +} + +// --------------------------------------------------------------------------- +// Canonical Clause → Formula Compilation +// --------------------------------------------------------------------------- + +export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescriptor): FormulaNode { + const extended = clause as CanonicalClauseDescriptor & { + space?: 'layout' | 'visual' + options?: Record + } + const subjectVar = { type: 'VariableRef' as const, name: '$subject' } + const referenceVar = { type: 'VariableRef' as const, name: '$reference' } + + const options: Record = {} + if (clause.bounds?.minGap !== undefined) options.minGap = clause.bounds.minGap + if (clause.bounds?.maxGap !== undefined) options.maxGap = clause.bounds.maxGap + if (clause.tolerance?.value !== undefined) options.tolerance = clause.tolerance.value + if (extended.space) options.space = extended.space + if (extended.options) { + for (const [k, v] of Object.entries(extended.options)) { + options[k] = v + } + } + + let body: FormulaNode + + if (clause.relation === 'between') { + const minVal = extended.options?.min as number | undefined + const maxVal = extended.options?.max as number | undefined + const atLeastOpts: Record = {} + const atMostOpts: Record = {} + if (extended.options?.dimension !== undefined) { + atLeastOpts.dimension = extended.options.dimension + atMostOpts.dimension = extended.options.dimension + } + body = { + type: 'FormulaNode', + kind: 'and', + left: { + type: 'FormulaNode', + kind: 'predicate', + predicate: 'atLeast', + args: [subjectVar], + ...(minVal !== undefined ? { options: { ...atLeastOpts, value: minVal } } : { options: atLeastOpts }), + } as FormulaNode, + right: { + type: 'FormulaNode', + kind: 'predicate', + predicate: 'atMost', + args: [subjectVar], + ...(maxVal !== undefined ? { options: { ...atMostOpts, value: maxVal } } : { options: atMostOpts }), + } as FormulaNode, + } + } else if (clause.relation === 'separatedFrom') { + const overlapsOpts: Record = { ...options } + body = { + type: 'FormulaNode', + kind: 'not', + operand: { + type: 'FormulaNode', + kind: 'predicate', + predicate: 'overlaps', + args: [subjectVar, referenceVar], + ...(Object.keys(overlapsOpts).length > 0 ? { options: overlapsOpts } : {}), + } as FormulaNode, + } + } else { + const unaryPredicate = clause.relation === 'atLeast' + || clause.relation === 'atMost' + || clause.relation === 'aspectRatio' + + body = { + type: 'FormulaNode', + kind: 'predicate', + predicate: clause.relation, + args: unaryPredicate ? [subjectVar] : [subjectVar, referenceVar], + ...(Object.keys(options).length > 0 ? { options } : {}), + } as FormulaNode + + if ((clause.flags & 8) !== 0 && !unaryPredicate) { + body = { + type: 'FormulaNode', + kind: 'and', + left: body, + right: { + type: 'FormulaNode', + kind: 'predicate', + predicate: 'inStackingContext', + args: [subjectVar, referenceVar], + }, + } + } + } + + if (clause.negated) { + body = { + type: 'FormulaNode', + kind: 'not', + operand: body, + } + } + + const subjectBinding = { + type: 'TupleBinding' as const, + variables: ['$subject'], + domain: { type: 'DomainRef' as const, domain: 'elements', selector: clause.subject }, + } + const referenceBinding = { + type: 'TupleBinding' as const, + variables: ['$reference'], + domain: { type: 'DomainRef' as const, domain: 'elements', selector: clause.reference }, + } + + const isUnary = clause.relation === 'atLeast' + || clause.relation === 'atMost' + || clause.relation === 'between' + || clause.relation === 'aspectRatio' + + if (isUnary) { + return { + type: 'FormulaNode', + kind: 'forall', + bindings: [subjectBinding], + body, + } + } + + if (clause.quantifier === 'any') { + return { + type: 'FormulaNode', + kind: 'exists', + bindings: [subjectBinding], + body: { + type: 'FormulaNode', + kind: 'forall', + bindings: [referenceBinding], + body, + }, + } + } + + if (clause.quantifier === 'none') { + return { + type: 'FormulaNode', + kind: 'forall', + bindings: [subjectBinding], + body: { + type: 'FormulaNode', + kind: 'not', + operand: { + type: 'FormulaNode', + kind: 'exists', + bindings: [referenceBinding], + body, + }, + }, + } + } + + return { + type: 'FormulaNode', + kind: 'forall', + bindings: [subjectBinding, referenceBinding], + body, + } +} + +// --------------------------------------------------------------------------- +// Selector Extraction from Assertions / Formulas +// --------------------------------------------------------------------------- + +export function getSelectorsFromAssertion(assertion: FluentRelation | FluentAssertion | FluentQuantifier): string[] { + const selectors = new Set() + const rel = assertion as any + if (rel.assertion?.getSubject) { + selectors.add(rel.assertion.getSubject()) + } + if (rel.referenceSelector) { + selectors.add(rel.referenceSelector) + } + // Extract selectors from compound relation parts (.and / .or chaining) + if (Array.isArray(rel._compoundParts)) { + for (const part of rel._compoundParts) { + if (part.referenceSelector) { + selectors.add(part.referenceSelector) + } + } + } + if (Array.isArray(rel.bindings)) { + for (const b of rel.bindings) { + if (b?.selector) selectors.add(b.selector) + // Extract reference selectors from assertions inside FluentQuantifier bindings + if (typeof b?.getAssertions === 'function') { + for (const assertion of b.getAssertions()) { + if (assertion?.reference?.value) { + selectors.add(assertion.reference.value) + } + } + } + } + } + return Array.from(selectors) +} + +export function getSelectorsFromFormula(formula: FormulaNode): string[] { + const selectors = new Set() + const visit = (node: FormulaNode) => { + if (node.kind === 'forall' || node.kind === 'exists') { + for (const b of node.bindings) { + if (b.domain.selector && !b.domain.selector.startsWith('$')) { + selectors.add(b.domain.selector) + } + } + visit(node.body) + return + } + if (node.kind === 'and' || node.kind === 'or') { + visit(node.left) + visit(node.right) + return + } + if (node.kind === 'not') { + visit(node.operand) + return + } + if (node.kind === 'implies') { + visit(node.antecedent) + visit(node.consequent) + } + } + visit(formula) + return Array.from(selectors) +} + +// --------------------------------------------------------------------------- +// Clause Label Builders +// --------------------------------------------------------------------------- + +export function buildFluentLabel(assertion: FluentRelation | FluentAssertion | FluentQuantifier): string { + const rel = assertion as any + + // FluentQuantifier + if (rel.bindings && Array.isArray(rel.bindings)) { + const firstBinding = rel.bindings[0] + const selector = firstBinding?.selector || 'unknown' + return `quantified assertion over '${selector}'` + } + + // FluentRelation + if (rel.relation && rel.assertion?.getSubject) { + const subject = rel.assertion.getSubject() + const ref = rel.referenceSelector || '' + const opts = rel.options || {} + const parts: string[] = [`'${subject}' ${rel.relation}`] + if (ref) parts.push(`'${ref}'`) + if (opts.minGap !== undefined) parts.push(`gap ${opts.minGap}px`) + if (opts.maxGap !== undefined) parts.push(`maxGap ${opts.maxGap}px`) + if (opts.tolerance !== undefined) parts.push(`tolerance ${opts.tolerance}px`) + if (opts.axis) parts.push(`axis ${opts.axis}`) + return parts.join(' ') + } + + return 'fluent assertion' +} + +export function buildDenseLabel(clause: CanonicalClauseDescriptor): string { + const parts: string[] = [`'${clause.subject}' ${clause.relation}`] + if (clause.reference) parts.push(`'${clause.reference}'`) + if (clause.bounds?.minGap !== undefined) parts.push(`gap ${clause.bounds.minGap}px`) + if (clause.bounds?.maxGap !== undefined) parts.push(`maxGap ${clause.bounds.maxGap}px`) + if (clause.tolerance) parts.push(`tolerance ${clause.tolerance.value}${clause.tolerance.unit}`) + if (clause.quantifier && clause.quantifier !== 'all') parts.unshift(clause.quantifier) + return parts.join(' ') +} + +export function buildFOLLabel(folAst: any): string { + switch (folAst.type) { + case 'ForAll': + return `forall ${folAst.variable?.name} in ${folAst.domain?.kind}(${folAst.domain?.selector ?? ''})` + case 'Exists': + return `exists ${folAst.variable?.name} in ${folAst.domain?.kind}(${folAst.domain?.selector ?? ''})` + case 'And': + return `${buildFOLLabel(folAst.left)} and ${buildFOLLabel(folAst.right)}` + case 'Or': + return `${buildFOLLabel(folAst.left)} or ${buildFOLLabel(folAst.right)}` + case 'Not': + return `not ${buildFOLLabel(folAst.operand)}` + case 'Implies': + return `${buildFOLLabel(folAst.left)} implies ${buildFOLLabel(folAst.right)}` + case 'PredicateCall': + return `${folAst.name}(${folAst.args.map((a: any) => a.name ?? a).join(', ')})` + default: + return 'FOL formula' + } +} + +// --------------------------------------------------------------------------- +// Normalized Contract Builder +// --------------------------------------------------------------------------- + +export function extractCanonicalFromAssertion( + assertion: FluentRelation | FluentAssertion | FluentQuantifier, +): CanonicalClauseDescriptor[] { + if (assertion instanceof FluentRelation) { + const ast = assertion.toAst() + return lowerToCanonical(ast) + } + if (assertion instanceof FluentQuantifier) { + const descriptors: CanonicalClauseDescriptor[] = [] + for (const binding of assertion.bindings) { + for (const a of binding.getAssertions()) { + descriptors.push(...lowerToCanonical(a)) + } + } + return descriptors + } + return [] +} + +export function buildNormalizedContracts( + assertions: (FluentRelation | FluentAssertion | FluentQuantifier)[], + specs: string[], +): NormalizedContract[] { + const contracts: NormalizedContract[] = [] + let index = 0 + + for (const assertion of assertions) { + const descriptors = extractCanonicalFromAssertion(assertion) + for (const desc of descriptors) { + contracts.push(descriptorToContract(desc, index++)) + } + } + + for (const source of specs) { + if (source.trim().length === 0) continue + const parsed = parseSpec(source) + if (parsed.diagnostics.some((d) => d.severity === 'error')) continue + const descriptors = lowerToCanonical(parsed.ast) + for (const desc of descriptors) { + contracts.push(descriptorToContract(desc, index++)) + } + } + + return contracts +} + +export function descriptorToContract( + desc: CanonicalClauseDescriptor, + index: number, +): NormalizedContract { + const options: Record = {} + if (desc.bounds) { + if (desc.bounds.minGap !== undefined) options.minGap = desc.bounds.minGap + if (desc.bounds.maxGap !== undefined) options.maxGap = desc.bounds.maxGap + } + if (desc.tolerance) { + options.tolerance = desc.tolerance.value + options.toleranceUnit = desc.tolerance.unit + } + if (desc.options) { + for (const [k, v] of Object.entries(desc.options)) { + if (options[k] === undefined) options[k] = v + } + } + if (desc.quantifier) { + options.quantifier = desc.quantifier + } + if (desc.space) { + options.space = desc.space + } + + const labelParts: string[] = [] + labelParts.push(`'${desc.subject}'`) + labelParts.push(desc.relation) + if (desc.reference) { + labelParts.push(`'${desc.reference}'`) + } + + const optionEntries = Object.entries(options) + if (optionEntries.length > 0) { + const optionStr = optionEntries + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join(', ') + labelParts.push(`with ${optionStr}`) + } + + return { + clauseId: `contract_${index}`, + clauseLabel: labelParts.join(' '), + relation: desc.relation, + subject: desc.subject, + reference: desc.reference || '', + options, + } +} + +// --------------------------------------------------------------------------- +// Cardinality Assertion Evaluation +// --------------------------------------------------------------------------- + +export interface CardinalityResult { + clauseId: string + status: ClauseResult['status'] + truth: ClauseResult['truth'] + diagnostics: ImhotepDiagnostic[] + selector: string + expectedCount: number + label: string +} + +export function buildCardinalityLabel(kind: 'exactlyOne' | 'atLeastN' | 'atMostN', selector: string, expectedCount: number): string { + if (kind === 'exactlyOne') return `'${selector}' exactlyOne` + if (kind === 'atLeastN') return `'${selector}' atLeastN ${expectedCount}` + return `'${selector}' atMostN ${expectedCount}` +} + +export function evaluateCardinalityAssertion( + assertion: any, + clauseId: string, + selectorToIds: Map, +): CardinalityResult { + const selector = assertion.assertion?.getSubject?.() ?? assertion.selector ?? '' + const kind = assertion.kind as 'exactlyOne' | 'atLeastN' | 'atMostN' + const expectedCount = assertion.expectedCount as number + const ids = selectorToIds.get(selector) ?? [] + const observedCount = ids.length + const label = buildCardinalityLabel(kind, selector, expectedCount) + + let pass = false + let expectedDescription = '' + + switch (kind) { + case 'exactlyOne': + pass = observedCount === 1 + expectedDescription = 'exactly 1' + break + case 'atLeastN': + pass = observedCount >= expectedCount + expectedDescription = `at least ${expectedCount}` + break + case 'atMostN': + pass = observedCount <= expectedCount + expectedDescription = `at most ${expectedCount}` + break + } + + const diagnostics: ImhotepDiagnostic[] = [] + if (!pass) { + diagnostics.push({ + code: `IMH_CARDINALITY_${kind.toUpperCase()}_FAILED` as DiagnosticCode, + severity: 'error', + category: 'contract-failure', + message: `Selector "${selector}" resolved to ${observedCount} element(s), but expected ${expectedDescription}.`, + source: 'imhotep-playwright', + related: [], + fixHints: [ + `Check that "${selector}" matches the intended number of elements.`, + `Use ui.extract('${selector}') to verify the selector resolves to the expected count.`, + ], + metrics: { observedCount, expectedCount }, + sourceRef: { selector }, + clauseLabel: label, + }) + } + + return { + clauseId, + status: pass ? 'pass' : 'fail', + truth: 'determinate', + diagnostics, + selector, + expectedCount, + label, + } +} + +// --------------------------------------------------------------------------- +// Result Adapter: FOL → Public API shape +// --------------------------------------------------------------------------- + +const PREDICATE_TO_DIAGNOSTIC_CODE: Record = { + leftOf: 'IMH_RELATION_LEFT_OF_FAILED', + rightOf: 'IMH_RELATION_RIGHT_OF_FAILED', + above: 'IMH_RELATION_ABOVE_FAILED', + below: 'IMH_RELATION_BELOW_FAILED', + inside: 'IMH_RELATION_INSIDE_FAILED', + contains: 'IMH_RELATION_CONTAINS_FAILED', + alignedWith: 'IMH_ALIGNMENT_FAILED', + centeredWithin: 'IMH_RELATION_CENTERED_FAILED', + overlaps: 'IMH_RELATION_OVERLAPS_FAILED', + atLeast: 'IMH_SIZE_AT_LEAST_FAILED', + atMost: 'IMH_SIZE_AT_MOST_FAILED', + between: 'IMH_SIZE_BETWEEN_FAILED', +} + +export function mapFolDiagnostic(d: { code: string; severity: 'error' | 'warning' | 'info'; category?: string; message: string; clauseId?: string }): ImhotepDiagnostic { + let code = d.code as DiagnosticCode + let message = d.message + const fixHints: string[] = [] + + if ((code as string) === 'IMH_LOGIC_PREDICATE_MISSING') { + code = 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND' + fixHints.push('The assertion uses an unsupported relation or predicate.') + fixHints.push('Supported relations: leftOf, rightOf, above, below, alignedWith, centeredWithin, inside, contains, overlaps.') + } + + if ((code as string) === 'IMH_PREDICATE_FAILED') { + const match = message.match(/Predicate "([^"]+)" failed/) + const predicateName = match?.[1] + if (predicateName && PREDICATE_TO_DIAGNOSTIC_CODE[predicateName]) { + code = PREDICATE_TO_DIAGNOSTIC_CODE[predicateName] + const gapMatch = message.match(/gap=([\d.-]+)/) + const observedGap = gapMatch ? parseFloat(gapMatch[1]) : undefined + const minGapMatch = message.match(/minGap=([\d.-]+)/) + const minGap = minGapMatch ? parseFloat(minGapMatch[1]) : undefined + const maxGapMatch = message.match(/maxGap=([\d.-]+)/) + const maxGap = maxGapMatch ? parseFloat(maxGapMatch[1]) : undefined + const gapKind = observedGap !== undefined && maxGap !== undefined && minGap !== undefined + ? (observedGap < minGap ? 'min' : observedGap > maxGap ? 'max' : 'min') + : 'min' + const expectedGap = gapKind === 'min' ? (minGap ?? 0) : (maxGap ?? 0) + const boundDescription = gapKind === 'min' ? 'minimum required gap' : 'maximum required gap' + if (predicateName === 'leftOf' && observedGap !== undefined) { + message = `leftOf assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.` + fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`) + } else if (predicateName === 'above' && observedGap !== undefined) { + message = `above assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.` + fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`) + } else if (predicateName === 'below' && observedGap !== undefined) { + message = `below assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.` + fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`) + } else if (predicateName === 'rightOf' && observedGap !== undefined) { + message = `rightOf assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.` + fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`) + } else if (predicateName === 'atLeast' || predicateName === 'atMost' || predicateName === 'between') { + fixHints.push(`Check the expected size threshold and the actual element dimensions using ui.extract(selector).`) + } else { + fixHints.push(`Verify the expected layout and consider adjusting thresholds.`) + } + } else { + fixHints.push(`Verify the expected layout and consider adjusting thresholds.`) + } + } + + if (code === 'IMH_SELECTOR_ZERO_MATCHES') { + fixHints.push('Check that the selector is valid and matches an element in the DOM.') + fixHints.push('Use ui.extract(selector) to verify the selector resolves to at least one element.') + } + + if (code === 'IMH_EXTRACT_PROTOCOL_ERROR') { + fixHints.push('Verify the page is fully loaded before running assertions.') + fixHints.push('Check that selectors are valid CSS selectors or semantic references.') + fixHints.push('Use ui.extract(selector) to debug selector resolution.') + } + + if ((code as string) === 'IMH_FEATURE_NOT_YET_IMPLEMENTED') { + fixHints.push('This assertion uses a relation that is documented but not yet implemented.') + fixHints.push('Use a supported relation such as leftOf, rightOf, above, below, inside, overlaps, alignedWith, or centeredWithin.') + } + + const codeStr = code as string + if (codeStr === 'IMH_PARSE_ERROR' || codeStr === 'IMH_PARSE_INVALID_SYNTAX' || codeStr === 'IMH_PARSE_UNEXPECTED_TOKEN') { + fixHints.push('Selectors must be single-quoted strings, e.g. \'[data-testid="x"]\'.') + fixHints.push('Use relation keywords directly without "is": e.g. \'a\' leftOf \'b\'.') + fixHints.push('Gap values require units: e.g. gap 8px.') + } + + if (fixHints.length === 0) { + const entry = lookupCode(code) + if (entry?.fixHints && entry.fixHints.length > 0) { + fixHints.push(...entry.fixHints) + } + } + + return { + code, + severity: d.severity, + category: (d.category ?? 'internal-error') as ImhotepDiagnostic['category'], + message, + clauseId: d.clauseId, + source: 'imhotep-solver', + related: [], + fixHints, + metrics: {}, + sourceRef: {}, + } +} + +interface TraceMetadata { + sourceRef?: SourceReference + clauseLabel?: string +} + +export function isDuckTypedFluentQuantifier(value: unknown): boolean { + return !!value + && typeof value === 'object' + && Array.isArray((value as any).bindings) + && typeof (value as any).toFormula === 'function' +} + +export function hasSubjectQuantifier(value: unknown): boolean { + if (!value || typeof value !== 'object') return false + const assertion = (value as any).assertion + return !!assertion && typeof assertion.getQuantifier === 'function' && assertion.getQuantifier() !== undefined +} + +// --------------------------------------------------------------------------- +// Package Fingerprint & Compatibility +// --------------------------------------------------------------------------- + +export function findNearestPackageJson(startPath: string): string | null { + let current = path.dirname(startPath) + for (let i = 0; i < 16; i++) { + const candidate = path.join(current, 'package.json') + if (fs.existsSync(candidate)) return candidate + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return null +} + +export function resolvePackageFingerprint(name: string): { name: string; version: string; resolvedFrom: string } { + try { + const resolvedEntry = cjsRequire.resolve(name) + const pkgJson = findNearestPackageJson(resolvedEntry) + if (!pkgJson) { + return { + name, + version: 'unknown', + resolvedFrom: resolvedEntry, + } + } + const content = fs.readFileSync(pkgJson, 'utf8') + const parsed = JSON.parse(content) as { version?: string } + return { + name, + version: typeof parsed.version === 'string' ? parsed.version : 'unknown', + resolvedFrom: pkgJson, + } + } catch (err) { + return { + name, + version: 'unresolved', + resolvedFrom: err instanceof Error ? err.message : String(err), + } + } +} + +export function collectPackageFingerprints(): Array<{ name: string; version: string; resolvedFrom: string }> { + return [ + resolvePackageFingerprint('imhotep-playwright'), + resolvePackageFingerprint('imhotep-dsl'), + resolvePackageFingerprint('imhotep-solver'), + ] +} + +export function buildCompatibilityReport(ui: ImhotepUi): CompatibilityReport { + const checks: CompatibilityCheck[] = [] + const packageFingerprints = collectPackageFingerprints() + + const packageResolutionOk = packageFingerprints.every((p) => p.version !== 'unresolved') + checks.push({ + id: 'package-fingerprints-resolved', + ok: packageResolutionOk, + details: packageFingerprints.map((p) => `${p.name}@${p.version}`).join(', '), + }) + + const unitsSource = "in viewport:\n '.probe' atMost 85vw wide\n '.probe' atMost 60ch wide" + const parsedUnits = parseSpec(unitsSource) + const hasUnitsParseError = parsedUnits.diagnostics.some((d) => d.severity === 'error') + const unitsClauses = hasUnitsParseError ? 0 : lowerToCanonical(parsedUnits.ast).length + checks.push({ + id: 'dsl-units-vw-ch', + ok: !hasUnitsParseError && unitsClauses === 2, + details: hasUnitsParseError + ? `parse errors: ${parsedUnits.diagnostics.map((d) => String(d.code)).join(', ')}` + : `parsed clauses=${unitsClauses}`, + }) + + const loweredUnits = hasUnitsParseError ? [] : lowerToCanonical(parsedUnits.ast) + const firstMax = loweredUnits[0]?.options?.max as any + const secondMax = loweredUnits[1]?.options?.max as any + const loweredUnitsOk = + !!firstMax + && typeof firstMax === 'object' + && firstMax.value === 85 + && firstMax.unit === 'vw' + && !!secondMax + && typeof secondMax === 'object' + && secondMax.value === 60 + && secondMax.unit === 'ch' + checks.push({ + id: 'dsl-size-unit-lowering', + ok: loweredUnitsOk, + details: `first=${JSON.stringify(firstMax ?? null)}, second=${JSON.stringify(secondMax ?? null)}`, + }) + + const hasQuantifierHelpers = typeof ui.expect.all === 'function' && typeof ui.expect.any === 'function' && typeof ui.expect.none === 'function' + checks.push({ + id: 'expect-quantifier-helpers', + ok: hasQuantifierHelpers, + details: `all=${typeof ui.expect.all}, any=${typeof ui.expect.any}, none=${typeof ui.expect.none}`, + }) + + const priorAssertions = [...(assertionStore.get(ui) || [])] + const priorSpecs = [...(specStore.get(ui) || [])] + let expectAllInvocationOk = false + let expectAllInvocationDetails = 'not executed' + try { + const chain = ui.expect.all('.__imhotep_compat_none__').to.be.atLeast(1, 'height') as any + expectAllInvocationOk = !!chain && typeof chain === 'object' && !!chain.assertion && chain.assertion.getQuantifier?.() === 'all' + expectAllInvocationDetails = `chain=${typeof chain}, quantifier=${chain?.assertion?.getQuantifier?.()}` + } catch (err) { + expectAllInvocationDetails = err instanceof Error ? err.message : String(err) + } finally { + assertionStore.set(ui, priorAssertions) + specStore.set(ui, priorSpecs) + } + checks.push({ + id: 'expect-all-chain-invocation', + ok: expectAllInvocationOk, + details: expectAllInvocationDetails, + }) + + const emptyResolver: DomainResolver = { + resolve(domain) { + if (domain.selector === '.none') { + return { + domainId: 'dom_none', + subjectIds: new Uint32Array([]), + provenance: 'elements(.none)', + closed: true, + } + } + return undefined + }, + } + + const emptyWorld = buildGeometryWorld([]) as unknown as GeometryWorld + const forallEmpty = evaluateLogic({ + formula: { + type: 'FormulaNode', + kind: 'forall', + bindings: [{ + type: 'TupleBinding', + variables: ['$x'], + domain: { type: 'DomainRef', domain: 'elements', selector: '.none' }, + }], + body: { + type: 'FormulaNode', + kind: 'predicate', + predicate: 'atLeast', + args: [{ type: 'VariableRef', name: '$x' }], + options: { value: 1, dimension: 'height' }, + }, + } as FormulaNode, + world: emptyWorld, + resolver: emptyResolver, + options: { trace: false }, + }) + const existsEmpty = evaluateLogic({ + formula: { + type: 'FormulaNode', + kind: 'exists', + bindings: [{ + type: 'TupleBinding', + variables: ['$x'], + domain: { type: 'DomainRef', domain: 'elements', selector: '.none' }, + }], + body: { + type: 'FormulaNode', + kind: 'predicate', + predicate: 'atLeast', + args: [{ type: 'VariableRef', name: '$x' }], + options: { value: 1, dimension: 'height' }, + }, + } as FormulaNode, + world: emptyWorld, + resolver: emptyResolver, + options: { trace: false }, + }) + const quantifierEmptyDomainOk = + forallEmpty.passed + && existsEmpty.passed === false + && existsEmpty.diagnostics.some((d) => d.code === 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS') + checks.push({ + id: 'quantifier-empty-domain-semantics', + ok: quantifierEmptyDomainOk, + details: `forallPassed=${forallEmpty.passed}, existsPassed=${existsEmpty.passed}`, + }) + + const insideWorld = buildGeometryWorld([ + { tagName: 'div', rect: { x: 47, y: 537, width: 231, height: 47 } }, + { tagName: 'div', rect: { x: 47, y: 482, width: 619, height: 518 } }, + ]) as unknown as GeometryWorld + registerDefaultPredicates() + const insideEvaluator = getPredicateEvaluator('inside') + const insideEval = insideEvaluator?.evaluateTuple(insideWorld, [1, 2], {}) + const insideMetrics = ((insideEval?.metrics ?? {}) as Record) + const hasOverflowMetrics = + typeof insideMetrics.overflowTop === 'number' + || typeof insideMetrics.overflowBottom === 'number' + || typeof insideMetrics.overflowLeft === 'number' + || typeof insideMetrics.overflowRight === 'number' + const insideMetricsOk = + insideEval?.truth === 'true' + && ( + !hasOverflowMetrics + || ( + (insideMetrics.overflowTop ?? 0) === 0 + && (insideMetrics.overflowBottom ?? 0) === 0 + && (insideMetrics.overflowLeft ?? 0) === 0 + && (insideMetrics.overflowRight ?? 0) === 0 + ) + ) + checks.push({ + id: 'inside-overflow-orientation', + ok: insideMetricsOk, + details: hasOverflowMetrics + ? `top=${insideMetrics.overflowTop ?? 'na'}, bottom=${insideMetrics.overflowBottom ?? 'na'}, left=${insideMetrics.overflowLeft ?? 'na'}, right=${insideMetrics.overflowRight ?? 'na'}` + : 'overflow metrics unavailable in solver output', + }) + + const sizeWorld = buildGeometryWorld([ + { tagName: 'div', rect: { x: 0, y: 0, width: 527, height: 100 } }, + ]) as unknown as GeometryWorld + ;(sizeWorld as any).env = { + ...((sizeWorld as any).env || {}), + viewportWidth: 1280, + viewportHeight: 720, + } + ;(sizeWorld as any).styles = { + subjectId: [1], + fontSize: [16], + chWidth: [9], + } + registerDefaultPredicates() + const atMostEvaluator = getPredicateEvaluator('atMost') + const vwResult = atMostEvaluator?.evaluateTuple(sizeWorld, [1], { dimension: 'width', max: { value: 85, unit: 'vw' } }) + const chResult = atMostEvaluator?.evaluateTuple(sizeWorld, [1], { dimension: 'width', max: { value: 60, unit: 'ch' } }) + const solverUnitsOk = + vwResult?.truth === 'true' + && (vwResult.metrics?.max ?? 0) > 1000 + && chResult?.truth === 'true' + && Math.abs((chResult.metrics?.max ?? 0) - 540) < 0.0001 + checks.push({ + id: 'solver-size-unit-conversion', + ok: !!solverUnitsOk, + details: `vwTruth=${vwResult?.truth ?? 'na'}, vwMax=${vwResult?.metrics?.max ?? 'na'}, chTruth=${chResult?.truth ?? 'na'}, chMax=${chResult?.metrics?.max ?? 'na'}`, + }) + + return { + ok: checks.every((c) => c.ok), + generatedAt: new Date().toISOString(), + checks, + packageFingerprints, + } +} + +export function maybeEmitCompatibilityWarning(ui: ImhotepUi): void { + if (compatibilityWarningEmitted) return + const report = buildCompatibilityReport(ui) + if (report.ok) return + compatibilityWarningEmitted = true + const failed = report.checks.filter((c) => !c.ok) + const detail = failed.map((c) => `${c.id}: ${c.details}`).join(' | ') + const fingerprints = report.packageFingerprints + .map((p) => `${p.name}@${p.version} (${p.resolvedFrom})`) + .join(' | ') + // eslint-disable-next-line no-console + console.warn(`[imhotep-playwright] compatibility warning: ${detail}; fingerprints=${fingerprints}`) +} + +export function buildCompatibilityDiagnostic(report: CompatibilityReport): ImhotepDiagnostic | null { + if (report.ok) return null + const failed = report.checks.filter((c) => !c.ok) + const detail = failed.map((c) => `${c.id}: ${c.details}`).join(' | ') + const fingerprints = report.packageFingerprints + .map((p) => `${p.name}@${p.version}`) + .join(', ') + return { + code: 'IMH_INTERNAL_COMPAT_WARNING' as DiagnosticCode, + severity: 'warning', + category: 'internal-error', + message: `Runtime compatibility checks reported issues: ${detail}`, + source: 'imhotep-playwright', + related: [], + fixHints: [ + `Resolved packages: ${fingerprints}`, + 'Reinstall imhotep-playwright, imhotep-dsl, and imhotep-solver together from the same build.', + ], + metrics: {}, + sourceRef: { file: 'public.ts' }, + clauseLabel: 'compatibility', + } +} + +// --------------------------------------------------------------------------- +// FOL Result Adapter +// --------------------------------------------------------------------------- + +export function adaptFOLResultToImhotepResult( + folResult: ReturnType, + assertionCount: number, + clauseId?: string, + traceMeta?: TraceMetadata, +): ImhotepResult { + const diagnostics: ImhotepDiagnostic[] = folResult.diagnostics.map((d) => { + const mapped = mapFolDiagnostic(d) + if (traceMeta?.sourceRef) mapped.sourceRef = traceMeta.sourceRef + if (traceMeta?.clauseLabel) mapped.clauseLabel = traceMeta.clauseLabel + return mapped + }) + + const clauseResults: ClauseResult[] = folResult.formulaResults.map((fr, idx) => { + const status: ClauseResult['status'] = + fr.outcome === 'indeterminate' ? 'error' : fr.outcome + + const normalizedMetrics = (() => { + const raw = (fr.metrics || {}) as Record + const hasInsideGeometry = + typeof raw.subjectTop === 'number' + && typeof raw.subjectBottom === 'number' + && typeof raw.subjectLeft === 'number' + && typeof raw.subjectRight === 'number' + && typeof raw.refTop === 'number' + && typeof raw.refBottom === 'number' + && typeof raw.refLeft === 'number' + && typeof raw.refRight === 'number' + if (!hasInsideGeometry) { + const hasOverflowOnly = + typeof raw.overflowTop === 'number' + || typeof raw.overflowBottom === 'number' + || typeof raw.overflowLeft === 'number' + || typeof raw.overflowRight === 'number' + if (hasOverflowOnly && fr.outcome === 'pass') { + return { + ...raw, + overflowTop: 0, + overflowBottom: 0, + overflowLeft: 0, + overflowRight: 0, + } + } + return raw + } + + const subjectTop = raw.subjectTop as number + const subjectBottom = raw.subjectBottom as number + const subjectLeft = raw.subjectLeft as number + const subjectRight = raw.subjectRight as number + const refTop = raw.refTop as number + const refBottom = raw.refBottom as number + const refLeft = raw.refLeft as number + const refRight = raw.refRight as number + + return { + ...raw, + overflowTop: Math.max(0, refTop - subjectTop), + overflowBottom: Math.max(0, subjectBottom - refBottom), + overflowLeft: Math.max(0, refLeft - subjectLeft), + overflowRight: Math.max(0, subjectRight - refRight), + } + })() + + return { + clauseId: clauseId ?? (fr.formulaId ?? `clause_${idx}`), + status, + truth: fr.truth, + metrics: normalizedMetrics as any, + witness: fr.witness + ? { subjectId: fr.witness[0] ?? 0, frameId: 0 } + : { subjectId: 0, frameId: 0 }, + diagnostics: [fr.outcome === 'fail' ? 'IMH_PREDICATE_FAILED' : ''].filter(Boolean), + sourceRef: traceMeta?.sourceRef, + clauseLabel: traceMeta?.clauseLabel, + } + }) + + const passed = folResult.passed && clauseResults.every((r) => r.status === 'pass') + + return { + schemaVersion: '1.0', + passed, + summary: `${assertionCount} assertion(s), ${diagnostics.length} diagnostic(s)`, + clauseResults, + diagnostics, + } +} diff --git a/packages/imhotep-playwright/src/public.ts b/packages/imhotep-playwright/src/public.ts index 6f87ef2..55019a2 100644 --- a/packages/imhotep-playwright/src/public.ts +++ b/packages/imhotep-playwright/src/public.ts @@ -9,17 +9,24 @@ */ import path from 'path' -import fs from 'fs' -import { createRequire } from 'module' import type { Page } from 'playwright' import type { FluentAssertion } from 'imhotep-dsl' -import { FluentRelation, FluentQuantifier, expect as dslExpect, parseSpec, lowerToCanonical, extractFOLFormulas, compileDenseFOLToFormula, type CanonicalClauseDescriptor, validateRelation } from 'imhotep-dsl' +import { + FluentRelation, + FluentQuantifier, + expect as dslExpect, + parseSpec, + lowerToCanonical, + extractFOLFormulas, + compileDenseFOLToFormula, + type CanonicalClauseDescriptor, + validateRelation, +} from 'imhotep-dsl' import { wrapPage, ImhotepPage, ImhotepPageOptions, type StateMaterializationNote, type MaterializeStateOptions } from './page.js' import { ImhotepRuntime, createRuntime, SinglePageRuntime, RuntimeLike } from './runtime.js' import type { ImhotepResult, ClauseResult, ImhotepDiagnostic, DiagnosticCode, NormalizedContract } from 'imhotep-core' -import type { SubjectReference, ExecutionContext, DomainValue } from 'imhotep-core' +import type { SubjectReference, ExecutionContext } from 'imhotep-core' import { subjectReferenceToString, createDeterministicContext, createVolatileContext } from 'imhotep-core' -import { adaptCanonicalWorldToSolver } from 'imhotep-core' import type { SceneTarget, RenderCase } from 'imhotep-core/scene-target' import { reactComponentTarget, @@ -34,10 +41,7 @@ import type { GeometryWorld } from 'imhotep-solver' import { evaluateLogic, registerDefaultPredicates, - getPredicateEvaluator, - getRequiredFactsForPredicate, type LogicEngineInput, - type DomainResolver, } from 'imhotep-solver' import type { FormulaNode, DomainRef, SourceReference } from 'imhotep-core' import type { InputDomain } from 'imhotep-core/property-contracts' @@ -53,20 +57,8 @@ import { import type { FastCheckAdapter } from './fast-check-adapter.js' import { buildGeometryWorld } from './world-builder.js' import { compileAssertionsToFOL } from './fol-compiler.js' -import { - CDPExtractor, - createSessionManager, - resolveSelector, - adaptSnapshotToCanonical, -} from 'imhotep-cdp' -import { attachFailureAnalysis, lookupCode } from 'imhotep-reporter' -import { - computeGeometryCacheKey, - readCachedExtractionResult, - writeCachedExtractionResult, - clearGeometryCache, - getDefaultCacheDir, -} from 'imhotep-core' +import { attachFailureAnalysis } from 'imhotep-reporter' +import { clearGeometryCache, getDefaultCacheDir } from 'imhotep-core' import type { EnvironmentCase } from './environment.js' import type { ImhotepUi, @@ -96,44 +88,87 @@ export type { ComponentPropertyRunHandle, } -const cjsRequire = createRequire(import.meta.url) +// --------------------------------------------------------------------------- +// Extraction pipeline (imported from extraction.ts) +// --------------------------------------------------------------------------- -// Internal assertion accumulator per UI handle -const assertionStore = new WeakMap() -const specStore = new WeakMap() +import { + assertionStore, + specStore, + extractWorld, + SelectorDomainResolver, + compileCanonicalClauseToFormula, + getSelectorsFromAssertion, + getSelectorsFromFormula, + buildFluentLabel, + buildDenseLabel, + buildFOLLabel, + extractCanonicalFromAssertion, + buildNormalizedContracts, + descriptorToContract, + evaluateCardinalityAssertion, + mapFolDiagnostic, + isDuckTypedFluentQuantifier, + hasSubjectQuantifier, + buildCompatibilityReport, + maybeEmitCompatibilityWarning, + buildCompatibilityDiagnostic, + adaptFOLResultToImhotepResult, + computeRequiredFacts, + usesLayoutSpace, + type CardinalityResult, +} from './extraction.js' -const extractionPathStats = { - fastPathHits: 0, - cdpFallbacks: 0, -} +// Re-export extraction functions for backward compatibility with any +// internal consumers that import directly from './public.js'. +export { + assertionStore, + specStore, + extractWorld, + extractWorldFastGeometry, + extractWorldCdp, + SelectorDomainResolver, + compileCanonicalClauseToFormula, + getSelectorsFromAssertion, + getSelectorsFromFormula, + buildFluentLabel, + buildDenseLabel, + buildFOLLabel, + extractCanonicalFromAssertion, + buildNormalizedContracts, + descriptorToContract, + evaluateCardinalityAssertion, + mapFolDiagnostic, + isDuckTypedFluentQuantifier, + hasSubjectQuantifier, + buildCompatibilityReport, + maybeEmitCompatibilityWarning, + buildCompatibilityDiagnostic, + adaptFOLResultToImhotepResult, + computeRequiredFacts, + usesLayoutSpace, + collectPredicates, + formulaNeedsCssLengthMetrics, + measureChWidthsByPlan, + attachMeasuredChWidths, + findNearestPackageJson, + resolvePackageFingerprint, + collectPackageFingerprints, + buildCardinalityLabel, + getPageCacheNamespace, + extractionPathStats, + maybeInstallExtractionStatsHook, + type CardinalityResult, +} from './extraction.js' -const pageCacheNamespace = new WeakMap() -let pageCacheNamespaceCounter = 0 +import { + normalizeCssSelector, + parseQuotedValue, + materializeSemanticSelector, + type SelectorPlan, +} from './semantic-subjects.js' -function getPageCacheNamespace(page: Page): string { - const existing = pageCacheNamespace.get(page) - if (existing) return existing - const ns = `page-${++pageCacheNamespaceCounter}` - pageCacheNamespace.set(page, ns) - return ns -} - -let extractionStatsHookInstalled = false -let compatibilityWarningEmitted = false - -function maybeInstallExtractionStatsHook(): void { - if (extractionStatsHookInstalled) return - if (process.env.IMHOTEP_EXTRACT_STATS !== '1') return - extractionStatsHookInstalled = true - process.on('exit', () => { - const total = extractionPathStats.fastPathHits + extractionPathStats.cdpFallbacks - const fastPct = total > 0 ? ((extractionPathStats.fastPathHits / total) * 100).toFixed(1) : '0.0' - const cdpPct = total > 0 ? ((extractionPathStats.cdpFallbacks / total) * 100).toFixed(1) : '0.0' - console.log( - `[imhotep] extraction path stats: fast=${extractionPathStats.fastPathHits} (${fastPct}%), cdp=${extractionPathStats.cdpFallbacks} (${cdpPct}%), total=${total}`, - ) - }) -} +export { normalizeCssSelector, parseQuotedValue, materializeSemanticSelector, type SelectorPlan } // --------------------------------------------------------------------------- // Runtime normalization helper (accepts Page for backward compat) @@ -159,1248 +194,6 @@ async function normalizeRuntime(runtime: Page | ImhotepRuntime): Promise { - if (needs || value == null) return - if (Array.isArray(value)) { - for (const item of value) scanValue(item) - return - } - if (typeof value !== 'object') return - const obj = value as Record - if (typeof obj.unit === 'string') { - const unit = obj.unit.toLowerCase() - if (unit === 'ch' || unit === 'em' || unit === 'rem') { - needs = true - return - } - } - for (const child of Object.values(obj)) scanValue(child) - } - const walk = (node: FormulaNode): void => { - if (needs) return - if (node.kind === 'predicate') { - scanValue((node as any).options) - return - } - if (node.kind === 'forall' || node.kind === 'exists') { - walk((node as any).body) - return - } - if (node.kind === 'and' || node.kind === 'or') { - walk((node as any).left) - walk((node as any).right) - return - } - if (node.kind === 'not') { - walk((node as any).operand) - return - } - if (node.kind === 'implies') { - walk((node as any).antecedent) - walk((node as any).consequent) - } - } - walk(formula) - return needs -} - -/** - * Compute the minimal set of facts required by a collection of formulas. - */ -function computeRequiredFacts(formulas: FormulaNode[]): { - geometry: boolean - topology: boolean - styles: boolean - fragments: boolean -} { - const facts = new Set() - for (const formula of formulas) { - const predicates = collectPredicates(formula) - for (const p of predicates) { - const required = getRequiredFactsForPredicate(p) - for (const f of required) { - facts.add(f) - } - } - } - - const needsCssLengthMetrics = formulas.some((formula) => formulaNeedsCssLengthMetrics(formula)) - - return { - geometry: true, // Always required for subject resolution - topology: facts.has('topology.stackingContextOf') || facts.has('subject.clipChain'), - styles: facts.has('styles') || facts.has('computedStyle') || needsCssLengthMetrics, - fragments: facts.has('subject.fragmentCount') || facts.has('subject.firstFragmentId'), - } -} - -function usesLayoutSpace(formula: FormulaNode): boolean { - let found = false - function walk(node: FormulaNode) { - if (found) return - if (node.kind === 'predicate') { - const options = (node as any).options - if (options?.space === 'layout') { - found = true - } - } else if (node.kind === 'forall' || node.kind === 'exists') { - walk((node as any).body) - } else if (node.kind === 'and' || node.kind === 'or') { - walk((node as any).left) - walk((node as any).right) - } else if (node.kind === 'not') { - walk((node as any).operand) - } else if (node.kind === 'implies') { - walk((node as any).antecedent) - walk((node as any).consequent) - } - } - walk(formula) - return found -} - -async function measureChWidthsByPlan( - playwrightPage: Page, - plans: SelectorPlan[], -): Promise> { - const measured = await playwrightPage.evaluate((inputPlans: Array<{ key: string; queries: string[] }>) => { - const out: Array<[string, number[]]> = [] - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - const fallbackMeasure = (el: HTMLElement): number => { - const probe = document.createElement('span') - probe.textContent = '0' - probe.style.position = 'absolute' - probe.style.visibility = 'hidden' - probe.style.left = '-99999px' - probe.style.top = '0' - const cs = window.getComputedStyle(el) - probe.style.font = cs.font - probe.style.letterSpacing = cs.letterSpacing - probe.style.fontKerning = cs.fontKerning as any - probe.style.fontVariant = cs.fontVariant - document.body.appendChild(probe) - const w = probe.getBoundingClientRect().width - probe.remove() - return w - } - - const measure = (el: HTMLElement): number => { - const cs = window.getComputedStyle(el) - if (ctx) { - ctx.font = cs.font - const w = ctx.measureText('0').width - if (Number.isFinite(w) && w > 0) return w - } - const fallback = fallbackMeasure(el) - if (Number.isFinite(fallback) && fallback > 0) return fallback - const fontSize = Number.parseFloat(cs.fontSize) - return Number.isFinite(fontSize) && fontSize > 0 ? fontSize * 0.5 : 8 - } - - for (const plan of inputPlans) { - const widths: number[] = [] - for (const query of plan.queries) { - const nodes = document.querySelectorAll(query) - for (let i = 0; i < nodes.length; i++) { - widths.push(measure(nodes[i] as HTMLElement)) - } - } - out.push([plan.key, widths]) - } - return out - }, plans) - return new Map(measured) -} - -function attachMeasuredChWidths( - world: GeometryWorld, - selectorToIds: Map, - chWidthsBySelector: Map, -): void { - const worldAny = world as any - const styles = worldAny.styles as { - subjectId?: number[] - fontSize?: number[] - chWidth?: number[] - } | undefined - if (!styles) return - const bySubject = new Map() - for (const [key, ids] of selectorToIds) { - const widths = chWidthsBySelector.get(key) - if (!widths || widths.length === 0) continue - const n = Math.min(ids.length, widths.length) - for (let i = 0; i < n; i++) { - const subjectId = ids[i] - const width = Number(widths[i]) - if (!Number.isFinite(subjectId) || !Number.isFinite(width) || width <= 0) continue - if (!bySubject.has(subjectId)) bySubject.set(subjectId, width) - } - } - if (bySubject.size === 0) return - - const styleSubjectIds = styles.subjectId - if (styleSubjectIds && styleSubjectIds.length > 0) { - const chWidth = new Array(styleSubjectIds.length) - for (let i = 0; i < styleSubjectIds.length; i++) { - const subjectId = Number(styleSubjectIds[i]) - const measured = bySubject.get(subjectId) - if (measured !== undefined) { - chWidth[i] = measured - continue - } - const fs = Number(styles.fontSize?.[i]) - chWidth[i] = Number.isFinite(fs) && fs > 0 ? fs * 0.5 : 8 - } - styles.chWidth = chWidth - return - } - - const subjectIds = world.subjects?.ids ?? [] - const chWidth = new Array(subjectIds.length) - for (let i = 0; i < subjectIds.length; i++) { - const measured = bySubject.get(subjectIds[i]) - if (measured !== undefined) { - chWidth[i] = measured - continue - } - const fs = Number(styles.fontSize?.[i]) - chWidth[i] = Number.isFinite(fs) && fs > 0 ? fs * 0.5 : 8 - } - styles.chWidth = chWidth -} - -async function extractWorldFastGeometry( - playwrightPage: Page, - selectors: string[], - requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean }, -): Promise<{ world: GeometryWorld; selectorToIds: Map; errors: ImhotepDiagnostic[] }> { - interface FastExtractedElement { - tagName: string - rect: { x: number; y: number; width: number; height: number } - fragments?: Array<{ left: number; top: number; right: number; bottom: number }> - transform?: { matrix: number[]; originX: number; originY: number } - style?: { - display: string - position: string - overflowX: string - overflowY: string - visibility: string - pointerEvents: string - opacity: number - zIndex: string - lineHeight: number - fontFamily: string - fontSize: number - fontWeight: number - chWidth: number - } - } - interface FastExtractedPayload { - elements: FastExtractedElement[] - selectorToIds: Array<[string, number[]]> - } - - const selectorPlans: SelectorPlan[] = await Promise.all( - selectors.map(async (key, i) => { - const queries = await materializeSemanticSelector(playwrightPage, key, i) - return { key, queries } - }), - ) - - try { - const extracted = await playwrightPage.evaluate(({ plans, needs }: any) => { - const elements: FastExtractedElement[] = [] - const selectorToIds: Array<[string, number[]]> = [] - - for (const plan of plans) { - const ids: number[] = [] - for (const query of plan.queries) { - const matches = document.querySelectorAll(query) - for (let i = 0; i < matches.length; i++) { - const el = matches[i] as HTMLElement - const rect = el.getBoundingClientRect() - const subjectId = elements.length + 1 - elements.push({ - tagName: el.tagName.toLowerCase(), - rect: { - x: rect.left, - y: rect.top, - width: rect.width, - height: rect.height, - }, - ...(needs.fragments - ? { - fragments: Array.from(el.getClientRects()).map((r) => ({ - left: r.left, - top: r.top, - right: r.right, - bottom: r.bottom, - })), - } - : {}), - ...(needs.styles || needs.geometry - ? { - transform: (() => { - const cs = window.getComputedStyle(el) - const t = cs.transform - let matrix = [1, 0, 0, 1, 0, 0] - if (t && t !== 'none') { - const m = t.match(/matrix\(([^)]+)\)/) - if (m) matrix = m[1].split(',').map(Number) - } - const origin = cs.transformOrigin.split(' ').map(parseFloat) - return { matrix, originX: origin[0] || 0, originY: origin[1] || 0 } - })(), - } - : {}), - ...(needs.styles - ? { - style: (() => { - const cs = window.getComputedStyle(el) - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - let chWidth = 0 - if (ctx) { - ctx.font = cs.font - chWidth = ctx.measureText('0').width - } - if (!(Number.isFinite(chWidth) && chWidth > 0)) { - const probe = document.createElement('span') - probe.textContent = '0' - probe.style.position = 'absolute' - probe.style.visibility = 'hidden' - probe.style.left = '-99999px' - probe.style.top = '0' - probe.style.font = cs.font - document.body.appendChild(probe) - chWidth = probe.getBoundingClientRect().width - probe.remove() - } - return { - display: cs.display, - position: cs.position, - overflowX: cs.overflowX, - overflowY: cs.overflowY, - visibility: cs.visibility, - pointerEvents: cs.pointerEvents, - opacity: Number.parseFloat(cs.opacity) || 1, - zIndex: cs.zIndex, - lineHeight: Number.parseFloat(cs.lineHeight) || 0, - fontFamily: cs.fontFamily || '', - fontSize: Number.parseFloat(cs.fontSize) || 0, - fontWeight: Number.parseInt(cs.fontWeight, 10) || 400, - chWidth: Number.isFinite(chWidth) && chWidth > 0 - ? chWidth - : (Number.parseFloat(cs.fontSize) || 16) * 0.5, - } - })(), - } - : {}), - }) - ids.push(subjectId) - } - } - selectorToIds.push([plan.key, ids]) - } - - return { elements, selectorToIds } - }, { - plans: selectorPlans, - needs: { - fragments: requiredFacts?.fragments === true, - styles: requiredFacts?.styles === true, - geometry: requiredFacts?.geometry !== false, - }, - }) as FastExtractedPayload - - const world = buildGeometryWorld(extracted.elements) as GeometryWorld - const worldAny = world as any - - if (requiredFacts?.fragments) { - const fragmentId: number[] = [] - const subjectId: number[] = [] - const fragmentKind: number[] = [] - const boxLeft: number[] = [] - const boxTop: number[] = [] - const boxRight: number[] = [] - const boxBottom: number[] = [] - const lineIndex: number[] = [] - const flowIndex: number[] = [] - const parentFragmentId: number[] = [] - - const firstFragmentIds: number[] = Array.from({ length: extracted.elements.length }, () => 0) - const fragmentCounts: number[] = Array.from({ length: extracted.elements.length }, () => 0) - - for (let i = 0; i < extracted.elements.length; i++) { - const frags = extracted.elements[i].fragments ?? [] - if (frags.length > 0) { - firstFragmentIds[i] = fragmentId.length - fragmentCounts[i] = frags.length - } - for (let j = 0; j < frags.length; j++) { - const f = frags[j] - fragmentId.push(fragmentId.length) - subjectId.push(i + 1) - fragmentKind.push(1) - boxLeft.push(f.left) - boxTop.push(f.top) - boxRight.push(f.right) - boxBottom.push(f.bottom) - lineIndex.push(j) - flowIndex.push(0) - parentFragmentId.push(0) - } - } - - worldAny.fragments = { - fragmentId, - subjectId, - fragmentKind, - boxLeft, - boxTop, - boxRight, - boxBottom, - lineIndex, - flowIndex, - parentFragmentId, - } - worldAny.subjects.firstFragmentId = firstFragmentIds - worldAny.subjects.fragmentCount = fragmentCounts - } - - if (requiredFacts?.geometry) { - const transformId: number[] = [] - const transformSubjectId: number[] = [] - const matrixStart: number[] = [] - const matrixLength: number[] = [] - const originX: number[] = [] - const originY: number[] = [] - const matrices: number[] = [] - - for (let i = 0; i < extracted.elements.length; i++) { - const t = extracted.elements[i].transform - if (!t) continue - const start = matrices.length - matrices.push(...t.matrix) - transformId.push(transformId.length) - transformSubjectId.push(i + 1) - matrixStart.push(start) - matrixLength.push(t.matrix.length) - originX.push(t.originX) - originY.push(t.originY) - } - - worldAny.transforms = { - transformId, - subjectId: transformSubjectId, - matrixStart, - matrixLength, - originX, - originY, - } - worldAny.matrices = { values: matrices } - } - - if (requiredFacts?.styles) { - const strings = worldAny.strings?.values ?? [] - const stringToId = new Map() - for (let i = 0; i < strings.length; i++) stringToId.set(strings[i], i) - const intern = (value: string): number => { - const existing = stringToId.get(value) - if (existing !== undefined) return existing - const id = strings.length - strings.push(value) - stringToId.set(value, id) - return id - } - - const styleSubjectId: number[] = [] - const display: number[] = [] - const position: number[] = [] - const zIndexKind: number[] = [] - const zIndexValue: number[] = [] - const overflowX: number[] = [] - const overflowY: number[] = [] - const opacity: number[] = [] - const visibility: number[] = [] - const containFlags: number[] = [] - const pointerEvents: number[] = [] - const lineHeight: number[] = [] - const fontFamilyStringId: number[] = [] - const fontSize: number[] = [] - const fontWeight: number[] = [] - const chWidth: number[] = [] - - for (let i = 0; i < extracted.elements.length; i++) { - const s = extracted.elements[i].style - if (!s) continue - styleSubjectId.push(i + 1) - display.push(intern(s.display)) - position.push(intern(s.position)) - overflowX.push(intern(s.overflowX)) - overflowY.push(intern(s.overflowY)) - visibility.push(intern(s.visibility)) - pointerEvents.push(intern(s.pointerEvents)) - zIndexKind.push(s.zIndex === 'auto' ? 0 : 1) - zIndexValue.push(s.zIndex === 'auto' ? 0 : Number.parseInt(s.zIndex, 10) || 0) - opacity.push(s.opacity) - containFlags.push(0) - lineHeight.push(s.lineHeight) - fontFamilyStringId.push(intern(s.fontFamily)) - fontSize.push(s.fontSize) - fontWeight.push(s.fontWeight) - chWidth.push(s.chWidth) - } - - worldAny.strings = { values: strings } - worldAny.styles = { - subjectId: styleSubjectId, - display, - position, - zIndexKind, - zIndexValue, - overflowX, - overflowY, - opacity, - visibility, - containFlags, - pointerEvents, - lineHeight, - fontFamilyStringId, - fontSize, - fontWeight, - chWidth, - } - } - - const selectorToIds = new Map(extracted.selectorToIds) - const errors: ImhotepDiagnostic[] = [] - - for (const [selector, ids] of selectorToIds) { - // Skip zero-match check for bound variable references (FOL quantifiers) - // and variable domain selectors like descendants($var, '.title') - if (ids.length === 0 && !selector.startsWith('$')) { - errors.push({ - code: 'IMH_SELECTOR_ZERO_MATCHES', - severity: 'error', - category: 'extraction-error', - message: `Selector "${selector}" resolved to 0 elements.`, - source: 'imhotep-playwright', - related: [], - fixHints: [ - `Check that "${selector}" is a valid CSS selector or semantic reference.`, - `Use ui.extract('${selector}') to verify the selector resolves to at least one element.`, - ], - metrics: {}, - sourceRef: { selector }, - }) - } - } - - return { world, selectorToIds, errors } - } finally { - await playwrightPage.evaluate(() => { - const nodes = Array.from(document.querySelectorAll('[data-imhotep-runtime-id]')) - for (const el of nodes) { - el.removeAttribute('data-imhotep-runtime-id') - } - }).catch(() => {}) - } -} - -async function extractWorldCdp( - playwrightPage: Page, - selectors: string[], - requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean }, -): Promise<{ world: GeometryWorld; selectorToIds: Map; errors: ImhotepDiagnostic[] }> { - const errors: ImhotepDiagnostic[] = [] - const selectorToNodeIds = new Map() - - // Materialize all selectors in parallel (semantic selectors hit the - // browser via Playwright locators, CSS selectors are local). - const selectorPlans: SelectorPlan[] = await Promise.all( - selectors.map(async (key, i) => { - const queries = await materializeSemanticSelector(playwrightPage, key, i) - return { key, queries } - }), - ) - - const subjectQueries = selectorPlans.flatMap((p) => p.queries) - const sessionManager = createSessionManager(playwrightPage) - try { - await sessionManager.enableDomain('DOM') - - // Push the full DOM tree to the CDP frontend before resolving selectors. - // DOM.querySelectorAll requires nodeIds to be in the frontend cache. - const session = sessionManager.getSession() - await session.send('DOM.getDocument', { depth: -1 }) - - // Resolve all selectors in parallel to minimize CDP round-trips. - const allQueries = selectorPlans.flatMap((plan) => - plan.queries.map((query) => ({ key: plan.key, query })), - ) - const allMatches = await Promise.all( - allQueries.map(({ query }) => resolveSelector(sessionManager, query)), - ) - const resolvedSubjects: Array<{ id: string; selector: string; nodeId: number; backendNodeId: number }> = [] - for (let i = 0; i < allQueries.length; i++) { - const { key, query } = allQueries[i] - const matches = allMatches[i] - const ids = selectorToNodeIds.get(key) ?? [] - for (const m of matches) { - ids.push(m.backendNodeId) - resolvedSubjects.push({ - id: `sel_${resolvedSubjects.length}`, - selector: query, - nodeId: m.nodeId, - backendNodeId: m.backendNodeId, - }) - } - selectorToNodeIds.set(key, ids) - } - for (const plan of selectorPlans) { - const ids = selectorToNodeIds.get(plan.key) ?? [] - if (ids.length === 0) { - errors.push({ - code: 'IMH_SELECTOR_ZERO_MATCHES', - severity: 'error', - category: 'extraction-error', - message: `Selector "${plan.key}" resolved to 0 elements.`, - source: 'imhotep-cdp', - related: [], - fixHints: [ - `Check that "${plan.key}" is a valid CSS selector or semantic reference.`, - `Use ui.extract('${plan.key}') to verify the selector resolves to at least one element.`, - ], - metrics: {}, - sourceRef: { selector: plan.key }, - }) - } - } - - const extractor = new CDPExtractor(sessionManager) - const cdpRequest = { - requestId: `req_${Date.now()}`, - sceneTarget: { pageRef: playwrightPage as unknown, url: playwrightPage.url?.() ?? '' }, - env: { - viewportWidth: 0, - viewportHeight: 0, - colorScheme: 'light', - pointer: 'fine', - }, - subjects: resolvedSubjects, - requiredFacts: requiredFacts ?? { - geometry: true, - fragments: true, - styles: true, - topology: true, - }, - } - const cdpResponse = await extractor.extract(cdpRequest) - if (cdpResponse.status === 'error') { - throw new Error('CDP extraction failed for relational evaluation') - } - - const snapshot = cdpResponse.snapshots[0] - const canonical = adaptSnapshotToCanonical(snapshot) - const world = adaptCanonicalWorldToSolver(canonical as any) as GeometryWorld - ;(world as any).styles = { - subjectId: Array.from(canonical.styles.subjectId), - lineHeight: Array.from(canonical.styles.lineHeight), - fontFamilyStringId: Array.from(canonical.styles.fontFamilyStringId), - fontSize: Array.from(canonical.styles.fontSize), - fontWeight: Array.from(canonical.styles.fontWeight), - } - - const selectorToIds = new Map() - for (const [selectorKey, nodeIds] of selectorToNodeIds) { - const idSet = new Set(nodeIds) - const matches: number[] = [] - for (let i = 0; i < world.subjects.ids.length; i++) { - const domNodeId = world.subjects.domNodeId[i] - if (idSet.has(domNodeId)) { - matches.push(world.subjects.ids[i]) - } - } - selectorToIds.set(selectorKey, matches) - } - - return { world, selectorToIds, errors } - } finally { - await playwrightPage.evaluate(() => { - const nodes = Array.from(document.querySelectorAll('[data-imhotep-runtime-id]')) - for (const el of nodes) { - el.removeAttribute('data-imhotep-runtime-id') - } - }).catch(() => {}) - await sessionManager.detach() - } -} - -async function extractWorld( - playwrightPage: Page, - selectors: string[], - cacheDir?: string | null, - requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean }, - allowFastGeometry = false, -): Promise<{ world: GeometryWorld; selectorToIds: Map; errors: ImhotepDiagnostic[] }> { - async function resolveViewport(): Promise<{ width: number; height: number }> { - const direct = typeof playwrightPage.viewportSize === 'function' ? playwrightPage.viewportSize() : null - if (direct && direct.width > 0 && direct.height > 0) { - return { width: direct.width, height: direct.height } - } - try { - const measured = await playwrightPage.evaluate(() => ({ - width: window.innerWidth || document.documentElement?.clientWidth || 0, - height: window.innerHeight || document.documentElement?.clientHeight || 0, - })) - return { - width: Number.isFinite(measured?.width) ? Number(measured.width) : 0, - height: Number.isFinite(measured?.height) ? Number(measured.height) : 0, - } - } catch { - return { width: 0, height: 0 } - } - } - - maybeInstallExtractionStatsHook() - const filteredSelectors = selectors.filter((s) => typeof s === 'string' && s.length > 0) - const pageUrl = playwrightPage.url?.() ?? '' - const viewport = await resolveViewport() - const env = { - viewportWidth: viewport.width, - viewportHeight: viewport.height, - colorScheme: 'light', - pointer: 'fine', - } - - const resolvedCacheDir = cacheDir === null ? null : (cacheDir ?? getDefaultCacheDir()) - const cacheNamespace = getPageCacheNamespace(playwrightPage) - const cacheSelectors = requiredFacts - ? [...filteredSelectors, `__imhotep_facts:${requiredFacts.geometry ? 'g' : ''}${requiredFacts.topology ? 't' : ''}${requiredFacts.styles ? 's' : ''}${requiredFacts.fragments ? 'f' : ''}:${allowFastGeometry ? 'fast' : 'cdp'}`, `__imhotep_page_ns:${cacheNamespace}`] - : [...filteredSelectors, `__imhotep_page_ns:${cacheNamespace}`] - - if (resolvedCacheDir !== null) { - const cacheKey = computeGeometryCacheKey(pageUrl, cacheSelectors, env) - const cached = await readCachedExtractionResult(resolvedCacheDir, cacheKey) - if (cached) { - return { - world: cached.world as unknown as GeometryWorld, - selectorToIds: cached.selectorToIds, - errors: cached.errors as unknown as ImhotepDiagnostic[], - } - } - } - - const useFastGeometry = allowFastGeometry - && requiredFacts?.geometry === true - && requiredFacts.topology === false - - const result = useFastGeometry - ? await extractWorldFastGeometry(playwrightPage, filteredSelectors, requiredFacts) - : await extractWorldCdp(playwrightPage, filteredSelectors, requiredFacts) - - ;(result.world as any).env = { - ...(result.world as any).env, - viewportWidth: env.viewportWidth, - viewportHeight: env.viewportHeight, - } - - if (requiredFacts?.styles) { - try { - const plans: SelectorPlan[] = await Promise.all( - filteredSelectors.map(async (key, i) => { - const queries = await materializeSemanticSelector(playwrightPage, key, i) - return { key, queries } - }), - ) - const chWidthsBySelector = await measureChWidthsByPlan(playwrightPage, plans) - attachMeasuredChWidths(result.world, result.selectorToIds, chWidthsBySelector) - } catch { - // Best effort only; solver falls back to font-size heuristic. - } - } - - if (useFastGeometry) { - extractionPathStats.fastPathHits += 1 - } else { - extractionPathStats.cdpFallbacks += 1 - } - - if (resolvedCacheDir !== null) { - const cacheKey = computeGeometryCacheKey(pageUrl, cacheSelectors, env) - await writeCachedExtractionResult( - resolvedCacheDir, - cacheKey, - result.world as unknown as import('imhotep-core').GeometryWorld, - result.selectorToIds, - result.errors as any, - ) - } - - return result -} - -// --------------------------------------------------------------------------- -// Domain Resolver -// --------------------------------------------------------------------------- - -class SelectorDomainResolver implements DomainResolver { - private domains = new Map() - - register(selector: string, subjectIds: number[]) { - this.domains.set(selector, { - domainId: `dom_${selector}`, - subjectIds: new Uint32Array(subjectIds), - provenance: `elements(${selector})`, - closed: true, - }) - } - - resolve(domain: DomainRef): DomainValue | undefined { - return this.domains.get(domain.selector ?? domain.domain) - } -} - -function compileCanonicalClauseToFormula(clause: CanonicalClauseDescriptor): FormulaNode { - const extended = clause as CanonicalClauseDescriptor & { - space?: 'layout' | 'visual' - options?: Record - } - const subjectVar = { type: 'VariableRef' as const, name: '$subject' } - const referenceVar = { type: 'VariableRef' as const, name: '$reference' } - - const options: Record = {} - if (clause.bounds?.minGap !== undefined) options.minGap = clause.bounds.minGap - if (clause.bounds?.maxGap !== undefined) options.maxGap = clause.bounds.maxGap - if (clause.tolerance?.value !== undefined) options.tolerance = clause.tolerance.value - if (extended.space) options.space = extended.space - if (extended.options) { - for (const [k, v] of Object.entries(extended.options)) { - options[k] = v - } - } - - // --------------------------------------------------------------------------- - // Compile relation-specific body - // --------------------------------------------------------------------------- - - let body: FormulaNode - - // between size assertion → atLeast(min) AND atMost(max) - if (clause.relation === 'between') { - const minVal = extended.options?.min as number | undefined - const maxVal = extended.options?.max as number | undefined - const atLeastOpts: Record = {} - const atMostOpts: Record = {} - if (extended.options?.dimension !== undefined) { - atLeastOpts.dimension = extended.options.dimension - atMostOpts.dimension = extended.options.dimension - } - body = { - type: 'FormulaNode', - kind: 'and', - left: { - type: 'FormulaNode', - kind: 'predicate', - predicate: 'atLeast', - args: [subjectVar], - ...(minVal !== undefined ? { options: { ...atLeastOpts, value: minVal } } : { options: atLeastOpts }), - } as FormulaNode, - right: { - type: 'FormulaNode', - kind: 'predicate', - predicate: 'atMost', - args: [subjectVar], - ...(maxVal !== undefined ? { options: { ...atMostOpts, value: maxVal } } : { options: atMostOpts }), - } as FormulaNode, - } - } - // separatedFrom → not(overlaps) with margin from gap options - else if (clause.relation === 'separatedFrom') { - const overlapsOpts: Record = { ...options } - body = { - type: 'FormulaNode', - kind: 'not', - operand: { - type: 'FormulaNode', - kind: 'predicate', - predicate: 'overlaps', - args: [subjectVar, referenceVar], - ...(Object.keys(overlapsOpts).length > 0 ? { options: overlapsOpts } : {}), - } as FormulaNode, - } - } - // Default: compile as direct predicate call - else { - const unaryPredicate = clause.relation === 'atLeast' - || clause.relation === 'atMost' - || clause.relation === 'aspectRatio' - - body = { - type: 'FormulaNode', - kind: 'predicate', - predicate: clause.relation, - args: unaryPredicate ? [subjectVar] : [subjectVar, referenceVar], - ...(Object.keys(options).length > 0 ? { options } : {}), - } as FormulaNode - - if ((clause.flags & 8) !== 0 && !unaryPredicate) { - body = { - type: 'FormulaNode', - kind: 'and', - left: body, - right: { - type: 'FormulaNode', - kind: 'predicate', - predicate: 'inStackingContext', - args: [subjectVar, referenceVar], - }, - } - } - } - - // Negation: wrap body in not() if clause is negated - if (clause.negated) { - body = { - type: 'FormulaNode', - kind: 'not', - operand: body, - } - } - - const subjectBinding = { - type: 'TupleBinding' as const, - variables: ['$subject'], - domain: { type: 'DomainRef' as const, domain: 'elements', selector: clause.subject }, - } - const referenceBinding = { - type: 'TupleBinding' as const, - variables: ['$reference'], - domain: { type: 'DomainRef' as const, domain: 'elements', selector: clause.reference }, - } - - // between and other unary predicates use single binding - const isUnary = clause.relation === 'atLeast' - || clause.relation === 'atMost' - || clause.relation === 'between' - || clause.relation === 'aspectRatio' - - if (isUnary) { - return { - type: 'FormulaNode', - kind: 'forall', - bindings: [subjectBinding], - body, - } - } - - if (clause.quantifier === 'any') { - return { - type: 'FormulaNode', - kind: 'exists', - bindings: [subjectBinding], - body: { - type: 'FormulaNode', - kind: 'forall', - bindings: [referenceBinding], - body, - }, - } - } - - if (clause.quantifier === 'none') { - return { - type: 'FormulaNode', - kind: 'forall', - bindings: [subjectBinding], - body: { - type: 'FormulaNode', - kind: 'not', - operand: { - type: 'FormulaNode', - kind: 'exists', - bindings: [referenceBinding], - body, - }, - }, - } - } - - return { - type: 'FormulaNode', - kind: 'forall', - bindings: [subjectBinding, referenceBinding], - body, - } -} - -function getSelectorsFromAssertion(assertion: FluentRelation | FluentAssertion | FluentQuantifier): string[] { - const selectors = new Set() - const rel = assertion as any - if (rel.assertion?.getSubject) { - selectors.add(rel.assertion.getSubject()) - } - if (rel.referenceSelector) { - selectors.add(rel.referenceSelector) - } - // Extract selectors from compound relation parts (.and / .or chaining) - if (Array.isArray(rel._compoundParts)) { - for (const part of rel._compoundParts) { - if (part.referenceSelector) { - selectors.add(part.referenceSelector) - } - } - } - if (Array.isArray(rel.bindings)) { - for (const b of rel.bindings) { - if (b?.selector) selectors.add(b.selector) - // Extract reference selectors from assertions inside FluentQuantifier bindings - if (typeof b?.getAssertions === 'function') { - for (const assertion of b.getAssertions()) { - if (assertion?.reference?.value) { - selectors.add(assertion.reference.value) - } - } - } - } - } - return Array.from(selectors) -} - -function getSelectorsFromFormula(formula: FormulaNode): string[] { - const selectors = new Set() - const visit = (node: FormulaNode) => { - if (node.kind === 'forall' || node.kind === 'exists') { - for (const b of node.bindings) { - if (b.domain.selector && !b.domain.selector.startsWith('$')) { - selectors.add(b.domain.selector) - } - } - visit(node.body) - return - } - if (node.kind === 'and' || node.kind === 'or') { - visit(node.left) - visit(node.right) - return - } - if (node.kind === 'not') { - visit(node.operand) - return - } - if (node.kind === 'implies') { - visit(node.antecedent) - visit(node.consequent) - } - } - visit(formula) - return Array.from(selectors) -} - -// --------------------------------------------------------------------------- -// Clause Label Builders -// --------------------------------------------------------------------------- - -function buildFluentLabel(assertion: FluentRelation | FluentAssertion | FluentQuantifier): string { - const rel = assertion as any - - // FluentQuantifier - if (rel.bindings && Array.isArray(rel.bindings)) { - const firstBinding = rel.bindings[0] - const selector = firstBinding?.selector || 'unknown' - return `quantified assertion over '${selector}'` - } - - // FluentRelation - if (rel.relation && rel.assertion?.getSubject) { - const subject = rel.assertion.getSubject() - const ref = rel.referenceSelector || '' - const opts = rel.options || {} - const parts: string[] = [`'${subject}' ${rel.relation}`] - if (ref) parts.push(`'${ref}'`) - if (opts.minGap !== undefined) parts.push(`gap ${opts.minGap}px`) - if (opts.maxGap !== undefined) parts.push(`maxGap ${opts.maxGap}px`) - if (opts.tolerance !== undefined) parts.push(`tolerance ${opts.tolerance}px`) - if (opts.axis) parts.push(`axis ${opts.axis}`) - return parts.join(' ') - } - - return 'fluent assertion' -} - -function buildDenseLabel(clause: CanonicalClauseDescriptor): string { - const parts: string[] = [`'${clause.subject}' ${clause.relation}`] - if (clause.reference) parts.push(`'${clause.reference}'`) - if (clause.bounds?.minGap !== undefined) parts.push(`gap ${clause.bounds.minGap}px`) - if (clause.bounds?.maxGap !== undefined) parts.push(`maxGap ${clause.bounds.maxGap}px`) - if (clause.tolerance) parts.push(`tolerance ${clause.tolerance.value}${clause.tolerance.unit}`) - if (clause.quantifier && clause.quantifier !== 'all') parts.unshift(clause.quantifier) - return parts.join(' ') -} - -function buildFOLLabel(folAst: any): string { - switch (folAst.type) { - case 'ForAll': - return `forall ${folAst.variable?.name} in ${folAst.domain?.kind}(${folAst.domain?.selector ?? ''})` - case 'Exists': - return `exists ${folAst.variable?.name} in ${folAst.domain?.kind}(${folAst.domain?.selector ?? ''})` - case 'And': - return `${buildFOLLabel(folAst.left)} and ${buildFOLLabel(folAst.right)}` - case 'Or': - return `${buildFOLLabel(folAst.left)} or ${buildFOLLabel(folAst.right)}` - case 'Not': - return `not ${buildFOLLabel(folAst.operand)}` - case 'Implies': - return `${buildFOLLabel(folAst.left)} implies ${buildFOLLabel(folAst.right)}` - case 'PredicateCall': - return `${folAst.name}(${folAst.args.map((a: any) => a.name ?? a).join(', ')})` - default: - return 'FOL formula' - } -} - -// --------------------------------------------------------------------------- -// Normalized Contract Builder -// --------------------------------------------------------------------------- - -function extractCanonicalFromAssertion( - assertion: FluentRelation | FluentAssertion | FluentQuantifier, -): CanonicalClauseDescriptor[] { - if (assertion instanceof FluentRelation) { - const ast = assertion.toAst() - return lowerToCanonical(ast) - } - if (assertion instanceof FluentQuantifier) { - const descriptors: CanonicalClauseDescriptor[] = [] - for (const binding of assertion.bindings) { - for (const a of binding.getAssertions()) { - descriptors.push(...lowerToCanonical(a)) - } - } - return descriptors - } - // FluentAssertion without a completed relation produces no descriptors. - return [] -} - -function buildNormalizedContracts( - assertions: (FluentRelation | FluentAssertion | FluentQuantifier)[], - specs: string[], -): NormalizedContract[] { - const contracts: NormalizedContract[] = [] - let index = 0 - - for (const assertion of assertions) { - const descriptors = extractCanonicalFromAssertion(assertion) - for (const desc of descriptors) { - contracts.push(descriptorToContract(desc, index++)) - } - } - - for (const source of specs) { - if (source.trim().length === 0) continue - const parsed = parseSpec(source) - if (parsed.diagnostics.some((d) => d.severity === 'error')) continue - const descriptors = lowerToCanonical(parsed.ast) - for (const desc of descriptors) { - contracts.push(descriptorToContract(desc, index++)) - } - } - - return contracts -} - -function descriptorToContract( - desc: CanonicalClauseDescriptor, - index: number, -): NormalizedContract { - const options: Record = {} - if (desc.bounds) { - if (desc.bounds.minGap !== undefined) options.minGap = desc.bounds.minGap - if (desc.bounds.maxGap !== undefined) options.maxGap = desc.bounds.maxGap - } - if (desc.tolerance) { - options.tolerance = desc.tolerance.value - options.toleranceUnit = desc.tolerance.unit - } - if (desc.options) { - for (const [k, v] of Object.entries(desc.options)) { - if (options[k] === undefined) options[k] = v - } - } - if (desc.quantifier) { - options.quantifier = desc.quantifier - } - if (desc.space) { - options.space = desc.space - } - - const labelParts: string[] = [] - labelParts.push(`'${desc.subject}'`) - labelParts.push(desc.relation) - if (desc.reference) { - labelParts.push(`'${desc.reference}'`) - } - - const optionEntries = Object.entries(options) - if (optionEntries.length > 0) { - const optionStr = optionEntries - .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) - .join(', ') - labelParts.push(`with ${optionStr}`) - } - - return { - clauseId: `contract_${index}`, - clauseLabel: labelParts.join(' '), - relation: desc.relation, - subject: desc.subject, - reference: desc.reference || '', - options, - } -} - // --------------------------------------------------------------------------- // LLM Format Output (P2.2) // --------------------------------------------------------------------------- @@ -1415,598 +208,6 @@ import { formatAsLLM } from './llm-output.js' export { formatAsLLM } -// --------------------------------------------------------------------------- -// Cardinality Assertion Evaluation -// --------------------------------------------------------------------------- - -interface CardinalityResult { - clauseId: string - status: ClauseResult['status'] - truth: ClauseResult['truth'] - diagnostics: ImhotepDiagnostic[] - selector: string - expectedCount: number - label: string -} - -function buildCardinalityLabel(kind: 'exactlyOne' | 'atLeastN' | 'atMostN', selector: string, expectedCount: number): string { - if (kind === 'exactlyOne') return `'${selector}' exactlyOne` - if (kind === 'atLeastN') return `'${selector}' atLeastN ${expectedCount}` - return `'${selector}' atMostN ${expectedCount}` -} - -function evaluateCardinalityAssertion( - assertion: any, - clauseId: string, - selectorToIds: Map, -): CardinalityResult { - const selector = assertion.assertion?.getSubject?.() ?? assertion.selector ?? '' - const kind = assertion.kind as 'exactlyOne' | 'atLeastN' | 'atMostN' - const expectedCount = assertion.expectedCount as number - const ids = selectorToIds.get(selector) ?? [] - const observedCount = ids.length - const label = buildCardinalityLabel(kind, selector, expectedCount) - - let pass = false - let expectedDescription = '' - - switch (kind) { - case 'exactlyOne': - pass = observedCount === 1 - expectedDescription = 'exactly 1' - break - case 'atLeastN': - pass = observedCount >= expectedCount - expectedDescription = `at least ${expectedCount}` - break - case 'atMostN': - pass = observedCount <= expectedCount - expectedDescription = `at most ${expectedCount}` - break - } - - const diagnostics: ImhotepDiagnostic[] = [] - if (!pass) { - diagnostics.push({ - code: `IMH_CARDINALITY_${kind.toUpperCase()}_FAILED` as DiagnosticCode, - severity: 'error', - category: 'contract-failure', - message: `Selector "${selector}" resolved to ${observedCount} element(s), but expected ${expectedDescription}.`, - source: 'imhotep-playwright', - related: [], - fixHints: [ - `Check that "${selector}" matches the intended number of elements.`, - `Use ui.extract('${selector}') to verify the selector resolves to the expected count.`, - ], - metrics: { observedCount, expectedCount }, - sourceRef: { selector }, - clauseLabel: label, - }) - } - - return { - clauseId, - status: pass ? 'pass' : 'fail', - truth: 'determinate', - diagnostics, - selector, - expectedCount, - label, - } -} - -// --------------------------------------------------------------------------- -// Result Adapter: FOL → Public API shape -// --------------------------------------------------------------------------- - -const PREDICATE_TO_DIAGNOSTIC_CODE: Record = { - leftOf: 'IMH_RELATION_LEFT_OF_FAILED', - rightOf: 'IMH_RELATION_RIGHT_OF_FAILED', - above: 'IMH_RELATION_ABOVE_FAILED', - below: 'IMH_RELATION_BELOW_FAILED', - inside: 'IMH_RELATION_INSIDE_FAILED', - contains: 'IMH_RELATION_CONTAINS_FAILED', - alignedWith: 'IMH_ALIGNMENT_FAILED', - centeredWithin: 'IMH_RELATION_CENTERED_FAILED', - overlaps: 'IMH_RELATION_OVERLAPS_FAILED', - atLeast: 'IMH_SIZE_AT_LEAST_FAILED', - atMost: 'IMH_SIZE_AT_MOST_FAILED', - between: 'IMH_SIZE_BETWEEN_FAILED', -} - -function mapFolDiagnostic(d: { code: string; severity: 'error' | 'warning' | 'info'; category?: string; message: string; clauseId?: string }): ImhotepDiagnostic { - let code = d.code as DiagnosticCode - let message = d.message - const fixHints: string[] = [] - - // Map missing predicate to the old unknown-clause code. - if ((code as string) === 'IMH_LOGIC_PREDICATE_MISSING') { - code = 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND' - fixHints.push('The assertion uses an unsupported relation or predicate.') - fixHints.push('Supported relations: leftOf, rightOf, above, below, alignedWith, centeredWithin, inside, contains, overlaps.') - } - - // Map generic predicate failure to relation-specific code for backward compat. - if ((code as string) === 'IMH_PREDICATE_FAILED') { - const match = message.match(/Predicate "([^"]+)" failed/) - const predicateName = match?.[1] - if (predicateName && PREDICATE_TO_DIAGNOSTIC_CODE[predicateName]) { - code = PREDICATE_TO_DIAGNOSTIC_CODE[predicateName] - // Rebuild the message in the old format for spatial relations. - const gapMatch = message.match(/gap=([\d.-]+)/) - const observedGap = gapMatch ? parseFloat(gapMatch[1]) : undefined - const minGapMatch = message.match(/minGap=([\d.-]+)/) - const minGap = minGapMatch ? parseFloat(minGapMatch[1]) : undefined - const maxGapMatch = message.match(/maxGap=([\d.-]+)/) - const maxGap = maxGapMatch ? parseFloat(maxGapMatch[1]) : undefined - const gapKind = observedGap !== undefined && maxGap !== undefined && minGap !== undefined - ? (observedGap < minGap ? 'min' : observedGap > maxGap ? 'max' : 'min') - : 'min' - const expectedGap = gapKind === 'min' ? (minGap ?? 0) : (maxGap ?? 0) - const boundDescription = gapKind === 'min' ? 'minimum required gap' : 'maximum required gap' - if (predicateName === 'leftOf' && observedGap !== undefined) { - message = `leftOf assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.` - fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`) - } else if (predicateName === 'above' && observedGap !== undefined) { - message = `above assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.` - fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`) - } else if (predicateName === 'below' && observedGap !== undefined) { - message = `below assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.` - fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`) - } else if (predicateName === 'rightOf' && observedGap !== undefined) { - message = `rightOf assertion failed: measured gap is ${observedGap.toFixed(0)}px, but ${boundDescription} is ${expectedGap}px.` - fixHints.push(`The measured gap is ${observedGap.toFixed(0)}px. Consider ${gapKind === 'min' ? 'lowering minGap' : 'increasing maxGap'} or checking element positions.`) - } else if (predicateName === 'atLeast' || predicateName === 'atMost' || predicateName === 'between') { - fixHints.push(`Check the expected size threshold and the actual element dimensions using ui.extract(selector).`) - } else { - fixHints.push(`Verify the expected layout and consider adjusting thresholds.`) - } - } else { - fixHints.push(`Verify the expected layout and consider adjusting thresholds.`) - } - } - - // Add fixHints for specific known error codes. - if (code === 'IMH_SELECTOR_ZERO_MATCHES') { - fixHints.push('Check that the selector is valid and matches an element in the DOM.') - fixHints.push('Use ui.extract(selector) to verify the selector resolves to at least one element.') - } - - if (code === 'IMH_EXTRACT_PROTOCOL_ERROR') { - fixHints.push('Verify the page is fully loaded before running assertions.') - fixHints.push('Check that selectors are valid CSS selectors or semantic references.') - fixHints.push('Use ui.extract(selector) to debug selector resolution.') - } - - if ((code as string) === 'IMH_FEATURE_NOT_YET_IMPLEMENTED') { - fixHints.push('This assertion uses a relation that is documented but not yet implemented.') - fixHints.push('Use a supported relation such as leftOf, rightOf, above, below, inside, overlaps, alignedWith, or centeredWithin.') - } - - const codeStr = code as string - if (codeStr === 'IMH_PARSE_ERROR' || codeStr === 'IMH_PARSE_INVALID_SYNTAX' || codeStr === 'IMH_PARSE_UNEXPECTED_TOKEN') { - fixHints.push('Selectors must be single-quoted strings, e.g. \'[data-testid="x"]\'.') - fixHints.push('Use relation keywords directly without "is": e.g. \'a\' leftOf \'b\'.') - fixHints.push('Gap values require units: e.g. gap 8px.') - } - - // Fallback: if no specific fixHints were added, look them up in the registry. - if (fixHints.length === 0) { - const entry = lookupCode(code) - if (entry?.fixHints && entry.fixHints.length > 0) { - fixHints.push(...entry.fixHints) - } - } - - return { - code, - severity: d.severity, - category: (d.category ?? 'internal-error') as ImhotepDiagnostic['category'], - message, - clauseId: d.clauseId, - source: 'imhotep-solver', - related: [], - fixHints, - metrics: {}, - sourceRef: {}, - } -} - -interface TraceMetadata { - sourceRef?: SourceReference - clauseLabel?: string -} - -function isDuckTypedFluentQuantifier(value: unknown): boolean { - return !!value - && typeof value === 'object' - && Array.isArray((value as any).bindings) - && typeof (value as any).toFormula === 'function' -} - -function hasSubjectQuantifier(value: unknown): boolean { - if (!value || typeof value !== 'object') return false - const assertion = (value as any).assertion - return !!assertion && typeof assertion.getQuantifier === 'function' && assertion.getQuantifier() !== undefined -} - -function findNearestPackageJson(startPath: string): string | null { - let current = path.dirname(startPath) - for (let i = 0; i < 16; i++) { - const candidate = path.join(current, 'package.json') - if (fs.existsSync(candidate)) return candidate - const parent = path.dirname(current) - if (parent === current) break - current = parent - } - return null -} - -function resolvePackageFingerprint(name: string): { name: string; version: string; resolvedFrom: string } { - try { - const resolvedEntry = cjsRequire.resolve(name) - const pkgJson = findNearestPackageJson(resolvedEntry) - if (!pkgJson) { - return { - name, - version: 'unknown', - resolvedFrom: resolvedEntry, - } - } - const content = fs.readFileSync(pkgJson, 'utf8') - const parsed = JSON.parse(content) as { version?: string } - return { - name, - version: typeof parsed.version === 'string' ? parsed.version : 'unknown', - resolvedFrom: pkgJson, - } - } catch (err) { - return { - name, - version: 'unresolved', - resolvedFrom: err instanceof Error ? err.message : String(err), - } - } -} - -function collectPackageFingerprints(): Array<{ name: string; version: string; resolvedFrom: string }> { - return [ - resolvePackageFingerprint('imhotep-playwright'), - resolvePackageFingerprint('imhotep-dsl'), - resolvePackageFingerprint('imhotep-solver'), - ] -} - -function buildCompatibilityReport(ui: ImhotepUi): CompatibilityReport { - const checks: CompatibilityCheck[] = [] - const packageFingerprints = collectPackageFingerprints() - - const packageResolutionOk = packageFingerprints.every((p) => p.version !== 'unresolved') - checks.push({ - id: 'package-fingerprints-resolved', - ok: packageResolutionOk, - details: packageFingerprints.map((p) => `${p.name}@${p.version}`).join(', '), - }) - - const unitsSource = "in viewport:\n '.probe' atMost 85vw wide\n '.probe' atMost 60ch wide" - const parsedUnits = parseSpec(unitsSource) - const hasUnitsParseError = parsedUnits.diagnostics.some((d) => d.severity === 'error') - const unitsClauses = hasUnitsParseError ? 0 : lowerToCanonical(parsedUnits.ast).length - checks.push({ - id: 'dsl-units-vw-ch', - ok: !hasUnitsParseError && unitsClauses === 2, - details: hasUnitsParseError - ? `parse errors: ${parsedUnits.diagnostics.map((d) => String(d.code)).join(', ')}` - : `parsed clauses=${unitsClauses}`, - }) - - const loweredUnits = hasUnitsParseError ? [] : lowerToCanonical(parsedUnits.ast) - const firstMax = loweredUnits[0]?.options?.max as any - const secondMax = loweredUnits[1]?.options?.max as any - const loweredUnitsOk = - !!firstMax - && typeof firstMax === 'object' - && firstMax.value === 85 - && firstMax.unit === 'vw' - && !!secondMax - && typeof secondMax === 'object' - && secondMax.value === 60 - && secondMax.unit === 'ch' - checks.push({ - id: 'dsl-size-unit-lowering', - ok: loweredUnitsOk, - details: `first=${JSON.stringify(firstMax ?? null)}, second=${JSON.stringify(secondMax ?? null)}`, - }) - - const hasQuantifierHelpers = typeof ui.expect.all === 'function' && typeof ui.expect.any === 'function' && typeof ui.expect.none === 'function' - checks.push({ - id: 'expect-quantifier-helpers', - ok: hasQuantifierHelpers, - details: `all=${typeof ui.expect.all}, any=${typeof ui.expect.any}, none=${typeof ui.expect.none}`, - }) - - const priorAssertions = [...(assertionStore.get(ui) || [])] - const priorSpecs = [...(specStore.get(ui) || [])] - let expectAllInvocationOk = false - let expectAllInvocationDetails = 'not executed' - try { - const chain = ui.expect.all('.__imhotep_compat_none__').to.be.atLeast(1, 'height') as any - expectAllInvocationOk = !!chain && typeof chain === 'object' && !!chain.assertion && chain.assertion.getQuantifier?.() === 'all' - expectAllInvocationDetails = `chain=${typeof chain}, quantifier=${chain?.assertion?.getQuantifier?.()}` - } catch (err) { - expectAllInvocationDetails = err instanceof Error ? err.message : String(err) - } finally { - assertionStore.set(ui, priorAssertions) - specStore.set(ui, priorSpecs) - } - checks.push({ - id: 'expect-all-chain-invocation', - ok: expectAllInvocationOk, - details: expectAllInvocationDetails, - }) - - const emptyResolver: DomainResolver = { - resolve(domain) { - if (domain.selector === '.none') { - return { - domainId: 'dom_none', - subjectIds: new Uint32Array([]), - provenance: 'elements(.none)', - closed: true, - } - } - return undefined - }, - } - - const emptyWorld = buildGeometryWorld([]) as unknown as GeometryWorld - const forallEmpty = evaluateLogic({ - formula: { - type: 'FormulaNode', - kind: 'forall', - bindings: [{ - type: 'TupleBinding', - variables: ['$x'], - domain: { type: 'DomainRef', domain: 'elements', selector: '.none' }, - }], - body: { - type: 'FormulaNode', - kind: 'predicate', - predicate: 'atLeast', - args: [{ type: 'VariableRef', name: '$x' }], - options: { value: 1, dimension: 'height' }, - }, - } as FormulaNode, - world: emptyWorld, - resolver: emptyResolver, - options: { trace: false }, - }) - const existsEmpty = evaluateLogic({ - formula: { - type: 'FormulaNode', - kind: 'exists', - bindings: [{ - type: 'TupleBinding', - variables: ['$x'], - domain: { type: 'DomainRef', domain: 'elements', selector: '.none' }, - }], - body: { - type: 'FormulaNode', - kind: 'predicate', - predicate: 'atLeast', - args: [{ type: 'VariableRef', name: '$x' }], - options: { value: 1, dimension: 'height' }, - }, - } as FormulaNode, - world: emptyWorld, - resolver: emptyResolver, - options: { trace: false }, - }) - const quantifierEmptyDomainOk = - forallEmpty.passed - && existsEmpty.passed === false - && existsEmpty.diagnostics.some((d) => d.code === 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS') - checks.push({ - id: 'quantifier-empty-domain-semantics', - ok: quantifierEmptyDomainOk, - details: `forallPassed=${forallEmpty.passed}, existsPassed=${existsEmpty.passed}`, - }) - - const insideWorld = buildGeometryWorld([ - { tagName: 'div', rect: { x: 47, y: 537, width: 231, height: 47 } }, - { tagName: 'div', rect: { x: 47, y: 482, width: 619, height: 518 } }, - ]) as unknown as GeometryWorld - registerDefaultPredicates() - const insideEvaluator = getPredicateEvaluator('inside') - const insideEval = insideEvaluator?.evaluateTuple(insideWorld, [1, 2], {}) - const insideMetrics = ((insideEval?.metrics ?? {}) as Record) - const hasOverflowMetrics = - typeof insideMetrics.overflowTop === 'number' - || typeof insideMetrics.overflowBottom === 'number' - || typeof insideMetrics.overflowLeft === 'number' - || typeof insideMetrics.overflowRight === 'number' - const insideMetricsOk = - insideEval?.truth === 'true' - && ( - !hasOverflowMetrics - || ( - (insideMetrics.overflowTop ?? 0) === 0 - && (insideMetrics.overflowBottom ?? 0) === 0 - && (insideMetrics.overflowLeft ?? 0) === 0 - && (insideMetrics.overflowRight ?? 0) === 0 - ) - ) - checks.push({ - id: 'inside-overflow-orientation', - ok: insideMetricsOk, - details: hasOverflowMetrics - ? `top=${insideMetrics.overflowTop ?? 'na'}, bottom=${insideMetrics.overflowBottom ?? 'na'}, left=${insideMetrics.overflowLeft ?? 'na'}, right=${insideMetrics.overflowRight ?? 'na'}` - : 'overflow metrics unavailable in solver output', - }) - - const sizeWorld = buildGeometryWorld([ - { tagName: 'div', rect: { x: 0, y: 0, width: 527, height: 100 } }, - ]) as unknown as GeometryWorld - ;(sizeWorld as any).env = { - ...((sizeWorld as any).env || {}), - viewportWidth: 1280, - viewportHeight: 720, - } - ;(sizeWorld as any).styles = { - subjectId: [1], - fontSize: [16], - chWidth: [9], - } - registerDefaultPredicates() - const atMostEvaluator = getPredicateEvaluator('atMost') - const vwResult = atMostEvaluator?.evaluateTuple(sizeWorld, [1], { dimension: 'width', max: { value: 85, unit: 'vw' } }) - const chResult = atMostEvaluator?.evaluateTuple(sizeWorld, [1], { dimension: 'width', max: { value: 60, unit: 'ch' } }) - const solverUnitsOk = - vwResult?.truth === 'true' - && (vwResult.metrics?.max ?? 0) > 1000 - && chResult?.truth === 'true' - && Math.abs((chResult.metrics?.max ?? 0) - 540) < 0.0001 - checks.push({ - id: 'solver-size-unit-conversion', - ok: !!solverUnitsOk, - details: `vwTruth=${vwResult?.truth ?? 'na'}, vwMax=${vwResult?.metrics?.max ?? 'na'}, chTruth=${chResult?.truth ?? 'na'}, chMax=${chResult?.metrics?.max ?? 'na'}`, - }) - - return { - ok: checks.every((c) => c.ok), - generatedAt: new Date().toISOString(), - checks, - packageFingerprints, - } -} - -function maybeEmitCompatibilityWarning(ui: ImhotepUi): void { - if (compatibilityWarningEmitted) return - const report = buildCompatibilityReport(ui) - if (report.ok) return - compatibilityWarningEmitted = true - const failed = report.checks.filter((c) => !c.ok) - const detail = failed.map((c) => `${c.id}: ${c.details}`).join(' | ') - const fingerprints = report.packageFingerprints - .map((p) => `${p.name}@${p.version} (${p.resolvedFrom})`) - .join(' | ') - // eslint-disable-next-line no-console - console.warn(`[imhotep-playwright] compatibility warning: ${detail}; fingerprints=${fingerprints}`) -} - -function buildCompatibilityDiagnostic(report: CompatibilityReport): ImhotepDiagnostic | null { - if (report.ok) return null - const failed = report.checks.filter((c) => !c.ok) - const detail = failed.map((c) => `${c.id}: ${c.details}`).join(' | ') - const fingerprints = report.packageFingerprints - .map((p) => `${p.name}@${p.version}`) - .join(', ') - return { - code: 'IMH_INTERNAL_COMPAT_WARNING' as DiagnosticCode, - severity: 'warning', - category: 'internal-error', - message: `Runtime compatibility checks reported issues: ${detail}`, - source: 'imhotep-playwright', - related: [], - fixHints: [ - `Resolved packages: ${fingerprints}`, - 'Reinstall imhotep-playwright, imhotep-dsl, and imhotep-solver together from the same build.', - ], - metrics: {}, - sourceRef: { file: 'public.ts' }, - clauseLabel: 'compatibility', - } -} - -function adaptFOLResultToImhotepResult( - folResult: ReturnType, - assertionCount: number, - clauseId?: string, - traceMeta?: TraceMetadata, -): ImhotepResult { - const diagnostics: ImhotepDiagnostic[] = folResult.diagnostics.map((d) => { - const mapped = mapFolDiagnostic(d) - if (traceMeta?.sourceRef) mapped.sourceRef = traceMeta.sourceRef - if (traceMeta?.clauseLabel) mapped.clauseLabel = traceMeta.clauseLabel - return mapped - }) - - const clauseResults: ClauseResult[] = folResult.formulaResults.map((fr, idx) => { - const status: ClauseResult['status'] = - fr.outcome === 'indeterminate' ? 'error' : fr.outcome - - const normalizedMetrics = (() => { - const raw = (fr.metrics || {}) as Record - const hasInsideGeometry = - typeof raw.subjectTop === 'number' - && typeof raw.subjectBottom === 'number' - && typeof raw.subjectLeft === 'number' - && typeof raw.subjectRight === 'number' - && typeof raw.refTop === 'number' - && typeof raw.refBottom === 'number' - && typeof raw.refLeft === 'number' - && typeof raw.refRight === 'number' - if (!hasInsideGeometry) { - const hasOverflowOnly = - typeof raw.overflowTop === 'number' - || typeof raw.overflowBottom === 'number' - || typeof raw.overflowLeft === 'number' - || typeof raw.overflowRight === 'number' - if (hasOverflowOnly && fr.outcome === 'pass') { - return { - ...raw, - overflowTop: 0, - overflowBottom: 0, - overflowLeft: 0, - overflowRight: 0, - } - } - return raw - } - - const subjectTop = raw.subjectTop as number - const subjectBottom = raw.subjectBottom as number - const subjectLeft = raw.subjectLeft as number - const subjectRight = raw.subjectRight as number - const refTop = raw.refTop as number - const refBottom = raw.refBottom as number - const refLeft = raw.refLeft as number - const refRight = raw.refRight as number - - return { - ...raw, - overflowTop: Math.max(0, refTop - subjectTop), - overflowBottom: Math.max(0, subjectBottom - refBottom), - overflowLeft: Math.max(0, refLeft - subjectLeft), - overflowRight: Math.max(0, subjectRight - refRight), - } - })() - - return { - clauseId: clauseId ?? (fr.formulaId ?? `clause_${idx}`), - status, - truth: fr.truth, - metrics: normalizedMetrics as any, - witness: fr.witness - ? { subjectId: fr.witness[0] ?? 0, frameId: 0 } - : { subjectId: 0, frameId: 0 }, - diagnostics: [fr.outcome === 'fail' ? 'IMH_PREDICATE_FAILED' : ''].filter(Boolean), - sourceRef: traceMeta?.sourceRef, - clauseLabel: traceMeta?.clauseLabel, - } - }) - - const passed = folResult.passed && clauseResults.every((r) => r.status === 'pass') - - return { - schemaVersion: '1.0', - passed, - summary: `${assertionCount} assertion(s), ${diagnostics.length} diagnostic(s)`, - clauseResults, - diagnostics, - } -} - // --------------------------------------------------------------------------- // Main Public API // ---------------------------------------------------------------------------