Files
Imhotep/packages/imhotep-playwright/src/extraction.ts
T

2049 lines
70 KiB
TypeScript
Raw Normal View History

/**
* 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,
BindingEnv,
} 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<ImhotepUi, (FluentRelation | FluentAssertion | FluentQuantifier)[]>()
const specStore = new WeakMap<ImhotepUi, string[]>()
const extractionPathStats = {
fastPathHits: 0,
cdpFallbacks: 0,
}
const pageCacheNamespace = new WeakMap<Page, string>()
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<string, unknown>
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
domAncestry: boolean
} {
const facts = new Set<string>()
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))
const needsDomAncestry = formulas.some((formula) => {
let found = false
const scan = (node: FormulaNode) => {
if (found) return
if (node.kind === 'forall' || node.kind === 'exists') {
for (const b of node.bindings) {
if (b.domain.parentVar) { found = true; return }
}
scan(node.body)
} else if (node.kind === 'and' || node.kind === 'or') {
scan(node.left); scan(node.right)
} else if (node.kind === 'not') {
scan(node.operand)
} else if (node.kind === 'implies') {
scan(node.antecedent); scan(node.consequent)
}
}
scan(formula)
return found
})
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'),
domAncestry: needsDomAncestry,
}
}
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<Map<string, number[]>> {
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<string, number[]>(measured)
}
export function attachMeasuredChWidths(
world: GeometryWorld,
selectorToIds: Map<string, number[]>,
chWidthsBySelector: Map<string, number[]>,
): 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<number, number>()
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; domAncestry: boolean },
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; 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,
contain: cs.contain,
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<string, number>()
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)
const csContain = (s as any).contain
let flags = 0
if (typeof csContain === 'string') {
for (const part of csContain.split(/\s+/)) {
switch (part) {
case 'layout': flags |= 1; break
case 'paint': flags |= 2; break
case 'size': flags |= 4; break
case 'style': flags |= 8; break
case 'strict': flags |= 15; break
case 'content': flags |= 7; break
case 'inline-size': flags |= 16; break
}
}
}
containFlags.push(flags)
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<string, number[]>(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; domAncestry: boolean },
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: ImhotepDiagnostic[] }> {
const errors: ImhotepDiagnostic[] = []
const selectorToNodeIds = new Map<string, number[]>()
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<string, number[]>()
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; domAncestry: boolean },
allowFastGeometry = false,
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; 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' : ''}${requiredFacts.domAncestry ? 'a' : ''}:${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
&& requiredFacts.domAncestry === 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 function buildAncestorIndex(world: GeometryWorld): Map<number, Set<number>> {
const ancestors = new Map<number, Set<number>>()
const nodeIdToSubjectId = new Map<number, number>()
for (let i = 0; i < world.subjects.ids.length; i++) {
const sid = world.subjects.ids[i]
const nodeId = world.subjects.domNodeId[i]
if (nodeId > 0) {
nodeIdToSubjectId.set(nodeId, sid)
}
}
if (nodeIdToSubjectId.size === 0) return ancestors
for (const [cid, sid] of nodeIdToSubjectId) {
const chain = new Set<number>()
let current = cid
while (current > 0) {
const pos = world.dom.nodeId.indexOf(current)
if (pos === -1) break
const parent = world.dom.parentNodeId[pos]
if (parent <= 0) break
const parentSid = nodeIdToSubjectId.get(parent)
if (parentSid !== undefined) {
chain.add(parentSid)
}
current = parent
}
if (chain.size > 0) {
ancestors.set(sid, chain)
}
}
return ancestors
}
export class SelectorDomainResolver implements DomainResolver {
private domains = new Map<string, DomainValue>()
private ancestorIndex?: Map<number, Set<number>>
setAncestorIndex(ancestors: Map<number, Set<number>>) {
this.ancestorIndex = ancestors
}
register(selector: string, subjectIds: number[]) {
this.domains.set(selector, {
domainId: `dom_${selector}`,
subjectIds: new Uint32Array(subjectIds),
provenance: `elements(${selector})`,
closed: true,
})
}
resolve(domain: DomainRef, env?: BindingEnv): DomainValue | undefined {
if (domain.parentVar) {
if (!env || !this.ancestorIndex) return undefined
const parentId = env.lookup(domain.parentVar)
if (parentId === undefined) return undefined
const selector = domain.selector ?? domain.domain
const globalDomain = this.domains.get(selector)
if (!globalDomain) return undefined
const filtered: number[] = []
for (const id of globalDomain.subjectIds) {
const ancestors = this.ancestorIndex.get(id)
if (ancestors?.has(parentId)) {
filtered.push(id)
}
}
const key = `descendants_p${parentId}_${selector}`
return {
domainId: `dom_${key}`,
subjectIds: new Uint32Array(filtered),
provenance: `descendants(parentId=${parentId}, ${selector})`,
closed: true,
}
}
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<string, unknown>
}
const subjectVar = { type: 'VariableRef' as const, name: '$subject' }
const referenceVar = { type: 'VariableRef' as const, name: '$reference' }
const options: Record<string, unknown> = {}
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<string, unknown> = {}
const atMostOpts: Record<string, unknown> = {}
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<string, unknown> = { ...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<string>()
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<string>()
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<string, unknown> = {}
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<string, number[]>,
): 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<string, DiagnosticCode> = {
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 },
metrics?: Record<string, number>,
): 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: 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, _env?) {
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<string, number>)
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<typeof evaluateLogic>,
assertionCount: number,
clauseId?: string,
traceMeta?: TraceMetadata,
): ImhotepResult {
const formulaMetricsById = new Map<string, Record<string, number> | undefined>()
for (const fr of folResult.formulaResults) {
formulaMetricsById.set(fr.formulaId, fr.metrics)
}
const diagnostics: ImhotepDiagnostic[] = folResult.diagnostics.map((d) => {
const metrics = d.clauseId ? formulaMetricsById.get(d.clauseId) : undefined
const mapped = mapFolDiagnostic(d, metrics)
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<string, unknown>
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,
}
}