v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)

This commit is contained in:
John Dvorak
2025-08-15 10:00:00 -07:00
commit 92deb689cd
321 changed files with 79170 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"name": "imhotep-bench",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"imhotep-core": "^1.0.0",
"imhotep-solver": "^1.0.0"
}
}
@@ -0,0 +1,53 @@
import { performance } from 'node:perf_hooks'
import { createEmptyWorld, adaptCanonicalWorldToSolver } from 'imhotep-core'
console.log('=== World Adaptation Benchmark ===\n')
for (const count of [10, 50, 100, 500, 1000, 5000]) {
const canonical = createEmptyWorld('scene', 'snap')
canonical.subjects.ids = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
canonical.subjects.domNodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
canonical.subjects.subjectKind = new Uint8Array(Array.from({length: count}, () => 1))
canonical.subjects.primaryBoxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
canonical.subjects.firstFragmentId = new Uint32Array(Array.from({length: count}, () => 0))
canonical.subjects.fragmentCount = new Uint8Array(Array.from({length: count}, () => 1))
canonical.boxes.boxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
canonical.boxes.subjectId = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
canonical.boxes.frameId = new Uint32Array(Array.from({length: count}, () => 0))
canonical.boxes.borderLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10))
canonical.boxes.borderTop = new Float64Array(Array.from({length: count}, () => 0))
canonical.boxes.borderRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 50))
canonical.boxes.borderBottom = new Float64Array(Array.from({length: count}, () => 50))
canonical.boxes.paddingLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 2))
canonical.boxes.paddingTop = new Float64Array(Array.from({length: count}, () => 2))
canonical.boxes.paddingRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 48))
canonical.boxes.paddingBottom = new Float64Array(Array.from({length: count}, () => 48))
canonical.boxes.contentLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 5))
canonical.boxes.contentTop = new Float64Array(Array.from({length: count}, () => 5))
canonical.boxes.contentRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 45))
canonical.boxes.contentBottom = new Float64Array(Array.from({length: count}, () => 45))
canonical.dom.nodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
canonical.dom.parentNodeId = new Uint32Array(Array.from({length: count}, () => 1))
canonical.dom.childCount = new Uint8Array(Array.from({length: count}, () => 0))
canonical.dom.tagNameStringId = new Uint16Array(Array.from({length: count}, () => 0))
canonical.topology.containingBlockOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.nearestPositionedAncestorOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.scrollContainerOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.stackingContextOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.formattingContextOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.clippingRootOf = new Uint32Array(Array.from({length: count}, () => 0))
canonical.topology.paintOrderBucket = new Uint8Array(Array.from({length: count}, () => 0))
canonical.topology.paintOrderIndex = new Uint32Array(Array.from({length: count}, (_, i) => i))
const times = []
for (let i = 0; i < 100; i++) {
const start = performance.now()
adaptCanonicalWorldToSolver(canonical)
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} elements: ${mean.toFixed(2)}ms (avg of 100 runs)`)
}
+204
View File
@@ -0,0 +1,204 @@
// bench.test.ts - Tests for imhotep-bench harness
// Validates benchmark execution and cache correctness.
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
MemoryCache,
CompilationCache,
ExtractionCache,
runBenchmark,
BenchmarkSuite,
profileRun,
DEFAULT_PROFILES,
checkBudget,
} from './index.js'
describe('Cache correctness', () => {
it('MemoryCache stores and retrieves values', () => {
const cache = new MemoryCache<string>()
const key = { hash: 'abc', version: '1' }
cache.set(key, 'hello')
assert.strictEqual(cache.get(key), 'hello')
assert.strictEqual(cache.size(), 1)
})
it('MemoryCache returns undefined for missing keys', () => {
const cache = new MemoryCache<number>()
assert.strictEqual(cache.get({ hash: 'missing', version: '1' }), undefined)
})
it('MemoryCache invalidates entries', () => {
const cache = new MemoryCache<number>()
const key = { hash: 'del', version: '1' }
cache.set(key, 42)
assert.strictEqual(cache.invalidate(key), true)
assert.strictEqual(cache.get(key), undefined)
assert.strictEqual(cache.invalidate(key), false)
})
it('MemoryCache respects TTL expiration', async () => {
const cache = new MemoryCache<string>()
const key = { hash: 'ttl', version: '1' }
cache.set(key, 'temp', 10)
assert.strictEqual(cache.get(key), 'temp')
await new Promise(r => setTimeout(r, 20))
assert.strictEqual(cache.get(key), undefined)
assert.strictEqual(cache.size(), 0)
})
it('MemoryCache clear removes all entries', () => {
const cache = new MemoryCache<number>()
cache.set({ hash: 'a', version: '1' }, 1)
cache.set({ hash: 'b', version: '1' }, 2)
cache.clear()
assert.strictEqual(cache.size(), 0)
})
it('CompilationCache caches compile results by source', () => {
const inner = new MemoryCache<{ code: string }>()
const cache = new CompilationCache(inner, 'v1')
const result = { code: 'compiled' }
cache.set('source-a', result)
assert.deepStrictEqual(cache.get('source-a'), result)
assert.strictEqual(cache.get('source-b'), undefined)
})
it('CompilationCache invalidation targets specific source', () => {
const inner = new MemoryCache<number>()
const cache = new CompilationCache(inner, 'v1')
cache.set('src1', 1)
cache.set('src2', 2)
cache.invalidate('src1')
assert.strictEqual(cache.get('src1'), undefined)
assert.strictEqual(cache.get('src2'), 2)
})
it('ExtractionCache caches by selector and facts', () => {
const inner = new MemoryCache<{ rects: number[] }>()
const cache = new ExtractionCache(inner, 'v1')
const result = { rects: [0, 0, 10, 10] }
cache.set('#app', ['box', 'style'], result)
assert.deepStrictEqual(cache.get('#app', ['box', 'style']), result)
assert.strictEqual(cache.get('#app', ['box']), undefined)
})
it('ExtractionCache invalidation targets specific selector+facts', () => {
const inner = new MemoryCache<number>()
const cache = new ExtractionCache(inner, 'v1')
cache.set('sel1', ['a'], 1)
cache.set('sel1', ['b'], 2)
cache.set('sel2', ['a'], 3)
cache.invalidate('sel1', ['a'])
assert.strictEqual(cache.get('sel1', ['a']), undefined)
assert.strictEqual(cache.get('sel1', ['b']), 2)
assert.strictEqual(cache.get('sel2', ['a']), 3)
})
it('Cache version isolates entries', () => {
const cache = new MemoryCache<string>()
cache.set({ hash: 'x', version: 'v1' }, 'old')
cache.set({ hash: 'x', version: 'v2' }, 'new')
assert.strictEqual(cache.get({ hash: 'x', version: 'v1' }), 'old')
assert.strictEqual(cache.get({ hash: 'x', version: 'v2' }), 'new')
})
})
describe('Benchmark execution', () => {
it('profileRun measures duration and memory', async () => {
const { durationMs, memoryDeltaBytes, result } = await profileRun(() => {
const arr = new Array(1000).fill(0)
return arr.length
})
assert.strictEqual(typeof durationMs, 'number')
assert.strictEqual(durationMs >= 0, true)
assert.strictEqual(typeof memoryDeltaBytes, 'number')
assert.strictEqual(result, 1000)
})
it('runBenchmark returns aggregated stats', async () => {
let counter = 0
const result = await runBenchmark('inc', () => {
counter++
return counter
})
assert.strictEqual(result.name, 'inc')
assert.strictEqual(typeof result.meanDurationMs, 'number')
assert.strictEqual(typeof result.minDurationMs, 'number')
assert.strictEqual(typeof result.maxDurationMs, 'number')
assert.strictEqual(typeof result.stdDevDurationMs, 'number')
assert.strictEqual(typeof result.meanMemoryDeltaBytes, 'number')
assert.strictEqual(result.runs.length, 5)
assert.strictEqual(result.minDurationMs <= result.meanDurationMs, true)
assert.strictEqual(result.meanDurationMs <= result.maxDurationMs, true)
})
it('runBenchmark applies budget check', async () => {
const result = await runBenchmark('slow', () => {}, {
budgetName: 'compile',
profile: DEFAULT_PROFILES.benchmark,
})
assert.ok(result.budgetCheck)
assert.strictEqual(result.budgetCheck!.budgetName, 'compile')
assert.strictEqual(typeof result.budgetCheck!.passed, 'boolean')
})
it('runBenchmark uses custom run counts', async () => {
let calls = 0
await runBenchmark(
'count',
() => {
calls++
},
{ warmupRuns: 2, measurementRuns: 3 }
)
assert.strictEqual(calls, 5)
})
it('BenchmarkSuite runs sequentially', async () => {
const suite = new BenchmarkSuite('seq')
const order: number[] = []
suite.add('a', () => order.push(1), { warmupRuns: 0, measurementRuns: 1 })
suite.add('b', () => order.push(2), { warmupRuns: 0, measurementRuns: 1 })
const result = await suite.runSequential()
assert.strictEqual(result.suiteName, 'seq')
assert.strictEqual(result.results.length, 2)
assert.deepStrictEqual(order, [1, 2])
assert.strictEqual(typeof result.allBudgetsPassed, 'boolean')
assert.strictEqual(typeof result.totalDurationMs, 'number')
})
it('BenchmarkSuite runs in parallel', async () => {
const suite = new BenchmarkSuite('par', {
poolOptions: { maxConcurrency: 2, taskTimeoutMs: 5000 },
})
suite.add('x', async () => {
await new Promise(r => setTimeout(r, 10))
}, { warmupRuns: 0, measurementRuns: 1 })
suite.add('y', async () => {
await new Promise(r => setTimeout(r, 10))
}, { warmupRuns: 0, measurementRuns: 1 })
const result = await suite.runParallel()
assert.strictEqual(result.results.length, 2)
assert.strictEqual(typeof result.totalDurationMs, 'number')
// Parallel total should be less than sequential sum of sleeps (20ms) plus generous overhead
assert.ok(result.totalDurationMs < 150)
})
it('Budget check passes when under budget', () => {
const check = checkBudget('compile', 10, undefined, DEFAULT_PROFILES.dev)
assert.strictEqual(check.passed, true)
assert.strictEqual(check.maxDurationMs, 50)
})
it('Budget check fails when over budget', () => {
const check = checkBudget('compile', 1000, undefined, DEFAULT_PROFILES.dev)
assert.strictEqual(check.passed, false)
})
it('Missing budget returns passed=true with Infinity', () => {
const check = checkBudget('unknown', 99999, undefined, DEFAULT_PROFILES.dev)
assert.strictEqual(check.passed, true)
assert.strictEqual(check.maxDurationMs, Infinity)
})
})
+229
View File
@@ -0,0 +1,229 @@
// benchmark.ts - Benchmark runner for Imhotep bench harness
// Measures execution time, memory, and CPU for extraction and solving paths.
import { performance } from 'node:perf_hooks'
import {
type PerformanceProfile,
type BudgetCheck,
DEFAULT_PROFILES,
checkBudget,
} from './profiles.js'
import { type CompilationCache, type ExtractionCache } from './cache.js'
import { PromisePool, type PromisePoolOptions } from './parallel.js'
export interface BenchmarkOptions {
/** Number of warm-up runs before measurement */
warmupRuns?: number
/** Number of measured runs */
measurementRuns?: number
/** Performance profile to check budgets against */
profile?: PerformanceProfile
/** Budget category name (compile | extract | solve | e2e) */
budgetName?: string
/** Optional compilation cache injected by caller */
compilationCache?: CompilationCache<unknown>
/** Optional extraction cache injected by caller */
extractionCache?: ExtractionCache<unknown>
}
export interface BenchmarkResult {
/** Benchmark name */
name: string
/** Mean duration across measured runs in milliseconds */
meanDurationMs: number
/** Minimum duration in milliseconds */
minDurationMs: number
/** Maximum duration in milliseconds */
maxDurationMs: number
/** Standard deviation of duration */
stdDevDurationMs: number
/** Mean memory delta in bytes */
meanMemoryDeltaBytes: number
/** Budget check result, if a budget name was provided */
budgetCheck?: BudgetCheck
/** Per-run raw measurements */
runs: RunMeasurement[]
}
export interface RunMeasurement {
durationMs: number
memoryDeltaBytes: number
}
/**
* Profile a single function execution.
* Returns duration and memory delta.
*/
export async function profileRun<T>(
fn: () => T | Promise<T>
): Promise<{ durationMs: number; memoryDeltaBytes: number; result: T }> {
const memBefore = process.memoryUsage().heapUsed
const start = performance.now()
const result = await fn()
const end = performance.now()
const memAfter = process.memoryUsage().heapUsed
return {
durationMs: end - start,
memoryDeltaBytes: memAfter - memBefore,
result,
}
}
/**
* Run a benchmark with warm-up and measurement phases.
*/
export async function runBenchmark<T>(
name: string,
fn: () => T | Promise<T>,
options: BenchmarkOptions = {}
): Promise<BenchmarkResult> {
const warmupRuns = options.warmupRuns ?? 1
const measurementRuns = options.measurementRuns ?? 5
const profile = options.profile ?? DEFAULT_PROFILES.dev
const budgetName = options.budgetName ?? 'e2e'
// Warm-up phase: discard results
for (let i = 0; i < warmupRuns; i++) {
await fn()
}
// Measurement phase
const runs: RunMeasurement[] = []
for (let i = 0; i < measurementRuns; i++) {
const { durationMs, memoryDeltaBytes } = await profileRun(fn)
runs.push({ durationMs, memoryDeltaBytes })
}
const durations = runs.map(r => r.durationMs)
const meanDurationMs =
durations.reduce((a, b) => a + b, 0) / durations.length
const minDurationMs = Math.min(...durations)
const maxDurationMs = Math.max(...durations)
const variance =
durations.reduce((sum, d) => sum + Math.pow(d - meanDurationMs, 2), 0) /
durations.length
const stdDevDurationMs = Math.sqrt(variance)
const meanMemoryDeltaBytes =
runs.reduce((sum, r) => sum + r.memoryDeltaBytes, 0) / runs.length
const budgetCheck = checkBudget(
budgetName,
meanDurationMs,
meanMemoryDeltaBytes,
profile
)
return {
name,
meanDurationMs,
minDurationMs,
maxDurationMs,
stdDevDurationMs,
meanMemoryDeltaBytes,
budgetCheck,
runs,
}
}
export interface BenchmarkSuiteOptions {
/** Global benchmark options applied to every benchmark */
defaults?: BenchmarkOptions
/** Promise pool options for parallel execution */
poolOptions?: PromisePoolOptions
}
export interface BenchmarkSuiteResult {
suiteName: string
results: BenchmarkResult[]
totalDurationMs: number
allBudgetsPassed: boolean
}
/**
* BenchmarkSuite collects and runs multiple named benchmarks.
* Supports sequential and parallel execution.
*/
export class BenchmarkSuite {
private benchmarks: Array<{
name: string
fn: () => unknown | Promise<unknown>
options?: BenchmarkOptions
}> = []
constructor(private suiteName: string, private options: BenchmarkSuiteOptions = {}) {}
/** Register a benchmark function */
add<T>(
name: string,
fn: () => T | Promise<T>,
options?: BenchmarkOptions
): this {
this.benchmarks.push({ name, fn, options })
return this
}
/** Run all benchmarks sequentially */
async runSequential(): Promise<BenchmarkSuiteResult> {
const results: BenchmarkResult[] = []
const suiteStart = performance.now()
for (const { name, fn, options } of this.benchmarks) {
const merged: BenchmarkOptions = {
...this.options.defaults,
...options,
}
const result = await runBenchmark(name, fn, merged)
results.push(result)
}
const suiteEnd = performance.now()
return {
suiteName: this.suiteName,
results,
totalDurationMs: suiteEnd - suiteStart,
allBudgetsPassed: results.every(
r => !r.budgetCheck || r.budgetCheck.passed
),
}
}
/** Run all benchmarks in parallel with controlled concurrency */
async runParallel(): Promise<BenchmarkSuiteResult> {
const poolOptions: PromisePoolOptions = {
maxConcurrency: this.options.poolOptions?.maxConcurrency ?? 4,
taskTimeoutMs: this.options.poolOptions?.taskTimeoutMs ?? 30000,
}
const pool = new PromisePool(poolOptions)
const suiteStart = performance.now()
try {
const tasks = this.benchmarks.map(({ name, fn, options }) => ({
id: name,
input: undefined,
fn: async () => {
const merged: BenchmarkOptions = {
...this.options.defaults,
...options,
}
return runBenchmark(name, fn, merged)
},
}))
const results = (await pool.executeAll(tasks)) as BenchmarkResult[]
const suiteEnd = performance.now()
return {
suiteName: this.suiteName,
results,
totalDurationMs: suiteEnd - suiteStart,
allBudgetsPassed: results.every(
r => !r.budgetCheck || r.budgetCheck.passed
),
}
} finally {
await pool.shutdown()
}
}
}
@@ -0,0 +1,56 @@
// benchmarks.test.ts - Validation tests for benchmark suites.
// Runs each benchmark with reduced parameters to verify correctness.
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { runFolBenchmarks } from './fol-benchmark.js'
import { runPropertyBenchmarks } from './property-benchmark.js'
import { runRenderTargetBenchmarks } from './render-target-benchmark.js'
import { runPublicApiBenchmarks } from './public-api-benchmark.js'
describe('FOL benchmarks', () => {
it('runs forall benchmarks without error', async () => {
const results = await runFolBenchmarks();
assert.ok(results.length >= 4);
for (const r of results) {
assert.strictEqual(typeof r.meanDurationMs, 'number');
assert.ok(r.meanDurationMs >= 0);
assert.ok(r.runs.length > 0);
}
});
});
describe('Property benchmarks', () => {
it('runs enumerated and sampled benchmarks', async () => {
const results = await runPropertyBenchmarks();
assert.ok(results.length >= 2);
for (const r of results) {
assert.strictEqual(typeof r.meanDurationMs, 'number');
assert.ok(r.meanDurationMs >= 0);
}
});
});
describe('Render target benchmarks', () => {
it('runs renderer mount benchmarks', async () => {
const results = await runRenderTargetBenchmarks();
assert.ok(results.length >= 4);
for (const r of results) {
assert.strictEqual(typeof r.meanDurationMs, 'number');
assert.ok(r.meanDurationMs >= 0);
}
});
});
describe('Public API benchmarks', () => {
it('runs leftOf relation benchmark under 100ms', async () => {
const results = await runPublicApiBenchmarks();
assert.ok(results.length >= 1);
for (const r of results) {
assert.strictEqual(typeof r.meanDurationMs, 'number');
assert.ok(r.meanDurationMs >= 0);
assert.ok(r.meanDurationMs < 100, `Expected public API benchmark to complete in under 100ms, but took ${r.meanDurationMs}ms`);
}
});
});
@@ -0,0 +1,52 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-cache-hit-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
// Warmup: run once to populate cache
const ui1 = await imhotep(page)
for (const sel of selectors) {
ui1.expect(sel).to.be.leftOf('[data-testid="item-0"]')
}
await ui1.checkAll()
console.log('=== Cache Hit Performance ===\n')
for (const count of [1, 5, 10, 25, 50]) {
const ui = await imhotep(page)
for (let i = 0; i < count; i++) {
ui.expect(selectors[i]).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 10; run++) {
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} selectors (cache hit): ${mean.toFixed(2)}ms (avg of 10 runs)`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
+171
View File
@@ -0,0 +1,171 @@
// cache.ts - Compilation and extraction cache implementations for Imhotep bench harness
// Provides in-memory caching with TTL, versioning, and explicit invalidation.
export interface CacheKey {
/** Content hash or deterministic identifier */
hash: string
/** Cache schema version for invalidation on format changes */
version: string
}
export interface CacheEntry<T> {
/** Stored value */
value: T
/** Unix timestamp when the entry was created */
createdAt: number
/** Optional Unix timestamp when the entry expires */
expiresAt?: number
}
export interface Cache<T> {
get(key: CacheKey): T | undefined
set(key: CacheKey, value: T, ttlMs?: number): void
invalidate(key: CacheKey): boolean
clear(): void
size(): number
}
/** Simple in-memory cache backed by a Map. */
export class MemoryCache<T> implements Cache<T> {
private store = new Map<string, CacheEntry<T>>()
private makeKey(key: CacheKey): string {
return `${key.version}:${key.hash}`
}
get(key: CacheKey): T | undefined {
const k = this.makeKey(key)
const entry = this.store.get(k)
if (!entry) return undefined
if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
this.store.delete(k)
return undefined
}
return entry.value
}
set(key: CacheKey, value: T, ttlMs?: number): void {
const k = this.makeKey(key)
const now = Date.now()
this.store.set(k, {
value,
createdAt: now,
expiresAt: ttlMs !== undefined ? now + ttlMs : undefined,
})
}
invalidate(key: CacheKey): boolean {
return this.store.delete(this.makeKey(key))
}
clear(): void {
this.store.clear()
}
size(): number {
// Prune expired entries before reporting size for accuracy
const now = Date.now()
for (const [k, entry] of this.store) {
if (entry.expiresAt !== undefined && now > entry.expiresAt) {
this.store.delete(k)
}
}
return this.store.size
}
}
/** Cache key factory for compilation inputs. */
export function makeCompilationCacheKey(
source: string,
version: string
): CacheKey {
// Simple hash: in production this should be a real hash function
let hash = 0
for (let i = 0; i < source.length; i++) {
const char = source.charCodeAt(i)
hash = (hash << 5) - hash + char
hash |= 0
}
return { hash: String(hash), version }
}
/** Cache key factory for extraction plans. */
export function makeExtractionCacheKey(
selector: string,
facts: string[],
version: string
): CacheKey {
const combined = `${selector}::${facts.sort().join(',')}`
let hash = 0
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i)
hash = (hash << 5) - hash + char
hash |= 0
}
return { hash: String(hash), version }
}
/**
* CompilationCache wraps a generic Cache for CompileResult-like objects.
* Accepts the underlying Cache via constructor injection.
*/
export class CompilationCache<TCompileResult> {
constructor(
private cache: Cache<TCompileResult>,
private version: string
) {}
get(source: string): TCompileResult | undefined {
return this.cache.get(makeCompilationCacheKey(source, this.version))
}
set(source: string, result: TCompileResult, ttlMs?: number): void {
this.cache.set(makeCompilationCacheKey(source, this.version), result, ttlMs)
}
invalidate(source: string): boolean {
return this.cache.invalidate(makeCompilationCacheKey(source, this.version))
}
clear(): void {
this.cache.clear()
}
}
/**
* ExtractionCache wraps a generic Cache for extraction payloads.
* Accepts the underlying Cache via constructor injection.
*/
export class ExtractionCache<TExtractResult> {
constructor(
private cache: Cache<TExtractResult>,
private version: string
) {}
get(selector: string, facts: string[]): TExtractResult | undefined {
return this.cache.get(makeExtractionCacheKey(selector, facts, this.version))
}
set(
selector: string,
facts: string[],
result: TExtractResult,
ttlMs?: number
): void {
this.cache.set(
makeExtractionCacheKey(selector, facts, this.version),
result,
ttlMs
)
}
invalidate(selector: string, facts: string[]): boolean {
return this.cache.invalidate(
makeExtractionCacheKey(selector, facts, this.version)
)
}
clear(): void {
this.cache.clear()
}
}
@@ -0,0 +1,232 @@
/**
* Contention analysis benchmark.
*
* Tests parallel execution at different granularities to identify
* where contention occurs: browser, context, or page level.
*/
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { Semaphore } from '../../imhotep-playwright/src/semaphore.js'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-contention-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="app">
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
<div id="result"></div>
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'fixture.html'), html)
function mean(values) {
return values.reduce((a, b) => a + b, 0) / values.length
}
async function benchmark() {
console.log('========================================')
console.log(' Contention Analysis Benchmark')
console.log('========================================\n')
const browser = await chromium.launch()
const fixtureUrl = `file:${join(tmpdir2, 'fixture.html')}`
// ============================================================
// Test 1: Baseline sequential page operations
// ============================================================
console.log('--- Test 1: Sequential Baseline ---')
const seqTimes = []
for (let run = 0; run < 3; run++) {
const start = performance.now()
for (let i = 0; i < 20; i++) {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => document.getElementById('result').textContent = 'done')
await page.close()
await ctx.close()
}
seqTimes.push(performance.now() - start)
}
const seqMean = mean(seqTimes)
console.log(` 20 ops (new context each): ${seqMean.toFixed(1)}ms\n`)
// ============================================================
// Test 2: Parallel with varying concurrency
// ============================================================
console.log('--- Test 2: Parallel Execution Sweep ---')
console.log(' Using isolated contexts (no sharing)')
for (const concurrency of [1, 2, 4, 6, 8, 12, 16]) {
const sem = new Semaphore(concurrency)
const times = []
for (let run = 0; run < 3; run++) {
const start = performance.now()
await Promise.all(
Array.from({ length: 20 }, () =>
sem.run(async () => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => document.getElementById('result').textContent = 'done')
await page.close()
await ctx.close()
})
)
)
times.push(performance.now() - start)
}
const t = mean(times)
const speedup = seqMean / t
const efficiency = (speedup / concurrency) * 100
console.log(` concurrency=${concurrency.toString().padStart(2)}: ${t.toFixed(1).padStart(7)}ms speedup: ${speedup.toFixed(1).padStart(4)}x efficiency: ${efficiency.toFixed(0).padStart(3)}%`)
}
console.log('')
// ============================================================
// Test 3: Page-level parallelism within single context
// ============================================================
console.log('--- Test 3: Pages in Single Context ---')
for (const concurrency of [1, 2, 4, 6, 8]) {
const sem = new Semaphore(concurrency)
const times = []
for (let run = 0; run < 3; run++) {
const ctx = await browser.newContext()
const start = performance.now()
await Promise.all(
Array.from({ length: 20 }, () =>
sem.run(async () => {
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => document.getElementById('result').textContent = 'done')
await page.close()
})
)
)
times.push(performance.now() - start)
await ctx.close()
}
const t = mean(times)
const speedup = seqMean / t
console.log(` concurrency=${concurrency}: ${t.toFixed(1).padStart(7)}ms speedup: ${speedup.toFixed(1)}x`)
}
console.log('')
// ============================================================
// Test 4: I/O vs CPU operation types
// ============================================================
console.log('--- Test 4: Operation Type Comparison ---')
async function ioOp() {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await ctx.close()
}
async function mixedOp() {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => {
const div = document.createElement('div')
div.textContent = 'test'
document.body.appendChild(div)
return div.getBoundingClientRect()
})
await ctx.close()
}
async function cpuOp() {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await page.goto(fixtureUrl)
await page.evaluate(() => {
let a = 0, b = 1
for (let i = 0; i < 1000000; i++) {
const temp = a + b; a = b; b = temp
}
return b
})
await ctx.close()
}
for (const [name, op] of [['I/O', ioOp], ['Mixed', mixedOp], ['CPU', cpuOp]]) {
const sem = new Semaphore(4)
const seqT = []
const parT = []
for (let run = 0; run < 3; run++) {
const s0 = performance.now()
for (let i = 0; i < 4; i++) await op()
seqT.push(performance.now() - s0)
const p0 = performance.now()
await Promise.all(Array.from({ length: 4 }, () => sem.run(op)))
parT.push(performance.now() - p0)
}
const speedup = mean(seqT) / mean(parT)
console.log(` ${name.padEnd(5)}: seq=${mean(seqT).toFixed(1).padStart(6)}ms par=${mean(parT).toFixed(1).padStart(6)}ms speedup: ${speedup.toFixed(1)}x`)
}
console.log('')
// ============================================================
// Test 5: Resource degradation with many pages
// ============================================================
console.log('--- Test 5: Resource Saturation ---')
const ctx = await browser.newContext()
const pageCreationTimes = []
const pages = []
for (let i = 0; i < 20; i++) {
const t0 = performance.now()
const p = await ctx.newPage()
pageCreationTimes.push(performance.now() - t0)
pages.push(p)
}
console.log(` Page creation (1st): ${pageCreationTimes[0].toFixed(1)}ms`)
console.log(` Page creation (10th): ${pageCreationTimes[9].toFixed(1)}ms`)
console.log(` Page creation (20th): ${pageCreationTimes[19].toFixed(1)}ms`)
// Now navigate all simultaneously
const navStart = performance.now()
await Promise.all(pages.map(p => p.goto(fixtureUrl)))
const navTime = performance.now() - navStart
console.log(` Simultaneous navigation (20 pages): ${navTime.toFixed(1)}ms`)
await Promise.all(pages.map(p => p.close()))
await ctx.close()
console.log('')
// ============================================================
// Cleanup
// ============================================================
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
console.log('========================================')
console.log(' Contention Analysis Complete')
console.log('========================================')
}
benchmark().catch(err => {
console.error('Benchmark failed:', err)
process.exit(1)
})
@@ -0,0 +1,76 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-e2e-profile-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 100}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
// Profile each phase separately
console.log('=== End-to-End Pipeline Profile ===\n')
// Phase 1: Page creation + imhotep setup
const setupTimes = []
for (let i = 0; i < 5; i++) {
const start = performance.now()
const ui = await imhotep(page)
setupTimes.push(performance.now() - start)
}
console.log(`imhotep() setup: ${(setupTimes.reduce((a,b) => a+b, 0) / setupTimes.length).toFixed(1)}ms`)
// Phase 2: Assertion accumulation
const ui = await imhotep(page)
const accStart = performance.now()
for (let i = 0; i < 50; i++) {
ui.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
}
console.log(`Assertion accumulation (50): ${(performance.now() - accStart).toFixed(2)}ms`)
// Phase 3: First extraction (cold, no cache)
const coldStart = performance.now()
await ui.checkAll()
const coldDur = performance.now() - coldStart
console.log(`First extraction (cold): ${coldDur.toFixed(1)}ms`)
// Phase 4: Second extraction (warm, with cache)
const ui2 = await imhotep(page, { cacheDir: join(tmpdir2, 'cache') })
for (let i = 0; i < 50; i++) {
ui2.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
}
const warmStart = performance.now()
await ui2.checkAll()
const warmDur = performance.now() - warmStart
console.log(`First check with cache dir: ${warmDur.toFixed(1)}ms`)
// Phase 5: Cached run
const warmStart2 = performance.now()
await ui2.checkAll()
const warmDur2 = performance.now() - warmStart2
console.log(`Second check (cache hit): ${warmDur2.toFixed(1)}ms`)
// Phase 6: validate() only
const ui3 = await imhotep(page)
for (let i = 0; i < 50; i++) {
ui3.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
}
const valStart = performance.now()
ui3.validate()
console.log(`validate() only: ${(performance.now() - valStart).toFixed(2)}ms`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,167 @@
import { performance } from 'node:perf_hooks'
import {
evaluateLogic,
registerDefaultPredicates,
} from 'imhotep-solver'
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
registerDefaultPredicates()
// Build a world with 100 elements arranged in a grid
function buildWorld(n) {
const canonical = {
sceneId: 's',
snapshotId: 'sn',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
},
boxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
visualBoxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
dom: {
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
},
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
matrices: { values: new Float64Array(0) },
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
topology: {
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
},
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
}
return adaptCanonicalWorldToSolver(canonical)
}
class SimpleResolver {
constructor() {
this.domains = new Map()
}
register(selector, ids) {
this.domains.set(selector, {
domainId: `dom_${selector}`,
subjectIds: new Uint32Array(ids),
provenance: `elements(${selector})`,
closed: true,
})
}
resolve(domain) {
return this.domains.get(domain.selector ?? domain.domain)
}
}
console.log('=== Evaluation Allocation Benchmark ===\n')
for (const n of [10, 50, 100]) {
const world = buildWorld(n)
const resolver = new SimpleResolver()
resolver.register('.a', Array.from({length: n}, (_, i) => i))
resolver.register('.b', Array.from({length: n}, (_, i) => i))
const formula = {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$subject'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
}],
body: {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$reference'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'leftOf',
args: [
{ type: 'VariableRef', name: '$subject' },
{ type: 'VariableRef', name: '$reference' }
]
}
}
}
const timesTrace = []
const timesNoTrace = []
for (let i = 0; i < 100; i++) {
const start1 = performance.now()
evaluateLogic({ formula, world, resolver, options: { trace: true } })
timesTrace.push(performance.now() - start1)
const start2 = performance.now()
evaluateLogic({ formula, world, resolver, options: { trace: false } })
timesNoTrace.push(performance.now() - start2)
}
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
const withTrace = mean(timesTrace)
const withoutTrace = mean(timesNoTrace)
console.log(`${n}×${n} pairs (${n*n} evaluations):`)
console.log(` With trace: ${withTrace.toFixed(2)}ms`)
console.log(` Without trace: ${withoutTrace.toFixed(2)}ms`)
console.log(` Savings: ${((1 - withoutTrace/withTrace) * 100).toFixed(0)}%`)
}
+151
View File
@@ -0,0 +1,151 @@
import { performance } from 'node:perf_hooks'
import {
evaluateLogic,
registerDefaultPredicates,
} from 'imhotep-solver'
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
registerDefaultPredicates()
function buildWorld(n) {
const canonical = {
sceneId: 's',
snapshotId: 'sn',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
},
boxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
visualBoxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
dom: {
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
},
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
matrices: { values: new Float64Array(0) },
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
topology: {
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
},
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
}
return adaptCanonicalWorldToSolver(canonical)
}
class SimpleResolver {
constructor() {
this.domains = new Map()
}
register(selector, ids) {
this.domains.set(selector, {
domainId: `dom_${selector}`,
subjectIds: new Uint32Array(ids),
provenance: `elements(${selector})`,
closed: true,
})
}
resolve(domain) {
return this.domains.get(domain.selector ?? domain.domain)
}
}
// Run many iterations for V8 profiling
const n = 100
const world = buildWorld(n)
const resolver = new SimpleResolver()
resolver.register('.a', Array.from({length: n}, (_, i) => i))
resolver.register('.b', Array.from({length: n}, (_, i) => i))
const formula = {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$subject'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
}],
body: {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$reference'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'leftOf',
args: [
{ type: 'VariableRef', name: '$subject' },
{ type: 'VariableRef', name: '$reference' }
]
}
}
}
for (let i = 0; i < 200; i++) {
evaluateLogic({ formula, world, resolver, options: { trace: false } })
}
console.log('Evaluation profiling complete')
@@ -0,0 +1,79 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-breakdown-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Extraction Pipeline Breakdown ===\n')
// Profile CDP session creation
const sessionStart = performance.now()
const session = await page.context().newCDPSession(page)
await session.send('DOM.enable')
console.log(`CDP session + DOM.enable: ${(performance.now() - sessionStart).toFixed(1)}ms`)
// Profile selector resolution (50 selectors)
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
const doc = await session.send('DOM.getDocument')
const rootNodeId = doc.root.nodeId
const resolveStart = performance.now()
for (const sel of selectors) {
await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })
}
console.log(`Resolve 50 selectors (DOM.querySelectorAll): ${(performance.now() - resolveStart).toFixed(1)}ms`)
// Profile box model extraction (10 elements)
const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: '[data-testid="item-0"]' })
const boxModelStart = performance.now()
for (const nodeId of nodeIds.slice(0, 10)) {
try {
await session.send('DOM.getBoxModel', { nodeId })
} catch {}
}
console.log(`Get box model for 10 elements: ${(performance.now() - boxModelStart).toFixed(1)}ms`)
// Profile full CDP extraction via Imhotep
const { CDPExtractor, createSessionManager } = await import('imhotep-cdp')
const fullStart = performance.now()
const sm = createSessionManager(page)
const extractor = new CDPExtractor(sm)
const result = await extractor.extract({
requestId: 'bench',
sceneTarget: { kind: 'page', pageRef: page, url: page.url() },
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
subjects: selectors.slice(0, 10).map((s, i) => ({ id: `subj_${i}`, selector: s })),
requiredFacts: { geometry: true, topology: true, styles: false, fragments: false }
})
const fullDur = performance.now() - fullStart
console.log(`\nFull CDPExtractor (10 subjects): ${fullDur.toFixed(1)}ms`)
console.log(`Status: ${result.status}, Snapshots: ${result.snapshots?.length ?? 0}`)
// Profile world adaptation
if (result.snapshots?.length > 0) {
const { adaptSnapshotToCanonical } = await import('imhotep-cdp')
const { adaptCanonicalWorldToSolver } = await import('imhotep-core')
const adaptStart = performance.now()
const canonical = adaptSnapshotToCanonical(result.snapshots[0])
const solver = adaptCanonicalWorldToSolver(canonical)
console.log(`World adaptation (canonical -> solver): ${(performance.now() - adaptStart).toFixed(1)}ms`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,45 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-cold-extract-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Cold Extraction (cache cleared each run) ===\n')
for (const count of [1, 5, 10, 25, 50]) {
const selectors = Array.from({length: count}, (_, i) => `[data-testid="item-${i}"]`)
const ui = await imhotep(page)
for (const sel of selectors) {
ui.expect(sel).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 3; run++) {
await ui.clearCache()
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} selectors: ${mean.toFixed(1)}ms (3 cold runs)`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,36 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-hotspot-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
// Run extraction 50 times for profiling
for (let i = 0; i < 50; i++) {
const ui = await imhotep(page)
for (let j = 0; j < 10; j++) {
ui.expect(`[data-testid="item-${j}"]`).to.be.leftOf('[data-testid="item-0"]')
}
await ui.checkAll()
}
console.log('Extraction profiling complete')
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,47 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-profile-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 200}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 20) * 60}px;top:${Math.floor(i / 20) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
// Warm up
const ui0 = await imhotep(page)
ui0.expect('[data-testid=item-0]').to.be.leftOf('[data-testid=item-1]')
await ui0.checkAll()
// Profile extraction + evaluation
const times = []
for (let run = 0; run < 5; run++) {
const ui = await imhotep(page)
ui.expect('[data-testid=item-0]').to.be.leftOf('[data-testid=item-1]')
for (let i = 2; i < 20; i++) {
ui.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i}]`)
}
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`Extraction + 20 assertions: ${mean.toFixed(1)}ms (avg of 5)`)
console.log(`Min: ${Math.min(...times).toFixed(1)}ms, Max: ${Math.max(...times).toFixed(1)}ms`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,71 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-prof2-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Extraction Phase Breakdown ===\n')
// Profile with varying number of selectors
for (const count of [1, 5, 10, 25, 50]) {
const selectors = Array.from({length: count}, (_, i) => `[data-testid="item-${i}"]`)
const ui = await imhotep(page)
for (const sel of selectors) {
ui.expect(sel).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 3; run++) {
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} selectors: ${mean.toFixed(1)}ms (3 runs)`)
}
// Now profile cache serialization separately
console.log('\n=== Cache Serialization Profile ===')
const { createEmptyWorld, serializeGeometryWorld } = await import('imhotep-core')
for (const count of [100, 500, 1000, 5000]) {
const world = createEmptyWorld('s', 'sn')
world.subjects.ids = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
world.subjects.domNodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
world.boxes.boxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
world.boxes.subjectId = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
world.boxes.borderLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10))
world.boxes.borderTop = new Float64Array(Array.from({length: count}, () => 0))
world.boxes.borderRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 50))
world.boxes.borderBottom = new Float64Array(Array.from({length: count}, () => 50))
const times = []
for (let i = 0; i < 100; i++) {
const start = performance.now()
serializeGeometryWorld(world)
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
const json = serializeGeometryWorld(world)
console.log(`${count} elements: ${mean.toFixed(2)}ms, ${(json.length / 1024).toFixed(1)}KB JSON`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,54 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-fact-plan-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 10}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
const selectors = Array.from({length: 10}, (_, i) => `[data-testid="item-${i}"]`)
const { CDPExtractor, createSessionManager } = await import('imhotep-cdp')
const sm = createSessionManager(page)
// Full extraction (all facts)
const fullStart = performance.now()
const fullResult = await new CDPExtractor(sm).extract({
requestId: 'full',
sceneTarget: { pageRef: page, url: page.url() },
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
subjects: selectors.map((s, i) => ({ id: `subj_${i}`, selector: s })),
requiredFacts: { geometry: true, topology: true, styles: true, fragments: true }
})
const fullDur = performance.now() - fullStart
console.log(`Full extraction (all facts): ${fullDur.toFixed(1)}ms`)
// Geometry only
const geoStart = performance.now()
const geoResult = await new CDPExtractor(sm).extract({
requestId: 'geo',
sceneTarget: { pageRef: page, url: page.url() },
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
subjects: selectors.map((s, i) => ({ id: `subj_${i}`, selector: s })),
requiredFacts: { geometry: true, topology: false, styles: false, fragments: false }
})
const geoDur = performance.now() - geoStart
console.log(`Geometry only: ${geoDur.toFixed(1)}ms`)
console.log(`Savings: ${((1 - geoDur/fullDur) * 100).toFixed(0)}%`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,78 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-final-bench-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `
<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Final Comprehensive Benchmark ===\n')
// Cold extraction with varying assertion counts
console.log('Cold extraction (cache cleared):')
for (const count of [1, 10, 50]) {
const ui = await imhotep(page)
for (let i = 0; i < count; i++) {
ui.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 5; run++) {
await ui.clearCache()
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(` ${count} assertions: ${mean.toFixed(1)}ms`)
}
// Cache hit performance
console.log('\nCache hit (warm):')
const ui2 = await imhotep(page)
for (let i = 0; i < 50; i++) {
ui2.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
}
await ui2.checkAll() // warm up cache
const warmTimes = []
for (let run = 0; run < 10; run++) {
const start = performance.now()
await ui2.checkAll()
warmTimes.push(performance.now() - start)
}
const warmMean = warmTimes.reduce((a,b) => a+b, 0) / warmTimes.length
console.log(` 50 assertions: ${warmMean.toFixed(2)}ms`)
// validate() only
console.log('\nValidation only (no browser):')
const ui3 = await imhotep(page)
for (let i = 0; i < 50; i++) {
ui3.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
}
const validateTimes = []
for (let run = 0; run < 100; run++) {
const start = performance.now()
ui3.validate()
validateTimes.push(performance.now() - start)
}
const validateMean = validateTimes.reduce((a,b) => a+b, 0) / validateTimes.length
console.log(` 50 assertions: ${validateMean.toFixed(2)}ms`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
+308
View File
@@ -0,0 +1,308 @@
// fol-benchmark.ts - Benchmark deterministic first-order logic evaluation.
// Measures forall/exists performance over finite domains.
import { performance } from 'node:perf_hooks';
import {
runBenchmark,
BenchmarkSuite,
type BenchmarkResult,
} from './benchmark.js';
import {
evaluateLogic,
registerDefaultPredicates,
clearPredicateRegistry,
} from 'imhotep-solver';
import type {
GeometryWorld,
} from 'imhotep-solver';
interface DomainValue {
domainId: string;
subjectIds: Uint32Array;
provenance: string;
closed: boolean;
}
// ---------------------------------------------------------------------------
// World Factory
// ---------------------------------------------------------------------------
function makeLinearWorld(count: number): GeometryWorld {
const ids = Array.from({ length: count }, (_, i) => i + 1);
const boxIds = ids.map((id) => id * 100);
// Place elements in a horizontal row, each 50px wide with 10px gap
const borderLeft = ids.map((_, i) => i * 60);
const borderTop = ids.map(() => 0);
const borderRight = borderLeft.map((l) => l + 50);
const borderBottom = ids.map(() => 50);
return {
sceneId: `linear_${count}`,
snapshotId: 'snap_1',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids,
domNodeId: ids.map((id) => id * 10),
subjectKind: ids.map(() => 1),
primaryBoxId: boxIds,
firstFragmentId: ids.map(() => 0),
fragmentCount: ids.map(() => 0),
},
dom: {
nodeId: ids.map((id) => id * 10),
parentNodeId: ids.map(() => 0),
childCount: ids.map(() => 0),
tagNameStringId: ids.map(() => 0),
},
boxes: {
boxId: boxIds,
subjectId: ids,
frameId: ids.map(() => 1),
borderLeft,
borderTop,
borderRight,
borderBottom,
paddingLeft: ids.map(() => 0),
paddingTop: ids.map(() => 0),
paddingRight: ids.map(() => 0),
paddingBottom: ids.map(() => 0),
contentLeft: ids.map(() => 0),
contentTop: ids.map(() => 0),
contentRight: ids.map(() => 0),
contentBottom: ids.map(() => 0),
},
visualBoxes: {
boxId: boxIds,
subjectId: ids,
frameId: ids.map(() => 1),
borderLeft,
borderTop,
borderRight,
borderBottom,
paddingLeft: ids.map(() => 0),
paddingTop: ids.map(() => 0),
paddingRight: ids.map(() => 0),
paddingBottom: ids.map(() => 0),
contentLeft: ids.map(() => 0),
contentTop: ids.map(() => 0),
contentRight: ids.map(() => 0),
contentBottom: ids.map(() => 0),
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
matrices: {
values: [],
},
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
topology: {
containingBlockOf: ids.map(() => 0),
nearestPositionedAncestorOf: ids.map(() => 0),
scrollContainerOf: ids.map(() => 0),
stackingContextOf: ids.map(() => 0),
formattingContextOf: ids.map(() => 0),
clippingRootOf: ids.map(() => 0),
paintOrderBucket: ids.map(() => 0),
paintOrderIndex: ids.map(() => 0),
},
scroll: {
containerId: [], scrollLeft: [], scrollTop: [],
scrollWidth: [], scrollHeight: [], clientWidth: [], clientHeight: []
},
clipping: {
clipNodeId: [], subjectId: [], clipKind: [],
clipLeft: [], clipTop: [], clipRight: [], clipBottom: [], parentClipNodeId: []
},
visibility: {
subjectId: [], isRendered: [], isVisible: [], visibleArea: [], clippedArea: []
},
};
}
// ---------------------------------------------------------------------------
// Resolver Factory
// ---------------------------------------------------------------------------
function makeResolver(count: number) {
return {
resolve(domain: any): DomainValue | undefined {
const key = domain.selector ?? domain.domain;
if (key === '.el') {
return {
domainId: 'dom_el',
subjectIds: new Uint32Array(Array.from({ length: count }, (_, i) => i + 1)),
provenance: 'elements(.el)',
closed: true,
};
}
return undefined;
}
};
}
// ---------------------------------------------------------------------------
// Formula Factories
// ---------------------------------------------------------------------------
function forallFormula(): any {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['x'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'atLeast',
args: [{ type: 'VariableRef', name: 'x' }]
}
};
}
function existsFormula(): any {
return {
type: 'FormulaNode',
kind: 'exists',
bindings: [{
type: 'TupleBinding',
variables: ['x'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'atLeast',
args: [{ type: 'VariableRef', name: 'x' }]
}
};
}
function nestedFormula(): any {
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['x'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
}],
body: {
type: 'FormulaNode',
kind: 'exists',
bindings: [{
type: 'TupleBinding',
variables: ['y'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'leftOf',
args: [{ type: 'VariableRef', name: 'x' }, { type: 'VariableRef', name: 'y' }]
}
}
};
}
// ---------------------------------------------------------------------------
// Benchmarks
// ---------------------------------------------------------------------------
export async function runFolBenchmarks(): Promise<BenchmarkResult[]> {
clearPredicateRegistry();
registerDefaultPredicates();
const suite = new BenchmarkSuite('fol-evaluation', {
defaults: { warmupRuns: 1, measurementRuns: 5 }
});
// forall over 100 elements
suite.add('forall-100', () => {
const world = makeLinearWorld(100);
evaluateLogic({
formula: forallFormula(),
world,
resolver: makeResolver(100),
});
});
// forall over 1,000 elements
suite.add('forall-1000', () => {
const world = makeLinearWorld(1000);
evaluateLogic({
formula: forallFormula(),
world,
resolver: makeResolver(1000),
});
});
// forall over 10,000 elements
suite.add('forall-10000', () => {
const world = makeLinearWorld(10000);
evaluateLogic({
formula: forallFormula(),
world,
resolver: makeResolver(10000),
});
});
// exists with early exit (first element always matches)
suite.add('exists-early-exit-10000', () => {
const world = makeLinearWorld(10000);
evaluateLogic({
formula: existsFormula(),
world,
resolver: makeResolver(10000),
});
});
// nested quantifiers (forall + exists) over 100 elements
suite.add('nested-quantifiers-100', () => {
const world = makeLinearWorld(100);
evaluateLogic({
formula: nestedFormula(),
world,
resolver: makeResolver(100),
});
});
const result = await suite.runSequential();
return result.results;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
runFolBenchmarks().then((results) => {
console.log('\n=== FOL Benchmark Results ===\n');
for (const r of results) {
console.log(`${r.name}:`);
console.log(` mean: ${r.meanDurationMs.toFixed(2)}ms`);
console.log(` min: ${r.minDurationMs.toFixed(2)}ms`);
console.log(` max: ${r.maxDurationMs.toFixed(2)}ms`);
console.log(` memory: ${(r.meanMemoryDeltaBytes / 1024).toFixed(1)}KB`);
console.log();
}
});
}
@@ -0,0 +1,44 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { imhotep } from 'imhotep'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-full-pipeline-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
console.log('=== Full Pipeline Benchmark ===\n')
for (const count of [1, 5, 10, 25, 50]) {
const ui = await imhotep(page)
for (let i = 0; i < count; i++) {
ui.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
}
const times = []
for (let run = 0; run < 5; run++) {
await ui.clearCache()
const start = performance.now()
await ui.checkAll()
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`${count} assertions (cold): ${mean.toFixed(1)}ms (5 runs)`)
}
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
+83
View File
@@ -0,0 +1,83 @@
// index.ts - Package exports for imhotep-bench
// Benchmarking and performance testing harness for Imhotep.
export {
type PerformanceBudget,
type PerformanceProfile,
type BudgetCheck,
DEFAULT_PROFILES,
checkBudget,
checkAllBudgets,
} from './profiles.js'
export {
type CacheKey,
type CacheEntry,
type Cache,
MemoryCache,
CompilationCache,
ExtractionCache,
makeCompilationCacheKey,
makeExtractionCacheKey,
} from './cache.js'
export {
type WorkerTask,
type WorkerPoolOptions,
type WorkerPoolMetrics,
WorkerPool,
type PromiseTask,
type PromisePoolOptions,
PromisePool,
} from './parallel.js'
export {
type BenchmarkOptions,
type BenchmarkResult,
type RunMeasurement,
type BenchmarkSuiteOptions,
type BenchmarkSuiteResult,
profileRun,
runBenchmark,
BenchmarkSuite,
} from './benchmark.js'
export {
type ProfileEntry,
ProfileSession,
type CompilationProfilerOptions,
type ExtractionProfilerOptions,
type SolvingProfilerOptions,
type WorldCreationProfilerOptions,
type FrameResolutionProfilerOptions,
profileCompilation,
profileExtraction,
profileSolving,
profileWorldCreation,
profileFrameResolution,
type RegressionResult,
detectRegression,
formatProfileReport,
} from './profile.js'
export {
type MemoCache,
LruMemoCache,
memoize,
type StringInterner,
FastStringInterner,
type ClauseDescriptor,
type ClauseResult,
type ClauseEvaluator,
batchEvaluateClauses,
type FrameResolver,
createLazyFrameResolver,
type FlatTable,
buildIdIndex,
buildSubjectIndex,
binarySearchUint32,
type TimedFunction,
timeFunction,
type BufferPool,
Float64BufferPool,
} from './optimize.js'
+373
View File
@@ -0,0 +1,373 @@
// optimize.ts - Optimization helpers for Imhotep hot paths
// Provides memoization, batching, string interning, and lazy evaluation utilities.
// All helpers use dependency injection (no optional imports or global state).
import { performance } from 'node:perf_hooks'
// ---------------------------------------------------------------------------
// Memoization — cache function results by serialized key
// ---------------------------------------------------------------------------
export interface MemoCache<K, V> {
get(key: K): V | undefined
set(key: K, value: V): void
has(key: K): boolean
clear(): void
}
/** Simple Map-backed memo cache with size limit and LRU eviction. */
export class LruMemoCache<K, V> implements MemoCache<K, V> {
private cache = new Map<K, V>()
constructor(private maxSize: number = 1024) {}
get(key: K): V | undefined {
const value = this.cache.get(key)
if (value !== undefined) {
// Touch entry for LRU ordering
this.cache.delete(key)
this.cache.set(key, value)
}
return value
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
// Evict oldest (first inserted)
const firstKey = this.cache.keys().next().value
if (firstKey !== undefined) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, value)
}
has(key: K): boolean {
return this.cache.has(key)
}
clear(): void {
this.cache.clear()
}
}
/**
* Memoize a pure function using an injected cache.
* The keyFn serializes arguments; the cache stores results.
*/
export function memoize<A extends unknown[], R>(
fn: (...args: A) => R,
keyFn: (...args: A) => string,
cache: MemoCache<string, R>
): (...args: A) => R {
return (...args: A): R => {
const key = keyFn(...args)
const cached = cache.get(key)
if (cached !== undefined) {
return cached
}
const result = fn(...args)
cache.set(key, result)
return result
}
}
// ---------------------------------------------------------------------------
// String interning — cache selector strings to avoid repeated hashing
// ---------------------------------------------------------------------------
export interface StringInterner {
intern(value: string): number
resolve(index: number): string
size(): number
}
/** Fast string interner backed by a Map for O(1) lookups. */
export class FastStringInterner implements StringInterner {
private indexByValue = new Map<string, number>()
private values: string[] = []
intern(value: string): number {
const existing = this.indexByValue.get(value)
if (existing !== undefined) {
return existing
}
const idx = this.values.length
this.values.push(value)
this.indexByValue.set(value, idx)
return idx
}
resolve(index: number): string {
return this.values[index] ?? ''
}
size(): number {
return this.values.length
}
/** Export the string table as a plain array (for world construction). */
exportTable(): string[] {
return this.values.slice()
}
}
// ---------------------------------------------------------------------------
// Batch clause evaluation — evaluate multiple clauses in a single pass
// ---------------------------------------------------------------------------
export interface ClauseDescriptor {
clauseId: string
clauseKind: string
version: number
}
export interface ClauseResult {
clauseId: string
status: string
truth: string
}
export type ClauseEvaluator = (clause: ClauseDescriptor) => ClauseResult
/**
* Batch-evaluate clauses by grouping them by kind.
* Returns results in the same order as input clauses.
* The evaluatorFactory is injected so callers can supply their own registry.
*/
export function batchEvaluateClauses(
clauses: ClauseDescriptor[],
evaluatorFactory: (kind: string, version: number) => ClauseEvaluator | undefined
): ClauseResult[] {
// Group clause indices by kind+version to minimize evaluator lookups
const groups = new Map<string, number[]>()
for (let i = 0; i < clauses.length; i++) {
const c = clauses[i]
const key = `${c.clauseKind}@v${c.version}`
const indices = groups.get(key)
if (indices) {
indices.push(i)
} else {
groups.set(key, [i])
}
}
const results: ClauseResult[] = new Array(clauses.length)
for (const [key, indices] of groups) {
const first = clauses[indices[0]]
const evaluator = evaluatorFactory(first.clauseKind, first.version)
if (!evaluator) {
// Fill missing evaluators with error results
for (const idx of indices) {
results[idx] = {
clauseId: clauses[idx].clauseId,
status: 'error',
truth: 'indeterminate',
}
}
continue
}
for (const idx of indices) {
results[idx] = evaluator(clauses[idx])
}
}
return results
}
// ---------------------------------------------------------------------------
// Lazy frame resolution — only resolve frames when needed
// ---------------------------------------------------------------------------
export interface FrameResolver {
resolve(frameId: number): unknown
invalidate(frameId: number): void
clear(): void
}
/**
* Lazy frame resolver that caches resolved transforms.
* The resolveFn is injected so callers can supply their own coordinate system.
*/
export function createLazyFrameResolver(
resolveFn: (frameId: number) => unknown,
cache: MemoCache<number, unknown>
): FrameResolver {
return {
resolve(frameId: number): unknown {
const cached = cache.get(frameId)
if (cached !== undefined) {
return cached
}
const result = resolveFn(frameId)
cache.set(frameId, result)
return result
},
invalidate(frameId: number): void {
// No-op for Map-based cache; real implementation may support deletion
void frameId
},
clear(): void {
cache.clear()
},
}
}
// ---------------------------------------------------------------------------
// Index pre-computation — build lookup maps during normalization
// ---------------------------------------------------------------------------
export interface FlatTable {
ids: Uint32Array
}
/**
* Build a bidirectional index for a flat id table.
* Returns a Map<id, index> for O(1) lookups.
*/
export function buildIdIndex(table: FlatTable): Map<number, number> {
const map = new Map<number, number>()
const ids = table.ids
for (let i = 0; i < ids.length; i++) {
map.set(ids[i], i)
}
return map
}
/**
* Build a multi-value index (one-to-many) from a subject-id column.
* Returns a Map<subjectId, index[]> for fast reverse lookups.
*/
export function buildSubjectIndex(subjectIdColumn: Uint32Array): Map<number, number[]> {
const map = new Map<number, number[]>()
for (let i = 0; i < subjectIdColumn.length; i++) {
const sid = subjectIdColumn[i]
const arr = map.get(sid)
if (arr) {
arr.push(i)
} else {
map.set(sid, [i])
}
}
return map
}
// ---------------------------------------------------------------------------
// Array scan replacement — binary search on sorted arrays
// ---------------------------------------------------------------------------
/**
* Binary search for a value in a sorted Uint32Array.
* Returns the index if found, otherwise -1.
* This replaces linear scans when data is pre-sorted.
*/
export function binarySearchUint32(arr: Uint32Array, value: number): number {
let lo = 0
let hi = arr.length - 1
while (lo <= hi) {
const mid = (lo + hi) >>> 1
const midVal = arr[mid]
if (midVal === value) {
return mid
} else if (midVal < value) {
lo = mid + 1
} else {
hi = mid - 1
}
}
return -1
}
// ---------------------------------------------------------------------------
// Performance timing decorator — wrap functions with measurement
// ---------------------------------------------------------------------------
export interface TimedFunction<T extends unknown[], R> {
(...args: T): R
readonly totalCalls: number
readonly totalDurationMs: number
readonly meanDurationMs: number
}
/**
* Wrap a function to measure its execution time.
* Returns a new function with attached statistics.
*/
export function timeFunction<T extends unknown[], R>(
fn: (...args: T) => R,
label?: string
): TimedFunction<T, R> {
let calls = 0
let duration = 0
const wrapped = (...args: T): R => {
const start = performance.now()
try {
return fn(...args)
} finally {
duration += performance.now() - start
calls++
}
}
Object.defineProperty(wrapped, 'totalCalls', {
get: () => calls,
})
Object.defineProperty(wrapped, 'totalDurationMs', {
get: () => duration,
})
Object.defineProperty(wrapped, 'meanDurationMs', {
get: () => (calls > 0 ? duration / calls : 0),
})
return wrapped as TimedFunction<T, R>
}
// ---------------------------------------------------------------------------
// Buffer pooling — reuse typed arrays to reduce GC pressure
// ---------------------------------------------------------------------------
export interface BufferPool<T> {
acquire(minSize: number): T
release(buf: T): void
}
/** Simple pool for Float64Array buffers with power-of-2 sizing. */
export class Float64BufferPool implements BufferPool<Float64Array> {
private pool: Float64Array[] = []
private maxPoolSize: number
constructor(maxPoolSize = 16) {
this.maxPoolSize = maxPoolSize
}
acquire(minSize: number): Float64Array {
const size = nextPowerOf2(minSize)
// Find a buffer that is large enough
for (let i = 0; i < this.pool.length; i++) {
if (this.pool[i].length >= size) {
return this.pool.splice(i, 1)[0]
}
}
return new Float64Array(size)
}
release(buf: Float64Array): void {
if (this.pool.length < this.maxPoolSize) {
this.pool.push(buf)
}
}
}
function nextPowerOf2(n: number): number {
if (n <= 1) return 1
let p = 1
while (p < n) {
p <<= 1
}
return p
}
+283
View File
@@ -0,0 +1,283 @@
// parallel.ts - Worker parallelism utilities for Imhotep bench harness
// Supports both worker_thread pools and lightweight Promise-based pools.
import { Worker } from 'node:worker_threads'
import { EventEmitter } from 'node:events'
export interface WorkerTask<TInput, TOutput> {
/** Unique task identifier */
id: string
/** Task payload */
input: TInput
/** Worker script path for worker_thread execution */
workerScript: string
}
export interface WorkerPoolOptions {
/** Maximum concurrent workers */
maxWorkers: number
/** Per-task timeout in milliseconds */
taskTimeoutMs: number
}
export interface WorkerPoolMetrics {
queued: number
running: number
completed: number
failed: number
}
interface PendingTask<TInput, TOutput> {
task: WorkerTask<TInput, TOutput>
resolve: (value: TOutput) => void
reject: (reason: Error) => void
timer?: ReturnType<typeof setTimeout>
}
/**
* WorkerPool manages a fixed number of worker threads.
* Tasks are queued and executed as workers become available.
*/
export class WorkerPool extends EventEmitter {
private queue: Array<PendingTask<unknown, unknown>> = []
private activeTasks = new Map<string, PendingTask<unknown, unknown>>()
private metrics = { queued: 0, running: 0, completed: 0, failed: 0 }
private shuttingDown = false
constructor(private options: WorkerPoolOptions) {
super()
}
/**
* Execute a task on an available worker.
* Returns a promise that resolves with the worker output.
*/
execute<TInput, TOutput>(
task: WorkerTask<TInput, TOutput>
): Promise<TOutput> {
if (this.shuttingDown) {
return Promise.reject(new Error('WorkerPool is shutting down'))
}
return new Promise<TOutput>((resolve, reject) => {
const pending: PendingTask<TInput, TOutput> = {
task,
resolve: resolve as (value: unknown) => void,
reject: reject as (reason: Error) => void,
}
this.queue.push(pending as PendingTask<unknown, unknown>)
this.metrics.queued++
this.emit('queued', task.id)
this._pump()
})
}
/** Execute multiple tasks in parallel, returning results in input order. */
async executeAll<TInput, TOutput>(
tasks: WorkerTask<TInput, TOutput>[]
): Promise<TOutput[]> {
const promises = tasks.map(t => this.execute(t))
return Promise.all(promises)
}
/** Current pool metrics snapshot */
getMetrics(): WorkerPoolMetrics {
return { ...this.metrics }
}
/** Terminate all workers and clear the queue */
async shutdown(): Promise<void> {
this.shuttingDown = true
for (const pending of this.queue) {
pending.reject(new Error('WorkerPool shutdown'))
}
this.queue = []
for (const [, pending] of this.activeTasks) {
if (pending.timer) clearTimeout(pending.timer)
pending.reject(new Error('WorkerPool shutdown'))
}
this.activeTasks.clear()
}
private _pump(): void {
if (this.queue.length === 0) return
if (this.activeTasks.size >= this.options.maxWorkers) return
const pending = this.queue.shift()!
this.metrics.queued--
this.metrics.running++
const taskId = pending.task.id
this.activeTasks.set(taskId, pending)
const timer = setTimeout(() => {
pending.reject(
new Error(
`Task ${pending.task.id} timed out after ${this.options.taskTimeoutMs}ms`
)
)
this.activeTasks.delete(taskId)
this.metrics.running--
this.metrics.failed++
this.emit('timeout', pending.task.id)
this._pump()
}, this.options.taskTimeoutMs)
pending.timer = timer
const worker = new Worker(pending.task.workerScript)
worker.once(
'message',
(message: {
success: boolean
result?: unknown
error?: string
}) => {
clearTimeout(timer)
this.activeTasks.delete(taskId)
this.metrics.running--
void worker.terminate()
if (message.success) {
pending.resolve(message.result)
this.metrics.completed++
this.emit('completed', pending.task.id)
} else {
pending.reject(new Error(message.error || 'Worker task failed'))
this.metrics.failed++
this.emit('failed', pending.task.id)
}
this._pump()
}
)
worker.once('error', (err: Error) => {
clearTimeout(timer)
this.activeTasks.delete(taskId)
this.metrics.running--
this.metrics.failed++
void worker.terminate()
pending.reject(err)
this.emit('failed', pending.task.id)
this._pump()
})
worker.postMessage({ id: pending.task.id, input: pending.task.input })
}
}
export interface PromiseTask<TInput, TOutput> {
id: string
input: TInput
fn: (input: TInput) => TOutput | Promise<TOutput>
}
export interface PromisePoolOptions {
maxConcurrency: number
taskTimeoutMs: number
}
/**
* PromisePool provides lightweight parallelism without worker threads.
* Runs async functions with controlled concurrency.
*/
export class PromisePool extends EventEmitter {
private queue: Array<{
task: PromiseTask<unknown, unknown>
resolve: (value: unknown) => void
reject: (reason: Error) => void
}> = []
private running = 0
private metrics = { queued: 0, completed: 0, failed: 0 }
private shuttingDown = false
constructor(private options: PromisePoolOptions) {
super()
}
execute<TInput, TOutput>(
task: PromiseTask<TInput, TOutput>
): Promise<TOutput> {
if (this.shuttingDown) {
return Promise.reject(new Error('PromisePool is shutting down'))
}
return new Promise<TOutput>((resolve, reject) => {
this.queue.push({
task: task as PromiseTask<unknown, unknown>,
resolve: resolve as (value: unknown) => void,
reject,
})
this.metrics.queued++
this.emit('queued', task.id)
this._pump()
})
}
async executeAll<TInput, TOutput>(
tasks: PromiseTask<TInput, TOutput>[]
): Promise<TOutput[]> {
const promises = tasks.map(t => this.execute(t))
return Promise.all(promises)
}
getMetrics(): WorkerPoolMetrics {
return {
queued: this.metrics.queued,
running: this.running,
completed: this.metrics.completed,
failed: this.metrics.failed,
}
}
async shutdown(): Promise<void> {
this.shuttingDown = true
for (const item of this.queue) {
item.reject(new Error('PromisePool shutdown'))
}
this.queue = []
}
private _pump(): void {
if (this.queue.length === 0) return
if (this.running >= this.options.maxConcurrency) return
const item = this.queue.shift()!
this.metrics.queued--
this.running++
const timer = setTimeout(() => {
this.running--
this.metrics.failed++
item.reject(
new Error(
`Task ${item.task.id} timed out after ${this.options.taskTimeoutMs}ms`
)
)
this.emit('timeout', item.task.id)
this._pump()
}, this.options.taskTimeoutMs)
Promise.resolve(item.task.fn(item.task.input))
.then(result => {
clearTimeout(timer)
this.running--
this.metrics.completed++
item.resolve(result)
this.emit('completed', item.task.id)
this._pump()
})
.catch(err => {
clearTimeout(timer)
this.running--
this.metrics.failed++
item.reject(err instanceof Error ? err : new Error(String(err)))
this.emit('failed', item.task.id)
this._pump()
})
}
}
@@ -0,0 +1,55 @@
import { performance } from 'node:perf_hooks'
// Profile dense spec parsing
const spec = `
in viewport:
'.header' leftOf '.content' gap 16px
'.sidebar' above '.main' gap 8px
'.footer' below '.main' gap 24px
'.button' atLeast 44px wide
'.card' inside '.container'
'.modal' centeredWithin '.viewport' tolerance 2px
`
async function profileParse() {
const { parseSpec } = await import('imhotep-dsl')
const times = []
for (let i = 0; i < 1000; i++) {
const start = performance.now()
parseSpec(spec)
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`Parse 6-clause spec: ${mean.toFixed(3)}ms (avg of 1000)`)
console.log(`Min: ${Math.min(...times).toFixed(3)}ms, Max: ${Math.max(...times).toFixed(3)}ms`)
}
async function profileSerialize() {
const { createEmptyWorld } = await import('imhotep-core')
const { serializeGeometryWorld } = await import('imhotep-core')
const world = createEmptyWorld('scene', 'snap')
// Populate with 1000 elements
const n = 1000
world.subjects.ids = new Uint32Array(Array.from({length: n}, (_, i) => i + 1))
world.subjects.domNodeId = new Uint32Array(Array.from({length: n}, (_, i) => i + 10))
world.boxes.boxId = new Uint32Array(Array.from({length: n}, (_, i) => i + 100))
world.boxes.subjectId = new Uint32Array(Array.from({length: n}, (_, i) => i + 1))
world.boxes.borderLeft = new Float64Array(Array.from({length: n}, (_, i) => i * 10))
world.boxes.borderTop = new Float64Array(Array.from({length: n}, () => 0))
world.boxes.borderRight = new Float64Array(Array.from({length: n}, (_, i) => i * 10 + 50))
world.boxes.borderBottom = new Float64Array(Array.from({length: n}, () => 50))
const times = []
for (let i = 0; i < 100; i++) {
const start = performance.now()
serializeGeometryWorld(world)
times.push(performance.now() - start)
}
const mean = times.reduce((a,b) => a+b, 0) / times.length
console.log(`Serialize 1000-element world: ${mean.toFixed(2)}ms (avg of 100)`)
console.log(`Min: ${Math.min(...times).toFixed(2)}ms, Max: ${Math.max(...times).toFixed(2)}ms`)
}
await profileParse()
await profileSerialize()
@@ -0,0 +1,279 @@
/**
* Comprehensive pooling performance benchmark.
*
* Measures REAL speedups from the new parallel pooling architecture
* using actual Playwright + Imhotep code paths.
*/
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { createRuntime } from '../../imhotep-playwright/src/runtime.js'
import { runEnumeratedProperty } from '../../imhotep-playwright/src/property-runner.js'
import { Semaphore } from '../../imhotep-playwright/src/semaphore.js'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-pool-bench-'))
// Create a simple fixture page
const html = `
<!DOCTYPE html>
<html><body>
<div id="app">
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
<div id="result"></div>
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'fixture.html'), html)
function median(values) {
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function mean(values) {
return values.reduce((a, b) => a + b, 0) / values.length
}
async function benchmark() {
console.log('========================================')
console.log(' Imhotep Pooling Performance Benchmark')
console.log('========================================\n')
const browser = await chromium.launch()
// ============================================================
// Benchmark 1: Page Lifecycle — Direct vs Pool
// ============================================================
console.log('--- Benchmark 1: Page Creation vs Pool Borrow ---')
const runtime = await createRuntime(browser, {
poolOptions: { maxPagesPerContext: 8, warmPages: 4 }
})
// Warm up the pool first
const warmPages = []
for (let i = 0; i < 4; i++) {
warmPages.push(await runtime.createPage())
}
for (const p of warmPages) {
await runtime.releasePage(p)
}
const directTimes = []
const poolTimes = []
const iterations = 20
for (let i = 0; i < iterations; i++) {
// Direct: newContext → newPage → close
const ctx = await browser.newContext()
const t0 = performance.now()
const p = await ctx.newPage()
await p.goto(`file:${join(tmpdir2, 'fixture.html')}`)
await p.close()
directTimes.push(performance.now() - t0)
await ctx.close()
// Pool: borrow → goto → return
const t1 = performance.now()
const page = await runtime.createPage()
await page.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
await runtime.releasePage(page)
poolTimes.push(performance.now() - t1)
}
const directMean = mean(directTimes)
const poolMean = mean(poolTimes)
console.log(` Direct create/nav/close (n=${iterations}):`)
console.log(` mean: ${directMean.toFixed(2)}ms median: ${median(directTimes).toFixed(2)}ms`)
console.log(` Pool borrow/nav/return (n=${iterations}):`)
console.log(` mean: ${poolMean.toFixed(2)}ms median: ${median(poolTimes).toFixed(2)}ms`)
console.log(` Speedup: ${(directMean / poolMean).toFixed(1)}x\n`)
// ============================================================
// Benchmark 2: Context Reuse
// ============================================================
console.log('--- Benchmark 2: Context Creation vs Context Pool ---')
const env = { viewport: { width: 1280, height: 720 } }
const envKey = JSON.stringify(env)
const noPoolCtxTimes = []
const poolCtxTimes = []
for (let i = 0; i < 10; i++) {
// No pool: create fresh context
const t0 = performance.now()
const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 } })
const p = await ctx.newPage()
await p.goto(`file:${join(tmpdir2, 'fixture.html')}`)
await ctx.close()
noPoolCtxTimes.push(performance.now() - t0)
// Pool: acquire from pool (should reuse after first iteration)
const t1 = performance.now()
const ctx2 = await runtime.createContext(env)
const p2 = await runtime.createPage(ctx2)
await p2.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
await runtime.releasePage(p2)
// Note: we don't close context, pool reuses it
poolCtxTimes.push(performance.now() - t1)
}
const noPoolCtxMean = mean(noPoolCtxTimes)
const poolCtxMean = mean(poolCtxTimes)
console.log(` New context each time (n=10):`)
console.log(` mean: ${noPoolCtxMean.toFixed(2)}ms median: ${median(noPoolCtxTimes).toFixed(2)}ms`)
console.log(` Pooled context (n=10, first=cold):`)
console.log(` mean: ${poolCtxMean.toFixed(2)}ms median: ${median(poolCtxTimes).toFixed(2)}ms`)
console.log(` Speedup: ${(noPoolCtxMean / poolCtxMean).toFixed(1)}x\n`)
// ============================================================
// Benchmark 3: Sequential vs Parallel Enumerated Properties
// ============================================================
console.log('--- Benchmark 3: Enumerated Property Runner ---')
// Create a simple mock renderer adapter that tracks timing
const mockAdapter = {
async mount(target, renderCase) {
const page = await runtime.createPage()
await page.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
return {
caseId: renderCase.caseId,
world: { sceneId: renderCase.caseId },
page,
}
},
async unmount(mounted) {
await runtime.releasePage(mounted.page)
},
}
const mockFC = {
assert: async () => ({ passed: true, numRuns: 100 }),
record: () => ({}),
constantFrom: (...values) => values,
sample: () => [],
property: () => ({}),
asyncProperty: () => ({}),
}
const domain = {
mode: 'enumerated',
values: Array.from({ length: 20 }, (_, i) => ({ id: i })),
}
const predicate = async () => true
const deps = {
rendererAdapter: mockAdapter,
worldExtractor: { extract: async (mounted) => mounted.world },
fc: mockFC,
}
// Sequential (concurrency = 1)
const seqTimes = []
for (let run = 0; run < 3; run++) {
const t0 = performance.now()
await runEnumeratedProperty(
{ kind: 'fixture', fixtureId: 'test.html' },
domain,
predicate,
deps,
{ concurrency: 1, failFast: true }
)
seqTimes.push(performance.now() - t0)
}
// Parallel (concurrency = 4)
const par4Times = []
for (let run = 0; run < 3; run++) {
const t0 = performance.now()
await runEnumeratedProperty(
{ kind: 'fixture', fixtureId: 'test.html' },
domain,
predicate,
deps,
{ concurrency: 4, failFast: true }
)
par4Times.push(performance.now() - t0)
}
// Parallel (concurrency = 8)
const par8Times = []
for (let run = 0; run < 3; run++) {
const t0 = performance.now()
await runEnumeratedProperty(
{ kind: 'fixture', fixtureId: 'test.html' },
domain,
predicate,
deps,
{ concurrency: 8, failFast: true }
)
par8Times.push(performance.now() - t0)
}
const seqMean = mean(seqTimes)
const par4Mean = mean(par4Times)
const par8Mean = mean(par8Times)
console.log(` Sequential (concurrency=1, n=20 cases):`)
console.log(` mean: ${seqMean.toFixed(1)}ms`)
console.log(` Parallel (concurrency=4):`)
console.log(` mean: ${par4Mean.toFixed(1)}ms speedup: ${(seqMean / par4Mean).toFixed(1)}x`)
console.log(` Parallel (concurrency=8):`)
console.log(` mean: ${par8Mean.toFixed(1)}ms speedup: ${(seqMean / par8Mean).toFixed(1)}x`)
console.log(` Theoretical max (if perfectly parallel): ${(seqMean / 20).toFixed(1)}ms\n`)
// ============================================================
// Benchmark 4: Semaphore Overhead
// ============================================================
console.log('--- Benchmark 4: Semaphore Scheduling Overhead ---')
const sem = new Semaphore(4)
const taskCount = 100
const taskDuration = 5 // ms
// Sequential baseline
const seqSemStart = performance.now()
for (let i = 0; i < taskCount; i++) {
await new Promise(resolve => setTimeout(resolve, taskDuration))
}
const seqSemTime = performance.now() - seqSemStart
// Parallel with semaphore
const parSemStart = performance.now()
await Promise.all(
Array.from({ length: taskCount }, () =>
sem.run(() => new Promise(resolve => setTimeout(resolve, taskDuration)))
)
)
const parSemTime = performance.now() - parSemStart
console.log(` Sequential (${taskCount} tasks × ${taskDuration}ms): ${seqSemTime.toFixed(1)}ms`)
console.log(` Parallel (concurrency=4): ${parSemTime.toFixed(1)}ms`)
console.log(` Speedup: ${(seqSemTime / parSemTime).toFixed(1)}x`)
console.log(` Theoretical max: ${(taskCount * taskDuration / 4).toFixed(1)}ms\n`)
// ============================================================
// Cleanup
// ============================================================
await runtime.shutdown()
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
console.log('========================================')
console.log(' Benchmark Complete')
console.log('========================================')
}
benchmark().catch(err => {
console.error('Benchmark failed:', err)
process.exit(1)
})
@@ -0,0 +1,61 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-describe-profile-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `
<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
const session = await page.context().newCDPSession(page)
await session.send('DOM.enable')
// Get document root
const doc = await session.send('DOM.getDocument')
const rootId = doc.root.nodeId
// Profile querySelectorAll
const queryStart = performance.now()
const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: rootId, selector: '.item' })
console.log(`querySelectorAll for 50 items: ${(performance.now() - queryStart).toFixed(1)}ms`)
// Profile sequential DOM.describeNode
const seqStart = performance.now()
for (const nodeId of nodeIds.slice(0, 10)) {
await session.send('DOM.describeNode', { nodeId })
}
console.log(`Sequential describeNode (10): ${(performance.now() - seqStart).toFixed(1)}ms`)
// Profile parallel DOM.describeNode
const parStart = performance.now()
await Promise.all(nodeIds.slice(0, 10).map(nodeId => session.send('DOM.describeNode', { nodeId })))
console.log(`Parallel describeNode (10): ${(performance.now() - parStart).toFixed(1)}ms`)
// Profile all 50 sequential
const seq50Start = performance.now()
for (const nodeId of nodeIds) {
await session.send('DOM.describeNode', { nodeId })
}
console.log(`Sequential describeNode (50): ${(performance.now() - seq50Start).toFixed(1)}ms`)
// Profile all 50 parallel
const par50Start = performance.now()
await Promise.all(nodeIds.map(nodeId => session.send('DOM.describeNode', { nodeId })))
console.log(`Parallel describeNode (50): ${(performance.now() - par50Start).toFixed(1)}ms`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
@@ -0,0 +1,47 @@
import { performance } from 'node:perf_hooks'
import { chromium } from 'playwright'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-parallel-resolve-'))
const html = `
<!DOCTYPE html>
<html><body>
<div id="container">
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
`).join('')}
</div>
</body></html>
`
writeFileSync(join(tmpdir2, 'page.html'), html)
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
const { createSessionManager } = await import('imhotep-cdp')
const sm = createSessionManager(page)
const session = await sm.attach()
await session.send('DOM.enable')
const rootNodeId = await sm.getDocumentRootNodeId()
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
// Sequential resolution
const seqStart = performance.now()
for (const sel of selectors) {
await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })
}
const seqDur = performance.now() - seqStart
console.log(`Sequential querySelectorAll (50): ${seqDur.toFixed(1)}ms`)
// Parallel resolution
const parStart = performance.now()
await Promise.all(selectors.map(sel => session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })))
const parDur = performance.now() - parStart
console.log(`Parallel querySelectorAll (50): ${parDur.toFixed(1)}ms`)
console.log(`Speedup: ${(seqDur / parDur).toFixed(1)}x`)
await browser.close()
rmSync(tmpdir2, { recursive: true, force: true })
+470
View File
@@ -0,0 +1,470 @@
// profile.ts - Profiling utilities for Imhotep hot paths
// Measures compilation, extraction, solving, world creation, and frame resolution.
// Injected dependencies: performance hooks, benchmark runner, budget checker.
import { performance } from 'node:perf_hooks'
import { runBenchmark, type BenchmarkOptions, type BenchmarkResult } from './benchmark.js'
import { type CompilationCache, type ExtractionCache } from './cache.js'
// ---------------------------------------------------------------------------
// Profile entry — single timed observation
// ---------------------------------------------------------------------------
export interface ProfileEntry {
phase: string
durationMs: number
memoryDeltaBytes: number
metadata?: Record<string, unknown>
}
// ---------------------------------------------------------------------------
// Profile session — collects multiple entries for a single benchmark run
// ---------------------------------------------------------------------------
export class ProfileSession {
private entries: ProfileEntry[] = []
private lastTime = 0
private lastMemory = 0
constructor(private sessionName: string) {
this.lastTime = performance.now()
this.lastMemory = process.memoryUsage().heapUsed
}
/**
* Record a named phase boundary.
* Duration is measured from the previous mark() or session start.
*/
mark(phase: string, metadata?: Record<string, unknown>): ProfileEntry {
const now = performance.now()
const memNow = process.memoryUsage().heapUsed
const entry: ProfileEntry = {
phase,
durationMs: now - this.lastTime,
memoryDeltaBytes: memNow - this.lastMemory,
metadata,
}
this.entries.push(entry)
this.lastTime = now
this.lastMemory = memNow
return entry
}
/**
* Return all recorded entries.
*/
getEntries(): ProfileEntry[] {
return this.entries.slice()
}
/**
* Return the total duration across all phases.
*/
totalDurationMs(): number {
return this.entries.reduce((sum, e) => sum + e.durationMs, 0)
}
/**
* Return the entry for a specific phase, or undefined.
*/
findPhase(phase: string): ProfileEntry | undefined {
return this.entries.find(e => e.phase === phase)
}
}
// ---------------------------------------------------------------------------
// Hot-path profilers — targeted wrappers around core operations
// ---------------------------------------------------------------------------
export interface CompilationProfilerOptions {
/** Number of assertions to compile */
assertionCount: number
/** Optional compilation cache injected by caller */
compilationCache?: CompilationCache<unknown>
/** Benchmark options (warmup runs, measurement runs, profile) */
benchmarkOptions?: BenchmarkOptions
}
export interface ExtractionProfilerOptions {
/** Number of subjects to extract */
subjectCount: number
/** Optional extraction cache injected by caller */
extractionCache?: ExtractionCache<unknown>
benchmarkOptions?: BenchmarkOptions
}
export interface SolvingProfilerOptions {
/** Number of clauses to evaluate */
clauseCount: number
benchmarkOptions?: BenchmarkOptions
}
export interface WorldCreationProfilerOptions {
/** Number of raw facts (subjects, boxes, etc.) to normalize */
factCount: number
benchmarkOptions?: BenchmarkOptions
}
export interface FrameResolutionProfilerOptions {
/** Number of frames to resolve */
frameCount: number
benchmarkOptions?: BenchmarkOptions
}
/**
* Profile compilation: time to compile N assertions to Execution IR.
* Returns a BenchmarkResult with mean/min/max/std-dev durations.
*/
export async function profileCompilation(
compileFn: (assertions: unknown[]) => unknown,
options: CompilationProfilerOptions
): Promise<BenchmarkResult> {
const { assertionCount, benchmarkOptions } = options
const assertions = generateMockAssertions(assertionCount)
return runBenchmark(
`compile-${assertionCount}`,
() => compileFn(assertions),
{
budgetName: 'compile',
...benchmarkOptions,
}
)
}
/**
* Profile extraction: time to extract geometry from a page.
*/
export async function profileExtraction(
extractFn: (subjects: unknown[]) => unknown,
options: ExtractionProfilerOptions
): Promise<BenchmarkResult> {
const { subjectCount, benchmarkOptions } = options
const subjects = generateMockSubjects(subjectCount)
return runBenchmark(
`extract-${subjectCount}`,
() => extractFn(subjects),
{
budgetName: 'extract',
...benchmarkOptions,
}
)
}
/**
* Profile solving: time to evaluate M clauses against a world.
*/
export async function profileSolving(
solveFn: (clauses: unknown[], world: unknown) => unknown,
options: SolvingProfilerOptions
): Promise<BenchmarkResult> {
const { clauseCount, benchmarkOptions } = options
const clauses = generateMockClauses(clauseCount)
const world = generateMockWorld()
return runBenchmark(
`solve-${clauseCount}`,
() => solveFn(clauses, world),
{
budgetName: 'solve',
...benchmarkOptions,
}
)
}
/**
* Profile world creation: time to normalize raw facts into a world.
*/
export async function profileWorldCreation(
normalizeFn: (raw: unknown) => unknown,
options: WorldCreationProfilerOptions
): Promise<BenchmarkResult> {
const { factCount, benchmarkOptions } = options
const raw = generateMockRawExtraction(factCount)
return runBenchmark(
`world-create-${factCount}`,
() => normalizeFn(raw),
{
budgetName: 'extract',
...benchmarkOptions,
}
)
}
/**
* Profile frame resolution: time to resolve transforms and coordinates.
*/
export async function profileFrameResolution(
resolveFn: (frames: unknown[]) => unknown,
options: FrameResolutionProfilerOptions
): Promise<BenchmarkResult> {
const { frameCount, benchmarkOptions } = options
const frames = generateMockFrames(frameCount)
return runBenchmark(
`frame-resolve-${frameCount}`,
() => resolveFn(frames),
{
budgetName: 'solve',
...benchmarkOptions,
}
)
}
// ---------------------------------------------------------------------------
// Mock data generators — deterministic, lightweight
// ---------------------------------------------------------------------------
function generateMockAssertions(n: number): unknown[] {
const assertions: unknown[] = []
for (let i = 0; i < n; i++) {
assertions.push({
type: 'RelationAssertion',
relation: i % 2 === 0 ? 'leftOf' : 'above',
subject: { type: 'SelectorRef', value: `.s-${i}` },
reference: { type: 'SelectorRef', value: `.r-${i}` },
options: { type: 'RelationOptions' },
})
}
return assertions
}
function generateMockSubjects(n: number): unknown[] {
const subjects: unknown[] = []
for (let i = 0; i < n; i++) {
subjects.push({
id: i,
selector: `.subj-${i}`,
kind: 'element',
})
}
return subjects
}
function generateMockClauses(n: number): unknown[] {
const clauses: unknown[] = []
for (let i = 0; i < n; i++) {
clauses.push({
clauseId: `clause-${i}`,
clauseKind: i % 2 === 0 ? 'relation.leftOf' : 'relation.above',
version: 1,
subjectRef: i,
referenceRef: i + 1,
})
}
return clauses
}
function generateMockWorld(): unknown {
return {
sceneId: 'scene_1',
snapshotId: 'snapshot_1',
env: {
viewportWidth: 1280,
viewportHeight: 720,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array(0),
domNodeId: new Uint32Array(0),
subjectKind: new Uint16Array(0),
primaryBoxId: new Uint32Array(0),
firstFragmentId: new Uint32Array(0),
fragmentCount: new Uint16Array(0),
},
boxes: {
boxId: new Uint32Array(0),
subjectId: new Uint32Array(0),
frameId: new Uint32Array(0),
borderLeft: new Float64Array(0),
borderTop: new Float64Array(0),
borderRight: new Float64Array(0),
borderBottom: new Float64Array(0),
paddingLeft: new Float64Array(0),
paddingTop: new Float64Array(0),
paddingRight: new Float64Array(0),
paddingBottom: new Float64Array(0),
contentLeft: new Float64Array(0),
contentTop: new Float64Array(0),
contentRight: new Float64Array(0),
contentBottom: new Float64Array(0),
},
topology: {
containingBlockOf: new Uint32Array(0),
nearestPositionedAncestorOf: new Uint32Array(0),
scrollContainerOf: new Uint32Array(0),
stackingContextOf: new Uint32Array(0),
formattingContextOf: new Uint32Array(0),
clippingRootOf: new Uint32Array(0),
paintOrderBucket: new Uint16Array(0),
paintOrderIndex: new Uint32Array(0),
},
}
}
function generateMockRawExtraction(factCount: number): unknown {
const subjects: unknown[] = []
const boxes: unknown[] = []
for (let i = 0; i < factCount; i++) {
subjects.push({
id: i,
domNodeId: i,
kind: 'element',
primaryBoxId: i,
firstFragmentId: 0,
fragmentCount: 0,
firstTextRunId: 0,
textRunCount: 0,
})
boxes.push({
id: i,
subjectId: i,
frameId: 0,
borderLeft: 0,
borderTop: 0,
borderRight: 10,
borderBottom: 10,
paddingLeft: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
contentLeft: 0,
contentTop: 0,
contentRight: 10,
contentBottom: 10,
})
}
return {
sceneId: 'scene_1',
snapshotId: 'snapshot_1',
env: {
viewportWidth: 1280,
viewportHeight: 720,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: 'none',
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
source: {
url: 'http://localhost/',
browserName: 'chromium',
browserVersion: '120',
engine: 'blink',
extractedAt: Date.now(),
},
subjects,
dom: [],
frames: [],
rects: [],
boxes,
fragments: [],
transforms: [],
styles: [],
text: [],
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
scroll: [],
clipping: [],
paint: [],
visibility: [],
provenance: [],
confidence: [],
}
}
function generateMockFrames(n: number): unknown[] {
const frames: unknown[] = []
for (let i = 0; i < n; i++) {
frames.push({
id: i,
kind: 'viewport',
ownerSubjectId: i,
parentFrameId: -1,
originX: 0,
originY: 0,
matrix: i % 3 === 0 ? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] : undefined,
clipRectId: 0,
scrollContainerId: 0,
writingMode: 'horizontal-tb',
})
}
return frames
}
// ---------------------------------------------------------------------------
// Comparison helpers — detect regressions between two benchmark results
// ---------------------------------------------------------------------------
export interface RegressionResult {
phase: string
baselineMs: number
currentMs: number
deltaMs: number
deltaPercent: number
regressed: boolean
}
/**
* Compare two benchmark results and flag regressions.
* A regression is defined as current being > thresholdPercent slower than baseline.
*/
export function detectRegression(
baseline: BenchmarkResult,
current: BenchmarkResult,
thresholdPercent = 10
): RegressionResult {
const deltaMs = current.meanDurationMs - baseline.meanDurationMs
const deltaPercent = baseline.meanDurationMs > 0
? (deltaMs / baseline.meanDurationMs) * 100
: 0
return {
phase: current.name,
baselineMs: baseline.meanDurationMs,
currentMs: current.meanDurationMs,
deltaMs,
deltaPercent,
regressed: deltaPercent > thresholdPercent,
}
}
/**
* Format a profile report as plain text (no markdown per user preference).
*/
export function formatProfileReport(results: BenchmarkResult[]): string {
const lines: string[] = []
lines.push('Performance Profile Report')
lines.push('='.repeat(40))
for (const r of results) {
lines.push(`\nBenchmark: ${r.name}`)
lines.push(` Mean: ${r.meanDurationMs.toFixed(3)} ms`)
lines.push(` Min: ${r.minDurationMs.toFixed(3)} ms`)
lines.push(` Max: ${r.maxDurationMs.toFixed(3)} ms`)
lines.push(` StdDev: ${r.stdDevDurationMs.toFixed(3)} ms`)
lines.push(` Memory: ${r.meanMemoryDeltaBytes.toFixed(0)} bytes`)
if (r.budgetCheck) {
lines.push(` Budget: ${r.budgetCheck.budgetName} ${r.budgetCheck.passed ? 'PASS' : 'FAIL'}`)
}
}
lines.push('\n' + '='.repeat(40))
return lines.join('\n')
}
+113
View File
@@ -0,0 +1,113 @@
// profiles.ts - Performance profiles and budgets for Imhotep benchmark harness
// Defines budgets for compilation, extraction, solving, and end-to-end runs.
export interface PerformanceBudget {
/** Human-readable budget name */
name: string
/** Maximum allowed duration in milliseconds */
maxDurationMs: number
/** Optional maximum memory usage in bytes */
maxMemoryBytes?: number
}
export interface PerformanceProfile {
/** Profile identifier */
name: string
/** Budgets active in this profile */
budgets: PerformanceBudget[]
}
/** Budget presets keyed by profile name */
export const DEFAULT_PROFILES: Record<string, PerformanceProfile> = {
dev: {
name: 'dev',
budgets: [
{ name: 'compile', maxDurationMs: 50 },
{ name: 'extract', maxDurationMs: 200 },
{ name: 'solve', maxDurationMs: 100 },
{ name: 'e2e', maxDurationMs: 1000 },
],
},
ci: {
name: 'ci',
budgets: [
{ name: 'compile', maxDurationMs: 30 },
{ name: 'extract', maxDurationMs: 150 },
{ name: 'solve', maxDurationMs: 80 },
{ name: 'e2e', maxDurationMs: 800 },
],
},
benchmark: {
name: 'benchmark',
budgets: [
{ name: 'compile', maxDurationMs: 20 },
{ name: 'extract', maxDurationMs: 100 },
{ name: 'solve', maxDurationMs: 50 },
{ name: 'e2e', maxDurationMs: 500 },
],
},
}
/** Result of a single budget check */
export interface BudgetCheck {
budgetName: string
passed: boolean
actualDurationMs: number
actualMemoryBytes?: number
maxDurationMs: number
maxMemoryBytes?: number
}
/**
* Check whether a measurement fits within a budget.
* Returns a BudgetCheck with the pass/fail result.
*/
export function checkBudget(
budgetName: string,
actualDurationMs: number,
actualMemoryBytes?: number,
profile: PerformanceProfile = DEFAULT_PROFILES.dev
): BudgetCheck {
const budget = profile.budgets.find(b => b.name === budgetName)
if (!budget) {
return {
budgetName,
passed: true,
actualDurationMs,
actualMemoryBytes,
maxDurationMs: Infinity,
maxMemoryBytes: undefined,
}
}
const passedDuration = actualDurationMs <= budget.maxDurationMs
const passedMemory =
budget.maxMemoryBytes === undefined ||
actualMemoryBytes === undefined ||
actualMemoryBytes <= budget.maxMemoryBytes
return {
budgetName,
passed: passedDuration && passedMemory,
actualDurationMs,
actualMemoryBytes,
maxDurationMs: budget.maxDurationMs,
maxMemoryBytes: budget.maxMemoryBytes,
}
}
/**
* Check all budgets in a profile against a map of measurements.
*/
export function checkAllBudgets(
measurements: Record<string, { durationMs: number; memoryBytes?: number }>,
profile: PerformanceProfile = DEFAULT_PROFILES.dev
): BudgetCheck[] {
const results: BudgetCheck[] = []
for (const [name, measurement] of Object.entries(measurements)) {
results.push(
checkBudget(name, measurement.durationMs, measurement.memoryBytes, profile)
)
}
return results
}
@@ -0,0 +1,144 @@
// property-benchmark.ts - Benchmark sampled property runs.
// Measures enumerated combinations, fast-check generated runs, and shrink performance.
import {
runBenchmark,
BenchmarkSuite,
type BenchmarkResult,
} from './benchmark.js';
import fc from 'fast-check';
// ---------------------------------------------------------------------------
// Enumerated prop combinations
// ---------------------------------------------------------------------------
interface ButtonProps {
size: 'sm' | 'md' | 'lg';
disabled: boolean;
label: string;
}
function* enumerateProps(count: number): Generator<ButtonProps> {
const sizes: Array<'sm' | 'md' | 'lg'> = ['sm', 'md', 'lg'];
const disableds = [false, true];
const labels = ['OK', 'Cancel', 'Submit', 'Delete'];
let n = 0;
for (const size of sizes) {
for (const disabled of disableds) {
for (const label of labels) {
if (n >= count) return;
yield { size, disabled, label };
n++;
}
}
}
}
function generateEnumeratedCases(count: number): ButtonProps[] {
return Array.from(enumerateProps(count));
}
// ---------------------------------------------------------------------------
// Fast-check arbitrary for ButtonProps
// ---------------------------------------------------------------------------
const buttonPropsArb = fc.record<ButtonProps>({
size: fc.constantFrom('sm', 'md', 'lg'),
disabled: fc.boolean(),
label: fc.string({ minLength: 1, maxLength: 20 }),
});
// ---------------------------------------------------------------------------
// Property assertion (simulated layout check)
// ---------------------------------------------------------------------------
function assertButtonLayout(props: ButtonProps): boolean {
// Simulated: buttons are always at least 44px wide regardless of props
const minWidths = { sm: 60, md: 80, lg: 100 };
return minWidths[props.size] >= 44;
}
// ---------------------------------------------------------------------------
// Benchmarks
// ---------------------------------------------------------------------------
export async function runPropertyBenchmarks(): Promise<BenchmarkResult[]> {
const suite = new BenchmarkSuite('property-runs', {
defaults: { warmupRuns: 1, measurementRuns: 5 }
});
// 100 enumerated prop combinations
suite.add('enumerated-100-combinations', () => {
const cases = generateEnumeratedCases(100);
for (const props of cases) {
assertButtonLayout(props);
}
});
// 100 fast-check generated runs
suite.add('sampled-100-fc-runs', () => {
fc.assert(
fc.property(buttonPropsArb, (props) => {
return assertButtonLayout(props);
}),
{ numRuns: 100, seed: 42 }
);
});
// Shrink performance: how many steps to minimal counterexample
suite.add('shrink-to-minimal-counterexample', () => {
let shrinkSteps = 0;
try {
fc.assert(
fc.property(
fc.record({
size: fc.constantFrom('sm', 'md', 'lg'),
disabled: fc.boolean(),
label: fc.string({ minLength: 1, maxLength: 40 }),
}),
(props) => {
// Artificial failure: any label longer than 30 chars fails
return props.label.length <= 30;
}
),
{
numRuns: 200,
seed: 123,
verbose: 0,
}
);
} catch (e: any) {
// Fast-check errors contain counterexample info
if (e.counterExamplePath) {
// Path depth approximates shrink steps
shrinkSteps = e.counterExamplePath.split(':').length;
}
}
return shrinkSteps;
});
const result = await suite.runSequential();
return result.results;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
runPropertyBenchmarks().then((results) => {
console.log('\n=== Property Benchmark Results ===\n');
for (const r of results) {
console.log(`${r.name}:`);
console.log(` mean: ${r.meanDurationMs.toFixed(2)}ms`);
console.log(` min: ${r.minDurationMs.toFixed(2)}ms`);
console.log(` max: ${r.maxDurationMs.toFixed(2)}ms`);
console.log(` memory: ${(r.meanMemoryDeltaBytes / 1024).toFixed(1)}KB`);
if (r.name === 'shrink-to-minimal-counterexample') {
// Report last run result as shrink steps
const lastRun = r.runs[r.runs.length - 1];
console.log(` shrink steps (last run): ${lastRun ? (lastRun as any).result || 'N/A' : 'N/A'}`);
}
console.log();
}
});
}
@@ -0,0 +1,169 @@
// public-api-benchmark.ts - Micro-benchmark for the public relation evaluation path.
// Measures the simplified vertical slice: world construction + clause evaluation.
// Target: under 100ms for a simple two-element assertion.
import { performance } from 'node:perf_hooks'
import { runBenchmark, type BenchmarkResult } from './benchmark.js'
import { evaluateLeftOf } from 'imhotep-solver'
import type { GeometryWorld } from 'imhotep-solver'
// ---------------------------------------------------------------------------
// Minimal world builder (mirrors the path in public.ts)
// ---------------------------------------------------------------------------
function buildTwoElementWorld(gapPx: number): GeometryWorld {
const s = { x: 0, y: 0, width: 50, height: 50 }
const r = { x: 50 + gapPx, y: 0, width: 50, height: 50 }
return {
sceneId: 'bench',
snapshotId: 'snap',
env: {
viewportWidth: 800,
viewportHeight: 600,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: [0, 1],
domNodeId: [0, 1],
subjectKind: [0, 0],
primaryBoxId: [0, 1],
firstFragmentId: [0, 0],
fragmentCount: [0, 0],
},
dom: {
nodeId: [],
parentNodeId: [],
childCount: [],
tagNameStringId: [],
},
boxes: {
boxId: [0, 1],
subjectId: [0, 1],
frameId: [0, 0],
borderLeft: [s.x, r.x],
borderTop: [s.y, r.y],
borderRight: [s.x + s.width, r.x + r.width],
borderBottom: [s.y + s.height, r.y + r.height],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [s.x, r.x],
contentTop: [s.y, r.y],
contentRight: [s.x + s.width, r.x + r.width],
contentBottom: [s.y + s.height, r.y + r.height],
},
visualBoxes: {
boxId: [0, 1],
subjectId: [0, 1],
frameId: [0, 0],
borderLeft: [s.x, r.x],
borderTop: [s.y, r.y],
borderRight: [s.x + s.width, r.x + r.width],
borderBottom: [s.y + s.height, r.y + r.height],
paddingLeft: [0, 0],
paddingTop: [0, 0],
paddingRight: [0, 0],
paddingBottom: [0, 0],
contentLeft: [s.x, r.x],
contentTop: [s.y, r.y],
contentRight: [s.x + s.width, r.x + r.width],
contentBottom: [s.y + s.height, r.y + r.height],
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
matrices: {
values: [],
},
rects: {
rectId: [0, 1],
left: [s.x, r.x],
top: [s.y, r.y],
right: [s.x + s.width, r.x + r.width],
bottom: [s.y + s.height, r.y + r.height],
},
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
}
}
// ---------------------------------------------------------------------------
// Benchmark entry point
// ---------------------------------------------------------------------------
export async function runPublicApiBenchmarks(): Promise<BenchmarkResult[]> {
const results: BenchmarkResult[] = []
// Benchmark: two-element leftOf evaluation with 10px gap and minGap: 8
const world = buildTwoElementWorld(10)
const clause = {
clauseId: 'bench_leftof',
clauseKind: 'relation.leftOf',
version: 1,
subjectRef: 0,
referenceRef: 1,
bounds: { minGap: 8 },
}
const r = await runBenchmark(
'public-api-leftOf-2element',
() => {
evaluateLeftOf(world, clause)
},
{
warmupRuns: 100,
measurementRuns: 1000,
}
)
results.push(r)
return results
}
@@ -0,0 +1,122 @@
// render-target-benchmark.ts - Benchmark renderer adapter mounting.
// Measures scene target creation and resolution overhead.
import {
runBenchmark,
BenchmarkSuite,
type BenchmarkResult,
} from './benchmark.js';
import {
pageTarget,
storybookStoryTarget,
reactComponentTarget,
vueComponentTarget,
fixtureTarget,
matchSceneTarget,
type SceneTarget,
} from 'imhotep-core';
// ---------------------------------------------------------------------------
// Simulated adapter resolution
// ---------------------------------------------------------------------------
function resolveTargetUrl(target: SceneTarget): string {
return matchSceneTarget(target, {
page: (url) => url,
'playwright-page': (pageRef, url) => url || `playwright://${pageRef}`,
'storybook-story': (storyId, storybookUrl) => `${storybookUrl}/iframe.html?id=${storyId}`,
'react-component': (rendererId, componentId) =>
`component://react/${rendererId}/${componentId}`,
'vue-component': (rendererId, componentId) =>
`component://vue/${rendererId}/${componentId}`,
'custom-renderer': (rendererId, targetId) =>
`component://custom/${rendererId}/${targetId}`,
fixture: (fixtureId) => `file:///fixtures/${fixtureId}.html`,
});
}
function mountComponent(target: SceneTarget): { targetId: string; url: string } {
const url = resolveTargetUrl(target);
const targetId = `mount_${Math.random().toString(36).slice(2, 8)}`;
// Simulate mount overhead
const overhead = Array.from({ length: 100 }, (_, i) => i * 2).reduce((a, b) => a + b, 0);
return { targetId, url };
}
// ---------------------------------------------------------------------------
// Benchmarks
// ---------------------------------------------------------------------------
export async function runRenderTargetBenchmarks(): Promise<BenchmarkResult[]> {
const suite = new BenchmarkSuite('render-target-mount', {
defaults: { warmupRuns: 2, measurementRuns: 5 }
});
// React mount time
suite.add('react-mount', () => {
const target = reactComponentTarget('react-dom', 'Button');
mountComponent(target);
});
// Vue mount time
suite.add('vue-mount', () => {
const target = vueComponentTarget('vue-dom', 'Button');
mountComponent(target);
});
// Storybook story load time
suite.add('storybook-load', () => {
const target = storybookStoryTarget('button--primary', 'http://localhost:6006');
mountComponent(target);
});
// Page target resolution
suite.add('page-target-resolve', () => {
const target = pageTarget('https://example.com/page');
resolveTargetUrl(target);
});
// Fixture target resolution
suite.add('fixture-target-resolve', () => {
const target = fixtureTarget('property-render-react');
resolveTargetUrl(target);
});
// Mixed renderer batch
suite.add('mixed-renderer-batch-10', () => {
const targets: SceneTarget[] = [
reactComponentTarget('react-dom', 'Button'),
vueComponentTarget('vue-dom', 'Button'),
storybookStoryTarget('button--primary', 'http://localhost:6006'),
pageTarget('https://example.com/page'),
fixtureTarget('property-render-react'),
reactComponentTarget('react-dom', 'Card'),
vueComponentTarget('vue-dom', 'Card'),
storybookStoryTarget('card--default', 'http://localhost:6006'),
pageTarget('https://example.com/other'),
fixtureTarget('property-render-vue'),
];
for (const t of targets) {
mountComponent(t);
}
});
const result = await suite.runSequential();
return result.results;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
runRenderTargetBenchmarks().then((results) => {
console.log('\n=== Render Target Benchmark Results ===\n');
for (const r of results) {
console.log(`${r.name}:`);
console.log(` mean: ${r.meanDurationMs.toFixed(3)}ms`);
console.log(` min: ${r.minDurationMs.toFixed(3)}ms`);
console.log(` max: ${r.maxDurationMs.toFixed(3)}ms`);
console.log(` memory: ${r.meanMemoryDeltaBytes.toFixed(0)}B`);
console.log();
}
});
}
+169
View File
@@ -0,0 +1,169 @@
import { performance } from 'node:perf_hooks'
import {
evaluateLogic,
registerDefaultPredicates,
} from 'imhotep-solver'
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
registerDefaultPredicates()
function buildWorld(n) {
const canonical = {
sceneId: 's',
snapshotId: 'sn',
env: {
viewportWidth: 1280,
viewportHeight: 800,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
},
boxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
visualBoxes: {
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
},
dom: {
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
},
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
matrices: { values: new Float64Array(0) },
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
topology: {
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
},
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
}
return adaptCanonicalWorldToSolver(canonical)
}
class SimpleResolver {
constructor() {
this.domains = new Map()
}
register(selector, ids) {
this.domains.set(selector, {
domainId: `dom_${selector}`,
subjectIds: new Uint32Array(ids),
provenance: `elements(${selector})`,
closed: true,
})
}
resolve(domain) {
return this.domains.get(domain.selector ?? domain.domain)
}
}
console.log('=== Evaluation With/Without Trace ===\n')
for (const n of [10, 50, 100]) {
const world = buildWorld(n)
const resolver = new SimpleResolver()
resolver.register('.a', Array.from({length: n}, (_, i) => i))
resolver.register('.b', Array.from({length: n}, (_, i) => i))
const formula = {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$subject'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
}],
body: {
type: 'FormulaNode',
kind: 'forall',
bindings: [{
type: 'TupleBinding',
variables: ['$reference'],
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
}],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'leftOf',
args: [
{ type: 'VariableRef', name: '$subject' },
{ type: 'VariableRef', name: '$reference' }
]
}
}
}
const timesWithTrace = []
const timesWithoutTrace = []
for (let i = 0; i < 100; i++) {
const start1 = performance.now()
evaluateLogic({ formula, world, resolver, options: { trace: true } })
timesWithTrace.push(performance.now() - start1)
const start2 = performance.now()
evaluateLogic({ formula, world, resolver, options: { trace: false } })
timesWithoutTrace.push(performance.now() - start2)
}
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
const withTrace = mean(timesWithTrace)
const withoutTrace = mean(timesWithoutTrace)
console.log(`${n}×${n} pairs:`)
console.log(` With trace: ${withTrace.toFixed(2)}ms`)
console.log(` Without trace: ${withoutTrace.toFixed(2)}ms`)
console.log(` Savings: ${((1 - withoutTrace/withTrace) * 100).toFixed(0)}%`)
}
@@ -0,0 +1,42 @@
import { performance } from 'node:perf_hooks'
const sizes = [1000, 5000, 10000, 50000]
console.log('=== TypedArray to Array Conversion Benchmark ===\n')
for (const size of sizes) {
const arr = new Float64Array(Array.from({length: size}, (_, i) => i))
const times = {
arrayFrom: [],
spread: [],
slice: [],
bufferSlice: [],
}
for (let i = 0; i < 100; i++) {
let start = performance.now()
Array.from(arr)
times.arrayFrom.push(performance.now() - start)
start = performance.now()
;[...arr]
times.spread.push(performance.now() - start)
start = performance.now()
Array.prototype.slice.call(arr)
times.slice.push(performance.now() - start)
start = performance.now()
const buffer = arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength)
new Float64Array(buffer)
times.bufferSlice.push(performance.now() - start)
}
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
console.log(`${size} elements:`)
console.log(` Array.from: ${mean(times.arrayFrom).toFixed(3)}ms`)
console.log(` [...spread]: ${mean(times.spread).toFixed(3)}ms`)
console.log(` slice.call: ${mean(times.slice).toFixed(3)}ms`)
console.log(` buffer.slice: ${mean(times.bufferSlice).toFixed(3)}ms`)
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": false,
"paths": {},
"noEmitOnError": false
},
"include": [
"src/**/*"
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "imhotep-cdp",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"imhotep-core": "^1.0.0"
}
}
@@ -0,0 +1,392 @@
/**
* Canonical Adapter
*
* Converts raw CDP extraction output into the canonical GeometryWorld
* shape defined by imhotep-core. This adapter isolates CDP-specific
* structures from the canonical representation.
*/
import type { GeometryWorldSnapshot } from './extractor.js'
import type { DOMExtractionResult } from './dom.js'
// We define local canonical interfaces to avoid cross-package import
// failures when imhotep-core dist is stale. These mirror the core
// canonical contracts exactly.
export interface CanonicalWorldEnvironment {
viewportWidth: number
viewportHeight: number
deviceScaleFactor: number
colorScheme: string
pointer: string
hover: boolean
reducedMotion: string
locale: string
writingMode: string
}
export interface CanonicalWorldSource {
url: string
browserName: string
browserVersion: string
engine: 'chromium-cdp'
extractedAt: number
}
export interface CanonicalStringTable {
values: string[]
}
export interface CanonicalSubjectsTable {
ids: number[]
domNodeId: number[]
subjectKind: number[]
primaryBoxId: number[]
firstFragmentId: number[]
fragmentCount: number[]
firstTextRunId: number[]
textRunCount: number[]
}
export interface CanonicalDomTable {
nodeId: number[]
backendNodeId: number[]
parentNodeId: number[]
firstChildIndex: number[]
childCount: number[]
shadowRootKind: number[]
tagNameStringId: number[]
roleStringId: number[]
ariaNameStringId: number[]
}
export interface CanonicalFramesTable {
frameId: number[]
frameKind: number[]
ownerSubjectId: number[]
parentFrameId: number[]
originX: number[]
originY: number[]
axisMatrixStart: number[]
clipRectId: number[]
scrollContainerId: number[]
writingMode: number[]
}
export interface CanonicalMatricesTable {
values: number[]
}
export interface CanonicalRectsTable {
rectId: number[]
left: number[]
top: number[]
right: number[]
bottom: number[]
}
export interface CanonicalBoxesTable {
boxId: number[]
subjectId: number[]
frameId: number[]
borderLeft: number[]
borderTop: number[]
borderRight: number[]
borderBottom: number[]
paddingLeft: number[]
paddingTop: number[]
paddingRight: number[]
paddingBottom: number[]
contentLeft: number[]
contentTop: number[]
contentRight: number[]
contentBottom: number[]
}
export interface CanonicalFragmentsTable {
fragmentId: number[]
subjectId: number[]
fragmentKind: number[]
boxLeft: number[]
boxTop: number[]
boxRight: number[]
boxBottom: number[]
lineIndex: number[]
flowIndex: number[]
parentFragmentId: number[]
}
export interface CanonicalTransformsTable {
transformId: number[]
subjectId: number[]
matrixStart: number[]
matrixLength: number[]
originX: number[]
originY: number[]
}
export interface CanonicalStylesTable {
subjectId: number[]
display: number[]
position: number[]
zIndexKind: number[]
zIndexValue: number[]
overflowX: number[]
overflowY: number[]
opacity: number[]
visibility: number[]
containFlags: number[]
pointerEvents: number[]
lineHeight: number[]
fontFamilyStringId: number[]
fontSize: number[]
fontWeight: number[]
}
export interface CanonicalTextTable {
runId: number[]
subjectId: number[]
contentStringId: number[]
lineBoxId: number[]
inkLeft: number[]
inkTop: number[]
inkRight: number[]
inkBottom: number[]
baselineY: number[]
capHeight: number[]
computedLineHeight: number[]
}
export interface CanonicalTopologyTable {
containingBlockOf: number[]
nearestPositionedAncestorOf: number[]
scrollContainerOf: number[]
stackingContextOf: number[]
formattingContextOf: number[]
clippingRootOf: number[]
paintOrderBucket: number[]
paintOrderIndex: number[]
}
export interface CanonicalScrollTable {
containerId: number[]
scrollLeft: number[]
scrollTop: number[]
scrollWidth: number[]
scrollHeight: number[]
clientWidth: number[]
clientHeight: number[]
}
export interface CanonicalClippingTable {
clipNodeId: number[]
subjectId: number[]
clipKind: number[]
clipLeft: number[]
clipTop: number[]
clipRight: number[]
clipBottom: number[]
parentClipNodeId: number[]
}
export interface CanonicalPaintTable {
paintNodeId: number[]
subjectId: number[]
stackingContextId: number[]
bucket: number[]
localPaintIndex: number[]
}
export interface CanonicalVisibilityTable {
subjectId: number[]
isRendered: number[]
isVisible: number[]
visibleArea: number[]
clippedArea: number[]
}
export interface CanonicalProvenanceTable {
factId: number[]
extractionStepId: number[]
sourceKind: number[]
sourceRef: number[]
}
export interface CanonicalConfidenceTable {
factId: number[]
confidence: number[]
reasonCode: number[]
}
export interface CanonicalGeometryWorld {
sceneId: string
snapshotId: string
env: CanonicalWorldEnvironment
source: CanonicalWorldSource
strings: CanonicalStringTable
subjects: CanonicalSubjectsTable
dom: CanonicalDomTable
frames: CanonicalFramesTable
matrices: CanonicalMatricesTable
rects: CanonicalRectsTable
boxes: CanonicalBoxesTable
visualBoxes: CanonicalBoxesTable
fragments: CanonicalFragmentsTable
transforms: CanonicalTransformsTable
styles: CanonicalStylesTable
text: CanonicalTextTable
topology: CanonicalTopologyTable
scroll: CanonicalScrollTable
clipping: CanonicalClippingTable
paint: CanonicalPaintTable
visibility: CanonicalVisibilityTable
provenance: CanonicalProvenanceTable
confidence: CanonicalConfidenceTable
}
function adaptDom(dom: DOMExtractionResult): CanonicalDomTable {
const nodeId: number[] = []
const backendNodeId: number[] = []
const parentNodeId: number[] = []
const firstChildIndex: number[] = []
const childCount: number[] = []
const shadowRootKind: number[] = []
const tagNameStringId: number[] = []
const roleStringId: number[] = []
const ariaNameStringId: number[] = []
for (const node of dom.nodes) {
nodeId.push(node.nodeId)
backendNodeId.push(node.backendNodeId)
parentNodeId.push(node.parentNodeId)
firstChildIndex.push(node.firstChildIndex)
childCount.push(node.childCount)
shadowRootKind.push(node.shadowRootKind === 'open' ? 1 : node.shadowRootKind === 'closed' ? 2 : 0)
tagNameStringId.push(node.tagNameStringId)
roleStringId.push(node.roleStringId)
ariaNameStringId.push(node.ariaNameStringId)
}
return {
nodeId,
backendNodeId,
parentNodeId,
firstChildIndex,
childCount,
shadowRootKind,
tagNameStringId,
roleStringId,
ariaNameStringId,
}
}
function adaptStringTable(strings: string[]): CanonicalStringTable {
return { values: strings }
}
function adaptTimestamp(iso: string): number {
return new Date(iso).getTime()
}
function emptyTextTable(): CanonicalTextTable {
return {
runId: [],
subjectId: [],
contentStringId: [],
lineBoxId: [],
inkLeft: [],
inkTop: [],
inkRight: [],
inkBottom: [],
baselineY: [],
capHeight: [],
computedLineHeight: [],
}
}
function emptyPaintTable(): CanonicalPaintTable {
return {
paintNodeId: [],
subjectId: [],
stackingContextId: [],
bucket: [],
localPaintIndex: [],
}
}
function emptyVisibilityTable(subjectCount: number): CanonicalVisibilityTable {
return {
subjectId: Array.from({ length: subjectCount }, (_, i) => i),
isRendered: Array(subjectCount).fill(1),
isVisible: Array(subjectCount).fill(1),
visibleArea: Array(subjectCount).fill(0),
clippedArea: Array(subjectCount).fill(0),
}
}
/**
* Convert a CDP GeometryWorldSnapshot into the canonical GeometryWorld shape.
*
* This is a lossless structural mapping: CDP-specific structures (like the
* DOM tree object) are flattened into columnar arrays, and ISO timestamps
* are converted to epoch millis.
*/
export function adaptSnapshotToCanonical(snapshot: GeometryWorldSnapshot): CanonicalGeometryWorld {
return {
sceneId: snapshot.sceneId,
snapshotId: snapshot.snapshotId,
env: snapshot.env,
source: {
url: snapshot.source.url,
browserName: snapshot.source.browserName,
browserVersion: snapshot.source.browserVersion,
engine: 'chromium-cdp',
extractedAt: adaptTimestamp(snapshot.source.extractedAt),
},
strings: adaptStringTable(snapshot.strings),
subjects: snapshot.subjects,
dom: adaptDom(snapshot.dom),
frames: snapshot.frames,
matrices: snapshot.matrices,
rects: snapshot.rects,
boxes: snapshot.boxes,
visualBoxes: snapshot.visualBoxes,
fragments: snapshot.fragments,
transforms: snapshot.transforms,
styles: snapshot.styles,
text: emptyTextTable(),
topology: snapshot.topology,
scroll: {
containerId: snapshot.scroll.map((s) => s.containerId),
scrollLeft: snapshot.scroll.map((s) => s.scrollLeft),
scrollTop: snapshot.scroll.map((s) => s.scrollTop),
scrollWidth: snapshot.scroll.map((s) => s.scrollWidth),
scrollHeight: snapshot.scroll.map((s) => s.scrollHeight),
clientWidth: snapshot.scroll.map((s) => s.clientWidth),
clientHeight: snapshot.scroll.map((s) => s.clientHeight),
},
clipping: {
clipNodeId: snapshot.clipping.map((c) => c.clipNodeId),
subjectId: snapshot.clipping.map((c) => c.subjectId),
clipKind: snapshot.clipping.map((c) => c.clipKind),
clipLeft: snapshot.clipping.map((c) => c.clipLeft),
clipTop: snapshot.clipping.map((c) => c.clipTop),
clipRight: snapshot.clipping.map((c) => c.clipRight),
clipBottom: snapshot.clipping.map((c) => c.clipBottom),
parentClipNodeId: snapshot.clipping.map((c) => c.parentClipNodeId),
},
paint: emptyPaintTable(),
visibility: emptyVisibilityTable(snapshot.subjects.ids.length),
provenance: {
factId: snapshot.provenance.map((p) => p.factId),
extractionStepId: snapshot.provenance.map((p) => p.extractionStepId),
sourceKind: snapshot.provenance.map((p) => p.sourceKind),
sourceRef: snapshot.provenance.map((p) => p.sourceRef),
},
confidence: {
factId: snapshot.confidence.map((c) => c.factId),
confidence: snapshot.confidence.map((c) => c.confidence),
reasonCode: snapshot.confidence.map((c) => c.reasonCode),
},
}
}
+220
View File
@@ -0,0 +1,220 @@
/**
* DOM Extraction
*
* Captures the DOM tree, node identifiers, selectors, and shadow DOM
* boundaries using CDP DOM domain commands.
*
* Bulk-first: we request the full document tree in one call, then
* traverse the returned tree locally rather than making per-node
* round-trips.
*/
import type { CDPSession } from './session.js'
/**
* Raw DOM node as returned by CDP DOM.getDocument with depth -1.
*/
export interface CDPDOMNode {
nodeId: number
backendNodeId: number
nodeType: number
nodeName: string
localName?: string
nodeValue?: string
parentId?: number
children?: CDPDOMNode[]
shadowRoots?: CDPDOMNode[]
pseudoElements?: CDPDOMNode[]
attributes?: string[]
}
/**
* Flattened DOM record for a single node.
*/
export interface DOMNodeRecord {
nodeId: number
backendNodeId: number
parentNodeId: number
firstChildIndex: number
childCount: number
shadowRootKind: 'open' | 'closed' | 'none'
tagNameStringId: number
roleStringId: number
ariaNameStringId: number
}
/**
* Result of DOM extraction.
*/
export interface DOMExtractionResult {
/** Flat array of DOM nodes in document order */
nodes: DOMNodeRecord[]
/** String table for deduplication */
strings: string[]
/** Map from nodeId to index in nodes array */
nodeIdToIndex: Map<number, number>
/** Root node index */
rootIndex: number
}
/**
* Extract the full DOM tree from the current page via CDP.
*
* Uses DOM.getDocument with depth -1 to fetch the entire tree
* in a single protocol round-trip.
*/
export async function extractDOM(session: CDPSession): Promise<DOMExtractionResult> {
const response = await session.send<{ root: CDPDOMNode }>('DOM.getDocument', {
depth: -1,
pierce: true,
})
const strings: string[] = []
const stringIndex = new Map<string, number>()
function getStringId(value: string): number {
if (stringIndex.has(value)) return stringIndex.get(value)!
const id = strings.length
strings.push(value)
stringIndex.set(value, id)
return id
}
const nodes: DOMNodeRecord[] = []
const nodeIdToIndex = new Map<number, number>()
function walk(node: CDPDOMNode, parentNodeId: number): number {
const nodeIndex = nodes.length
nodeIdToIndex.set(node.nodeId, nodeIndex)
const children = node.children ?? []
const shadowRoots = node.shadowRoots ?? []
const pseudoElements = node.pseudoElements ?? []
const allChildren = [...children, ...shadowRoots, ...pseudoElements]
const firstChildIndex = nodes.length + 1
const childCount = allChildren.length
// Determine shadow root kind
let shadowRootKind: 'open' | 'closed' | 'none' = 'none'
if (shadowRoots.length > 0) {
// CDP does not explicitly return open/closed in getDocument;
// we default to 'open' and refine later if needed.
shadowRootKind = 'open'
}
// Extract tag name from localName or nodeName
const tagName = node.localName || node.nodeName.toLowerCase()
const tagNameStringId = getStringId(tagName)
// Extract role from attributes if present
let roleStringId = getStringId('')
let ariaNameStringId = getStringId('')
if (node.attributes) {
for (let i = 0; i < node.attributes.length; i += 2) {
const attrName = node.attributes[i]
const attrValue = node.attributes[i + 1]
if (attrName === 'role') {
roleStringId = getStringId(attrValue)
}
if (attrName === 'aria-label' || attrName === 'aria-labelledby') {
ariaNameStringId = getStringId(attrValue)
}
}
}
nodes.push({
nodeId: node.nodeId,
backendNodeId: node.backendNodeId,
parentNodeId,
firstChildIndex,
childCount,
shadowRootKind,
tagNameStringId,
roleStringId,
ariaNameStringId,
})
// Walk children after pushing parent so indices are stable.
for (const child of allChildren) {
walk(child, node.nodeId)
}
return nodeIndex
}
const rootIndex = walk(response.root, 0)
return {
nodes,
strings,
nodeIdToIndex,
rootIndex,
}
}
/**
* Resolved selector match with both frontend nodeId and stable backendNodeId.
*/
export interface SelectorMatch {
nodeId: number
backendNodeId: number
}
/**
* Resolve a CSS selector to node IDs via CDP.
*
* Returns an array of matches with both frontend nodeId and stable
* backendNodeId. We keep both because backendNodeId is the canonical
* identity for geometry/topology, while frontend nodeId is required
* by CSS.getComputedStyleForNode.
*/
export async function resolveSelector(
session: CDPSession | { send: CDPSession['send']; getDocumentRootNodeId?: () => Promise<number> },
selector: string
): Promise<SelectorMatch[]> {
// Use cached document root if available, otherwise fetch it.
let rootNodeId: number
if ('getDocumentRootNodeId' in session && session.getDocumentRootNodeId) {
rootNodeId = await session.getDocumentRootNodeId()
} else {
const doc = await session.send<{ root: { nodeId: number } }>('DOM.getDocument')
rootNodeId = doc.root.nodeId
}
const { nodeIds } = await session.send<{ nodeIds: number[] }>(
'DOM.querySelectorAll',
{
nodeId: rootNodeId,
selector,
}
)
// Convert each frontend nodeId to its stable backendNodeId in parallel.
const matches: SelectorMatch[] = []
const describeResults = await Promise.all(
nodeIds.map(async (nodeId) => {
try {
const result = await session.send<{ node: { backendNodeId: number } }>('DOM.describeNode', { nodeId })
return { nodeId, backendNodeId: result.node.backendNodeId }
} catch {
return null
}
})
)
for (const match of describeResults) {
if (match !== null) {
matches.push(match)
}
}
return matches
}
/**
* Resolve a single selector to one backend node ID, or null if none match.
*/
export async function resolveOneSelector(
session: CDPSession,
selector: string
): Promise<number | null> {
const matches = await resolveSelector(session, selector)
return matches.length > 0 ? matches[0].backendNodeId : null
}
+738
View File
@@ -0,0 +1,738 @@
/**
* Extractor Integration Tests
*
* Tests CDP session setup, DOM extraction, and geometry extraction
* using a mock CDP session to avoid requiring a real browser.
*
* Run with: node --test dist/extractor.test.js
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
CDPSessionManager,
CDPExtractor,
extractDOM,
extractBoxModels,
extractFragments,
extractTransforms,
extractStyles,
extractTopology,
type CDPSession,
type ExtractorRequest,
} from './index.js'
// ---------------------------------------------------------------------------
// Mock CDP Session Factory
// ---------------------------------------------------------------------------
function createMockSession(
responses: Map<string, unknown>,
fallback?: (method: string, params?: Record<string, unknown>) => unknown
): CDPSession {
return {
async send<T>(method: string, params?: Record<string, unknown>): Promise<T> {
const key = `${method}:${JSON.stringify(params ?? {})}`
let value: unknown = undefined
if (responses.has(method)) {
value = responses.get(method)
} else if (responses.has(key)) {
value = responses.get(key)
}
if (value instanceof Error) {
throw value
}
if (value !== undefined) {
return Promise.resolve(value as T)
}
if (fallback) {
const fallbackValue = fallback(method, params)
if (fallbackValue !== undefined) {
return Promise.resolve(fallbackValue as T)
}
}
throw new Error(`Unexpected CDP call: ${method} with ${JSON.stringify(params)}`)
},
on() {},
off() {},
async detach() {},
}
}
function createMockSessionFactory(
responses: Map<string, unknown>,
fallback?: (method: string, params?: Record<string, unknown>) => unknown
): (pageRef: unknown) => Promise<CDPSession> {
return async () => createMockSession(responses, fallback)
}
// ---------------------------------------------------------------------------
// CDP Session Setup
// ---------------------------------------------------------------------------
describe('CDP Session Management', () => {
it('should attach a session via the factory', async () => {
const factory = createMockSessionFactory(new Map())
const manager = new CDPSessionManager(factory, {})
const session = await manager.attach()
assert.ok(session, 'session should be defined')
})
it('should return the same session on re-attach', async () => {
const factory = createMockSessionFactory(new Map())
const manager = new CDPSessionManager(factory, {})
const s1 = await manager.attach()
const s2 = await manager.attach()
assert.strictEqual(s1, s2, 'should reuse attached session')
})
it('should throw if getSession called before attach', () => {
const factory = createMockSessionFactory(new Map())
const manager = new CDPSessionManager(factory, {})
assert.throws(() => manager.getSession(), /not attached/)
})
it('should detach cleanly', async () => {
const factory = createMockSessionFactory(new Map())
const manager = new CDPSessionManager(factory, {})
await manager.attach()
await manager.detach()
assert.throws(() => manager.getSession(), /not attached/)
})
})
// ---------------------------------------------------------------------------
// DOM Extraction
// ---------------------------------------------------------------------------
describe('DOM Extraction', () => {
it('should extract a flat DOM tree from CDP getDocument', async () => {
const responses = new Map<string, unknown>()
responses.set('DOM.getDocument', {
root: {
nodeId: 1,
backendNodeId: 101,
nodeType: 9,
nodeName: '#document',
children: [
{
nodeId: 2,
backendNodeId: 102,
nodeType: 1,
nodeName: 'HTML',
localName: 'html',
children: [
{
nodeId: 3,
backendNodeId: 103,
nodeType: 1,
nodeName: 'BODY',
localName: 'body',
children: [
{
nodeId: 4,
backendNodeId: 104,
nodeType: 1,
nodeName: 'DIV',
localName: 'div',
attributes: ['class', 'container', 'role', 'main'],
},
],
},
],
},
],
},
})
const session = createMockSession(responses)
const result = await extractDOM(session)
assert.strictEqual(result.nodes.length, 4, 'should have 4 nodes')
assert.strictEqual(result.rootIndex, 0, 'root should be index 0')
assert.ok(result.nodeIdToIndex.has(4), 'should map nodeId 4')
// Check string deduplication
assert.ok(result.strings.includes('div'), 'should include div tag')
assert.ok(result.strings.includes('main'), 'should include role main')
// Check node 4 record
const node4Idx = result.nodeIdToIndex.get(4)!
const node4 = result.nodes[node4Idx]
assert.strictEqual(node4.nodeId, 4)
assert.strictEqual(node4.parentNodeId, 3)
assert.strictEqual(node4.shadowRootKind, 'none')
})
it('should handle shadow roots', async () => {
const responses = new Map<string, unknown>()
responses.set('DOM.getDocument', {
root: {
nodeId: 1,
backendNodeId: 101,
nodeType: 9,
nodeName: '#document',
children: [
{
nodeId: 2,
backendNodeId: 102,
nodeType: 1,
nodeName: 'DIV',
localName: 'div',
shadowRoots: [
{
nodeId: 3,
backendNodeId: 103,
nodeType: 11,
nodeName: '#document-fragment',
children: [
{
nodeId: 4,
backendNodeId: 104,
nodeType: 1,
nodeName: 'SPAN',
localName: 'span',
},
],
},
],
},
],
},
})
const session = createMockSession(responses)
const result = await extractDOM(session)
assert.strictEqual(result.nodes.length, 4, 'should have 4 nodes including shadow root and its child')
// shadowRootKind is recorded on the host element (node 2), not the fragment
const hostIdx = result.nodeIdToIndex.get(2)!
assert.strictEqual(result.nodes[hostIdx].shadowRootKind, 'open')
})
})
// ---------------------------------------------------------------------------
// Geometry Extraction
// ---------------------------------------------------------------------------
describe('Geometry Extraction', () => {
it('should extract box models in bulk', async () => {
const responses = new Map<string, unknown>()
// Box model responses keyed by backendNodeId.
// DOM.getBoxModel now accepts backendNodeId directly and returns { model: CDPBoxModel }.
responses.set(
'DOM.getBoxModel:{"backendNodeId":10}',
{
model: {
content: [100, 100, 200, 100, 200, 200, 100, 200],
padding: [90, 90, 210, 90, 210, 210, 90, 210],
border: [80, 80, 220, 80, 220, 220, 80, 220],
margin: [70, 70, 230, 70, 230, 230, 70, 230],
width: 100,
height: 100,
},
}
)
responses.set(
'DOM.getBoxModel:{"backendNodeId":20}',
{
model: {
content: [300, 300, 400, 300, 400, 400, 300, 400],
padding: [290, 290, 410, 290, 410, 410, 290, 410],
border: [280, 280, 420, 280, 420, 420, 280, 420],
margin: [270, 270, 430, 270, 430, 430, 270, 430],
width: 100,
height: 100,
},
}
)
const session = createMockSession(responses)
const backendNodeIds = [10, 20]
const subjectIds = [0, 1]
const { boxes, errors } = await extractBoxModels(session, backendNodeIds, subjectIds)
assert.strictEqual(boxes.length, 2, 'should extract 2 boxes')
assert.strictEqual(errors.length, 0, 'should have no errors')
const box0 = boxes[0]
assert.strictEqual(box0.borderLeft, 80)
assert.strictEqual(box0.borderTop, 80)
assert.strictEqual(box0.borderRight, 220)
assert.strictEqual(box0.borderBottom, 220)
assert.strictEqual(box0.contentLeft, 100)
assert.strictEqual(box0.contentTop, 100)
assert.strictEqual(box0.contentRight, 200)
assert.strictEqual(box0.contentBottom, 200)
})
it('should handle box model extraction failures gracefully', async () => {
const responses = new Map<string, unknown>()
// DOM.getBoxModel now uses backendNodeId parameter.
responses.set('DOM.getBoxModel:{"backendNodeId":10}', new Error('Node not found'))
const session = createMockSession(responses)
const backendNodeIds = [10]
const subjectIds = [0]
const { boxes, errors } = await extractBoxModels(session, backendNodeIds, subjectIds)
assert.strictEqual(boxes.length, 0)
assert.strictEqual(errors.length, 1)
assert.ok(errors[0].reason.includes('Node not found'))
})
})
// ---------------------------------------------------------------------------
// Style Extraction
// ---------------------------------------------------------------------------
describe('Style Extraction', () => {
it('should extract computed styles and map to enums', async () => {
const responses = new Map<string, unknown>()
// extractStyles now resolves backendNodeId -> objectId via DOM.resolveNode
// then uses Runtime.callFunctionOn to read computed styles via JS.
responses.set('DOM.resolveNode', { object: { objectId: 'obj1' } })
responses.set('Runtime.callFunctionOn', {
result: {
value: {
display: 'flex',
position: 'relative',
zIndex: '10',
overflowX: 'hidden',
overflowY: 'auto',
opacity: '0.5',
visibility: 'visible',
contain: 'layout paint',
pointerEvents: 'none',
lineHeight: '24px',
fontFamily: 'Arial',
fontSize: '16px',
fontWeight: '700',
},
},
})
const session = createMockSession(responses)
const backendNodeIds = [110]
const subjectIds = [0]
const { styles, errors } = await extractStyles(session, backendNodeIds, subjectIds, { all: true }, [])
assert.strictEqual(styles.length, 1)
assert.strictEqual(errors.length, 0)
const s = styles[0]
assert.strictEqual(s.display, 5) // flex
assert.strictEqual(s.position, 2) // relative
assert.strictEqual(s.zIndexKind, 1) // integer
assert.strictEqual(s.zIndexValue, 10)
assert.strictEqual(s.overflowX, 2) // hidden
assert.strictEqual(s.overflowY, 5) // auto
assert.strictEqual(s.opacity, 0.5)
assert.strictEqual(s.containFlags, 3) // layout(1) | paint(2)
assert.strictEqual(s.pointerEvents, 2) // none
assert.strictEqual(s.fontSize, 16)
assert.strictEqual(s.fontWeight, 700)
})
})
// ---------------------------------------------------------------------------
// Topology Extraction
// ---------------------------------------------------------------------------
describe('Topology Extraction', () => {
it('should extract topology via Runtime.evaluate', async () => {
const mockTopologyResult = {
scroll: [
{
containerId: 1,
scrollLeft: 0,
scrollTop: 100,
scrollWidth: 500,
scrollHeight: 1000,
clientWidth: 400,
clientHeight: 300,
},
],
clipping: [
{
clipNodeId: 0,
subjectId: 1,
clipKind: 1,
clipLeft: 0,
clipTop: 0,
clipRight: 400,
clipBottom: 300,
parentClipNodeId: 0,
},
],
topology: {
containingBlockOf: [0],
nearestPositionedAncestorOf: [0],
scrollContainerOf: [0],
stackingContextOf: [0],
formattingContextOf: [0],
clippingRootOf: [0],
paintOrderBucket: [1],
paintOrderIndex: [0],
},
}
const responses = new Map<string, unknown>()
responses.set('Runtime.evaluate', {
result: { value: mockTopologyResult },
})
const session = createMockSession(responses)
const { result, errors } = await extractTopology(session, [10], [0])
assert.strictEqual(errors.length, 0)
assert.strictEqual(result.scroll.length, 1)
assert.strictEqual(result.scroll[0].scrollTop, 100)
assert.strictEqual(result.clipping.length, 1)
assert.strictEqual(result.topology.paintOrderBucket[0], 1)
})
})
// ---------------------------------------------------------------------------
// Full Orchestrator
// ---------------------------------------------------------------------------
describe('CDPExtractor', () => {
it('should perform full extraction with partial handling', async () => {
const responses = new Map<string, unknown>()
// Domain enablement is now part of the attach flow.
responses.set('DOM.enable', {})
responses.set('CSS.enable', {})
responses.set('Runtime.enable', {})
// DOM.getDocument
responses.set('DOM.getDocument', {
root: {
nodeId: 1,
backendNodeId: 101,
nodeType: 9,
nodeName: '#document',
children: [
{
nodeId: 2,
backendNodeId: 102,
nodeType: 1,
nodeName: 'BODY',
localName: 'body',
children: [
{
nodeId: 10,
backendNodeId: 110,
nodeType: 1,
nodeName: 'DIV',
localName: 'div',
attributes: ['class', 'test'],
},
],
},
],
},
})
// Selector resolution
responses.set('DOM.querySelectorAll', { nodeIds: [10] })
responses.set('DOM.describeNode', { node: { backendNodeId: 110 } })
// Box model (uses backendNodeId, returns { model: {...} })
responses.set(
'DOM.getBoxModel:{"backendNodeId":110}',
{
model: {
content: [10, 10, 110, 10, 110, 110, 10, 110],
padding: [0, 0, 120, 0, 120, 120, 0, 120],
border: [0, 0, 120, 0, 120, 120, 0, 120],
margin: [0, 0, 120, 0, 120, 120, 0, 120],
width: 100,
height: 100,
},
}
)
// Styles (uses DOM.resolveNode + Runtime.callFunctionOn)
responses.set('DOM.resolveNode', { object: { objectId: 'obj1' } })
responses.set('Runtime.callFunctionOn', {
result: {
value: {
display: 'block',
position: 'static',
zIndex: 'auto',
overflowX: 'visible',
overflowY: 'visible',
opacity: '1',
visibility: 'visible',
contain: 'none',
pointerEvents: 'auto',
lineHeight: 'normal',
fontFamily: 'sans-serif',
fontSize: '16px',
fontWeight: '400',
},
},
})
// Topology via Runtime.evaluate
responses.set('Runtime.evaluate', {
result: {
value: {
scroll: [],
clipping: [],
topology: {
containingBlockOf: [0],
nearestPositionedAncestorOf: [0],
scrollContainerOf: [0],
stackingContextOf: [0],
formattingContextOf: [0],
clippingRootOf: [0],
paintOrderBucket: [1],
paintOrderIndex: [0],
},
},
},
})
const factory = createMockSessionFactory(responses)
const manager = new CDPSessionManager(factory, {})
const extractor = new CDPExtractor(manager)
const request: ExtractorRequest = {
requestId: 'test-1',
sceneTarget: { pageRef: {}, url: 'https://example.test' },
env: {
viewportWidth: 1280,
viewportHeight: 720,
colorScheme: 'light',
pointer: 'fine',
},
subjects: [{ id: 's1', selector: '.test' }],
requiredFacts: {
geometry: true,
fragments: false,
styles: true,
topology: true,
},
}
const response = await extractor.extract(request)
assert.strictEqual(response.requestId, 'test-1')
assert.strictEqual(response.status, 'ok')
assert.strictEqual(response.diagnostics.length, 0)
assert.ok(response.snapshots, 'snapshots array should exist')
assert.strictEqual(response.snapshots.length, 1)
assert.ok(response.snapshot, 'deprecated snapshot field still present')
assert.strictEqual(response.snapshots[0].subjects.ids.length, 1)
assert.strictEqual(response.snapshots[0].boxes.boxId.length, 1)
assert.strictEqual(response.snapshots[0].styles.display[0], 2) // block
assert.strictEqual(response.snapshots[0].provenance.length, 5) // 5 extraction steps
})
it('should return partial status when selectors do not match', async () => {
const responses = new Map<string, unknown>()
// Domain enablement is now part of the attach flow.
responses.set('DOM.enable', {})
responses.set('CSS.enable', {})
responses.set('Runtime.enable', {})
responses.set('DOM.getDocument', {
root: {
nodeId: 1,
backendNodeId: 101,
nodeType: 9,
nodeName: '#document',
children: [],
},
})
responses.set('DOM.querySelectorAll', { nodeIds: [] })
const factory = createMockSessionFactory(responses)
const manager = new CDPSessionManager(factory, {})
const extractor = new CDPExtractor(manager)
const request: ExtractorRequest = {
requestId: 'test-2',
sceneTarget: { pageRef: {}, url: 'https://example.test' },
env: {
viewportWidth: 1280,
viewportHeight: 720,
colorScheme: 'light',
pointer: 'fine',
},
subjects: [{ id: 's1', selector: '.missing' }],
requiredFacts: { geometry: true },
}
const response = await extractor.extract(request)
assert.strictEqual(response.status, 'partial')
assert.ok(
response.diagnostics.some((d) => d.code === 'IMH_SELECTOR_NO_MATCH'),
'should emit selector no match diagnostic'
)
})
it('should resolve multiple matches per selector and preserve identity', async () => {
const responses = new Map<string, unknown>()
// Domain enablement is now part of the attach flow.
responses.set('DOM.enable', {})
responses.set('CSS.enable', {})
responses.set('Runtime.enable', {})
responses.set('DOM.getDocument', {
root: {
nodeId: 1,
backendNodeId: 101,
nodeType: 9,
nodeName: '#document',
children: [
{
nodeId: 2,
backendNodeId: 102,
nodeType: 1,
nodeName: 'DIV',
localName: 'div',
children: [
{
nodeId: 10,
backendNodeId: 110,
nodeType: 1,
nodeName: 'BUTTON',
localName: 'button',
attributes: ['class', 'btn'],
},
{
nodeId: 11,
backendNodeId: 111,
nodeType: 1,
nodeName: 'BUTTON',
localName: 'button',
attributes: ['class', 'btn'],
},
],
},
],
},
})
// querySelectorAll returns frontend nodeIds for the selector.
responses.set('DOM.querySelectorAll', { nodeIds: [10, 11] })
// DOM.describeNode converts each frontend nodeId to its stable backendNodeId.
// We mock the keyed response for each nodeId.
responses.set('DOM.describeNode:{"nodeId":10}', { node: { backendNodeId: 110 } })
responses.set('DOM.describeNode:{"nodeId":11}', { node: { backendNodeId: 111 } })
// Box models for each backendNodeId.
responses.set(
'DOM.getBoxModel:{"backendNodeId":110}',
{
model: {
content: [0, 0, 100, 0, 100, 40, 0, 40],
padding: [0, 0, 100, 0, 100, 40, 0, 40],
border: [0, 0, 100, 0, 100, 40, 0, 40],
margin: [0, 0, 100, 0, 100, 40, 0, 40],
width: 100,
height: 40,
},
}
)
responses.set(
'DOM.getBoxModel:{"backendNodeId":111}',
{
model: {
content: [110, 0, 210, 0, 210, 40, 110, 40],
padding: [110, 0, 210, 0, 210, 40, 110, 40],
border: [110, 0, 210, 0, 210, 40, 110, 40],
margin: [110, 0, 210, 0, 210, 40, 110, 40],
width: 100,
height: 40,
},
}
)
// Transforms use DOM.resolveNode + Runtime.callFunctionOn
responses.set('DOM.resolveNode', { object: { objectId: 'obj1' } })
responses.set('Runtime.callFunctionOn', {
result: {
value: {
matrix: [1, 0, 0, 1, 0, 0],
originX: 0,
originY: 0,
},
},
})
const factory = createMockSessionFactory(responses)
const manager = new CDPSessionManager(factory, {})
const extractor = new CDPExtractor(manager)
const request: ExtractorRequest = {
requestId: 'test-multi',
sceneTarget: { pageRef: {}, url: 'https://example.test' },
env: {
viewportWidth: 1280,
viewportHeight: 720,
colorScheme: 'light',
pointer: 'fine',
},
subjects: [{ id: 's1', selector: '.btn' }],
requiredFacts: { geometry: true, styles: false, topology: false },
}
const response = await extractor.extract(request)
assert.strictEqual(response.status, 'ok')
assert.strictEqual(response.diagnostics.length, 0)
assert.strictEqual(response.snapshots[0].subjects.ids.length, 2)
assert.strictEqual(response.snapshots[0].boxes.boxId.length, 2)
// Verify backendNodeIds are preserved in the subjects table.
const backendIds = response.snapshots[0].subjects.domNodeId
assert.strictEqual(backendIds.length, 2)
assert.ok(backendIds.includes(110), 'should include backendNodeId 110')
assert.ok(backendIds.includes(111), 'should include backendNodeId 111')
})
it('should return error status when session attach fails', async () => {
const failingFactory = async () => {
throw new Error('Browser not reachable')
}
const manager = new CDPSessionManager(failingFactory, {})
const extractor = new CDPExtractor(manager)
const request: ExtractorRequest = {
requestId: 'test-3',
sceneTarget: { pageRef: {}, url: 'https://example.test' },
env: {
viewportWidth: 1280,
viewportHeight: 720,
colorScheme: 'light',
pointer: 'fine',
},
subjects: [],
requiredFacts: {},
}
const response = await extractor.extract(request)
assert.strictEqual(response.status, 'error')
assert.ok(
response.diagnostics.some((d) => d.code === 'IMH_CDP_SESSION_ATTACH_FAILED'),
'should emit attach failed diagnostic'
)
assert.strictEqual(response.snapshots[0].subjects.ids.length, 0)
})
})
+973
View File
@@ -0,0 +1,973 @@
/**
* Main Extraction Orchestrator
*
* Consumes an extractor request plan, coordinates CDP session
* management, DOM resolution, geometry capture, style extraction,
* and topology building.
*
* Returns raw browser facts matching the geometry world schema,
* with provenance metadata and graceful partial handling.
*/
import type { DiagnosticCode } from 'imhotep-core'
import type { CDPSession, CDPSessionManager } from './session.js'
import { extractDOM, resolveSelector, type DOMExtractionResult, type SelectorMatch } from './dom.js'
import {
extractBoxModels,
extractFragments,
extractTransforms,
extractVisualBoxes,
type GeometryExtractionResult,
type BoxRecord,
type FragmentRecord,
type TransformRecord,
} from './geometry.js'
import { extractStyles, type StyleRecord, type StyleExtractionConfig } from './styles.js'
import { extractTopology, type TopologyExtractionResult } from './topology.js'
/**
* Extractor request plan.
*/
export interface ExtractorRequest {
requestId: string
sceneTarget: {
pageRef: unknown
url: string
}
env: {
viewportWidth: number
viewportHeight: number
colorScheme: string
pointer: string
}
subjects: Array<{ id: string; selector: string; nodeId?: number; backendNodeId?: number }>
requiredFacts: {
geometry?: boolean
fragments?: boolean
styles?: boolean | string[]
topology?: boolean | string[]
text?: boolean
}
}
/**
* Provenance entry for a fact.
*/
export interface ProvenanceEntry {
factId: number
extractionStepId: number
sourceKind: number
sourceRef: number
}
/**
* Confidence entry for a fact.
*/
export interface ConfidenceEntry {
factId: number
confidence: number
reasonCode: number
}
/**
* Extraction trace entry.
*/
export interface ExtractionTraceEntry {
stepId: number
factKind: string
status: 'ok' | 'partial' | 'error'
}
/**
* Extractor response.
*
* Canonical shape uses snapshots (array). The singular snapshot field is
* kept for backward compatibility but deprecated — use snapshots[0].
*/
export interface ExtractorResponse {
requestId: string
status: 'ok' | 'partial' | 'error'
/** @deprecated Use snapshots[0] instead. Kept for backward compatibility. */
snapshot: GeometryWorldSnapshot
/** Canonical shape: array of snapshots for multi-state extraction. */
snapshots: GeometryWorldSnapshot[]
diagnostics: ExtractorDiagnostic[]
extractionTrace: {
steps: ExtractionTraceEntry[]
timings: Array<{ stepId: number; startMs: number; endMs: number }>
protocolCalls: Array<{ stepId: number; protocol: string; method: string; params: Record<string, unknown> }>
}
}
/**
* Diagnostic for extraction failures.
*/
export interface ExtractorDiagnostic {
code: DiagnosticCode
severity: 'warning' | 'error'
message: string
subjectId?: string
selector?: string
}
/**
* Geometry world snapshot.
* Mirrors the geometry world schema from the core contracts.
*/
export interface GeometryWorldSnapshot {
sceneId: string
snapshotId: string
env: {
viewportWidth: number
viewportHeight: number
deviceScaleFactor: number
colorScheme: string
pointer: string
hover: boolean
reducedMotion: string
locale: string
writingMode: string
}
source: {
url: string
browserName: string
browserVersion: string
engine: string
extractedAt: string
}
strings: string[]
subjects: {
ids: number[]
domNodeId: number[]
subjectKind: number[]
primaryBoxId: number[]
firstFragmentId: number[]
fragmentCount: number[]
firstTextRunId: number[]
textRunCount: number[]
}
dom: DOMExtractionResult
frames: {
frameId: number[]
frameKind: number[]
ownerSubjectId: number[]
parentFrameId: number[]
originX: number[]
originY: number[]
axisMatrixStart: number[]
clipRectId: number[]
scrollContainerId: number[]
writingMode: number[]
}
matrices: {
values: number[]
}
rects: {
rectId: number[]
left: number[]
top: number[]
right: number[]
bottom: number[]
}
boxes: {
boxId: number[]
subjectId: number[]
frameId: number[]
borderLeft: number[]
borderTop: number[]
borderRight: number[]
borderBottom: number[]
paddingLeft: number[]
paddingTop: number[]
paddingRight: number[]
paddingBottom: number[]
contentLeft: number[]
contentTop: number[]
contentRight: number[]
contentBottom: number[]
}
visualBoxes: {
boxId: number[]
subjectId: number[]
frameId: number[]
borderLeft: number[]
borderTop: number[]
borderRight: number[]
borderBottom: number[]
paddingLeft: number[]
paddingTop: number[]
paddingRight: number[]
paddingBottom: number[]
contentLeft: number[]
contentTop: number[]
contentRight: number[]
contentBottom: number[]
}
fragments: {
fragmentId: number[]
subjectId: number[]
fragmentKind: number[]
boxLeft: number[]
boxTop: number[]
boxRight: number[]
boxBottom: number[]
lineIndex: number[]
flowIndex: number[]
parentFragmentId: number[]
}
transforms: {
transformId: number[]
subjectId: number[]
matrixStart: number[]
matrixLength: number[]
originX: number[]
originY: number[]
}
styles: {
subjectId: number[]
display: number[]
position: number[]
zIndexKind: number[]
zIndexValue: number[]
overflowX: number[]
overflowY: number[]
opacity: number[]
visibility: number[]
containFlags: number[]
pointerEvents: number[]
lineHeight: number[]
fontFamilyStringId: number[]
fontSize: number[]
fontWeight: number[]
}
topology: TopologyExtractionResult['topology']
scroll: TopologyExtractionResult['scroll']
clipping: TopologyExtractionResult['clipping']
provenance: ProvenanceEntry[]
confidence: ConfidenceEntry[]
}
/**
* Main extractor class.
*
* Orchestrates the full extraction pipeline:
* 1. Attach CDP session
* 2. Resolve selectors to node IDs
* 3. Extract DOM tree
* 4. Extract geometry (boxes, fragments, transforms)
* 5. Extract styles
* 6. Extract topology
* 7. Build geometry world snapshot
* 8. Return response with provenance and diagnostics
*/
export class CDPExtractor {
private readonly sessionManager: CDPSessionManager
constructor(sessionManager: CDPSessionManager) {
this.sessionManager = sessionManager
}
/**
* Execute extraction according to the request plan.
*/
async extract(request: ExtractorRequest): Promise<ExtractorResponse> {
const startTime = Date.now()
const diagnostics: ExtractorDiagnostic[] = []
const traceSteps: ExtractionTraceEntry[] = []
const traceTimings: Array<{ stepId: number; startMs: number; endMs: number }> = []
const traceCalls: Array<{
stepId: number
protocol: string
method: string
params: Record<string, unknown>
}> = []
function recordStep(
stepId: number,
factKind: string,
status: 'ok' | 'partial' | 'error',
startMs: number,
endMs: number
) {
traceSteps.push({ stepId, factKind, status })
traceTimings.push({ stepId, startMs, endMs })
}
let session: CDPSession
try {
session = await this.sessionManager.attach()
// Enable required CDP domains before extraction.
// Use cached enablement to avoid redundant round-trips.
await this.sessionManager.enableDomain('DOM')
await this.sessionManager.enableDomain('CSS')
await this.sessionManager.enableDomain('Runtime')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
diagnostics.push({
code: 'IMH_CDP_SESSION_ATTACH_FAILED',
severity: 'error',
message: `Failed to attach CDP session: ${message}`,
})
const emptySnapshot = createEmptySnapshot(request)
return {
requestId: request.requestId,
status: 'error',
snapshot: emptySnapshot,
snapshots: [emptySnapshot],
diagnostics,
extractionTrace: {
steps: traceSteps,
timings: traceTimings,
protocolCalls: traceCalls,
},
}
}
// --- Step 1: Extract DOM ---
// Extract DOM first so the full tree is pushed to the frontend.
// This ensures backendNodeIds returned by querySelectorAll remain valid.
const domStepStart = Date.now()
let domResult: DOMExtractionResult
try {
domResult = await extractDOM(session)
recordStep(1, 'dom-tree', 'ok', domStepStart, Date.now())
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
diagnostics.push({
code: 'IMH_DOM_EXTRACTION_FAILED',
severity: 'error',
message: `DOM extraction failed: ${message}`,
})
domResult = { nodes: [], strings: [], nodeIdToIndex: new Map(), rootIndex: 0 }
recordStep(1, 'dom-tree', 'error', domStepStart, Date.now())
}
// --- Step 2: Resolve selectors ---
const selectorStepStart = Date.now()
const backendNodeIds: number[] = []
const nodeIds: number[] = []
const subjectIds: number[] = []
const selectorDiagnosticsStart = diagnostics.length
const resolvedSubjects: Array<{ id: string; selector: string; backendNodeId: number; nodeId: number }> = []
// Resolve all selectors sequentially to avoid CDP race conditions
// when multiple querySelectorAll calls run concurrently.
// Callers that already resolved selectors can pass nodeId/backendNodeId
// to avoid a duplicate querySelectorAll + describeNode pass.
const selectorResults: Array<{ subject: typeof request.subjects[0]; matches: Awaited<ReturnType<typeof resolveSelector>>; error: string | null }> = []
for (const subject of request.subjects) {
if (subject.nodeId !== undefined && subject.backendNodeId !== undefined) {
selectorResults.push({
subject,
matches: [{ nodeId: subject.nodeId, backendNodeId: subject.backendNodeId }],
error: null,
})
continue
}
try {
const matches = await resolveSelector(session, subject.selector)
selectorResults.push({ subject, matches, error: null })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
selectorResults.push({ subject, matches: [], error: message })
}
}
for (const { subject, matches, error } of selectorResults) {
if (error) {
diagnostics.push({
code: 'IMH_SELECTOR_RESOLUTION_FAILED',
severity: 'error',
message: `Failed to resolve selector "${subject.selector}": ${error}`,
subjectId: subject.id,
selector: subject.selector,
})
} else if (matches.length === 0) {
diagnostics.push({
code: 'IMH_SELECTOR_NO_MATCH',
severity: 'warning',
message: `Selector "${subject.selector}" matched 0 elements.`,
subjectId: subject.id,
selector: subject.selector,
})
} else {
for (const match of matches) {
backendNodeIds.push(match.backendNodeId)
nodeIds.push(match.nodeId)
subjectIds.push(resolvedSubjects.length)
resolvedSubjects.push({ ...subject, backendNodeId: match.backendNodeId, nodeId: match.nodeId })
}
}
}
const selectorDiagnosticsAdded = diagnostics.length > selectorDiagnosticsStart
recordStep(2, 'selector-resolution', selectorDiagnosticsAdded ? 'partial' : 'ok', selectorStepStart, Date.now())
// --- Step 3: Extract Geometry ---
const geometryStepStart = Date.now()
let boxRecords: BoxRecord[] = []
let fragmentRecords: FragmentRecord[] = []
let transformRecords: TransformRecord[] = []
let matrixValues: number[] = []
let visualBoxRecords: BoxRecord[] = []
if (request.requiredFacts.geometry !== false && backendNodeIds.length > 0) {
try {
const { boxes, errors } = await extractBoxModels(session, backendNodeIds, subjectIds)
boxRecords = boxes
for (const e of errors) {
diagnostics.push({
code: 'IMH_BOX_MODEL_PARTIAL',
severity: 'warning',
message: `Box model extraction failed for node ${e.backendNodeId}: ${e.reason}`,
})
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
diagnostics.push({
code: 'IMH_BOX_MODEL_FAILED',
severity: 'error',
message: `Box model extraction failed: ${message}`,
})
}
// Extract visual boxes (post-transform coordinates via getBoundingClientRect)
try {
const { boxes, errors } = await extractVisualBoxes(session, backendNodeIds, subjectIds)
visualBoxRecords = boxes
for (const e of errors) {
diagnostics.push({
code: 'IMH_VISUAL_BOX_PARTIAL',
severity: 'warning',
message: `Visual box extraction failed for node ${e.backendNodeId}: ${e.reason}`,
})
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
diagnostics.push({
code: 'IMH_VISUAL_BOX_FAILED',
severity: 'error',
message: `Visual box extraction failed: ${message}`,
})
}
if (request.requiredFacts.fragments) {
try {
const { fragments, errors } = await extractFragments(session, backendNodeIds, subjectIds)
fragmentRecords = fragments
for (const e of errors) {
diagnostics.push({
code: 'IMH_FRAGMENT_PARTIAL',
severity: 'warning',
message: `Fragment extraction failed for node ${e.backendNodeId}: ${e.reason}`,
})
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
diagnostics.push({
code: 'IMH_FRAGMENT_FAILED',
severity: 'error',
message: `Fragment extraction failed: ${message}`,
})
}
}
try {
const { transforms, matrices, errors } = await extractTransforms(session, backendNodeIds, subjectIds)
transformRecords = transforms
matrixValues = matrices
for (const e of errors) {
diagnostics.push({
code: 'IMH_TRANSFORM_PARTIAL',
severity: 'warning',
message: `Transform extraction failed for node ${e.backendNodeId}: ${e.reason}`,
})
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
diagnostics.push({
code: 'IMH_TRANSFORM_FAILED',
severity: 'error',
message: `Transform extraction failed: ${message}`,
})
}
}
recordStep(
3,
'geometry',
diagnostics.some((d) => d.code.startsWith('IMH_BOX_MODEL') || d.code.startsWith('IMH_FRAGMENT') || d.code.startsWith('IMH_TRANSFORM'))
? 'partial'
: 'ok',
geometryStepStart,
Date.now()
)
// --- Step 4: Extract Styles ---
const styleStepStart = Date.now()
let styleRecords: StyleRecord[] = []
if (request.requiredFacts.styles !== false && backendNodeIds.length > 0) {
const styleConfig: StyleExtractionConfig =
Array.isArray(request.requiredFacts.styles)
? { all: false, properties: request.requiredFacts.styles }
: { all: true }
try {
const { styles, errors, strings: updatedStrings } = await extractStyles(
session,
backendNodeIds,
subjectIds,
styleConfig,
domResult.strings
)
styleRecords = styles
domResult.strings = updatedStrings
for (const e of errors) {
diagnostics.push({
code: 'IMH_STYLE_PARTIAL',
severity: 'warning',
message: `Style extraction failed for node ${e.backendNodeId}: ${e.reason}`,
})
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
diagnostics.push({
code: 'IMH_STYLE_FAILED',
severity: 'error',
message: `Style extraction failed: ${message}`,
})
}
}
recordStep(4, 'styles', diagnostics.some((d) => d.code.startsWith('IMH_STYLE')) ? 'partial' : 'ok', styleStepStart, Date.now())
// --- Step 5: Extract Topology ---
const topologyStepStart = Date.now()
let topologyResult: TopologyExtractionResult = {
scroll: [],
clipping: [],
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
}
if (request.requiredFacts.topology !== false && backendNodeIds.length > 0) {
try {
const { result, errors } = await extractTopology(session, backendNodeIds, subjectIds)
topologyResult = result
for (const e of errors) {
diagnostics.push({
code: 'IMH_TOPOLOGY_PARTIAL',
severity: 'warning',
message: `Topology extraction failed for node ${e.backendNodeId}: ${e.reason}`,
})
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
diagnostics.push({
code: 'IMH_TOPOLOGY_FAILED',
severity: 'error',
message: `Topology extraction failed: ${message}`,
})
}
}
recordStep(5, 'topology', diagnostics.some((d) => d.code.startsWith('IMH_TOPOLOGY')) ? 'partial' : 'ok', topologyStepStart, Date.now())
// --- Build Geometry World Snapshot ---
const snapshot = buildSnapshot(
request,
domResult,
boxRecords,
visualBoxRecords,
fragmentRecords,
transformRecords,
matrixValues,
styleRecords,
topologyResult,
resolvedSubjects
)
// Determine overall status
const hasErrors = diagnostics.some((d) => d.severity === 'error')
const hasWarnings = diagnostics.some((d) => d.severity === 'warning')
const status: ExtractorResponse['status'] = hasErrors ? 'error' : hasWarnings ? 'partial' : 'ok'
// Add provenance for extraction steps
let factId = 0
const provenance: ProvenanceEntry[] = []
for (const step of traceSteps) {
provenance.push({
factId: factId++,
extractionStepId: step.stepId,
sourceKind: 1, // CDP protocol
sourceRef: step.stepId,
})
}
snapshot.provenance = provenance
return {
requestId: request.requestId,
status,
snapshot,
snapshots: [snapshot],
diagnostics,
extractionTrace: {
steps: traceSteps,
timings: traceTimings,
protocolCalls: traceCalls,
},
}
}
}
/**
* Build a geometry world snapshot from extracted raw data.
*/
function buildSnapshot(
request: ExtractorRequest,
dom: DOMExtractionResult,
boxes: BoxRecord[],
visualBoxes: BoxRecord[],
fragments: FragmentRecord[],
transforms: TransformRecord[],
matrices: number[],
styles: StyleRecord[],
topology: TopologyExtractionResult,
resolvedSubjects: Array<{ id: string; selector: string; backendNodeId: number; nodeId: number }>
): GeometryWorldSnapshot {
// Build subjects table
const subjectIds: number[] = []
const domNodeIds: number[] = []
const subjectKinds: number[] = []
const primaryBoxIds: number[] = []
const firstFragmentIds: number[] = []
const fragmentCounts: number[] = []
const firstTextRunIds: number[] = []
const textRunCounts: number[] = []
const boxBySubject = new Map<number, number>()
for (const b of boxes) {
boxBySubject.set(b.subjectId, b.boxId)
}
const fragmentsBySubject = new Map<number, number[]>()
for (const f of fragments) {
if (!fragmentsBySubject.has(f.subjectId)) {
fragmentsBySubject.set(f.subjectId, [])
}
fragmentsBySubject.get(f.subjectId)!.push(f.fragmentId)
}
for (let i = 0; i < resolvedSubjects.length; i++) {
const rs = resolvedSubjects[i]
subjectIds.push(i)
domNodeIds.push(rs.backendNodeId)
subjectKinds.push(1) // element
primaryBoxIds.push(boxBySubject.get(i) ?? 0)
const frags = fragmentsBySubject.get(i)
if (frags && frags.length > 0) {
firstFragmentIds.push(frags[0])
fragmentCounts.push(frags.length)
} else {
firstFragmentIds.push(0)
fragmentCounts.push(0)
}
firstTextRunIds.push(0)
textRunCounts.push(0)
}
// Build boxes table arrays
const boxesTable = {
boxId: boxes.map((b) => b.boxId),
subjectId: boxes.map((b) => b.subjectId),
frameId: boxes.map((b) => b.frameId),
borderLeft: boxes.map((b) => b.borderLeft),
borderTop: boxes.map((b) => b.borderTop),
borderRight: boxes.map((b) => b.borderRight),
borderBottom: boxes.map((b) => b.borderBottom),
paddingLeft: boxes.map((b) => b.paddingLeft),
paddingTop: boxes.map((b) => b.paddingTop),
paddingRight: boxes.map((b) => b.paddingRight),
paddingBottom: boxes.map((b) => b.paddingBottom),
contentLeft: boxes.map((b) => b.contentLeft),
contentTop: boxes.map((b) => b.contentTop),
contentRight: boxes.map((b) => b.contentRight),
contentBottom: boxes.map((b) => b.contentBottom),
}
const visualBoxesTable = {
boxId: visualBoxes.map((b) => b.boxId),
subjectId: visualBoxes.map((b) => b.subjectId),
frameId: visualBoxes.map((b) => b.frameId),
borderLeft: visualBoxes.map((b) => b.borderLeft),
borderTop: visualBoxes.map((b) => b.borderTop),
borderRight: visualBoxes.map((b) => b.borderRight),
borderBottom: visualBoxes.map((b) => b.borderBottom),
paddingLeft: visualBoxes.map((b) => b.paddingLeft),
paddingTop: visualBoxes.map((b) => b.paddingTop),
paddingRight: visualBoxes.map((b) => b.paddingRight),
paddingBottom: visualBoxes.map((b) => b.paddingBottom),
contentLeft: visualBoxes.map((b) => b.contentLeft),
contentTop: visualBoxes.map((b) => b.contentTop),
contentRight: visualBoxes.map((b) => b.contentRight),
contentBottom: visualBoxes.map((b) => b.contentBottom),
}
const fragmentsTable = {
fragmentId: fragments.map((f) => f.fragmentId),
subjectId: fragments.map((f) => f.subjectId),
fragmentKind: fragments.map((f) => f.fragmentKind),
boxLeft: fragments.map((f) => f.boxLeft),
boxTop: fragments.map((f) => f.boxTop),
boxRight: fragments.map((f) => f.boxRight),
boxBottom: fragments.map((f) => f.boxBottom),
lineIndex: fragments.map((f) => f.lineIndex),
flowIndex: fragments.map((f) => f.flowIndex),
parentFragmentId: fragments.map((f) => f.parentFragmentId),
}
const transformsTable = {
transformId: transforms.map((t) => t.transformId),
subjectId: transforms.map((t) => t.subjectId),
matrixStart: transforms.map((t) => t.matrixStart),
matrixLength: transforms.map((t) => t.matrixLength),
originX: transforms.map((t) => t.originX),
originY: transforms.map((t) => t.originY),
}
const stylesTable = {
subjectId: styles.map((s) => s.subjectId),
display: styles.map((s) => s.display),
position: styles.map((s) => s.position),
zIndexKind: styles.map((s) => s.zIndexKind),
zIndexValue: styles.map((s) => s.zIndexValue),
overflowX: styles.map((s) => s.overflowX),
overflowY: styles.map((s) => s.overflowY),
opacity: styles.map((s) => s.opacity),
visibility: styles.map((s) => s.visibility),
containFlags: styles.map((s) => s.containFlags),
pointerEvents: styles.map((s) => s.pointerEvents),
lineHeight: styles.map((s) => s.lineHeight),
fontFamilyStringId: styles.map((s) => s.fontFamilyStringId),
fontSize: styles.map((s) => s.fontSize),
fontWeight: styles.map((s) => s.fontWeight),
}
return {
sceneId: request.requestId,
snapshotId: 'default',
env: {
viewportWidth: request.env.viewportWidth,
viewportHeight: request.env.viewportHeight,
deviceScaleFactor: 1,
colorScheme: request.env.colorScheme,
pointer: request.env.pointer,
hover: false,
reducedMotion: 'no-preference',
locale: 'en',
writingMode: 'horizontal-tb',
},
source: {
url: request.sceneTarget.url,
browserName: 'chromium',
browserVersion: '',
engine: 'chromium-cdp',
extractedAt: new Date().toISOString(),
},
strings: dom.strings,
subjects: {
ids: subjectIds,
domNodeId: domNodeIds,
subjectKind: subjectKinds,
primaryBoxId: primaryBoxIds,
firstFragmentId: firstFragmentIds,
fragmentCount: fragmentCounts,
firstTextRunId: firstTextRunIds,
textRunCount: textRunCounts,
},
dom,
frames: {
frameId: [],
frameKind: [],
ownerSubjectId: [],
parentFrameId: [],
originX: [],
originY: [],
axisMatrixStart: [],
clipRectId: [],
scrollContainerId: [],
writingMode: [],
},
matrices: { values: matrices },
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
boxes: boxesTable,
visualBoxes: visualBoxesTable,
fragments: fragmentsTable,
transforms: transformsTable,
styles: stylesTable,
topology: topology.topology,
scroll: topology.scroll,
clipping: topology.clipping,
provenance: [],
confidence: [],
}
}
/**
* Create an empty snapshot for error cases.
*/
function createEmptySnapshot(request: ExtractorRequest): GeometryWorldSnapshot {
return {
sceneId: request.requestId,
snapshotId: 'default',
env: {
viewportWidth: request.env.viewportWidth,
viewportHeight: request.env.viewportHeight,
deviceScaleFactor: 1,
colorScheme: request.env.colorScheme,
pointer: request.env.pointer,
hover: false,
reducedMotion: 'no-preference',
locale: 'en',
writingMode: 'horizontal-tb',
},
source: {
url: request.sceneTarget.url,
browserName: 'chromium',
browserVersion: '',
engine: 'chromium-cdp',
extractedAt: new Date().toISOString(),
},
strings: [],
subjects: {
ids: [],
domNodeId: [],
subjectKind: [],
primaryBoxId: [],
firstFragmentId: [],
fragmentCount: [],
firstTextRunId: [],
textRunCount: [],
},
dom: { nodes: [], strings: [], nodeIdToIndex: new Map(), rootIndex: 0 },
frames: {
frameId: [],
frameKind: [],
ownerSubjectId: [],
parentFrameId: [],
originX: [],
originY: [],
axisMatrixStart: [],
clipRectId: [],
scrollContainerId: [],
writingMode: [],
},
matrices: { values: [] },
rects: {
rectId: [],
left: [],
top: [],
right: [],
bottom: [],
},
boxes: {
boxId: [],
subjectId: [],
frameId: [],
borderLeft: [],
borderTop: [],
borderRight: [],
borderBottom: [],
paddingLeft: [],
paddingTop: [],
paddingRight: [],
paddingBottom: [],
contentLeft: [],
contentTop: [],
contentRight: [],
contentBottom: [],
},
visualBoxes: {
boxId: [],
subjectId: [],
frameId: [],
borderLeft: [],
borderTop: [],
borderRight: [],
borderBottom: [],
paddingLeft: [],
paddingTop: [],
paddingRight: [],
paddingBottom: [],
contentLeft: [],
contentTop: [],
contentRight: [],
contentBottom: [],
},
fragments: {
fragmentId: [],
subjectId: [],
fragmentKind: [],
boxLeft: [],
boxTop: [],
boxRight: [],
boxBottom: [],
lineIndex: [],
flowIndex: [],
parentFragmentId: [],
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
styles: {
subjectId: [],
display: [],
position: [],
zIndexKind: [],
zIndexValue: [],
overflowX: [],
overflowY: [],
opacity: [],
visibility: [],
containFlags: [],
pointerEvents: [],
lineHeight: [],
fontFamilyStringId: [],
fontSize: [],
fontWeight: [],
},
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
scroll: [],
clipping: [],
provenance: [],
confidence: [],
}
}
+398
View File
@@ -0,0 +1,398 @@
/**
* Geometry Extraction
*
* Captures box models, fragment boxes, and transform data for DOM nodes
* using CDP DOM.getBoxModel and Runtime.evaluate.
*
* We batch requests where possible: gather all target node IDs first,
* then request box models in bulk via parallel sends.
*/
import type { CDPSession } from './session.js'
/**
* Raw CDP box model for a single node.
*/
export interface CDPBoxModel {
content: number[]
padding: number[]
border: number[]
margin: number[]
width: number
height: number
shapeOutside?: unknown
}
/**
* Canonical box geometry record for the geometry world.
*/
export interface BoxRecord {
boxId: number
subjectId: number
frameId: number
borderLeft: number
borderTop: number
borderRight: number
borderBottom: number
paddingLeft: number
paddingTop: number
paddingRight: number
paddingBottom: number
contentLeft: number
contentTop: number
contentRight: number
contentBottom: number
}
/**
* Fragment record for multi-line or multi-column content.
*/
export interface FragmentRecord {
fragmentId: number
subjectId: number
fragmentKind: number
boxLeft: number
boxTop: number
boxRight: number
boxBottom: number
lineIndex: number
flowIndex: number
parentFragmentId: number
}
/**
* Transform record for a subject.
*/
export interface TransformRecord {
transformId: number
subjectId: number
matrixStart: number
matrixLength: number
originX: number
originY: number
}
/**
* Geometry extraction result.
*/
export interface GeometryExtractionResult {
boxes: BoxRecord[]
fragments: FragmentRecord[]
transforms: TransformRecord[]
/** Flat array of matrix values (6 elements per 2D matrix) */
matrices: number[]
}
/**
* Extract box models for a list of node IDs.
*
* Sends DOM.getBoxModel for each node in parallel to minimize
* round-trip latency.
*/
export async function extractBoxModels(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{ boxes: BoxRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
const boxes: BoxRecord[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
// Batch: request all box models in parallel using backendNodeId.
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
try {
const response = await session.send<{ model: CDPBoxModel }>('DOM.getBoxModel', {
backendNodeId,
})
return { status: 'ok' as const, model: response.model, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const model = result.model
const subjectId = subjectIds[result.idx]
// CDP returns quads as [x1,y1,x2,y2,x3,y3,x4,y4].
// For axis-aligned boxes, we use the first two points.
const borderQuad = model.border
const paddingQuad = model.padding
const contentQuad = model.content
boxes.push({
boxId: boxes.length,
subjectId,
frameId: 0, // Frame resolution happens later.
borderLeft: Math.min(borderQuad[0], borderQuad[2]),
borderTop: Math.min(borderQuad[1], borderQuad[5]),
borderRight: Math.max(borderQuad[4], borderQuad[6]),
borderBottom: Math.max(borderQuad[3], borderQuad[7]),
paddingLeft: Math.min(paddingQuad[0], paddingQuad[2]),
paddingTop: Math.min(paddingQuad[1], paddingQuad[5]),
paddingRight: Math.max(paddingQuad[4], paddingQuad[6]),
paddingBottom: Math.max(paddingQuad[3], paddingQuad[7]),
contentLeft: Math.min(contentQuad[0], contentQuad[2]),
contentTop: Math.min(contentQuad[1], contentQuad[5]),
contentRight: Math.max(contentQuad[4], contentQuad[6]),
contentBottom: Math.max(contentQuad[3], contentQuad[7]),
})
}
return { boxes, errors }
}
/**
* Extract client rect fragments for a list of backend node IDs.
*
* Resolves each backendNodeId to a RemoteObject via DOM.resolveNode,
* then uses Runtime.callFunctionOn to invoke getClientRects() directly
* on the element. This avoids DOM mutation and fragile attribute queries.
*/
export async function extractFragments(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{ fragments: FragmentRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
const fragments: FragmentRecord[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
try {
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
backendNodeId,
})
const response = await session.send<{
result: {
value: Array<{ left: number; top: number; right: number; bottom: number }>
}
}>('Runtime.callFunctionOn', {
objectId: resolved.object.objectId,
functionDeclaration: `function() {
const rects = this.getClientRects()
const boxes = []
for (let i = 0; i < rects.length; i++) {
boxes.push({
left: rects[i].left,
top: rects[i].top,
right: rects[i].right,
bottom: rects[i].bottom,
})
}
return boxes
}`,
returnByValue: true,
})
return { status: 'ok' as const, boxes: response.result.value, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const subjectId = subjectIds[result.idx]
for (let j = 0; j < (result.boxes?.length ?? 0); j++) {
const box = result.boxes![j]
fragments.push({
fragmentId: fragments.length,
subjectId,
fragmentKind: 1, // 1 = client rect fragment
boxLeft: box.left,
boxTop: box.top,
boxRight: box.right,
boxBottom: box.bottom,
lineIndex: j,
flowIndex: 0,
parentFragmentId: 0,
})
}
}
return { fragments, errors }
}
/**
* Extract visual bounding boxes for a list of backend node IDs.
*
* Uses getBoundingClientRect() via Runtime.callFunctionOn to obtain
* post-transform viewport coordinates. These are stored in visualBoxes
* and represent the effective rendered position after all CSS transforms.
*/
export async function extractVisualBoxes(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{ boxes: BoxRecord[]; errors: Array<{ backendNodeId: number; reason: string }> }> {
const boxes: BoxRecord[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
try {
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
backendNodeId,
})
const response = await session.send<{
result: {
value: {
left: number
top: number
right: number
bottom: number
width: number
height: number
}
}
}>('Runtime.callFunctionOn', {
objectId: resolved.object.objectId,
functionDeclaration: `function() {
const rect = this.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
}
}`,
returnByValue: true,
})
return { status: 'ok' as const, rect: response.result.value, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const rect = result.rect
const subjectId = subjectIds[result.idx]
boxes.push({
boxId: boxes.length,
subjectId,
frameId: 0,
borderLeft: rect.left,
borderTop: rect.top,
borderRight: rect.right,
borderBottom: rect.bottom,
paddingLeft: rect.left,
paddingTop: rect.top,
paddingRight: rect.right,
paddingBottom: rect.bottom,
contentLeft: rect.left,
contentTop: rect.top,
contentRight: rect.right,
contentBottom: rect.bottom,
})
}
return { boxes, errors }
}
/**
* Extract CSS transform matrices for a list of node IDs.
*
* Returns the computed 2D transform matrix (6 values) and origin
* for each node that has a transform.
*/
export async function extractTransforms(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{
transforms: TransformRecord[]
matrices: number[]
errors: Array<{ backendNodeId: number; reason: string }>
}> {
const transforms: TransformRecord[] = []
const matrices: number[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
try {
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
backendNodeId,
})
const response = await session.send<{
result: {
value: {
matrix: number[]
originX: number
originY: number
}
}
}>('Runtime.callFunctionOn', {
objectId: resolved.object.objectId,
functionDeclaration: `function() {
const style = window.getComputedStyle(this)
const transform = style.transform
let matrix = [1, 0, 0, 1, 0, 0]
if (transform && transform !== 'none') {
const match = transform.match(/matrix\\(([^)]+)\\)/)
if (match) {
matrix = match[1].split(',').map(Number)
}
}
const origin = style.transformOrigin.split(' ').map(parseFloat)
return {
matrix,
originX: origin[0] || 0,
originY: origin[1] || 0,
}
}`,
returnByValue: true,
})
return { status: 'ok' as const, data: response.result.value, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const matrix = result.data.matrix ?? [1, 0, 0, 1, 0, 0]
const matrixStart = matrices.length
matrices.push(...matrix)
transforms.push({
transformId: transforms.length,
subjectId: subjectIds[result.idx],
matrixStart,
matrixLength: matrix.length,
originX: result.data.originX ?? 0,
originY: result.data.originY ?? 0,
})
}
return { transforms, matrices, errors }
}
+76
View File
@@ -0,0 +1,76 @@
/**
* imhotep-cdp — Chrome DevTools Protocol extraction layer
*
* Captures browser truth for geometry, topology, and style facts.
* Returns raw browser data matching the geometry world schema.
*/
// Session management
export {
CDPSessionManager,
createPlaywrightCDPSession,
createSessionManager,
type CDPSession,
type CDPSessionFactory,
} from './session.js'
// DOM extraction
export {
extractDOM,
resolveSelector,
resolveOneSelector,
type CDPDOMNode,
type DOMNodeRecord,
type DOMExtractionResult,
} from './dom.js'
// Geometry extraction
export {
extractBoxModels,
extractFragments,
extractTransforms,
type BoxRecord,
type FragmentRecord,
type TransformRecord,
type GeometryExtractionResult,
type CDPBoxModel,
} from './geometry.js'
// Style extraction
export {
extractStyles,
DisplayEnum,
PositionEnum,
OverflowEnum,
VisibilityEnum,
PointerEventsEnum,
type StyleRecord,
type StyleExtractionConfig,
} from './styles.js'
// Topology extraction
export {
extractTopology,
type ScrollRecord,
type ClippingRecord,
type TopologyRecord,
type TopologyExtractionResult,
} from './topology.js'
// Canonical adapter
export {
adaptSnapshotToCanonical,
type CanonicalGeometryWorld,
} from './canonical-adapter.js'
// Main orchestrator
export {
CDPExtractor,
type ExtractorRequest,
type ExtractorResponse,
type ExtractorDiagnostic,
type GeometryWorldSnapshot,
type ProvenanceEntry,
type ConfidenceEntry,
type ExtractionTraceEntry,
} from './extractor.js'
+149
View File
@@ -0,0 +1,149 @@
/**
* CDP Session Management
*
* Wraps Playwright's CDP session access to provide a typed, injectable
* interface for sending Chrome DevTools Protocol commands.
*
* Design: dependency injection over optional imports. The extractor receives
* a session factory rather than importing Playwright directly.
*/
/**
* Minimal CDP session interface. Any object that can send CDP commands
* and return JSON-like responses satisfies this contract.
*/
export interface CDPSession {
send<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>
on(event: string, handler: (payload: unknown) => void): void
off(event: string, handler: (payload: unknown) => void): void
detach(): Promise<void>
}
/**
* Factory that creates a CDP session for a given Playwright page.
* This is injected so the package does not hard-depend on Playwright.
*/
export type CDPSessionFactory = (pageRef: unknown) => Promise<CDPSession>
/**
* Playwright-backed session factory implementation.
*
* Usage:
* const session = await createPlaywrightCDPSession(page)
*
* Where `page` is a Playwright Page object.
*/
export async function createPlaywrightCDPSession(
page: unknown
): Promise<CDPSession> {
// Playwright pages expose context() and a CDP session method.
// We access it dynamically to avoid a hard import dependency.
const pwPage = page as {
context: () => {
newCDPSession: (p: unknown) => Promise<CDPSession>
}
}
const context = pwPage.context()
const session = await context.newCDPSession(page)
return session
}
/**
* Session manager that owns the lifecycle of a CDP session.
*
* Handles attach, detach, and basic error recovery.
* Caches document root nodeId and enabled domains to minimize round-trips.
*/
export class CDPSessionManager {
private session: CDPSession | null = null
private attachPromise: Promise<CDPSession> | null = null
private readonly factory: CDPSessionFactory
private readonly pageRef: unknown
private documentRootNodeId: number | null = null
private enabledDomains = new Set<string>()
constructor(factory: CDPSessionFactory, pageRef: unknown) {
this.factory = factory
this.pageRef = pageRef
}
/**
* Ensure a session is attached. Idempotent and concurrency-safe.
*/
async attach(): Promise<CDPSession> {
if (this.session) return this.session
if (this.attachPromise) return this.attachPromise
this.attachPromise = this.factory(this.pageRef).then((s) => {
this.session = s
this.documentRootNodeId = null
this.enabledDomains.clear()
return s
})
return this.attachPromise
}
/**
* Get cached document root nodeId, or fetch and cache it.
*/
async getDocumentRootNodeId(): Promise<number> {
if (this.documentRootNodeId !== null) {
return this.documentRootNodeId
}
const s = await this.attach()
const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument')
this.documentRootNodeId = result.root.nodeId
return this.documentRootNodeId
}
/**
* Enable a CDP domain if not already enabled.
*/
async enableDomain(domain: string): Promise<void> {
if (this.enabledDomains.has(domain)) {
return
}
const s = await this.attach()
await s.send(`${domain}.enable`)
this.enabledDomains.add(domain)
}
/**
* Get the current session, throwing if not attached.
*/
getSession(): CDPSession {
if (!this.session) {
throw new Error('CDP session not attached. Call attach() first.')
}
return this.session
}
/**
* Send a CDP command, automatically attaching if needed.
*/
async send<T = unknown>(
method: string,
params?: Record<string, unknown>
): Promise<T> {
const s = await this.attach()
return s.send<T>(method, params)
}
/**
* Detach and clean up the session.
*/
async detach(): Promise<void> {
if (this.session) {
await this.session.detach()
this.session = null
}
this.attachPromise = null
}
}
/**
* Convenient builder for a manager using the Playwright factory.
*/
export function createSessionManager(page: unknown): CDPSessionManager {
return new CDPSessionManager(createPlaywrightCDPSession, page)
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Computed Style Extraction
*
* Captures only the style properties required by the active extraction plan.
* Uses Runtime.callFunctionOn with objectIds resolved from backendNodeIds
* to avoid CSS domain nodeId validity issues.
*/
import type { CDPSession } from './session.js'
/**
* Compact style record for a subject.
* Mirrors the geometry world `styles` table schema.
*/
export interface StyleRecord {
subjectId: number
display: number
position: number
zIndexKind: number
zIndexValue: number
overflowX: number
overflowY: number
opacity: number
visibility: number
containFlags: number
pointerEvents: number
lineHeight: number
fontFamilyStringId: number
fontSize: number
fontWeight: number
}
/**
* Enum mappings for compact storage.
*/
export const DisplayEnum: Record<string, number> = {
none: 1,
block: 2,
inline: 3,
'inline-block': 4,
flex: 5,
'inline-flex': 6,
grid: 7,
'inline-grid': 8,
table: 9,
'table-cell': 10,
contents: 11,
other: 99,
}
export const PositionEnum: Record<string, number> = {
static: 1,
relative: 2,
absolute: 3,
fixed: 4,
sticky: 5,
}
export const OverflowEnum: Record<string, number> = {
visible: 1,
hidden: 2,
clip: 3,
scroll: 4,
auto: 5,
}
export const VisibilityEnum: Record<string, number> = {
visible: 1,
hidden: 2,
collapse: 3,
}
export const PointerEventsEnum: Record<string, number> = {
auto: 1,
none: 2,
}
/**
* Style extraction configuration.
*/
export interface StyleExtractionConfig {
/** If true, extract all known style properties */
all?: boolean
/** Specific property names to extract */
properties?: string[]
}
/**
* Extract computed styles for a list of backend node IDs.
*
* Resolves each backendNodeId to an objectId, then uses
* Runtime.callFunctionOn to read computed styles via getComputedStyle
* in the browser. This avoids CSS domain nodeId validity issues.
*/
export async function extractStyles(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[],
config: StyleExtractionConfig,
strings: string[]
): Promise<{
styles: StyleRecord[]
errors: Array<{ backendNodeId: number; reason: string }>
strings: string[]
}> {
const styles: StyleRecord[] = []
const errors: Array<{ backendNodeId: number; reason: string }> = []
const stringIndex = new Map<string, number>()
strings.forEach((s, i) => stringIndex.set(s, i))
function getStringId(value: string): number {
if (stringIndex.has(value)) return stringIndex.get(value)!
const id = strings.length
strings.push(value)
stringIndex.set(value, id)
return id
}
// Determine which properties we need.
const wantAll = config.all ?? true
const wantProps = new Set(config.properties ?? [])
// Resolve backendNodeIds to objectIds
const objectIds: string[] = []
for (const backendNodeId of backendNodeIds) {
try {
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
backendNodeId,
})
objectIds.push(resolved.object.objectId)
} catch {
objectIds.push('')
}
}
const promises = backendNodeIds.map(async (backendNodeId, idx) => {
const objectId = objectIds[idx]
if (!objectId) {
return { status: 'error' as const, error: 'Failed to resolve backendNodeId to objectId', backendNodeId, idx }
}
try {
const response = await session.send<{
result: { value: Record<string, string> }
}>('Runtime.callFunctionOn', {
objectId,
functionDeclaration: `function() {
const s = window.getComputedStyle(this);
return {
display: s.display,
position: s.position,
zIndex: s.zIndex,
overflowX: s.overflowX,
overflowY: s.overflowY,
opacity: s.opacity,
visibility: s.visibility,
contain: s.contain,
pointerEvents: s.pointerEvents,
lineHeight: s.lineHeight,
fontFamily: s.fontFamily,
fontSize: s.fontSize,
fontWeight: s.fontWeight,
};
}`,
returnByValue: true,
})
return { status: 'ok' as const, computedStyle: response.result.value, backendNodeId, idx }
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
return { status: 'error' as const, error: reason, backendNodeId, idx }
}
})
const results = await Promise.all(promises)
for (const result of results) {
if (result.status === 'error') {
errors.push({ backendNodeId: result.backendNodeId, reason: result.error })
continue
}
const subjectId = subjectIds[result.idx]
const map = result.computedStyle
function get(name: string, defaultValue = ''): string {
if (!wantAll && !wantProps.has(name)) return defaultValue
return map[name] ?? defaultValue
}
function parseNumber(value: string, defaultValue = 0): number {
if (!value || value === 'none' || value === 'auto') return defaultValue
const parsed = parseFloat(value)
return Number.isNaN(parsed) ? defaultValue : parsed
}
const displayRaw = get('display', 'block')
const positionRaw = get('position', 'static')
const zIndexRaw = get('zIndex', 'auto')
const overflowXRaw = get('overflowX', 'visible')
const overflowYRaw = get('overflowY', 'visible')
const opacityRaw = get('opacity', '1')
const visibilityRaw = get('visibility', 'visible')
const containRaw = get('contain', 'none')
const pointerEventsRaw = get('pointerEvents', 'auto')
const lineHeightRaw = get('lineHeight', 'normal')
const fontFamilyRaw = get('fontFamily', '')
const fontSizeRaw = get('fontSize', '16px')
const fontWeightRaw = get('fontWeight', '400')
// z-index kind: 0 = auto, 1 = integer
const zIndexKind = zIndexRaw === 'auto' ? 0 : 1
const zIndexValue = zIndexKind === 1 ? parseInt(zIndexRaw, 10) : 0
// contain flags bitfield
let containFlags = 0
if (containRaw !== 'none') {
const parts = containRaw.split(' ')
for (const part of parts) {
if (part === 'layout') containFlags |= 1
if (part === 'paint') containFlags |= 2
if (part === 'size') containFlags |= 4
if (part === 'style') containFlags |= 8
if (part === 'strict') containFlags |= 15
if (part === 'content') containFlags |= 7
}
}
styles.push({
subjectId,
display: DisplayEnum[displayRaw] ?? DisplayEnum.other,
position: PositionEnum[positionRaw] ?? PositionEnum.static,
zIndexKind,
zIndexValue,
overflowX: OverflowEnum[overflowXRaw] ?? OverflowEnum.visible,
overflowY: OverflowEnum[overflowYRaw] ?? OverflowEnum.visible,
opacity: parseNumber(opacityRaw, 1),
visibility: VisibilityEnum[visibilityRaw] ?? VisibilityEnum.visible,
containFlags,
pointerEvents: PointerEventsEnum[pointerEventsRaw] ?? PointerEventsEnum.auto,
lineHeight: parseNumber(lineHeightRaw, 1.2),
fontFamilyStringId: getStringId(fontFamilyRaw),
fontSize: parseNumber(fontSizeRaw, 16),
fontWeight: parseInt(fontWeightRaw, 10) || 400,
})
}
return { styles, errors, strings }
}
+326
View File
@@ -0,0 +1,326 @@
/**
* Topology Extraction
*
* Captures non-planar layout facts: scroll containers, clipping chains,
* stacking contexts, containing blocks, and formatting contexts.
*
* We extract these in bulk via a single Runtime.evaluate script that
* walks the DOM and computes topology relationships in the browser,
* then returns compact tables.
*/
import type { CDPSession } from './session.js'
/**
* Scroll container record.
*/
export interface ScrollRecord {
containerId: number
scrollLeft: number
scrollTop: number
scrollWidth: number
scrollHeight: number
clientWidth: number
clientHeight: number
}
/**
* Clipping chain node.
*/
export interface ClippingRecord {
clipNodeId: number
subjectId: number
clipKind: number
clipLeft: number
clipTop: number
clipRight: number
clipBottom: number
parentClipNodeId: number
}
/**
* Topology relation record.
*/
export interface TopologyRecord {
containingBlockOf: number[]
nearestPositionedAncestorOf: number[]
scrollContainerOf: number[]
stackingContextOf: number[]
formattingContextOf: number[]
clippingRootOf: number[]
paintOrderBucket: number[]
paintOrderIndex: number[]
}
/**
* Full topology extraction result.
*/
export interface TopologyExtractionResult {
scroll: ScrollRecord[]
clipping: ClippingRecord[]
topology: TopologyRecord
}
/**
* Extract topology facts for a list of elements.
*
* Uses a single injected script to compute all topology relationships
* in the browser, avoiding per-node round-trips.
*/
export async function extractTopology(
session: CDPSession,
backendNodeIds: number[],
subjectIds: number[]
): Promise<{
result: TopologyExtractionResult
errors: Array<{ backendNodeId: number; reason: string }>
}> {
const errors: Array<{ backendNodeId: number; reason: string }> = []
if (backendNodeIds.length === 0) {
return {
result: {
scroll: [],
clipping: [],
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
},
errors,
}
}
// Inject temporary data attributes so the topology script can correlate
// elements with their backendNodeIds, then remove them afterwards.
const objectIds: string[] = []
for (const backendNodeId of backendNodeIds) {
try {
const resolved = await session.send<{ object: { objectId: string } }>('DOM.resolveNode', {
backendNodeId,
})
objectIds.push(resolved.object.objectId)
} catch {
objectIds.push('')
}
}
// Set attributes
for (let i = 0; i < backendNodeIds.length; i++) {
const objectId = objectIds[i]
if (!objectId) continue
try {
await session.send<unknown>('Runtime.callFunctionOn', {
objectId,
functionDeclaration: `function(id) { this.setAttribute('data-imhotep-backend-id', id) }`,
arguments: [{ value: String(backendNodeIds[i]) }],
})
} catch {
// Ignore injection errors
}
}
const script = `
(function() {
const subjects = Array.from(document.querySelectorAll('[data-imhotep-backend-id]'))
const idMap = new Map()
for (const el of subjects) {
idMap.set(el, parseInt(el.dataset.imhotepBackendId, 10))
}
const results = {
scroll: [],
clipping: [],
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
}
}
function isScrollContainer(el) {
const style = window.getComputedStyle(el)
return style.overflowX !== 'visible' || style.overflowY !== 'visible'
}
function isClippingElement(el) {
const style = window.getComputedStyle(el)
return style.overflowX === 'hidden' || style.overflowX === 'scroll' || style.overflowX === 'auto' ||
style.overflowY === 'hidden' || style.overflowY === 'scroll' || style.overflowY === 'auto' ||
style.clipPath !== 'none'
}
function createsStackingContext(el) {
const style = window.getComputedStyle(el)
if (style.position !== 'static' && style.zIndex !== 'auto') return true
if (parseFloat(style.opacity) < 1) return true
if (style.transform !== 'none') return true
if (style.filter !== 'none') return true
if (style.clipPath !== 'none') return true
if (style.isolation === 'isolate') return true
if (style.willChange.includes('transform') || style.willChange.includes('opacity')) return true
return false
}
function isPositioned(el) {
const style = window.getComputedStyle(el)
return style.position !== 'static'
}
function getContainingBlock(el) {
let current = el.parentElement
while (current) {
const style = window.getComputedStyle(current)
if (style.position !== 'static') return current
current = current.parentElement
}
return null
}
function getNearestPositionedAncestor(el) {
let current = el.parentElement
while (current) {
if (isPositioned(current)) return current
current = current.parentElement
}
return null
}
function getScrollContainer(el) {
let current = el.parentElement
while (current) {
if (isScrollContainer(current)) return current
current = current.parentElement
}
return null
}
function getClippingRoot(el) {
let current = el.parentElement
while (current) {
if (isClippingElement(current)) return current
current = current.parentElement
}
return null
}
function getStackingContextRoot(el) {
let current = el.parentElement
while (current) {
if (createsStackingContext(current)) return current
current = current.parentElement
}
return null
}
function rectFor(el) {
const r = el.getBoundingClientRect()
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }
}
function getId(el) {
return el ? (idMap.get(el) || 0) : 0
}
for (let i = 0; i < subjects.length; i++) {
const el = subjects[i]
const subjectId = idMap.get(el) || 0
if (isScrollContainer(el)) {
const r = rectFor(el)
results.scroll.push({
containerId: subjectId,
scrollLeft: el.scrollLeft,
scrollTop: el.scrollTop,
scrollWidth: el.scrollWidth,
scrollHeight: el.scrollHeight,
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
})
}
if (isClippingElement(el)) {
const r = rectFor(el)
results.clipping.push({
clipNodeId: results.clipping.length,
subjectId,
clipKind: 1,
clipLeft: r.left,
clipTop: r.top,
clipRight: r.right,
clipBottom: r.bottom,
parentClipNodeId: 0,
})
}
results.topology.containingBlockOf.push(getId(getContainingBlock(el)))
results.topology.nearestPositionedAncestorOf.push(getId(getNearestPositionedAncestor(el)))
results.topology.scrollContainerOf.push(getId(getScrollContainer(el)))
results.topology.stackingContextOf.push(getId(getStackingContextRoot(el)))
results.topology.paintOrderBucket.push(createsStackingContext(el) ? 2 : 1)
results.topology.paintOrderIndex.push(i)
results.topology.formattingContextOf.push(getId(el.parentElement))
results.topology.clippingRootOf.push(getId(getClippingRoot(el)))
}
return results
})()
`
let result: TopologyExtractionResult
try {
const response = await session.send<{
result: { value: TopologyExtractionResult }
}>('Runtime.evaluate', {
expression: script,
returnByValue: true,
})
result = response.result.value
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
for (const backendNodeId of backendNodeIds) {
errors.push({ backendNodeId, reason })
}
result = {
scroll: [],
clipping: [],
topology: {
containingBlockOf: [],
nearestPositionedAncestorOf: [],
scrollContainerOf: [],
stackingContextOf: [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
}
}
// Remove temporary attributes
for (let i = 0; i < backendNodeIds.length; i++) {
const objectId = objectIds[i]
if (!objectId) continue
try {
await session.send<unknown>('Runtime.callFunctionOn', {
objectId,
functionDeclaration: `function() { this.removeAttribute('data-imhotep-backend-id') }`,
})
} catch {
// Ignore cleanup errors
}
}
return { result, errors }
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {},
"composite": false,
"noEmitOnError": false
},
"include": [
"src/**/*"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "imhotep-cli",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"imhotep": "dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test dist/**/*.test.js"
},
"dependencies": {
"imhotep-core": "^1.0.0",
"imhotep-playwright": "^1.0.0"
},
"devDependencies": {
"@types/node": "^20.19.39",
"playwright": "^1.59.1",
"typescript": "^5.9.3"
}
}
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// CLI entry point for imhotep command
import { initProject } from './init.js';
import { getPresetNames } from './presets/index.js';
function showHelp(): void {
console.log(`
Usage: imhotep init --preset <name> [--dir <path>]
Commands:
init Scaffold a new Imhotep project
Options:
--preset <name> Project preset (${getPresetNames().join(', ')})
--dir <path> Target directory (default: current directory)
--help Show this help message
Examples:
npx imhotep init --preset react
npx imhotep init --preset next --dir ./next-tests
npx imhotep init --preset nuxt --dir ./nuxt-tests
npx imhotep init --preset vue --dir ./my-project
npx imhotep init --preset storybook --dir ./storybook-tests
`);
}
function main(): void {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
showHelp();
process.exit(0);
}
const command = args[0];
if (command === 'init') {
const presetIndex = args.indexOf('--preset');
const dirIndex = args.indexOf('--dir');
if (presetIndex === -1 || !args[presetIndex + 1]) {
console.error('Error: --preset is required');
console.error(`Available presets: ${getPresetNames().join(', ')}`);
process.exit(1);
}
const preset = args[presetIndex + 1];
const targetDir = dirIndex !== -1
? args[dirIndex + 1]
: process.cwd();
try {
initProject({ preset, targetDir });
console.log(`✓ Scaffolded ${preset} project in ${targetDir}`);
console.log(` Run: cd ${targetDir} && npm install && npm test`);
} catch (error: any) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
} else {
console.error(`Unknown command: ${command}`);
showHelp();
process.exit(1);
}
}
main();
+3
View File
@@ -0,0 +1,3 @@
// Public API exports for imhotep-cli
export { initProject, type InitOptions } from './init.js';
export { presets, getPresetNames, getPreset, type PresetName } from './presets/index.js';
+131
View File
@@ -0,0 +1,131 @@
// RED phase: Test that scaffolding produces working projects
import assert from 'node:assert';
import { test, describe } from 'node:test';
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initProject } from './init.js';
import { getPresetNames, getPreset } from './presets/index.js';
describe('imhotep init', () => {
test('react-playwright preset creates expected files', () => {
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
try {
initProject({ preset: 'react', targetDir });
// Verify all expected files exist
assert.ok(existsSync(join(targetDir, 'imhotep.config.js')), 'config file should exist');
assert.ok(existsSync(join(targetDir, 'tests', 'example.test.ts')), 'test file should exist');
assert.ok(existsSync(join(targetDir, 'fixtures', 'example.html')), 'fixture file should exist');
assert.ok(existsSync(join(targetDir, 'package.json')), 'package.json should exist');
assert.ok(existsSync(join(targetDir, 'README.md')), 'README should exist');
// Verify config has render target
const config = readFileSync(join(targetDir, 'imhotep.config.js'), 'utf-8');
assert.ok(config.includes('render'), 'config should mention render target');
// Verify test file has assertions
const testFile = readFileSync(join(targetDir, 'tests', 'example.test.ts'), 'utf-8');
assert.ok(testFile.includes('expect'), 'test should have assertions');
// Verify package.json has test scripts
const pkg = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8'));
assert.ok(pkg.scripts.test, 'package.json should have test script');
assert.ok(pkg.scripts['test:ci'], 'package.json should have ci script');
} finally {
rmSync(targetDir, { recursive: true, force: true });
}
});
test('vue preset creates expected files', () => {
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
try {
initProject({ preset: 'vue', targetDir });
assert.ok(existsSync(join(targetDir, 'imhotep.config.js')), 'config file should exist');
assert.ok(existsSync(join(targetDir, 'tests', 'example.test.ts')), 'test file should exist');
assert.ok(existsSync(join(targetDir, 'fixtures', 'example.html')), 'fixture file should exist');
assert.ok(existsSync(join(targetDir, 'package.json')), 'package.json should exist');
assert.ok(existsSync(join(targetDir, 'README.md')), 'README should exist');
const config = readFileSync(join(targetDir, 'imhotep.config.js'), 'utf-8');
assert.ok(config.includes('render'), 'config should mention render target');
} finally {
rmSync(targetDir, { recursive: true, force: true });
}
});
test('storybook preset creates expected files', () => {
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
try {
initProject({ preset: 'storybook', targetDir });
assert.ok(existsSync(join(targetDir, 'imhotep.config.js')), 'config file should exist');
assert.ok(existsSync(join(targetDir, 'tests', 'example.test.ts')), 'test file should exist');
assert.ok(existsSync(join(targetDir, 'fixtures', 'example.html')), 'fixture file should exist');
assert.ok(existsSync(join(targetDir, 'package.json')), 'package.json should exist');
assert.ok(existsSync(join(targetDir, 'README.md')), 'README should exist');
const config = readFileSync(join(targetDir, 'imhotep.config.js'), 'utf-8');
assert.ok(config.includes('render'), 'config should mention render target');
} finally {
rmSync(targetDir, { recursive: true, force: true });
}
});
test('throws on unknown preset', () => {
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
try {
assert.throws(() => {
initProject({ preset: 'unknown-preset', targetDir });
}, /Unknown preset/);
} finally {
rmSync(targetDir, { recursive: true, force: true });
}
});
test('scaffolded react tests have passing assertions syntax', () => {
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
try {
initProject({ preset: 'react', targetDir });
const testFile = readFileSync(join(targetDir, 'tests', 'example.test.ts'), 'utf-8');
assert.ok(testFile.includes('const ui = await imhotep(page);'), 'template should await imhotep(page)');
assert.ok(testFile.includes('await ui.checkAll();'), 'template should evaluate batched assertions with checkAll()');
} finally {
rmSync(targetDir, { recursive: true, force: true });
}
});
test('all presets use publish-safe dependency ranges (no workspace protocol)', () => {
for (const name of getPresetNames()) {
const preset = getPreset(name);
const pkg = preset.packageJson;
for (const [dep, version] of Object.entries(pkg.devDependencies ?? {})) {
assert.ok(typeof version === 'string', `dependency ${dep} in ${name} should be a string`);
assert.ok(!version.startsWith('workspace:'), `${name} must not use workspace protocol for ${dep}`);
}
}
});
test('new framework presets scaffold expected files', () => {
const extraPresets = ['next', 'nuxt', 'remix', 'astro'];
for (const preset of extraPresets) {
const targetDir = mkdtempSync(join(tmpdir(), 'imhotep-init-test-'));
try {
initProject({ preset, targetDir });
assert.ok(existsSync(join(targetDir, 'imhotep.config.js')), `${preset} config should exist`);
assert.ok(existsSync(join(targetDir, 'tests', 'example.test.ts')), `${preset} test should exist`);
assert.ok(existsSync(join(targetDir, 'fixtures', 'example.html')), `${preset} fixture should exist`);
} finally {
rmSync(targetDir, { recursive: true, force: true });
}
}
});
});
+56
View File
@@ -0,0 +1,56 @@
// Core init logic for scaffolding Imhotep projects from presets
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { getPreset } from './presets/index.js';
export interface InitOptions {
preset: string;
targetDir: string;
}
export function initProject(options: InitOptions): void {
const { preset, targetDir } = options;
// Resolve preset template
const template = getPreset(preset);
// Create directory structure
mkdirSync(targetDir, { recursive: true });
mkdirSync(join(targetDir, 'tests'), { recursive: true });
mkdirSync(join(targetDir, 'fixtures'), { recursive: true });
// Write config file
writeFileSync(
join(targetDir, 'imhotep.config.js'),
template.configFile,
'utf-8'
);
// Write test file
writeFileSync(
join(targetDir, 'tests', 'example.test.ts'),
template.testFile,
'utf-8'
);
// Write fixture file
writeFileSync(
join(targetDir, 'fixtures', 'example.html'),
template.fixtureFile,
'utf-8'
);
// Write package.json
writeFileSync(
join(targetDir, 'package.json'),
JSON.stringify(template.packageJson, null, 2),
'utf-8'
);
// Write README
writeFileSync(
join(targetDir, 'README.md'),
template.readme,
'utf-8'
);
}
@@ -0,0 +1,67 @@
export const astroPlaywrightPreset = {
name: 'astro',
description: 'Astro page testing with Playwright + Imhotep',
configFile: `module.exports = {
render: {
type: 'page',
framework: 'astro'
},
browser: {
viewport: { width: 1280, height: 720 }
}
};`,
testFile: `import { test, expect } from '@playwright/test';
import { imhotep } from 'imhotep';
test('marketing hero contracts', async ({ page }) => {
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
const ui = await imhotep(page);
ui.expect('[data-testid="hero-title"]').to.be.above('[data-testid="hero-cta"]', { minGap: 12 });
ui.expect('[data-testid="hero-cta"]').to.be.atLeast(44, 'height');
const result = await ui.checkAll();
expect(result.passed).toBe(true);
});`,
fixtureFile: `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body { margin: 0; font-family: ui-sans-serif, sans-serif; background: #f8fafc; }
.hero { max-width: 760px; margin: 80px auto; background: #fff; padding: 32px; border-radius: 16px; }
.title { margin: 0 0 20px; font-size: 36px; }
.cta { min-height: 44px; padding: 12px 18px; border: none; border-radius: 8px; }
</style>
</head>
<body>
<section class="hero">
<h1 class="title" data-testid="hero-title">Ship faster</h1>
<button class="cta" data-testid="hero-cta">Start now</button>
</section>
</body>
</html>`,
packageJson: {
name: 'imhotep-astro-project',
version: '1.0.0',
type: 'module',
scripts: {
test: 'playwright test',
'test:ci': 'playwright test --reporter=list'
},
devDependencies: {
'@playwright/test': '^1.59.1',
imhotep: '^1.0.0'
}
},
readme: `# Imhotep Astro + Playwright preset
Scaffolded with \`imhotep init --preset astro\`.
`
};
+33
View File
@@ -0,0 +1,33 @@
// Central registry of all available presets
import { reactPlaywrightPreset } from './react-playwright.js';
import { vueVitestPreset } from './vue-vitest.js';
import { storybookPreset } from './storybook.js';
import { nextPlaywrightPreset } from './next-playwright.js';
import { remixPlaywrightPreset } from './remix-playwright.js';
import { astroPlaywrightPreset } from './astro-playwright.js';
import { nuxtPlaywrightPreset } from './nuxt-playwright.js';
export const presets = {
// Canonical framework-first preset names
react: reactPlaywrightPreset,
vue: vueVitestPreset,
storybook: storybookPreset,
next: nextPlaywrightPreset,
nuxt: nuxtPlaywrightPreset,
remix: remixPlaywrightPreset,
astro: astroPlaywrightPreset,
};
export type PresetName = keyof typeof presets;
export function getPresetNames(): string[] {
return Object.keys(presets);
}
export function getPreset(name: string) {
const preset = presets[name as PresetName];
if (!preset) {
throw new Error(`Unknown preset: "${name}". Available presets: ${getPresetNames().join(', ')}`);
}
return preset;
}
@@ -0,0 +1,77 @@
export const nextPlaywrightPreset = {
name: 'next',
description: 'Next.js app testing with Playwright + Imhotep',
configFile: `module.exports = {
render: {
type: 'page',
framework: 'next'
},
browser: {
viewport: { width: 1280, height: 720 }
}
};`,
testFile: `import { test, expect } from '@playwright/test';
import { imhotep } from 'imhotep';
test('dashboard shell layout', async ({ page }) => {
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
const ui = await imhotep(page);
ui.expect('[data-testid="nav"]').to.be.leftOf('[data-testid="content"]', { minGap: 16 });
ui.expect('[data-testid="menu-button"]').to.be.atLeast(44, 'height');
const result = await ui.checkAll();
expect(result.passed).toBe(true);
});`,
fixtureFile: `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Next fixture</title>
<style>
body { margin: 0; font-family: sans-serif; }
.shell { display: grid; grid-template-columns: 240px 1fr; gap: 16px; min-height: 100vh; }
.nav { background: #111827; color: #fff; padding: 16px; }
.content { padding: 24px; }
.menu { min-height: 44px; min-width: 44px; }
</style>
</head>
<body>
<div class="shell">
<aside class="nav" data-testid="nav"><button class="menu" data-testid="menu-button">Menu</button></aside>
<main class="content" data-testid="content">Dashboard</main>
</div>
</body>
</html>`,
packageJson: {
name: 'imhotep-next-project',
version: '1.0.0',
type: 'module',
scripts: {
test: 'playwright test',
'test:ci': 'playwright test --reporter=list'
},
devDependencies: {
'@playwright/test': '^1.59.1',
imhotep: '^1.0.0'
}
},
readme: `# Imhotep Next.js + Playwright preset
Scaffolded with \`imhotep init --preset next\`.
Run:
\`\`\`bash
npm install
npx playwright install
npm test
\`\`\`
`
};
@@ -0,0 +1,79 @@
export const nuxtPlaywrightPreset = {
name: 'nuxt',
description: 'Nuxt ecommerce layout testing with Playwright + Imhotep',
configFile: `module.exports = {
render: {
type: 'page',
framework: 'nuxt'
},
browser: {
viewport: { width: 390, height: 844 }
}
};`,
testFile: `import { test, expect } from '@playwright/test';
import { imhotep } from 'imhotep';
test('product grid and buy button contracts', async ({ page }) => {
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
const ui = await imhotep(page);
ui.expect('[data-testid="filter-rail"]').to.be.leftOf('[data-testid="product-grid"]', { minGap: 12 });
ui.expect('[data-testid="buy-button"]').to.be.atLeast(44, 'height');
ui.expect('[data-testid="buy-button"]').to.be.atLeast(44, 'width');
const result = await ui.checkAll();
expect(result.passed).toBe(true);
});`,
fixtureFile: `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body { margin: 0; font-family: sans-serif; }
.layout { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 16px; }
.rail { position: sticky; top: 0; background: #f8fafc; padding: 12px; min-height: 200px; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; }
.buy { min-height: 44px; min-width: 44px; }
</style>
</head>
<body>
<div class="layout">
<aside class="rail" data-testid="filter-rail">Filters</aside>
<section class="grid" data-testid="product-grid">
<article class="card"><button class="buy" data-testid="buy-button">Buy</button></article>
<article class="card"><button class="buy">Buy</button></article>
<article class="card"><button class="buy">Buy</button></article>
</section>
</div>
</body>
</html>`,
packageJson: {
name: 'imhotep-nuxt-project',
version: '1.0.0',
type: 'module',
scripts: {
test: 'playwright test',
'test:ci': 'playwright test --reporter=list'
},
devDependencies: {
'@playwright/test': '^1.59.1',
imhotep: '^1.0.0'
}
},
readme: `# Imhotep Nuxt + Playwright preset
Scaffolded with \`imhotep init --preset nuxt\`.
This preset targets common ecommerce layout checks:
- sticky filter rail relative position
- product grid spacing invariants
- 44x44 buy-button touch target baseline
`
};
@@ -0,0 +1,137 @@
// Preset template for React + Playwright projects
export const reactPlaywrightPreset = {
name: 'react',
description: 'React component testing with Playwright',
configFile: `module.exports = {
// Render target for React components
render: {
type: 'component',
framework: 'react',
// Component mount function path (relative to project root)
mount: './tests/mount.js'
},
// Playwright browser configuration
browser: {
viewport: { width: 1280, height: 720 }
},
// Spatial assertion defaults
defaults: {
tolerance: 0.5
}
};`,
testFile: `import { test, expect } from '@playwright/test';
import { imhotep } from 'imhotep';
test('button layout assertions', async ({ page }) => {
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
const ui = await imhotep(page);
ui.expect('[data-testid="primary-btn"]').to.be.above('[data-testid="secondary-btn"]', { minGap: 8 });
ui.expect('[data-testid="primary-btn"]').to.be.atLeast(44, 'height');
const result = await ui.checkAll();
expect(result.passed).toBe(true);
});`,
fixtureFile: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React Playwright Fixture</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f5f5;
}
.button-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
min-height: 44px;
}
.btn-primary {
background: #0066cc;
color: white;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
</style>
</head>
<body>
<div class="button-group" data-testid="button-group">
<button class="btn btn-primary" data-testid="primary-btn">
Primary Action
</button>
<button class="btn btn-secondary" data-testid="secondary-btn">
Secondary Action
</button>
</div>
</body>
</html>`,
packageJson: {
name: 'imhotep-react-project',
version: '1.0.0',
type: 'module',
scripts: {
test: 'playwright test',
'test:ci': 'playwright test --reporter=list',
'test:ui': 'playwright test --ui'
},
devDependencies: {
'@playwright/test': '^1.59.1',
'imhotep': '^1.0.0'
}
},
readme: `# Imhotep React + Playwright Project
This project was scaffolded with \`imhotep init --preset react\`.
## Getting Started
\`\`\`bash
npm install
npx playwright install
npm test
\`\`\`
## Project Structure
- \`imhotep.config.js\` - Imhotep configuration
- \`tests/\` - Test files
- \`fixtures/\` - HTML fixtures for spatial testing
## Writing Tests
Use the Imhotep DSL for spatial assertions:
\`\`\`typescript
const ui = imhotep(page);
await ui.expect('.header').to.be.above('.content', { minGap: 16 });
\`\`\`
`
};
@@ -0,0 +1,69 @@
export const remixPlaywrightPreset = {
name: 'remix',
description: 'Remix route layout testing with Playwright + Imhotep',
configFile: `module.exports = {
render: {
type: 'page',
framework: 'remix'
},
browser: {
viewport: { width: 1280, height: 720 }
}
};`,
testFile: `import { test, expect } from '@playwright/test';
import { imhotep } from 'imhotep';
test('route layout contracts', async ({ page }) => {
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
const ui = await imhotep(page);
ui.expect('[data-testid="header"]').to.be.above('[data-testid="main"]', { minGap: 0 });
ui.expect('[data-testid="sidebar"]').to.be.leftOf('[data-testid="main"]', { minGap: 12 });
const result = await ui.checkAll();
expect(result.passed).toBe(true);
});`,
fixtureFile: `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body { margin: 0; font-family: sans-serif; }
header { height: 64px; background: #0f172a; color: #fff; padding: 16px; }
.layout { display: grid; grid-template-columns: 220px 1fr; gap: 12px; }
aside { background: #e2e8f0; min-height: calc(100vh - 64px); padding: 16px; }
main { padding: 24px; }
</style>
</head>
<body>
<header data-testid="header">Header</header>
<div class="layout">
<aside data-testid="sidebar">Filters</aside>
<main data-testid="main">Content</main>
</div>
</body>
</html>`,
packageJson: {
name: 'imhotep-remix-project',
version: '1.0.0',
type: 'module',
scripts: {
test: 'playwright test',
'test:ci': 'playwright test --reporter=list'
},
devDependencies: {
'@playwright/test': '^1.59.1',
imhotep: '^1.0.0'
}
},
readme: `# Imhotep Remix + Playwright preset
Scaffolded with \`imhotep init --preset remix\`.
`
};
@@ -0,0 +1,176 @@
// Preset template for Storybook projects
export const storybookPreset = {
name: 'storybook',
description: 'Storybook component testing with spatial assertions',
configFile: `module.exports = {
// Render target for Storybook stories
render: {
type: 'storybook',
// Storybook URL (local dev server)
url: 'http://localhost:6006',
// Story patterns to test
stories: './src/**/*.stories.@(js|jsx|ts|tsx)'
},
// Browser configuration
browser: {
viewport: { width: 1280, height: 720 }
},
// Spatial assertion defaults
defaults: {
tolerance: 0.5
}
};`,
testFile: `import { test, expect } from '@playwright/test';
import { imhotep } from 'imhotep';
test('storybook story layout', async ({ page }) => {
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
const ui = await imhotep(page);
ui.expect('[data-testid="sidebar"]').to.be.leftOf('[data-testid="main-content"]', { minGap: 0 });
ui.expect('[data-testid="header"]').to.be.above('[data-testid="main-content"]', { minGap: 0 });
ui.expect('[data-testid="story-canvas"]').to.be.contains('[data-testid="story-component"]');
const result = await ui.checkAll();
expect(result.passed).toBe(true);
});`,
fixtureFile: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Storybook Fixture</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
}
.app {
display: grid;
grid-template-areas:
"header header"
"sidebar main";
grid-template-columns: 240px 1fr;
grid-template-rows: 48px 1fr;
height: 100vh;
}
.header {
grid-area: header;
background: #1a1a2e;
color: white;
display: flex;
align-items: center;
padding: 0 16px;
font-weight: 600;
}
.sidebar {
grid-area: sidebar;
background: #f8f9fa;
border-right: 1px solid #e0e0e0;
padding: 16px;
}
.sidebar-item {
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 4px;
font-size: 14px;
color: #333;
}
.sidebar-item:hover {
background: #e9ecef;
}
.main {
grid-area: main;
padding: 24px;
overflow: auto;
}
.story-canvas {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 32px;
min-height: 200px;
}
.story-component {
padding: 16px;
background: #f0f0f0;
border-radius: 4px;
text-align: center;
}
</style>
</head>
<body>
<div class="app">
<header class="header" data-testid="header">
Storybook
</header>
<nav class="sidebar" data-testid="sidebar">
<div class="sidebar-item">Buttons</div>
<div class="sidebar-item">Cards</div>
<div class="sidebar-item">Forms</div>
</nav>
<main class="main" data-testid="main-content">
<div class="story-canvas" data-testid="story-canvas">
<div class="story-component" data-testid="story-component">
Example Story Component
</div>
</div>
</main>
</div>
</body>
</html>`,
packageJson: {
name: 'imhotep-storybook-project',
version: '1.0.0',
type: 'module',
scripts: {
'storybook': 'storybook dev -p 6006',
'build-storybook': 'storybook build',
test: 'playwright test',
'test:ci': 'playwright test --reporter=list',
'test:visual': 'playwright test --update-snapshots'
},
devDependencies: {
'@playwright/test': '^1.59.1',
'@storybook/react': '^7.0.0',
'storybook': '^7.0.0',
'imhotep': '^1.0.0'
}
},
readme: `# Imhotep Storybook Project
This project was scaffolded with \`imhotep init --preset storybook\`.
## Getting Started
\`\`\`bash
npm install
npm run storybook # Start Storybook dev server
npm test # Run spatial tests
\`\`\`
## Project Structure
- \`imhotep.config.js\` - Imhotep configuration
- \`tests/\` - Spatial test files
- \`fixtures/\` - HTML fixtures for offline testing
- \`src/**/*.stories.*\` - Storybook stories
## Writing Tests
Use the Imhotep DSL for spatial assertions against stories:
\`\`\`typescript
const ui = imhotep(page);
await ui.expect('.sidebar').to.be.leftOf('.main-content');
\`\`\`
`
};
@@ -0,0 +1,156 @@
// Preset template for Vue + Vitest projects
export const vueVitestPreset = {
name: 'vue',
description: 'Vue component testing with Vitest',
configFile: `module.exports = {
// Render target for Vue components
render: {
type: 'component',
framework: 'vue',
// Component mount function path (relative to project root)
mount: './tests/mount.js'
},
// Browser configuration for Vitest browser mode
browser: {
enabled: true,
name: 'chromium',
viewport: { width: 1280, height: 720 }
},
// Spatial assertion defaults
defaults: {
tolerance: 0.5
}
};`,
testFile: `import { test, expect } from '@playwright/test';
import { imhotep } from 'imhotep';
test('card layout assertions', async ({ page }) => {
await page.goto(new URL('../fixtures/example.html', import.meta.url).href);
const ui = await imhotep(page);
ui.expect('[data-testid="card-title"]').to.be.above('[data-testid="card-content"]', { minGap: 12 });
ui.expect('[data-testid="card"]').to.be.atLeast(300, 'width');
const result = await ui.checkAll();
expect(result.passed).toBe(true);
});`,
fixtureFile: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue Vitest Fixture</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card {
width: 320px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.card-title {
font-size: 20px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 12px;
}
.card-content {
font-size: 14px;
line-height: 1.6;
color: #4a4a68;
}
.card-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-secondary {
background: transparent;
color: #667eea;
border: 1px solid #667eea;
}
</style>
</head>
<body>
<div class="card" data-testid="card">
<h2 class="card-title" data-testid="card-title">
Vue Component
</h2>
<div class="card-content" data-testid="card-content">
This fixture demonstrates spatial testing for Vue components using Vitest.
</div>
<div class="card-actions">
<button class="btn btn-primary">Confirm</button>
<button class="btn btn-secondary">Cancel</button>
</div>
</div>
</body>
</html>`,
packageJson: {
name: 'imhotep-vue-project',
version: '1.0.0',
type: 'module',
scripts: {
test: 'playwright test',
'test:ci': 'playwright test --reporter=list',
'test:ui': 'playwright test --ui'
},
devDependencies: {
'@playwright/test': '^1.59.1',
'imhotep': '^1.0.0'
}
},
readme: `# Imhotep Vue + Vitest Project
This project was scaffolded with \`imhotep init --preset vue\`.
## Getting Started
\`\`\`bash
npm install
npm test
\`\`\`
## Project Structure
- \`imhotep.config.js\` - Imhotep configuration
- \`tests/\` - Test files
- \`fixtures/\` - HTML fixtures for spatial testing
## Writing Tests
Use the Imhotep DSL for spatial assertions:
\`\`\`typescript
const ui = imhotep(page);
await ui.expect('.card').to.be.centeredWithin('viewport');
\`\`\`
`
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {}
},
"include": ["src/**/*"]
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "imhotep-core",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "tsc -p tsconfig.test.json && node --test dist-test/**/*.test.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./scene-target": {
"types": "./dist/scene-target.d.ts",
"default": "./dist/scene-target.js"
},
"./property-contracts": {
"types": "./dist/property-contracts.d.ts",
"default": "./dist/property-contracts.js"
},
"./property-results": {
"types": "./dist/property-results.d.ts",
"default": "./dist/property-results.js"
},
"./world": {
"types": "./dist/world.d.ts",
"default": "./dist/world.js"
}
}
}
+309
View File
@@ -0,0 +1,309 @@
/**
* AST node definitions for Imhotep authoring syntax.
*
* All AST nodes follow a unist-style shape:
* {
* type: string,
* position?: Position,
* data?: Record<string, unknown>,
* children?: AstNode[]
* }
*
* This structure is intentionally rich for tooling, transforms, and diagnostics.
*/
import type { Position, LiteralValue } from './types.js'
import type {
FormulaNode,
PropertyRunBlock,
SampledRunBlock,
} from './logic-ast.js'
// ---------------------------------------------------------------------------
// Base Node
// ---------------------------------------------------------------------------
export interface AstNode {
type: string
position?: Position
data?: Record<string, unknown>
}
export interface ParentNode extends AstNode {
children: AstNode[]
}
// ---------------------------------------------------------------------------
// Program
// ---------------------------------------------------------------------------
export interface Program extends ParentNode {
type: 'Program'
children: BlockNode[]
}
// ---------------------------------------------------------------------------
// Block-Level Nodes
// ---------------------------------------------------------------------------
export type BlockNode =
| FrameBlock
| AcrossBlock
| AlwaysBlock
| WhenBlock
| FormulaNode
| PropertyRunBlock
| SampledRunBlock
export interface FrameBlock extends ParentNode {
type: 'FrameBlock'
frame: FrameRef
children: AssertionNode[]
}
export interface AcrossBlock extends ParentNode {
type: 'AcrossBlock'
axes: AcrossAxis[]
children: BlockNode[]
}
export interface AcrossAxis {
name: string
values: (string | number)[]
}
export interface AlwaysBlock extends ParentNode {
type: 'AlwaysBlock'
children: AssertionNode[]
}
export interface WhenBlock extends ParentNode {
type: 'WhenBlock'
guard: GuardExpression
children: AssertionNode[]
}
export interface GuardExpression extends AstNode {
type: 'GuardExpression'
op: string
left: string | number
right?: string | number
}
// ---------------------------------------------------------------------------
// Assertion Nodes
// ---------------------------------------------------------------------------
export type AssertionNode =
| RelationAssertion
| TopologyAssertion
| SizeAssertion
| StateAssertion
| TemporalAssertion
| CompoundAssertion
| FormulaNode
export interface RelationAssertion extends AstNode {
type: 'RelationAssertion'
relation: string
subject: SelectorRef
reference: SelectorRef
options: RelationOptions
}
export interface RelationOptions extends AstNode {
type: 'RelationOptions'
minGap?: LiteralValue
maxGap?: LiteralValue
tolerance?: LiteralValue
inStackingContext?: boolean
}
export interface TopologyAssertion extends AstNode {
type: 'TopologyAssertion'
predicate: string
subject: SelectorRef
reference?: SelectorRef
options?: TopologyOptions
}
export interface TopologyOptions extends AstNode {
type: 'TopologyOptions'
tolerance?: LiteralValue
}
export interface SizeAssertion extends AstNode {
type: 'SizeAssertion'
property: string
subject: SelectorRef
bounds: SizeBounds
}
export interface SizeBounds extends AstNode {
type: 'SizeBounds'
min?: LiteralValue
max?: LiteralValue
exact?: LiteralValue
}
export interface StateAssertion extends AstNode {
type: 'StateAssertion'
stateSelector: StateSelector
assertion: AssertionNode
}
export interface TemporalAssertion extends AstNode {
type: 'TemporalAssertion'
timelineSelector: TimelineSelector
assertion: AssertionNode
}
export interface CompoundAssertion extends ParentNode {
type: 'CompoundAssertion'
quantifier?: QuantifierNode
operator?: 'and' | 'or'
children: AssertionNode[]
}
// ---------------------------------------------------------------------------
// Selector and Reference Nodes
// ---------------------------------------------------------------------------
export interface SelectorRef extends AstNode {
type: 'SelectorRef'
value: string
}
// ---------------------------------------------------------------------------
// Semantic Subject Reference (Stream 7)
// ---------------------------------------------------------------------------
export type SubjectReference =
| { kind: 'css'; selector: string }
| { kind: 'role'; role: string; name?: string }
| { kind: 'text'; text: string }
| { kind: 'labelText'; label: string }
| { kind: 'testId'; testId: string }
export function subjectReferenceToString(ref: SubjectReference): string {
switch (ref.kind) {
case 'css':
return `css='${ref.selector}'`
case 'role':
return ref.name
? `role='${ref.role}' name='${ref.name}'`
: `role='${ref.role}'`
case 'text':
return `text='${ref.text}'`
case 'labelText':
return `labelText='${ref.label}'`
case 'testId':
return `testId='${ref.testId}'`
default:
return 'unknown'
}
}
export interface StateSelector extends AstNode {
type: 'StateSelector'
kind: string
name?: string
}
export interface TimelineSelector extends AstNode {
type: 'TimelineSelector'
mode: string
samples?: number[]
from?: number
to?: number
}
// ---------------------------------------------------------------------------
// Literal Nodes
// ---------------------------------------------------------------------------
export interface LiteralNode extends AstNode {
type: 'Literal'
kind: 'length' | 'range' | 'tolerance' | 'string' | 'number' | 'boolean'
value: unknown
unit?: string
}
export interface RangeLiteralNode extends AstNode {
type: 'RangeLiteral'
min: LiteralNode
max: LiteralNode
}
export interface ToleranceLiteralNode extends AstNode {
type: 'ToleranceLiteral'
value: number
unit: 'jnd' | 'px'
}
// ---------------------------------------------------------------------------
// Quantifier Nodes
// ---------------------------------------------------------------------------
export interface QuantifierNode extends AstNode {
type: 'Quantifier'
kind: string
count?: number
}
// ---------------------------------------------------------------------------
// Frame Reference
// ---------------------------------------------------------------------------
export interface FrameRef extends AstNode {
type: 'FrameRef'
kind: string
selector?: string
name?: string
}
// ---------------------------------------------------------------------------
// AST Type Guards
// ---------------------------------------------------------------------------
export function isAstNode(value: unknown): value is AstNode {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
typeof (value as Record<string, unknown>).type === 'string'
)
}
export function isParentNode(node: AstNode): node is ParentNode {
return 'children' in node && Array.isArray((node as ParentNode).children)
}
export function isProgram(node: AstNode): node is Program {
return node.type === 'Program'
}
export function isFrameBlock(node: AstNode): node is FrameBlock {
return node.type === 'FrameBlock'
}
export function isRelationAssertion(node: AstNode): node is RelationAssertion {
return node.type === 'RelationAssertion'
}
export function isCompoundAssertion(node: AstNode): node is CompoundAssertion {
return node.type === 'CompoundAssertion'
}
export function isFormulaNode(node: AstNode): node is FormulaNode {
return node.type === 'FormulaNode'
}
export function isPropertyRunBlock(node: AstNode): node is PropertyRunBlock {
return node.type === 'PropertyRunBlock'
}
export function isSampledRunBlock(node: AstNode): node is SampledRunBlock {
return node.type === 'SampledRunBlock'
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
/**
* Determinism proof tests — RED phase.
*
* These tests assert that Imhotep execution is deterministic when a
* seeded context is provided. They currently FAIL because the codebase
* relies on global mutable state (module-global id counter, Date.now,
* Math.random).
*
* Run: npm test -w packages/imhotep-core
*/
import { describe, it, beforeEach } from 'node:test'
import assert from 'node:assert'
import {
createDeterministicContext,
createVolatileContext,
getDefaultContext,
setDefaultContext,
resetDefaultContext,
} from './context.js'
import { compile } from 'imhotep-dsl'
import { createSnapshotId } from 'imhotep-state'
// ---------------------------------------------------------------------------
// Context unit tests
// ---------------------------------------------------------------------------
describe('ExecutionContext', () => {
beforeEach(() => {
resetDefaultContext()
})
it('deterministic context produces identical ids across calls', () => {
const ctx = createDeterministicContext(42)
const a = ctx.idGenerator()
const b = ctx.idGenerator()
const c = ctx.idGenerator()
assert.deepStrictEqual([a, b, c], ['id-1', 'id-2', 'id-3'])
})
it('deterministic context with same seed produces same rng sequence', () => {
const ctx1 = createDeterministicContext(123)
const ctx2 = createDeterministicContext(123)
const seq1 = Array.from({ length: 5 }, () => ctx1.rng())
const seq2 = Array.from({ length: 5 }, () => ctx2.rng())
assert.deepStrictEqual(seq1, seq2)
})
it('deterministic context with different seeds produces different rng sequence', () => {
const ctx1 = createDeterministicContext(123)
const ctx2 = createDeterministicContext(456)
const seq1 = Array.from({ length: 5 }, () => ctx1.rng())
const seq2 = Array.from({ length: 5 }, () => ctx2.rng())
assert.notDeepStrictEqual(seq1, seq2)
})
it('deterministic context clock is stable', () => {
const ctx = createDeterministicContext()
assert.strictEqual(ctx.clock(), 0)
assert.strictEqual(ctx.clock(), 0)
})
it('volatile context clock returns real time', () => {
const before = Date.now()
const ctx = createVolatileContext()
const t = ctx.clock()
const after = Date.now()
assert.ok(t >= before)
assert.ok(t <= after)
})
it('default context is deterministic in test mode', () => {
const ctx1 = getDefaultContext()
const ctx2 = getDefaultContext()
// In test mode, each call returns a fresh deterministic context.
assert.notStrictEqual(ctx1, ctx2)
assert.strictEqual(typeof ctx1.seed, 'number')
assert.strictEqual(ctx1.clock(), 0)
assert.strictEqual(ctx2.clock(), 0)
})
it('setDefaultContext overrides the global default', () => {
const det = createDeterministicContext(99)
setDefaultContext(det)
assert.strictEqual(getDefaultContext(), det)
assert.strictEqual(getDefaultContext().seed, 99)
})
it('parallel deterministic contexts do not interfere', () => {
const ctxA = createDeterministicContext(1)
const ctxB = createDeterministicContext(1)
const idsA = [ctxA.idGenerator(), ctxA.idGenerator()]
const idsB = [ctxB.idGenerator(), ctxB.idGenerator(), ctxB.idGenerator()]
assert.deepStrictEqual(idsA, ['id-1', 'id-2'])
assert.deepStrictEqual(idsB, ['id-1', 'id-2', 'id-3'])
})
})
// ---------------------------------------------------------------------------
// Cross-package determinism proof tests (currently RED)
// ---------------------------------------------------------------------------
describe('Determinism across packages', () => {
it('default compiler ids are non-deterministic across runs', () => {
// RED: defaultNextId uses a module-global counter.
// Two compile() calls without options produce different ids.
const ast = [
{
type: 'RelationAssertion' as const,
subject: { type: 'SelectorRef' as const, value: '.a' },
relation: 'leftOf' as const,
reference: { type: 'SelectorRef' as const, value: '.b' },
options: { type: 'RelationOptions' as const },
position: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 10, offset: 10 } },
},
] as const
const result1 = compile(ast as any)
const result2 = compile(ast as any)
const ids1 = Array.from(result1.semanticIr.clauses.values()).map((c: any) => c.id)
const ids2 = Array.from(result2.semanticIr.clauses.values()).map((c: any) => c.id)
// This assertion documents the desired deterministic behavior.
// It currently FAILS because the global counter advances between calls.
assert.deepStrictEqual(ids1, ids2, 'clause ids must be deterministic with same context')
})
it('injected compiler ids are deterministic across runs', () => {
// GREEN preview: when generateId is injected, ids are deterministic.
const ctx1 = createDeterministicContext(7)
const ctx2 = createDeterministicContext(7)
const ast = [
{
type: 'RelationAssertion' as const,
subject: { type: 'SelectorRef' as const, value: '.a' },
relation: 'leftOf' as const,
reference: { type: 'SelectorRef' as const, value: '.b' },
options: { type: 'RelationOptions' as const },
position: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 10, offset: 10 } },
},
] as const
const result1 = compile(ast as any, { generateId: () => ctx1.idGenerator() })
const result2 = compile(ast as any, { generateId: () => ctx2.idGenerator() })
const ids1 = Array.from(result1.semanticIr.clauses.values()).map((c: any) => c.id)
const ids2 = Array.from(result2.semanticIr.clauses.values()).map((c: any) => c.id)
assert.deepStrictEqual(ids1, ids2, 'injected ids must be deterministic with same seed')
})
it('default snapshot ids are deterministic and unique', () => {
// With the monotonic counter in createSnapshotId, consecutive calls
// produce predictable but unique ids in deterministic test contexts.
const id1 = createSnapshotId()
const id2 = createSnapshotId()
assert.strictEqual(id1, 'snap_0_id-1_1')
assert.strictEqual(id2, 'snap_0_id-1_2')
assert.notStrictEqual(id1, id2)
})
it('snapshot timestamps are stable with deterministic clock', () => {
const ctx = createDeterministicContext(7)
const t1 = ctx.clock()
const t2 = ctx.clock()
assert.strictEqual(t1, t2)
assert.strictEqual(t1, 0)
})
})
+130
View File
@@ -0,0 +1,130 @@
/**
* Execution context for deterministic and reproducible Imhotep runs.
*
* Every source of non-determinism (id generation, timestamps, randomness)
* is captured behind injectable functions so tests can control them.
*
* In production, use `createVolatileContext()`. In tests, use
* `createDeterministicContext(seed)`.
*/
export interface ExecutionContext {
/** Stable id generator (e.g. counter-based or UUID). */
idGenerator: () => string
/** Stable clock (e.g. fixed start time). */
clock: () => number
/** Seeded random number generator returning values in [0, 1). */
rng: () => number
/** The seed driving rng; stored in replay metadata. */
seed: number
}
// ---------------------------------------------------------------------------
// Deterministic default (test mode)
// ---------------------------------------------------------------------------
/** Simple linear congruential generator for deterministic random. */
function lcg(seed: number): () => number {
let s = seed >>> 0
return () => {
s = (1664525 * s + 1013904223) >>> 0
return s / 4294967296
}
}
/** Counter-based id generator. */
function counterIdGenerator(prefix = 'id'): () => string {
let n = 0
return () => `${prefix}-${++n}`
}
/**
* Create a fully deterministic execution context.
*
* IDs are counter-based, timestamps start at 0, and rng is a seeded LCG.
*/
export function createDeterministicContext(seed = 42): ExecutionContext {
return {
idGenerator: counterIdGenerator('id'),
clock: () => 0,
rng: lcg(seed),
seed,
}
}
// ---------------------------------------------------------------------------
// Volatile context (production)
// ---------------------------------------------------------------------------
/** Generate a random 32-bit integer seed. */
export function generateSeed(): number {
return Math.floor(Math.random() * 1_000_000_000)
}
/**
* Create a volatile execution context using real clocks and Math.random.
*
* This is the production default. The returned seed can be captured in
* replay metadata.
*/
export function createVolatileContext(seed?: number): ExecutionContext {
const actualSeed = seed ?? generateSeed()
// Use a fresh LCG even in volatile mode so that the seed is meaningful.
// If caller passes a seed we honour it; otherwise we use Math.random.
const rng = lcg(actualSeed)
return {
idGenerator: () => {
// Simple nanoid-like fallback (21 chars, base-64 alphabet)
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'
let id = ''
for (let i = 0; i < 21; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)]
}
return id
},
clock: () => Date.now(),
rng,
seed: actualSeed,
}
}
// ---------------------------------------------------------------------------
// Global default (backward compatible)
// ---------------------------------------------------------------------------
let _defaultContext: ExecutionContext | undefined
function isTestEnvironment(): boolean {
return (
typeof process !== 'undefined' &&
!!process.env.NODE_TEST_CONTEXT
)
}
/** Return the current default context.
*
* In production, a single volatile context is created and cached.
* In tests (detected via NODE_TEST_CONTEXT), a fresh deterministic
* context is returned on each call unless one has been explicitly set
* via setDefaultContext().
*/
export function getDefaultContext(): ExecutionContext {
if (_defaultContext) {
return _defaultContext
}
if (isTestEnvironment()) {
return createDeterministicContext()
}
_defaultContext = createVolatileContext()
return _defaultContext
}
/** Set the global default context (useful for test setup). */
export function setDefaultContext(ctx: ExecutionContext): void {
_defaultContext = ctx
}
/** Reset the global default context so the next call creates a fresh one. */
export function resetDefaultContext(): void {
_defaultContext = undefined
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Extractor and solver contract interfaces for Imhotep.
*
* The extractor is a compiler target: it consumes a fact request plan and
* produces a geometry world. The solver operates on explicit clause families
* with declared fact requirements and deterministic outputs.
*/
import type {
ImhotepId,
Environment,
StateSnapshot,
TimelineSnapshot,
Subject,
ClauseResult,
GroupResult,
Proof,
} from './types.js'
import type { GeometryWorld, FactRequestPlan } from './world.js'
import type { ExtractionTrace, EvaluationTrace, Diagnostic } from './diagnostics.js'
import type { SceneTarget, RenderCase } from './scene-target.js'
export type { SceneTarget, RenderCase }
// ---------------------------------------------------------------------------
// Extractor Request
// ---------------------------------------------------------------------------
export interface ExtractorRequest {
requestId: ImhotepId
sceneTarget: SceneTarget
env: Environment
statePlan: {
snapshots: string[]
}
timelinePlan: {
mode: string
}
subjects: Array<{
id: ImhotepId
selector: string
}>
requiredFacts: FactRequestPlan
}
// ---------------------------------------------------------------------------
// Extractor Response
// ---------------------------------------------------------------------------
export interface ExtractorResponse {
requestId: ImhotepId
status: 'ok' | 'partial' | 'error'
snapshots: GeometryWorld[]
diagnostics: Diagnostic[]
extractionTrace: ExtractionTrace
}
// ---------------------------------------------------------------------------
// Extractor Contract
// ---------------------------------------------------------------------------
export interface ExtractorContract {
/**
* Consume a fact request plan and produce a geometry world.
*/
extract(request: ExtractorRequest, runtime: unknown): Promise<ExtractorResponse>
}
// ---------------------------------------------------------------------------
// Extractor Adapter Interface
// ---------------------------------------------------------------------------
export interface ExtractorAdapter {
name: string
version: string
supportedFacts: string[]
execute(request: ExtractorRequest): Promise<AdapterExtractionResult>
}
export interface AdapterExtractionResult {
status: 'ok' | 'partial' | 'error'
world: GeometryWorld
diagnostics: Diagnostic[]
trace: ExtractionTrace
}
// ---------------------------------------------------------------------------
// Solver Clause Contract
// ---------------------------------------------------------------------------
export interface SolverClauseContract {
/**
* Descriptor declaring what facts this clause family requires.
*/
descriptor: ClauseDescriptor
/**
* Evaluate a single clause against the geometry world.
*/
evaluate(world: GeometryWorld, clauseId: ImhotepId, traceBuilder: TraceBuilder): ClauseResult
}
export interface ClauseDescriptor {
clauseKind: string
version: number
requiredFacts: string[]
}
export interface TraceBuilder {
addStep(step: unknown): void
build(): unknown[]
}
// ---------------------------------------------------------------------------
// Evaluation Result
// ---------------------------------------------------------------------------
export interface EvaluationResult {
clauseResults: ClauseResult[]
groupResults: GroupResult[]
proofs: Proof[]
diagnostics: Diagnostic[]
trace: EvaluationTrace
}
// ---------------------------------------------------------------------------
// Solver Evaluation Contract
// ---------------------------------------------------------------------------
export interface SolverContract {
/**
* Evaluate compiled execution IR against a set of geometry worlds.
*/
evaluate(executionIr: unknown, worldSet: GeometryWorld[]): EvaluationResult
}
// ---------------------------------------------------------------------------
// Compiler Contract
// ---------------------------------------------------------------------------
export interface CompilerContract {
compile(input: string | unknown): CompilerOutput
}
export interface CompilerOutput {
ast: unknown
semanticIr: unknown
executionIr: unknown
diagnostics: Diagnostic[]
}
// ---------------------------------------------------------------------------
// Reporter Contract
// ---------------------------------------------------------------------------
export interface ReporterContract {
report(evaluationResult: EvaluationResult, options: ReporterOptions): ReporterOutput
}
export interface ReporterOptions {
format: 'text' | 'json' | 'html'
includeTrace: boolean
includeProofs: boolean
}
export interface ReporterOutput {
text: string
json: string
overlays: unknown[]
}
// ---------------------------------------------------------------------------
// Fact Requirement Analysis
// ---------------------------------------------------------------------------
export interface FactRequirementAnalysis {
requiredFacts: string[]
optionalFacts: string[]
unsupportedFacts: string[]
plan: FactRequestPlan
}
// ---------------------------------------------------------------------------
// Scene Closure
// ---------------------------------------------------------------------------
export interface SceneClosure {
subjects: Map<ImhotepId, Subject>
frames: Map<ImhotepId, unknown>
states: Map<ImhotepId, StateSnapshot>
timelines: Map<ImhotepId, TimelineSnapshot>
resolved: boolean
diagnostics: Diagnostic[]
}
// ---------------------------------------------------------------------------
// Validation Contract
// ---------------------------------------------------------------------------
export interface ValidationContract {
validateAst(ast: unknown): ValidationResult
validateSemanticIr(semanticIr: unknown): ValidationResult
}
export interface ValidationResult {
valid: boolean
diagnostics: Diagnostic[]
}
+524
View File
@@ -0,0 +1,524 @@
/**
* Diagnostic and trace types for Imhotep.
*
* Imhotep maintains two major error classes:
* 1. Contract failure diagnostics — the UI violated a declared law
* 2. System use diagnostics — the user or engine could not formulate
* or evaluate the request correctly
*
* The diagnostic model is inspired by vfile / vfile-message ecosystems.
*/
import type { ImhotepId, Position, ClauseResult, Proof } from './types.js'
// ---------------------------------------------------------------------------
// Diagnostic Categories
// ---------------------------------------------------------------------------
export type DiagnosticCategory =
| 'parse-error'
| 'validation-error'
| 'resolution-error'
| 'extraction-error'
| 'contract-failure'
| 'indeterminate-result'
| 'internal-error'
export type DiagnosticSeverity = 'error' | 'warning' | 'info'
// ---------------------------------------------------------------------------
// Diagnostic Error Codes (Canonical Registry)
// ---------------------------------------------------------------------------
// All packages MUST use codes from this union. No package may invent a
// code that is not listed here.
// ---------------------------------------------------------------------------
export type DiagnosticCode =
// -------------------------------------------------------------------------
// Parse errors (imhotep-dsl)
// -------------------------------------------------------------------------
| 'IMH_PARSE_UNEXPECTED_TOKEN'
| 'IMH_PARSE_INVALID_SYNTAX'
| 'IMH_PARSE_UNTERMINATED_BLOCK'
| 'IMH_PARSE_ERROR'
| 'IMH_PARSE_IS_KEYWORD'
| 'IMH_PARSE_MISSING_QUOTES'
| 'IMH_PARSE_WRONG_QUOTE_STYLE'
| 'IMH_PARSE_MISSING_UNIT'
// -------------------------------------------------------------------------
// Validation errors (imhotep-dsl)
// -------------------------------------------------------------------------
| 'IMH_VALID_ILLEGAL_RELATION_OPTION'
| 'IMH_VALID_INVALID_UNIT'
| 'IMH_VALID_INVALID_STATE_TIMELINE'
| 'IMH_VALID_INVALID_ENV_GUARD'
| 'IMH_VALID_INVALID_QUANTIFIER_NESTING'
| 'IMH_VALID_CONTRADICTION'
// -------------------------------------------------------------------------
// Resolution errors (imhotep-playwright, imhotep-extractor)
// -------------------------------------------------------------------------
| 'IMH_SELECTOR_ZERO_MATCHES'
| 'IMH_SELECTOR_AMBIGUOUS'
| 'IMH_SELECTOR_NO_MATCH'
| 'IMH_SELECTOR_RESOLUTION_FAILED'
| 'IMH_FRAME_AMBIGUOUS'
| 'IMH_FRAME_UNSUPPORTED'
| 'IMH_TOPOLOGY_UNSUPPORTED'
| 'IMH_STATE_MATERIALIZATION_FAILED'
// -------------------------------------------------------------------------
// Extraction errors (imhotep-cdp, imhotep-extractor)
// -------------------------------------------------------------------------
| 'IMH_EXTRACT_PARTIAL'
| 'IMH_EXTRACT_UNAVAILABLE_FACT'
| 'IMH_EXTRACT_PROTOCOL_ERROR'
| 'IMH_CDP_SESSION_ATTACH_FAILED'
| 'IMH_DOM_EXTRACTION_FAILED'
| 'IMH_BOX_MODEL_PARTIAL'
| 'IMH_BOX_MODEL_FAILED'
| 'IMH_VISUAL_BOX_PARTIAL'
| 'IMH_VISUAL_BOX_FAILED'
| 'IMH_FRAGMENT_PARTIAL'
| 'IMH_FRAGMENT_FAILED'
| 'IMH_TRANSFORM_PARTIAL'
| 'IMH_TRANSFORM_FAILED'
| 'IMH_STYLE_PARTIAL'
| 'IMH_STYLE_FAILED'
| 'IMH_TOPOLOGY_PARTIAL'
| 'IMH_TOPOLOGY_FAILED'
// -------------------------------------------------------------------------
// Extractor planner errors (imhotep-extractor)
// -------------------------------------------------------------------------
| 'IMH_EXTRACTOR_EMPTY_SELECTOR'
| 'IMH_EXTRACTOR_MISSING_ENV_GUARD'
| 'IMH_EXTRACTOR_UNRESOLVED_ENV_GUARD'
| 'IMH_EXTRACTOR_MISSING_STATE'
| 'IMH_EXTRACTOR_UNRESOLVED_STATE'
| 'IMH_EXTRACTOR_UNSUPPORTED_STATE'
| 'IMH_EXTRACTOR_NON_STATIC_TIMELINE'
| 'IMH_EXTRACTOR_UNSUPPORTED_CLAUSE_TYPE'
// -------------------------------------------------------------------------
// Contract failures (imhotep-solver, imhotep-playwright)
// -------------------------------------------------------------------------
| 'IMH_RELATION_LEFT_OF_FAILED'
| 'IMH_RELATION_RIGHT_OF_FAILED'
| 'IMH_RELATION_ABOVE_FAILED'
| 'IMH_RELATION_BELOW_FAILED'
| 'IMH_RELATION_ALIGNED_FAILED'
| 'IMH_RELATION_CENTERED_FAILED'
| 'IMH_RELATION_INSIDE_FAILED'
| 'IMH_RELATION_CONTAINS_FAILED'
| 'IMH_RELATION_OVERLAPS_FAILED'
| 'IMH_RELATION_BESIDE_FAILED'
| 'IMH_RELATION_ADJACENT_FAILED'
| 'IMH_RELATION_NEAR_FAILED'
| 'IMH_RELATION_FAILED'
| 'IMH_SIZE_AT_LEAST_FAILED'
| 'IMH_SIZE_AT_MOST_FAILED'
| 'IMH_SIZE_BETWEEN_FAILED'
| 'IMH_ALIGNMENT_FAILED'
| 'IMH_TOPOLOGY_CLIPPED_FAILED'
| 'IMH_TOPOLOGY_STACKING_FAILED'
| 'IMH_VISIBILITY_FAILED'
| 'IMH_PREDICATE_FAILED'
// -------------------------------------------------------------------------
// Cardinality failures (imhotep-playwright)
// -------------------------------------------------------------------------
| 'IMH_CARDINALITY_EXACTLYONE_FAILED'
| 'IMH_CARDINALITY_ATLEASTN_FAILED'
| 'IMH_CARDINALITY_ATMOSTN_FAILED'
// -------------------------------------------------------------------------
// Indeterminate results
// -------------------------------------------------------------------------
| 'IMH_INDETERMINATE_MISSING_FACT'
| 'IMH_INDETERMINATE_UNSTABLE_INPUT'
// -------------------------------------------------------------------------
// Internal errors
// -------------------------------------------------------------------------
| 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND'
| 'IMH_INTERNAL_EVALUATION_EXCEPTION'
| 'IMH_FEATURE_NOT_YET_IMPLEMENTED'
| 'IMH_UNKNOWN_FAILURE'
// -------------------------------------------------------------------------
// System use / fact observation diagnostics
// -------------------------------------------------------------------------
| 'IMH_FACT_OBSERVED_GAP'
| 'IMH_FACT_OBSERVED_SIZE'
| 'IMH_FACT_OBSERVED_TOPOLOGY'
// -------------------------------------------------------------------------
// Logic engine errors (imhotep-solver)
// -------------------------------------------------------------------------
| 'IMH_LOGIC_UNKNOWN_FORMULA_KIND'
| 'IMH_LOGIC_DOMAIN_UNRESOLVED'
| 'IMH_LOGIC_VACUOUS_FORALL'
| 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS'
| 'IMH_LOGIC_PREDICATE_MISSING'
| 'IMH_LOGIC_UNBOUND_VARIABLE'
| 'IMH_LOGIC_UNSUPPORTED_TERM'
| 'IMH_LOGIC_UNKNOWN_NODE'
| 'IMH_LOGIC_ARITY_MISMATCH'
| 'IMH_LOGIC_UNKNOWN_PREDICATE'
| 'IMH_LOGIC_FREE_VARIABLE'
| 'IMH_LOGIC_EMPTY_DOMAIN'
// -------------------------------------------------------------------------
// Solver engine errors (imhotep-solver)
// -------------------------------------------------------------------------
| 'IMH_EVALUATOR_MISSING'
| 'IMH_EVALUATOR_EXCEPTION'
// -------------------------------------------------------------------------
// Quantifier errors (imhotep-solver)
// -------------------------------------------------------------------------
| 'IMH_QUANTIFIER_NO_SUBCLAUSES'
| 'IMH_PAIRWISE_INSUFFICIENT'
| 'IMH_UNKNOWN_QUANTIFIER'
// -------------------------------------------------------------------------
// Property-run diagnostics (imhotep-playwright)
// -------------------------------------------------------------------------
| 'IMH_ENUMERATED_RUN_ERROR'
| 'IMH_SAMPLED_RUN_ERROR'
| 'IMH_PROPERTY_RUN_FAILED'
| 'IMH_PROPERTY_PASSED'
| 'IMH_PROPERTY_FAILED'
| 'IMH_PROPERTY_REPLAY'
| 'IMH_PROPERTY_SHRUNK'
| 'IMH_ENUMERATED_PASSED'
| 'IMH_ENUMERATED_FAILED'
// -------------------------------------------------------------------------
// Reporter internal diagnostics (imhotep-reporter)
// -------------------------------------------------------------------------
| 'IMH_FRAME_CONTEXT'
| 'IMH_WITNESS_ENV'
| 'IMH_WITNESS_SNAPSHOT'
// ---------------------------------------------------------------------------
// Related Diagnostic
// ---------------------------------------------------------------------------
export interface RelatedDiagnostic {
code?: DiagnosticCode
message: string
position?: Position
}
// ---------------------------------------------------------------------------
// V1 Diagnostic Schema Extensions
// ---------------------------------------------------------------------------
export interface SourceRef {
file?: string
line?: number
column?: number
selector?: string
}
export interface SuggestedFix {
action: 'remove' | 'replace' | 'insert' | 'wrap'
target?: string
value?: string
rationale: string
}
// ---------------------------------------------------------------------------
// Diagnostic Shape
// ---------------------------------------------------------------------------
export interface Diagnostic {
code: DiagnosticCode
severity: DiagnosticSeverity
category: DiagnosticCategory
message: string
position?: Position
source: string
clauseId?: ImhotepId
sceneId?: ImhotepId
snapshotId?: ImhotepId
envCaseId?: ImhotepId
/** Subject resolution origin for semantic selectors (Stream 7). */
subjectOrigin?: string
related: RelatedDiagnostic[]
traceRef?: ImhotepId
fixHints: string[]
/** V1 required: structured metrics associated with this diagnostic. */
metrics: Record<string, number>
/** V1 required: canonical source reference. */
sourceRef: SourceRef | import('./types.js').SourceReference
/** V1 optional: automated fix suggestion. */
suggestedFix?: SuggestedFix
/** Human-readable label derived from the normalized contract (workstream C). */
clauseLabel?: string
}
// ---------------------------------------------------------------------------
// Trace Events
// ---------------------------------------------------------------------------
export type TracePhase =
| 'parse-started'
| 'ast-created'
| 'ir-normalized'
| 'fact-requirements-computed'
| 'extraction-started'
| 'extraction-step-completed'
| 'world-normalized'
| 'clause-evaluated'
| 'proof-created'
| 'witness-shrunk'
| 'diagnostic-emitted'
export interface TraceEvent {
traceEventId: ImhotepId
phase: TracePhase
at: number
refs: {
clauseId?: ImhotepId
proofId?: ImhotepId
snapshotId?: ImhotepId
diagnosticId?: ImhotepId
}
payload: Record<string, unknown>
}
export interface EvaluationTrace {
events: TraceEvent[]
}
// ---------------------------------------------------------------------------
// Witness Shrinking
// ---------------------------------------------------------------------------
export interface ShrinkAxis {
name: 'environment' | 'state' | 'timeline' | 'subject' | 'facts' | 'clauseGroup'
reduced: boolean
remainingValue: unknown
}
export interface ShrinkResult {
originalWitness: Proof['witness']
shrunkWitness: Proof['witness']
axes: ShrinkAxis[]
preserved: boolean
explanation: string
}
// ---------------------------------------------------------------------------
// Extraction Trace
// ---------------------------------------------------------------------------
export interface ExtractionTrace {
steps: ExtractionStep[]
timings: TimingEntry[]
protocolCalls: ProtocolCall[]
}
export interface ExtractionStep {
stepId: ImhotepId
factKind: string
status: 'ok' | 'partial' | 'error'
}
export interface TimingEntry {
stepId: ImhotepId
startMs: number
endMs: number
}
export interface ProtocolCall {
stepId: ImhotepId
protocol: string
method: string
params: Record<string, unknown>
}
// ---------------------------------------------------------------------------
// Diagnostic Factory
// ---------------------------------------------------------------------------
export function createDiagnostic(
code: DiagnosticCode,
category: DiagnosticCategory,
severity: DiagnosticSeverity,
message: string,
opts?: Partial<Omit<Diagnostic, 'code' | 'category' | 'severity' | 'message'>>,
): Diagnostic {
return {
code,
severity,
category,
message,
source: opts?.source ?? 'imhotep-core',
related: opts?.related ?? [],
fixHints: opts?.fixHints ?? getDefaultFixHints(code),
metrics: opts?.metrics ?? {},
sourceRef: opts?.sourceRef ?? {},
...opts,
}
}
// ---------------------------------------------------------------------------
// Default Severity / Category / Fix-Hint Helpers
// ---------------------------------------------------------------------------
/**
* Return the default severity for a canonical diagnostic code.
* This is a fallback; callers should override when context demands it.
*/
export function getDefaultSeverity(code: DiagnosticCode): DiagnosticSeverity {
switch (code) {
// Warnings
case 'IMH_SELECTOR_AMBIGUOUS':
case 'IMH_FRAME_AMBIGUOUS':
case 'IMH_SELECTOR_NO_MATCH':
case 'IMH_EXTRACT_PARTIAL':
case 'IMH_BOX_MODEL_PARTIAL':
case 'IMH_VISUAL_BOX_PARTIAL':
case 'IMH_FRAGMENT_PARTIAL':
case 'IMH_TRANSFORM_PARTIAL':
case 'IMH_STYLE_PARTIAL':
case 'IMH_TOPOLOGY_PARTIAL':
case 'IMH_INDETERMINATE_MISSING_FACT':
case 'IMH_INDETERMINATE_UNSTABLE_INPUT':
return 'warning'
// Info
case 'IMH_LOGIC_VACUOUS_FORALL':
case 'IMH_LOGIC_EMPTY_DOMAIN_EXISTS':
case 'IMH_FACT_OBSERVED_GAP':
case 'IMH_FACT_OBSERVED_SIZE':
case 'IMH_FACT_OBSERVED_TOPOLOGY':
case 'IMH_PROPERTY_PASSED':
case 'IMH_PROPERTY_REPLAY':
case 'IMH_ENUMERATED_PASSED':
case 'IMH_FRAME_CONTEXT':
case 'IMH_WITNESS_ENV':
case 'IMH_WITNESS_SNAPSHOT':
return 'info'
// Everything else defaults to error
default:
return 'error'
}
}
/**
* Return the default category for a canonical diagnostic code.
*/
export function getDefaultCategory(code: DiagnosticCode): DiagnosticCategory {
if (code.startsWith('IMH_PARSE_')) return 'parse-error'
if (code.startsWith('IMH_VALID_')) return 'validation-error'
if (code.startsWith('IMH_SELECTOR_') || code.startsWith('IMH_FRAME_') || code.startsWith('IMH_STATE_MATERIALIZATION')) return 'resolution-error'
if (code.startsWith('IMH_EXTRACTOR_')) return 'resolution-error'
if (code.startsWith('IMH_EXTRACT_') || code.startsWith('IMH_CDP_') || code.startsWith('IMH_DOM_') || code.startsWith('IMH_BOX_MODEL') || code.startsWith('IMH_VISUAL_BOX') || code.startsWith('IMH_FRAGMENT') || code.startsWith('IMH_TRANSFORM') || code.startsWith('IMH_STYLE') || code.startsWith('IMH_TOPOLOGY_PARTIAL') || code.startsWith('IMH_TOPOLOGY_FAILED')) return 'extraction-error'
if (code.startsWith('IMH_RELATION_') || code.startsWith('IMH_SIZE_') || code.startsWith('IMH_ALIGNMENT') || code.startsWith('IMH_TOPOLOGY_CLIPPED') || code.startsWith('IMH_TOPOLOGY_STACKING') || code.startsWith('IMH_VISIBILITY') || code.startsWith('IMH_PREDICATE') || code.startsWith('IMH_CARDINALITY')) return 'contract-failure'
if (code.startsWith('IMH_FACT_OBSERVED_')) return 'contract-failure'
if (code.startsWith('IMH_PROPERTY_') || code.startsWith('IMH_ENUMERATED_') || code === 'IMH_PROPERTY_RUN_FAILED') return 'contract-failure'
if (code.startsWith('IMH_INDETERMINATE_')) return 'indeterminate-result'
if (code.startsWith('IMH_INTERNAL_') || code.startsWith('IMH_FEATURE_') || code === 'IMH_UNKNOWN_FAILURE') return 'internal-error'
if (code.startsWith('IMH_LOGIC_') || code.startsWith('IMH_EVALUATOR_') || code.startsWith('IMH_QUANTIFIER_') || code === 'IMH_UNKNOWN_QUANTIFIER') return 'internal-error'
if (code.startsWith('IMH_FRAME_CONTEXT') || code.startsWith('IMH_WITNESS_')) return 'internal-error'
return 'internal-error'
}
/**
* Return default fix-hints for a canonical diagnostic code.
* Callers should augment or override these with context-specific hints.
*/
export function getDefaultFixHints(code: DiagnosticCode): string[] {
const hints: string[] = []
if (code.startsWith('IMH_PARSE_')) {
hints.push('Selectors must be single-quoted strings, e.g. \'[data-testid="x"]\'.')
hints.push('Use relation keywords directly without "is": e.g. \'a\' leftOf \'b\'.')
hints.push('Gap values require units: e.g. gap 8px.')
}
if (code === 'IMH_SELECTOR_ZERO_MATCHES' || code === 'IMH_SELECTOR_NO_MATCH') {
hints.push('Check that the selector is valid and matches an element in the DOM.')
hints.push('Use ui.extract(selector) to verify the selector resolves to at least one element.')
}
if (code === 'IMH_SELECTOR_AMBIGUOUS') {
hints.push('The selector matches more than one element. Use a more specific selector or add a quantifier.')
}
if (code === 'IMH_EXTRACT_PROTOCOL_ERROR' || code === 'IMH_CDP_SESSION_ATTACH_FAILED') {
hints.push('Verify the page is fully loaded before running assertions.')
hints.push('Check that selectors are valid CSS selectors or semantic references.')
hints.push('Use ui.extract(selector) to debug selector resolution.')
}
if (code === 'IMH_FEATURE_NOT_YET_IMPLEMENTED') {
hints.push('This assertion uses a relation that is documented but not yet implemented.')
hints.push('Use a supported relation such as leftOf, rightOf, above, below, inside, overlaps, alignedWith, or centeredWithin.')
}
if (code.startsWith('IMH_RELATION_') || code === 'IMH_ALIGNMENT_FAILED' || code === 'IMH_PREDICATE_FAILED') {
hints.push('Verify the expected layout and consider adjusting thresholds.')
hints.push('Check element positions using ui.extract(selector) to inspect actual geometry.')
}
if (code.startsWith('IMH_SIZE_')) {
hints.push('Check the expected size threshold and the actual element dimensions using ui.extract(selector).')
}
if (code.startsWith('IMH_CARDINALITY_')) {
hints.push('Verify the selector matches the expected number of elements.')
hints.push('Use a more specific selector or adjust the cardinality bound.')
}
if (code.startsWith('IMH_LOGIC_')) {
hints.push('Check formula syntax and ensure all referenced predicates and domains are defined.')
}
if (code.startsWith('IMH_TOPOLOGY_')) {
hints.push('Check CSS properties that affect topology (position, overflow, contain, z-index).')
}
if (code === 'IMH_PROPERTY_RUN_FAILED') {
hints.push('Review the failing counterexample and replay with the same seed.')
}
if (code.startsWith('IMH_ENUMERATED_RUN_ERROR') || code.startsWith('IMH_SAMPLED_RUN_ERROR')) {
hints.push('Check that the property callback does not throw for the reported input.')
}
return hints
}
// ---------------------------------------------------------------------------
// Category Classification Helpers
// ---------------------------------------------------------------------------
export function isParseError(diagnostic: Diagnostic): boolean {
return diagnostic.category === 'parse-error'
}
export function isContractFailure(diagnostic: Diagnostic): boolean {
return diagnostic.category === 'contract-failure'
}
export function isSystemUseError(diagnostic: Diagnostic): boolean {
return (
diagnostic.category === 'parse-error' ||
diagnostic.category === 'validation-error' ||
diagnostic.category === 'resolution-error' ||
diagnostic.category === 'extraction-error' ||
diagnostic.category === 'internal-error'
)
}
export function isIndeterminate(diagnostic: Diagnostic): boolean {
return diagnostic.category === 'indeterminate-result'
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Domain definitions for deterministic scene logic.
*
* A domain is a finite set of geometry subjects extracted from a scene.
* Domains are the ground over which quantifiers range.
*
* Invariant: every domain is enumerable and closed within a single
* materialized GeometryWorld. No domain may silently default to an
* empty set; empty domains must be reported explicitly.
*/
import type { ImhotepId, SubjectKind } from './types.js'
// ---------------------------------------------------------------------------
// Domain Descriptor
// ---------------------------------------------------------------------------
export interface DomainDescriptor {
domainId: ImhotepId
kind: DomainKind
selector?: string
parentVar?: string
subjectKind?: SubjectKind
}
export type DomainKind =
| 'elements'
| 'descendants'
| 'lineBoxes'
| 'textRuns'
| 'fragments'
| 'frames'
| 'custom'
// ---------------------------------------------------------------------------
// Domain Value (materialized)
// ---------------------------------------------------------------------------
export interface DomainValue {
domainId: ImhotepId
/** Stable numeric subject ids, stored as a flat array for fast iteration. */
subjectIds: Uint32Array
/** Source selector or domain expression that produced this set. */
provenance: string
/** If true, the domain is known to be complete for the scene. */
closed: boolean
}
// ---------------------------------------------------------------------------
// Built-in Domain Functions
// ---------------------------------------------------------------------------
export interface DomainFunctions {
/** Select elements matching a CSS selector. */
elements(selector: string): DomainDescriptor
/** Select descendants of a bound parent variable matching a selector. */
descendants(parentVar: string, selector: string): DomainDescriptor
/** Select line boxes for a given text node subject. */
lineBoxes(textNodeId: ImhotepId): DomainDescriptor
/** Select text runs for a given text node subject. */
textRuns(textNodeId: ImhotepId): DomainDescriptor
/** Select fragment boxes for a given subject. */
fragments(subjectId: ImhotepId): DomainDescriptor
/** Select frames of a given kind. */
frames(frameKind: string): DomainDescriptor
}
// ---------------------------------------------------------------------------
// Domain Enumeration Result
// ---------------------------------------------------------------------------
export interface DomainEnumeration {
domainId: ImhotepId
subjectIds: Uint32Array
empty: boolean
diagnostic?: DomainDiagnostic
}
export interface DomainDiagnostic {
code: string
message: string
domainId: ImhotepId
}
// ---------------------------------------------------------------------------
// Default Domain Registry Implementation
// ---------------------------------------------------------------------------
export class DomainRegistry {
private registry = new Map<ImhotepId, DomainDescriptor>()
register(descriptor: DomainDescriptor): void {
this.registry.set(descriptor.domainId, descriptor)
}
lookup(domainId: ImhotepId): DomainDescriptor | undefined {
return this.registry.get(domainId)
}
list(): DomainDescriptor[] {
return Array.from(this.registry.values())
}
clear(): void {
this.registry.clear()
}
}
// Global default instance for backward compatibility.
const globalDomainRegistry = new DomainRegistry()
export function registerDomain(descriptor: DomainDescriptor): void {
globalDomainRegistry.register(descriptor)
}
export function lookupDomain(domainId: ImhotepId): DomainDescriptor | undefined {
return globalDomainRegistry.lookup(domainId)
}
export function listDomains(): DomainDescriptor[] {
return globalDomainRegistry.list()
}
export function clearDomainRegistry(): void {
globalDomainRegistry.clear()
}
// ---------------------------------------------------------------------------
// Domain Factory
// ---------------------------------------------------------------------------
export function createElementsDomain(
domainId: ImhotepId,
selector: string,
): DomainDescriptor {
return {
domainId,
kind: 'elements',
selector,
}
}
export function createDescendantsDomain(
domainId: ImhotepId,
parentVar: string,
selector: string,
): DomainDescriptor {
return {
domainId,
kind: 'descendants',
parentVar,
selector,
}
}
export function createLineBoxesDomain(
domainId: ImhotepId,
textNodeId: ImhotepId,
): DomainDescriptor {
return {
domainId,
kind: 'lineBoxes',
selector: textNodeId,
}
}
export function createTextRunsDomain(
domainId: ImhotepId,
textNodeId: ImhotepId,
): DomainDescriptor {
return {
domainId,
kind: 'textRuns',
selector: textNodeId,
}
}
export function createFragmentsDomain(
domainId: ImhotepId,
subjectId: ImhotepId,
): DomainDescriptor {
return {
domainId,
kind: 'fragments',
selector: subjectId,
}
}
export function createFramesDomain(
domainId: ImhotepId,
frameKind: string,
): DomainDescriptor {
return {
domainId,
kind: 'frames',
selector: frameKind,
}
}
// ---------------------------------------------------------------------------
// Domain Value Factory
// ---------------------------------------------------------------------------
export function createDomainValue(
domainId: ImhotepId,
subjectIds: Uint32Array,
provenance: string,
closed = true,
): DomainValue {
return {
domainId,
subjectIds,
provenance,
closed,
}
}
@@ -0,0 +1,322 @@
/**
* Tests for geometry cache serialization and deserialization.
*/
import { test, describe } from 'node:test'
import assert from 'node:assert'
import {
serializeGeometryWorld,
deserializeGeometryWorld,
computeGeometryCacheKey,
readCachedWorld,
writeCachedWorld,
clearGeometryCache,
readCachedExtractionResult,
writeCachedExtractionResult,
} from './geometry-cache.js'
import { createEmptyWorld } from './world.js'
import type { GeometryWorld } from './world.js'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
describe('geometry cache', () => {
function createTestWorld(): GeometryWorld {
const world = createEmptyWorld('scene_1', 'snap_1')
world.env.viewportWidth = 1280
world.env.viewportHeight = 720
world.source.url = 'https://example.com'
world.strings.values = ['hello', 'world']
// Populate subjects table with some data
world.subjects.ids = new Uint32Array([1, 2, 3])
world.subjects.domNodeId = new Uint32Array([10, 20, 30])
world.subjects.subjectKind = new Uint16Array([1, 1, 2])
world.subjects.primaryBoxId = new Uint32Array([100, 200, 300])
// Populate boxes with geometry
world.boxes.boxId = new Uint32Array([100, 200])
world.boxes.subjectId = new Uint32Array([1, 2])
world.boxes.frameId = new Uint32Array([1, 1])
world.boxes.borderLeft = new Float64Array([0, 110])
world.boxes.borderTop = new Float64Array([0, 0])
world.boxes.borderRight = new Float64Array([100, 210])
world.boxes.borderBottom = new Float64Array([40, 40])
world.boxes.paddingLeft = new Float64Array([0, 0])
world.boxes.paddingTop = new Float64Array([0, 0])
world.boxes.paddingRight = new Float64Array([0, 0])
world.boxes.paddingBottom = new Float64Array([0, 0])
world.boxes.contentLeft = new Float64Array([0, 0])
world.boxes.contentTop = new Float64Array([0, 0])
world.boxes.contentRight = new Float64Array([0, 0])
world.boxes.contentBottom = new Float64Array([0, 0])
// Populate topology
world.topology.containingBlockOf = new Uint32Array([0, 1, 2])
world.topology.nearestPositionedAncestorOf = new Uint32Array([0, 0, 0])
world.topology.scrollContainerOf = new Uint32Array([0, 0, 0])
world.topology.stackingContextOf = new Uint32Array([1, 1, 1])
world.topology.formattingContextOf = new Uint32Array([1, 1, 1])
world.topology.clippingRootOf = new Uint32Array([0, 0, 0])
world.topology.paintOrderBucket = new Uint16Array([1, 1, 1])
world.topology.paintOrderIndex = new Uint32Array([0, 1, 2])
return world
}
test('round-trip serialization preserves primitive fields', () => {
const world = createTestWorld()
const json = serializeGeometryWorld(world)
const restored = deserializeGeometryWorld(json)
assert.strictEqual(restored.sceneId, 'scene_1')
assert.strictEqual(restored.snapshotId, 'snap_1')
assert.strictEqual(restored.env.viewportWidth, 1280)
assert.strictEqual(restored.env.viewportHeight, 720)
assert.strictEqual(restored.source.url, 'https://example.com')
assert.deepStrictEqual(restored.strings.values, ['hello', 'world'])
})
test('round-trip preserves TypedArray data and types', () => {
const world = createTestWorld()
const json = serializeGeometryWorld(world)
const restored = deserializeGeometryWorld(json)
// Verify subjects table
assert.ok(restored.subjects.ids instanceof Uint32Array)
assert.deepStrictEqual(
Array.from(restored.subjects.ids),
[1, 2, 3],
)
assert.ok(restored.subjects.subjectKind instanceof Uint16Array)
assert.deepStrictEqual(
Array.from(restored.subjects.subjectKind),
[1, 1, 2],
)
// Verify boxes table
assert.ok(restored.boxes.borderLeft instanceof Float64Array)
assert.deepStrictEqual(
Array.from(restored.boxes.borderLeft),
[0, 110],
)
assert.ok(restored.boxes.borderRight instanceof Float64Array)
assert.deepStrictEqual(
Array.from(restored.boxes.borderRight),
[100, 210],
)
})
test('round-trip preserves topology data', () => {
const world = createTestWorld()
const json = serializeGeometryWorld(world)
const restored = deserializeGeometryWorld(json)
assert.deepStrictEqual(
Array.from(restored.topology.stackingContextOf),
[1, 1, 1],
)
assert.deepStrictEqual(
Array.from(restored.topology.paintOrderIndex),
[0, 1, 2],
)
})
test('empty world round-trips', () => {
const world = createEmptyWorld('empty_scene', 'empty_snap')
const json = serializeGeometryWorld(world)
const restored = deserializeGeometryWorld(json)
assert.strictEqual(restored.sceneId, 'empty_scene')
assert.strictEqual(restored.snapshotId, 'empty_snap')
assert.strictEqual(restored.subjects.ids.length, 0)
assert.strictEqual(restored.boxes.boxId.length, 0)
})
test('world with undefined tables round-trips without crash', () => {
const world = createEmptyWorld('partial', 'snap') as any
// Simulate a world where some tables are undefined (common after extraction)
world.frames = undefined
world.fragments = undefined
world.styles = undefined
world.text = undefined
world.paint = undefined
world.provenance = undefined
world.confidence = undefined
const json = serializeGeometryWorld(world)
const restored = deserializeGeometryWorld(json)
assert.strictEqual(restored.sceneId, 'partial')
assert.ok(restored.frames)
assert.ok(restored.fragments)
assert.ok(restored.styles)
assert.ok(restored.text)
assert.ok(restored.paint)
assert.ok(restored.provenance)
assert.ok(restored.confidence)
})
test('cache key is stable for identical inputs', () => {
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], { viewportWidth: 1280 })
const key2 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], { viewportWidth: 1280 })
assert.strictEqual(key1, key2)
})
test('cache key differs for different selectors', () => {
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b'], {})
const key2 = computeGeometryCacheKey('https://example.com', ['.a', '.c'], {})
assert.notStrictEqual(key1, key2)
})
test('cache key is order-independent for selectors', () => {
const key1 = computeGeometryCacheKey('https://example.com', ['.a', '.b', '.c'], {})
const key2 = computeGeometryCacheKey('https://example.com', ['.c', '.a', '.b'], {})
assert.strictEqual(key1, key2)
})
test('cache key differs for different environments', () => {
const key1 = computeGeometryCacheKey('https://example.com', ['.a'], { viewportWidth: 1280 })
const key2 = computeGeometryCacheKey('https://example.com', ['.a'], { viewportWidth: 1920 })
assert.notStrictEqual(key1, key2)
})
test('write and read cached world', async () => {
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
try {
const world = createTestWorld()
const cacheKey = 'test-key-123'
await writeCachedWorld(cacheDir, cacheKey, world)
const restored = await readCachedWorld(cacheDir, cacheKey)
assert.ok(restored, 'should read back cached world')
assert.strictEqual(restored.sceneId, 'scene_1')
assert.deepStrictEqual(
Array.from(restored.subjects.ids),
[1, 2, 3],
)
// Cache miss for unknown key
const miss = await readCachedWorld(cacheDir, 'unknown-key')
assert.strictEqual(miss, null)
} finally {
rmSync(cacheDir, { recursive: true, force: true })
}
})
test('clear cache removes all entries', async () => {
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
try {
const world = createEmptyWorld('s', 'sn')
await writeCachedWorld(cacheDir, 'key1', world)
await writeCachedWorld(cacheDir, 'key2', world)
assert.ok(await readCachedWorld(cacheDir, 'key1'))
assert.ok(await readCachedWorld(cacheDir, 'key2'))
clearGeometryCache(cacheDir)
assert.strictEqual(await readCachedWorld(cacheDir, 'key1'), null)
assert.strictEqual(await readCachedWorld(cacheDir, 'key2'), null)
} finally {
rmSync(cacheDir, { recursive: true, force: true })
}
})
test('write and read cached extraction result with selector mappings', async () => {
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
try {
const world = createTestWorld()
const selectorToIds = new Map<string, number[]>([
['.button', [1, 2]],
['.label', [3]],
])
const errors = [
{
code: 'IMH_TEST_ERROR',
severity: 'error' as const,
category: 'test-error' as const,
message: 'Test error',
source: 'test',
related: [] as string[],
fixHints: [] as string[],
metrics: {} as Record<string, unknown>,
sourceRef: {} as Record<string, unknown>,
},
]
await writeCachedExtractionResult(cacheDir, 'extract-1', world, selectorToIds, errors)
const cached = await readCachedExtractionResult(cacheDir, 'extract-1')
assert.ok(cached, 'should read back cached extraction result')
assert.strictEqual(cached.world.sceneId, 'scene_1')
assert.deepStrictEqual(
Array.from(cached.selectorToIds.entries()),
[['.button', [1, 2]], ['.label', [3]]],
)
assert.strictEqual(cached.errors.length, 1)
assert.strictEqual(cached.errors[0].code, 'IMH_TEST_ERROR')
// Cache miss for unknown key
assert.strictEqual(await readCachedExtractionResult(cacheDir, 'unknown-key'), null)
} finally {
rmSync(cacheDir, { recursive: true, force: true })
}
})
test('cached extraction result round-trips empty maps and arrays', async () => {
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
try {
const world = createEmptyWorld('empty', 'snap')
const selectorToIds = new Map<string, number[]>()
const errors: any[] = []
await writeCachedExtractionResult(cacheDir, 'empty-extract', world, selectorToIds, errors)
const cached = await readCachedExtractionResult(cacheDir, 'empty-extract')
assert.ok(cached)
assert.strictEqual(cached.selectorToIds.size, 0)
assert.strictEqual(cached.errors.length, 0)
} finally {
rmSync(cacheDir, { recursive: true, force: true })
}
})
test('cache read is fast for large worlds', async () => {
const cacheDir = mkdtempSync(join(tmpdir(), 'imhotep-cache-test-'))
try {
// Build a world with 1000 subjects and boxes
const world = createEmptyWorld('bench', 'snap')
const n = 1000
world.subjects.ids = new Uint32Array(Array.from({ length: n }, (_, i) => i + 1))
world.subjects.domNodeId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 10))
world.subjects.subjectKind = new Uint16Array(Array.from({ length: n }, () => 1))
world.subjects.primaryBoxId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 100))
world.boxes.boxId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 100))
world.boxes.subjectId = new Uint32Array(Array.from({ length: n }, (_, i) => i + 1))
world.boxes.frameId = new Uint32Array(Array.from({ length: n }, () => 1))
world.boxes.borderLeft = new Float64Array(Array.from({ length: n }, (_, i) => i * 10))
world.boxes.borderTop = new Float64Array(Array.from({ length: n }, () => 0))
world.boxes.borderRight = new Float64Array(Array.from({ length: n }, (_, i) => i * 10 + 100))
world.boxes.borderBottom = new Float64Array(Array.from({ length: n }, () => 50))
const selectorToIds = new Map<string, number[]>([['.item', Array.from({ length: n }, (_, i) => i + 1)]])
const errors: any[] = []
await writeCachedExtractionResult(cacheDir, 'bench-extract', world, selectorToIds, errors)
const start = performance.now()
const cached = await readCachedExtractionResult(cacheDir, 'bench-extract')
const duration = performance.now() - start
assert.ok(cached, 'should read cached large world')
assert.strictEqual(cached.world.subjects.ids.length, n)
assert.ok(duration < 100, `cache read should be under 100ms, took ${duration.toFixed(2)}ms`)
} finally {
rmSync(cacheDir, { recursive: true, force: true })
}
})
})
+419
View File
@@ -0,0 +1,419 @@
/**
* Geometry snapshot caching for Imhotep.
*
* Serializes and deserializes GeometryWorld instances so extracted
* browser geometry can be cached to disk and reused across assertion
* batches without re-extraction.
*/
import type { GeometryWorld, WorldEnvironment, WorldSource } from './world.js'
import type { ImhotepId } from './types.js'
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
interface SerializedTypedArray {
type: 'Uint32Array' | 'Uint16Array' | 'Uint8Array' | 'Int32Array' | 'Int16Array' | 'Float64Array' | 'Float32Array'
data: number[]
}
interface SerializedTable {
[column: string]: SerializedTypedArray | string[] | number[]
}
interface SerializedWorld {
version: number
sceneId: ImhotepId
snapshotId: ImhotepId
env: WorldEnvironment
source: WorldSource
strings: { values: string[] }
subjects: SerializedTable
dom: SerializedTable
frames: SerializedTable
matrices: SerializedTable
rects: SerializedTable
boxes: SerializedTable
visualBoxes: SerializedTable
fragments: SerializedTable
transforms: SerializedTable
styles: SerializedTable
text: SerializedTable
topology: SerializedTable
scroll: SerializedTable
clipping: SerializedTable
paint: SerializedTable
visibility: SerializedTable
provenance: SerializedTable
confidence: SerializedTable
}
function serializeTypedArray(arr: TypedArray): SerializedTypedArray {
const ctor = arr.constructor.name as SerializedTypedArray['type']
return { type: ctor, data: Array.from(arr) }
}
function deserializeTypedArray(serialized: SerializedTypedArray): TypedArray {
const ctor = globalThis[serialized.type] as new (data: number[]) => TypedArray
return new ctor(serialized.data)
}
type TypedArray =
| Uint32Array
| Uint16Array
| Uint8Array
| Int32Array
| Int16Array
| Float64Array
| Float32Array
function isTypedArray(value: unknown): value is TypedArray {
return (
value instanceof Uint32Array ||
value instanceof Uint16Array ||
value instanceof Uint8Array ||
value instanceof Int32Array ||
value instanceof Int16Array ||
value instanceof Float64Array ||
value instanceof Float32Array
)
}
function serializeTable(table: Record<string, unknown>): SerializedTable {
const result: SerializedTable = {}
if (!table) return result
for (const [key, value] of Object.entries(table)) {
if (isTypedArray(value)) {
result[key] = serializeTypedArray(value)
} else if (Array.isArray(value)) {
result[key] = value as number[] | string[]
} else if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
result[key] = [value] as number[] | string[]
} else {
result[key] = value as SerializedTable[string]
}
}
return result
}
function deserializeTable(serialized: SerializedTable): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(serialized)) {
if (value && typeof value === 'object' && 'type' in value && 'data' in value) {
result[key] = deserializeTypedArray(value as SerializedTypedArray)
} else if (Array.isArray(value)) {
result[key] = value
} else {
result[key] = value
}
}
return result
}
/**
* Serialize a GeometryWorld to a JSON string.
*/
export function serializeGeometryWorld(world: GeometryWorld): string {
const serialized: SerializedWorld = {
version: 1,
sceneId: world.sceneId,
snapshotId: world.snapshotId,
env: world.env,
source: world.source,
strings: world.strings,
subjects: serializeTable(world.subjects as unknown as Record<string, unknown>),
dom: serializeTable(world.dom as unknown as Record<string, unknown>),
frames: serializeTable(world.frames as unknown as Record<string, unknown>),
matrices: serializeTable(world.matrices as unknown as Record<string, unknown>),
rects: serializeTable(world.rects as unknown as Record<string, unknown>),
boxes: serializeTable(world.boxes as unknown as Record<string, unknown>),
visualBoxes: serializeTable(world.visualBoxes as unknown as Record<string, unknown>),
fragments: serializeTable(world.fragments as unknown as Record<string, unknown>),
transforms: serializeTable(world.transforms as unknown as Record<string, unknown>),
styles: serializeTable(world.styles as unknown as Record<string, unknown>),
text: serializeTable(world.text as unknown as Record<string, unknown>),
topology: serializeTable(world.topology as unknown as Record<string, unknown>),
scroll: serializeTable(world.scroll as unknown as Record<string, unknown>),
clipping: serializeTable(world.clipping as unknown as Record<string, unknown>),
paint: serializeTable(world.paint as unknown as Record<string, unknown>),
visibility: serializeTable(world.visibility as unknown as Record<string, unknown>),
provenance: serializeTable(world.provenance as unknown as Record<string, unknown>),
confidence: serializeTable(world.confidence as unknown as Record<string, unknown>),
}
return JSON.stringify(serialized)
}
/**
* Deserialize a JSON string back into a GeometryWorld.
*/
export function deserializeGeometryWorld(json: string): GeometryWorld {
const serialized = JSON.parse(json) as SerializedWorld
if (serialized.version !== 1) {
throw new Error(`Unsupported geometry cache version: ${serialized.version}`)
}
return {
sceneId: serialized.sceneId,
snapshotId: serialized.snapshotId,
env: serialized.env,
source: serialized.source,
strings: serialized.strings,
subjects: deserializeTable(serialized.subjects) as unknown as GeometryWorld['subjects'],
dom: deserializeTable(serialized.dom) as unknown as GeometryWorld['dom'],
frames: deserializeTable(serialized.frames) as unknown as GeometryWorld['frames'],
matrices: deserializeTable(serialized.matrices) as unknown as GeometryWorld['matrices'],
rects: deserializeTable(serialized.rects) as unknown as GeometryWorld['rects'],
boxes: deserializeTable(serialized.boxes) as unknown as GeometryWorld['boxes'],
visualBoxes: deserializeTable(serialized.visualBoxes) as unknown as GeometryWorld['visualBoxes'],
fragments: deserializeTable(serialized.fragments) as unknown as GeometryWorld['fragments'],
transforms: deserializeTable(serialized.transforms) as unknown as GeometryWorld['transforms'],
styles: deserializeTable(serialized.styles) as unknown as GeometryWorld['styles'],
text: deserializeTable(serialized.text) as unknown as GeometryWorld['text'],
topology: deserializeTable(serialized.topology) as unknown as GeometryWorld['topology'],
scroll: deserializeTable(serialized.scroll) as unknown as GeometryWorld['scroll'],
clipping: deserializeTable(serialized.clipping) as unknown as GeometryWorld['clipping'],
paint: deserializeTable(serialized.paint) as unknown as GeometryWorld['paint'],
visibility: deserializeTable(serialized.visibility) as unknown as GeometryWorld['visibility'],
provenance: deserializeTable(serialized.provenance) as unknown as GeometryWorld['provenance'],
confidence: deserializeTable(serialized.confidence) as unknown as GeometryWorld['confidence'],
}
}
// ---------------------------------------------------------------------------
// Cache key computation
// ---------------------------------------------------------------------------
function djb2Hash(str: string): string {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xffffffff
}
return hash.toString(16).padStart(8, '0')
}
/**
* Compute a stable cache key for a geometry extraction.
*
* The key incorporates:
* - page URL
* - sorted selectors (so order doesn't matter)
* - environment (viewport dimensions, color scheme, etc.)
*/
export function computeGeometryCacheKey(
pageUrl: string,
selectors: string[],
env: Partial<WorldEnvironment>,
): string {
const selectorsHash = djb2Hash([...selectors].sort().join('\x00'))
const envHash = djb2Hash(
JSON.stringify({
viewportWidth: env.viewportWidth ?? 0,
viewportHeight: env.viewportHeight ?? 0,
deviceScaleFactor: env.deviceScaleFactor ?? 1,
colorScheme: env.colorScheme ?? 'light',
pointer: env.pointer ?? 'fine',
hover: env.hover ?? false,
reducedMotion: env.reducedMotion ?? 'no-preference',
locale: env.locale ?? 'en',
writingMode: env.writingMode ?? 'horizontal-tb',
}),
)
const urlHash = djb2Hash(pageUrl)
return `${urlHash}-${selectorsHash}-${envHash}`
}
// ---------------------------------------------------------------------------
// File-based cache storage
// ---------------------------------------------------------------------------
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
import { readFile, writeFile, unlink } from 'node:fs/promises'
import { join } from 'node:path'
const DEFAULT_CACHE_DIR = join(process.cwd(), '.imhotep-cache')
const DEFAULT_MAX_CACHE_ENTRIES = 100
function cacheFilePath(cacheDir: string, cacheKey: string): string {
return join(cacheDir, `${cacheKey}.json`)
}
/**
* Evict oldest cache entries when exceeding maxEntries.
*/
function evictOldestEntries(cacheDir: string, maxEntries: number): void {
if (!existsSync(cacheDir)) return
const files = readdirSync(cacheDir)
.filter((f) => f.endsWith('.json'))
.map((f) => {
const fullPath = join(cacheDir, f)
try {
return { name: f, path: fullPath, mtime: statSync(fullPath).mtimeMs }
} catch {
return null
}
})
.filter((f): f is NonNullable<typeof f> => f !== null)
.sort((a, b) => a.mtime - b.mtime)
if (files.length > maxEntries) {
const toRemove = files.length - maxEntries
for (let i = 0; i < toRemove; i++) {
try {
rmSync(files[i].path)
} catch {
// ignore deletion errors
}
}
}
}
/**
* Read a cached GeometryWorld from disk (async, non-blocking).
*/
export async function readCachedWorld(cacheDir: string, cacheKey: string): Promise<GeometryWorld | null> {
const path = cacheFilePath(cacheDir, cacheKey)
try {
const json = await readFile(path, 'utf-8')
return deserializeGeometryWorld(json)
} catch {
return null
}
}
/**
* Write a GeometryWorld to the cache on disk (async, non-blocking).
*/
export async function writeCachedWorld(cacheDir: string, cacheKey: string, world: GeometryWorld): Promise<void> {
mkdirSync(cacheDir, { recursive: true })
evictOldestEntries(cacheDir, DEFAULT_MAX_CACHE_ENTRIES)
const path = cacheFilePath(cacheDir, cacheKey)
await writeFile(path, serializeGeometryWorld(world))
}
/**
* Clear all cached geometry snapshots.
*/
export function clearGeometryCache(cacheDir: string = DEFAULT_CACHE_DIR): void {
if (!existsSync(cacheDir)) {
return
}
for (const file of readdirSync(cacheDir)) {
if (file.endsWith('.json')) {
try {
rmSync(join(cacheDir, file))
} catch {
// ignore deletion errors
}
}
}
}
// ---------------------------------------------------------------------------
// Full extraction result caching (world + selector mapping + errors)
// ---------------------------------------------------------------------------
interface CachedExtractionResult {
version: number
world: string
selectorToIds: [string, number[]][]
errors: Array<{
code: string
severity: string
category: string
message: string
source: string
related: string[]
fixHints: string[]
metrics: Record<string, unknown>
sourceRef: Record<string, unknown>
clauseLabel?: string
}>
}
/**
* Serialize a full extraction result (world + selector mappings + errors).
*/
export function serializeExtractionResult(
world: GeometryWorld,
selectorToIds: Map<string, number[]>,
errors: Array<{
code: string
severity: string
category: string
message: string
source: string
related: string[]
fixHints: string[]
metrics: Record<string, unknown>
sourceRef: Record<string, unknown>
clauseLabel?: string
}>,
): string {
const cached: CachedExtractionResult = {
version: 1,
world: serializeGeometryWorld(world),
selectorToIds: Array.from(selectorToIds.entries()),
errors,
}
return JSON.stringify(cached)
}
/**
* Deserialize a full extraction result from JSON.
*/
export function deserializeExtractionResult(json: string): {
world: GeometryWorld
selectorToIds: Map<string, number[]>
errors: CachedExtractionResult['errors']
} {
const cached = JSON.parse(json) as CachedExtractionResult
if (cached.version !== 1) {
throw new Error(`Unsupported extraction cache version: ${cached.version}`)
}
return {
world: deserializeGeometryWorld(cached.world),
selectorToIds: new Map(cached.selectorToIds),
errors: cached.errors,
}
}
/**
* Read a cached extraction result from disk (async, non-blocking).
*/
export async function readCachedExtractionResult(
cacheDir: string,
cacheKey: string,
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: CachedExtractionResult['errors'] } | null> {
const path = cacheFilePath(cacheDir, cacheKey)
try {
const json = await readFile(path, 'utf-8')
return deserializeExtractionResult(json)
} catch {
return null
}
}
/**
* Write an extraction result to the cache on disk (async, non-blocking).
*/
export async function writeCachedExtractionResult(
cacheDir: string,
cacheKey: string,
world: GeometryWorld,
selectorToIds: Map<string, number[]>,
errors: CachedExtractionResult['errors'],
): Promise<void> {
mkdirSync(cacheDir, { recursive: true })
evictOldestEntries(cacheDir, DEFAULT_MAX_CACHE_ENTRIES)
const path = cacheFilePath(cacheDir, cacheKey)
await writeFile(path, serializeExtractionResult(world, selectorToIds, errors))
}
/**
* Return the default cache directory path.
*/
export function getDefaultCacheDir(): string {
return DEFAULT_CACHE_DIR
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Imhotep Core — foundational contracts and type system.
*
* Exported modules:
* types — Core type definitions (positions, IDs, results, proofs)
* ast — Unist-style AST node definitions
* ir — Semantic IR and Execution IR definitions
* world — Geometry world schema types
* contracts — Extractor, solver, compiler, reporter interfaces
* diagnostics — Diagnostic categories, error codes, traces, shrinking
*/
export * from './types.js'
export * from './ast.js'
export * from './ir.js'
export { FragmentKind, createEmptyWorld } from './world.js'
export type {
GeometryWorld,
WorldEnvironment,
WorldSource,
StringTable,
SubjectsTable,
DomTable,
FramesTable,
MatricesTable,
RectsTable,
BoxesTable,
FragmentsTable,
TransformsTable,
StylesTable,
TextTable,
TopologyTable,
ScrollTable,
ClippingTable,
PaintTable,
VisibilityTable,
ProvenanceTable,
ConfidenceTable,
FactRequestPlan,
} from './world.js'
export * from './contracts.js'
export * from './diagnostics.js'
export * from './pipeline.js'
export * from './logic-ast.js'
export * from './logic-ir.js'
export * from './domains.js'
export * from './scene-target.js'
export * from './property-contracts.js'
export * from './property-results.js'
export * from './canonical.js'
export * from './context.js'
export {
serializeGeometryWorld,
deserializeGeometryWorld,
computeGeometryCacheKey,
readCachedWorld,
writeCachedWorld,
clearGeometryCache,
getDefaultCacheDir,
readCachedExtractionResult,
writeCachedExtractionResult,
} from './geometry-cache.js'
// Public API type aliases for high-level evaluation results
import type { ClauseResult, ImhotepId } from './types.js'
import type { Diagnostic } from './diagnostics.js'
export interface NormalizedContract {
clauseId: ImhotepId
clauseLabel: string
relation: string
subject: string
reference: string
options: Record<string, unknown>
}
export type ImhotepResult = {
schemaVersion: string
passed: boolean
summary: string
clauseResults: ClauseResult[]
diagnostics: ImhotepDiagnostic[]
normalizedContracts?: NormalizedContract[]
}
export type ImhotepDiagnostic = Diagnostic
@@ -0,0 +1,253 @@
/**
* DSL -> Geometry integration tests.
*
* Verifies that fluent assertions compile to IR that geometry worlds can satisfy,
* that frame references resolve correctly, and that tolerance values propagate.
*/
import { describe, it, before } from 'node:test'
import assert from 'node:assert'
import { expect, frame, compile as dslCompile } from 'imhotep-dsl'
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
import { createEmptySemanticIr } from './ir.js'
import type { ExecutionIr } from './ir.js'
import type { ExecutionContext } from 'imhotep-extractor'
import { ExtractionPlanner, FactRequirementRegistry, FACT_FAMILY } from 'imhotep-extractor'
import {
buildMockGeometryWorld,
mapSemanticIrToClauses,
} from './integration-mocks.js'
describe('DSL -> Geometry', () => {
before(() => {
registerDefaultClauses()
})
it('fluent assertions compile to IR that geometry world can satisfy', () => {
// Arrange: build DSL assertion via fluent API
const fluent = expect('.tooltip').to.be.above('.trigger')
const ast = fluent.toAst()
// Arrange: compile to semantic + execution IR with deterministic ids
let idCounter = 0
const result = dslCompile([ast], {
generateId: (prefix) => `${prefix}_${++idCounter}`,
})
// Assert compilation succeeded
assert.strictEqual(result.diagnostics.length, 0)
assert.strictEqual(result.executionIr.clauseCount, 1)
// Arrange: build geometry world with matching subjects in compiler order
const world = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.tooltip',
box: { top: 0, bottom: 10, left: 0, right: 50 },
},
{
id: 1,
selector: '.trigger',
box: { top: 20, bottom: 30, left: 0, right: 50 },
},
],
})
// Map semantic IR subject ids to world subject ids (compiler order)
const subjectIdMap = new Map<string, number>()
let worldIndex = 0
for (const [id] of result.semanticIr.subjects) {
subjectIdMap.set(id, worldIndex++)
}
// Act: build clause descriptors and evaluate
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
const evalResult = evaluate(world, clauses)
// Assert
assert.strictEqual(evalResult.clauseResults.length, 1)
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
assert.strictEqual(evalResult.clauseResults[0].truth, 'determinate')
})
it('frame references in DSL resolve correctly in geometry world', () => {
// Arrange: build DSL assertion with nearestPositionedAncestor frame
const fluent = expect('.tooltip')
.in(frame.nearestPositionedAncestor('.trigger'))
.to.be.above('.trigger')
const ast = fluent.toAst()
// Arrange: compile
let idCounter = 0
const result = dslCompile([ast], {
generateId: (prefix) => `${prefix}_${++idCounter}`,
})
// Assert: semantic IR has a non-default frame
const nonDefaultFrames: Array<[string, { kind: string }]> = []
for (const [, frame] of result.semanticIr.frames.entries()) {
if (frame.kind !== 'viewport') {
nonDefaultFrames.push(['', frame])
}
}
assert.strictEqual(nonDefaultFrames.length, 1)
assert.strictEqual(nonDefaultFrames[0][1].kind, 'nearestPositionedAncestor')
// Arrange: build world with frame topology
const world = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.tooltip',
box: { top: 0, bottom: 10, left: 0, right: 50 },
},
{
id: 1,
selector: '.trigger',
box: { top: 20, bottom: 30, left: 0, right: 50 },
},
],
topology: {
nearestPositionedAncestorOf: [1],
},
})
// Map subjects
const subjectIdMap = new Map<string, number>()
let worldIndex = 0
for (const [id] of result.semanticIr.subjects) {
subjectIdMap.set(id, worldIndex++)
}
// Act
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
const evalResult = evaluate(world, clauses)
// Assert
assert.strictEqual(evalResult.clauseResults.length, 1)
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
assert.strictEqual(evalResult.clauseResults[0].truth, 'determinate')
})
it('tolerance values propagate to solver correctly', () => {
// Arrange: build DSL assertion with tolerance
const fluent = expect('.tooltip').to.be.inside('.container', {
tolerance: 5,
})
const ast = fluent.toAst()
// Arrange: compile
let idCounter = 0
const result = dslCompile([ast], {
generateId: (prefix) => `${prefix}_${++idCounter}`,
})
// Assert: compiler produced a tolerance entry
const nonDefaultTolerances: Array<[string, { value: number; unit: string }]> = []
for (const [, tol] of result.semanticIr.tolerances.entries()) {
if (!(tol.value === 0 && tol.unit === 'px')) {
nonDefaultTolerances.push(['', tol])
}
}
assert.strictEqual(nonDefaultTolerances.length, 1)
assert.strictEqual(nonDefaultTolerances[0][1].value, 5)
assert.strictEqual(nonDefaultTolerances[0][1].unit, 'px')
// Arrange: build world where tooltip is almost inside container
const world = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.tooltip',
box: { top: 2, bottom: 8, left: 2, right: 48 },
},
{
id: 1,
selector: '.container',
box: { top: 0, bottom: 10, left: 0, right: 50 },
},
],
})
// Map subjects
const subjectIdMap = new Map<string, number>()
let worldIndex = 0
for (const [id] of result.semanticIr.subjects) {
subjectIdMap.set(id, worldIndex++)
}
// Act
const clauses = mapSemanticIrToClauses(result.semanticIr, subjectIdMap)
const evalResult = evaluate(world, clauses)
// Assert
assert.strictEqual(evalResult.clauseResults.length, 1)
assert.strictEqual(evalResult.clauseResults[0].status, 'pass')
assert.ok(evalResult.clauseResults[0].metrics)
})
it('execution IR produces extractable fact requirements', () => {
// Arrange: build DSL assertion
const fluent = expect('.a').to.be.leftOf('.b')
const ast = fluent.toAst()
// Arrange: compile
let idCounter = 0
const result = dslCompile([ast], {
generateId: (prefix) => `${prefix}_${++idCounter}`,
})
// Arrange: build execution context for planner
const semanticIr = result.semanticIr
const executionIr = result.executionIr
const subjectIds: string[] = []
for (const [id] of semanticIr.subjects) {
subjectIds.push(id)
}
const frameIds: string[] = []
for (const [id] of semanticIr.frames) {
frameIds.push(id)
}
const stateIds: string[] = []
for (const [id] of semanticIr.states) {
stateIds.push(id)
}
const timelineIds: string[] = []
for (const [id] of semanticIr.timelines) {
timelineIds.push(id)
}
const envGuardIds: string[] = []
for (const [id] of semanticIr.envGuards) {
envGuardIds.push(id)
}
const toleranceIds: string[] = []
for (const [id] of semanticIr.tolerances) {
toleranceIds.push(id)
}
const context: ExecutionContext = {
executionIr,
semanticIr,
subjectIds,
frameIds,
stateIds,
timelineIds,
envGuardIds,
toleranceIds,
}
// Arrange: register fact requirements for leftOf
const registry = new FactRequirementRegistry()
registry.register(1, [{ family: FACT_FAMILY.GEOMETRY }])
// Act: create extraction plan
const planner = new ExtractionPlanner({ factRegistry: registry })
const plan = planner.createPlan(context)
// Assert: plan requests geometry facts
assert.strictEqual(plan.requests.length >= 1, true)
assert.strictEqual(plan.requests[0].requiredFacts.geometry, true)
})
})
@@ -0,0 +1,282 @@
/**
* Extractor -> Solver integration tests.
*
* Verifies that raw extraction results normalize into worlds solvers can
* evaluate, that topology facts enable topology assertions, and that missing
* facts produce indeterminate results.
*/
import { describe, it, before } from 'node:test'
import assert from 'node:assert'
import {
ExtractionPlanner,
FactRequirementRegistry,
FACT_FAMILY,
} from 'imhotep-extractor'
import type { ExecutionContext } from 'imhotep-extractor'
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
import { createEmptySemanticIr } from './ir.js'
import type { ExecutionIr } from './ir.js'
import {
buildMockGeometryWorld,
buildClauseDescriptor,
} from './integration-mocks.js'
describe('Extractor -> Solver', () => {
before(() => {
registerDefaultClauses()
})
it('raw extraction results normalize into worlds solvers can evaluate', () => {
// Arrange: create semantic IR with two subjects and an above relation
const semanticIr = createEmptySemanticIr()
const subjectId = 'subject_1'
const refId = 'subject_2'
semanticIr.subjects.set(subjectId, {
id: subjectId,
selector: '.a',
kind: 'element',
origin: { astNodeIds: [], positions: [] },
})
semanticIr.subjects.set(refId, {
id: refId,
selector: '.b',
kind: 'element',
origin: { astNodeIds: [], positions: [] },
})
const clauseId = 'clause_1'
semanticIr.clauses.set(clauseId, {
id: clauseId,
origin: { astNodeIds: [], positions: [] },
subjectRef: subjectId,
referenceRef: refId,
relation: 'above',
frameRef: 'frame_default',
stateRef: 'state_default',
timelineRef: 'timeline_default',
envGuardRef: 'guard_default',
toleranceRef: 'tolerance_default',
bounds: {},
})
const executionIr: ExecutionIr = {
clauseCount: 1,
clauseType: new Uint16Array([3]),
clauseSubject: new Uint32Array([0]),
clauseReference: new Uint32Array([1]),
clauseFrame: new Uint32Array([0]),
clauseState: new Uint32Array([0]),
clauseTimeline: new Uint32Array([0]),
clauseTolerance: new Uint32Array([0]),
clauseEnvGuard: new Uint32Array([0]),
clauseArg0: new Float64Array([Number.NEGATIVE_INFINITY]),
clauseArg1: new Float64Array([Number.POSITIVE_INFINITY]),
clauseFlags: new Uint32Array([0]),
clauseOrigin: new Uint32Array([0]),
}
const context: ExecutionContext = {
executionIr,
semanticIr,
subjectIds: [subjectId, refId],
frameIds: ['frame_default'],
stateIds: ['state_default'],
timelineIds: ['timeline_default'],
envGuardIds: ['guard_default'],
toleranceIds: ['tolerance_default'],
}
// Arrange: create fact registry and planner
const registry = new FactRequirementRegistry()
registry.register(3, [{ family: FACT_FAMILY.GEOMETRY }])
const planner = new ExtractionPlanner({ factRegistry: registry })
// Act: create extraction plan
const plan = planner.createPlan(context)
// Assert: plan has one request requiring geometry
assert.strictEqual(plan.requests.length, 1)
assert.strictEqual(plan.requests[0].requiredFacts.geometry, true)
// Act: build mock extracted world and evaluate
const world = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.a',
box: { top: 0, bottom: 10, left: 0, right: 50 },
},
{
id: 1,
selector: '.b',
box: { top: 20, bottom: 30, left: 0, right: 50 },
},
],
})
const clauses = [
buildClauseDescriptor({
clauseId: 'c1',
clauseKind: 'relation.above',
subjectRef: 0,
referenceRef: 1,
}),
]
const result = evaluate(world, clauses)
assert.strictEqual(result.clauseResults[0].status, 'pass')
})
it('topology facts from extractor enable topology assertions', () => {
// Arrange: build world with topology data
const world = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.child',
box: { top: 0, bottom: 10, left: 0, right: 10 },
},
{
id: 1,
selector: '.scroll',
box: { top: 0, bottom: 100, left: 0, right: 100 },
},
],
topology: {
scrollContainerOf: [1],
},
})
// Act: evaluate topology clause
const clauses = [
buildClauseDescriptor({
clauseId: 'c1',
clauseKind: 'topology.attachedToScrollContainer',
subjectRef: 0,
referenceRef: 1,
}),
]
const result = evaluate(world, clauses)
// Assert
assert.strictEqual(result.clauseResults[0].status, 'pass')
assert.strictEqual(result.clauseResults[0].truth, 'determinate')
assert.strictEqual(
result.clauseResults[0].metrics?.attached,
1,
)
})
it('stacking context topology facts enable stacking assertions', () => {
// Arrange: build world with shared stacking context
const world = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.layer-a',
box: { top: 0, bottom: 10, left: 0, right: 10 },
},
{
id: 1,
selector: '.layer-b',
box: { top: 5, bottom: 15, left: 5, right: 15 },
},
],
topology: {
stackingContextOf: [42, 42],
},
})
// Act: evaluate stacking context clause
const clauses = [
buildClauseDescriptor({
clauseId: 'c1',
clauseKind: 'topology.inStackingContext',
subjectRef: 0,
referenceRef: 1,
}),
]
const result = evaluate(world, clauses)
// Assert: same stacking context id means pass
assert.strictEqual(result.clauseResults[0].status, 'pass')
assert.strictEqual(result.clauseResults[0].truth, 'determinate')
})
it('missing facts produce indeterminate results', () => {
// Arrange: build world with only one subject
const world = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.a',
box: { top: 0, bottom: 10, left: 0, right: 10 },
},
],
})
// Act: evaluate clause referencing non-existent subject
const clauses = [
buildClauseDescriptor({
clauseId: 'c1',
clauseKind: 'relation.above',
subjectRef: 0,
referenceRef: 99,
}),
]
const result = evaluate(world, clauses)
// Assert: indeterminate because reference box is missing
assert.strictEqual(result.clauseResults[0].status, 'error')
assert.strictEqual(result.clauseResults[0].truth, 'indeterminate')
})
it('unsupported clause types emit extraction diagnostics', () => {
// Arrange: create execution IR with unregistered clause type
const semanticIr = createEmptySemanticIr()
const executionIr: ExecutionIr = {
clauseCount: 1,
clauseType: new Uint16Array([999]),
clauseSubject: new Uint32Array([0]),
clauseReference: new Uint32Array([0]),
clauseFrame: new Uint32Array([0]),
clauseState: new Uint32Array([0]),
clauseTimeline: new Uint32Array([0]),
clauseTolerance: new Uint32Array([0]),
clauseEnvGuard: new Uint32Array([0]),
clauseArg0: new Float64Array([0]),
clauseArg1: new Float64Array([0]),
clauseFlags: new Uint32Array([0]),
clauseOrigin: new Uint32Array([0]),
}
const context: ExecutionContext = {
executionIr,
semanticIr,
subjectIds: [],
frameIds: [],
stateIds: [],
timelineIds: [],
envGuardIds: [],
toleranceIds: [],
}
const registry = new FactRequirementRegistry()
const planner = new ExtractionPlanner({ factRegistry: registry })
// Act: create plan
const plan = planner.createPlan(context)
// Assert: diagnostic emitted for unsupported clause
assert.ok(
plan.diagnostics.some(
(d) => d.code === 'IMH_EXTRACTOR_UNSUPPORTED_CLAUSE_TYPE',
),
)
})
})
@@ -0,0 +1,357 @@
/**
* Mock helpers for cross-package integration tests.
*
* Provides dependency-injected builders for geometry worlds,
* extraction results, solver results, and complete mock scenes.
*/
import type {
GeometryWorld,
ClauseDescriptor,
ClauseResult,
} from 'imhotep-solver'
import type { EvaluationOutput } from 'imhotep-solver'
import type { GeometrySnapshot, SnapshotMetadata } from 'imhotep-state'
import type { Diagnostic as CoreDiagnostic } from './diagnostics.js'
import type { ImhotepId } from './types.js'
import type { SemanticIr } from './ir.js'
// ---------------------------------------------------------------------------
// Geometry World Builder
// ---------------------------------------------------------------------------
export interface MockSubject {
id: number
selector: string
box: {
top: number
bottom: number
left: number
right: number
}
}
export interface MockFrame {
id: number
kind: string
subjectId: number
}
export interface MockWorldOptions {
sceneId?: string
snapshotId?: string
subjects: MockSubject[]
frames?: MockFrame[]
topology?: {
nearestPositionedAncestorOf?: number[]
scrollContainerOf?: number[]
stackingContextOf?: number[]
containingBlockOf?: number[]
}
}
/**
* Build a solver-compatible geometry world from a declarative description.
*/
export function buildMockGeometryWorld(opts: MockWorldOptions): GeometryWorld {
const subjectCount = opts.subjects.length
const world: GeometryWorld = {
sceneId: opts.sceneId ?? 'scene_test',
snapshotId: opts.snapshotId ?? 'snap_test',
env: {
viewportWidth: 1280,
viewportHeight: 720,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: opts.subjects.map((s) => s.id),
domNodeId: new Array(subjectCount).fill(0),
subjectKind: new Array(subjectCount).fill(0),
primaryBoxId: opts.subjects.map((_, i) => i),
firstFragmentId: new Array(subjectCount).fill(0),
fragmentCount: new Array(subjectCount).fill(0),
},
dom: {
nodeId: opts.subjects.map((s) => s.id),
parentNodeId: new Array(subjectCount).fill(0),
childCount: new Array(subjectCount).fill(0),
tagNameStringId: new Array(subjectCount).fill(0),
},
boxes: {
boxId: opts.subjects.map((_, i) => i),
subjectId: opts.subjects.map((s) => s.id),
frameId: new Array(subjectCount).fill(0),
borderLeft: opts.subjects.map((s) => s.box.left),
borderTop: opts.subjects.map((s) => s.box.top),
borderRight: opts.subjects.map((s) => s.box.right),
borderBottom: opts.subjects.map((s) => s.box.bottom),
paddingLeft: opts.subjects.map((s) => s.box.left),
paddingTop: opts.subjects.map((s) => s.box.top),
paddingRight: opts.subjects.map((s) => s.box.right),
paddingBottom: opts.subjects.map((s) => s.box.bottom),
contentLeft: opts.subjects.map((s) => s.box.left),
contentTop: opts.subjects.map((s) => s.box.top),
contentRight: opts.subjects.map((s) => s.box.right),
contentBottom: opts.subjects.map((s) => s.box.bottom),
},
rects: {
rectId: opts.subjects.map((_, i) => i),
left: opts.subjects.map((s) => s.box.left),
top: opts.subjects.map((s) => s.box.top),
right: opts.subjects.map((s) => s.box.right),
bottom: opts.subjects.map((s) => s.box.bottom),
},
topology: {
containingBlockOf: opts.topology?.containingBlockOf ?? [],
nearestPositionedAncestorOf:
opts.topology?.nearestPositionedAncestorOf ?? [],
scrollContainerOf: opts.topology?.scrollContainerOf ?? [],
stackingContextOf: opts.topology?.stackingContextOf ?? [],
formattingContextOf: [],
clippingRootOf: [],
paintOrderBucket: [],
paintOrderIndex: [],
},
scroll: {
containerId: [],
scrollLeft: [],
scrollTop: [],
scrollWidth: [],
scrollHeight: [],
clientWidth: [],
clientHeight: [],
},
visualBoxes: {
boxId: opts.subjects.map((_, i) => i),
subjectId: opts.subjects.map((s) => s.id),
frameId: new Array(subjectCount).fill(0),
borderLeft: opts.subjects.map((s) => s.box.left),
borderTop: opts.subjects.map((s) => s.box.top),
borderRight: opts.subjects.map((s) => s.box.right),
borderBottom: opts.subjects.map((s) => s.box.bottom),
paddingLeft: opts.subjects.map((s) => s.box.left),
paddingTop: opts.subjects.map((s) => s.box.top),
paddingRight: opts.subjects.map((s) => s.box.right),
paddingBottom: opts.subjects.map((s) => s.box.bottom),
contentLeft: opts.subjects.map((s) => s.box.left),
contentTop: opts.subjects.map((s) => s.box.top),
contentRight: opts.subjects.map((s) => s.box.right),
contentBottom: opts.subjects.map((s) => s.box.bottom),
},
transforms: {
transformId: [],
subjectId: [],
matrixStart: [],
matrixLength: [],
originX: [],
originY: [],
},
matrices: {
values: [],
},
clipping: {
clipNodeId: [],
subjectId: [],
clipKind: [],
clipLeft: [],
clipTop: [],
clipRight: [],
clipBottom: [],
parentClipNodeId: [],
},
visibility: {
subjectId: [],
isRendered: [],
isVisible: [],
visibleArea: [],
clippedArea: [],
},
}
return world
}
// ---------------------------------------------------------------------------
// Clause Descriptor Builder
// ---------------------------------------------------------------------------
export function buildClauseDescriptor(opts: {
clauseId: string
clauseKind: string
subjectRef: number
referenceRef?: number
frameRef?: number
bounds?: { minGap?: number; maxGap?: number; tolerance?: number; min?: number; max?: number }
options?: Record<string, unknown>
}): ClauseDescriptor {
return {
clauseId: opts.clauseId,
clauseKind: opts.clauseKind,
version: 1,
subjectRef: opts.subjectRef,
referenceRef: opts.referenceRef,
frameRef: opts.frameRef,
bounds: opts.bounds ?? {},
options: opts.options ?? {},
}
}
// ---------------------------------------------------------------------------
// Solver Result Builder
// ---------------------------------------------------------------------------
export function buildMockSolverResult(opts: {
clauseResults?: ClauseResult[]
diagnostics?: Array<{
code: string
severity: 'error' | 'warning' | 'info'
category: string
message: string
clauseId?: string
}>
}): EvaluationOutput {
return {
clauseResults: opts.clauseResults ?? [],
groupResults: [],
proofs: [],
diagnostics: opts.diagnostics ?? [],
trace: [],
}
}
// ---------------------------------------------------------------------------
// Extraction Result Builder
// ---------------------------------------------------------------------------
export function buildMockExtractionResult(opts: {
requestId?: string
worlds: GeometryWorld[]
diagnostics?: CoreDiagnostic[]
}): {
requestId: string
status: 'ok' | 'partial' | 'error'
worlds: GeometryWorld[]
diagnostics: CoreDiagnostic[]
} {
return {
requestId: opts.requestId ?? 'req_1',
status: 'ok',
worlds: opts.worlds,
diagnostics: opts.diagnostics ?? [],
}
}
// ---------------------------------------------------------------------------
// Scene Builder
// ---------------------------------------------------------------------------
export interface MockScene {
world: GeometryWorld
clauses: ClauseDescriptor[]
}
export function createMockScene(opts: {
subjects: MockSubject[]
relations: Array<{
kind: string
subjectId: number
referenceId: number
bounds?: { minGap?: number; maxGap?: number }
}>
}): MockScene {
const world = buildMockGeometryWorld({ subjects: opts.subjects })
const clauses = opts.relations.map((r, i) =>
buildClauseDescriptor({
clauseId: `clause_${i + 1}`,
clauseKind: r.kind,
subjectRef: r.subjectId,
referenceRef: r.referenceId,
bounds: r.bounds,
}),
)
return { world, clauses }
}
// ---------------------------------------------------------------------------
// Semantic IR to Solver Clause Mapping
// ---------------------------------------------------------------------------
/**
* Map a compiled Semantic IR to solver ClauseDescriptors.
*
* This bridges the DSL compiler output to the solver evaluation input.
* The `subjectIdMap` translates semantic IR subject ids to world subject ids.
*/
export function mapSemanticIrToClauses(
semanticIr: SemanticIr,
subjectIdMap: Map<ImhotepId, number>,
): ClauseDescriptor[] {
const clauses: ClauseDescriptor[] = []
for (const [, clause] of semanticIr.clauses) {
const subjectRef = subjectIdMap.get(clause.subjectRef)
const referenceRef = clause.referenceRef
? subjectIdMap.get(clause.referenceRef)
: undefined
if (subjectRef === undefined) continue
const tolerance = semanticIr.tolerances.get(clause.toleranceRef)
const bounds: Record<string, number> = {}
if (clause.bounds.minGap) {
bounds.minGap = clause.bounds.minGap.value
}
if (clause.bounds.maxGap) {
bounds.maxGap = clause.bounds.maxGap.value
}
if (tolerance) {
bounds.tolerance = tolerance.value
}
clauses.push({
clauseId: clause.id,
clauseKind: `relation.${clause.relation}`,
version: 1,
subjectRef,
referenceRef,
bounds,
})
}
return clauses
}
// ---------------------------------------------------------------------------
// Snapshot Builder
// ---------------------------------------------------------------------------
export function buildMockSnapshot(opts: {
id?: string
stateKind?: string
stateSource?: string
selector?: string
world: GeometryWorld
}): GeometrySnapshot {
const metadata: SnapshotMetadata = {
snapshotId: opts.id ?? 'snap_1',
stateKind: (opts.stateKind as any) ?? 'default',
stateSource: (opts.stateSource as any) ?? 'synthetic',
selector: opts.selector,
timestamp: Date.now(),
}
return {
id: opts.id ?? 'snap_1',
metadata,
world: opts.world,
}
}
@@ -0,0 +1,328 @@
/**
* Reporter -> Diagnostics integration tests.
*
* Verifies that solver results produce correct diagnostics, that proof traces
* map to human-readable output, and that witness shrinking works end-to-end.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
makeDiagnostic,
diagnosticFromProof,
renderHumanReport,
renderJsonReport,
buildJsonReport,
shrinkWitness,
createTraceBuilder,
findClauseTraces,
formatDiagnosticCompact,
} from 'imhotep-reporter'
import type {
ProofLike,
Witness,
ShrinkResult,
} from 'imhotep-reporter'
import { buildMockSolverResult } from './integration-mocks.js'
describe('Reporter -> Diagnostics', () => {
it('solver results produce correct diagnostics from failing proofs', () => {
// Arrange: create a failing proof with predicate and witness
const proof: ProofLike = {
proofId: 'p1',
clauseId: 'c1',
outcome: 'fail',
truth: 'determinate',
failedPredicate: { op: '>=', left: 10, right: 24 },
witness: {
subjectId: 1,
referenceId: 2,
envCaseId: 'env_1',
snapshotId: 'default',
},
}
// Act: convert proof to diagnostic
const diagnostic = diagnosticFromProof(proof, {
idGen: () => 'd1',
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
messageForClause: () => 'Expected .tooltip to be above .trigger',
fixHintsForClause: () => ['Increase vertical gap to at least 24px'],
})
// Assert
assert.ok(diagnostic)
assert.strictEqual(diagnostic!.code, 'IMH_RELATION_ABOVE_FAILED')
assert.strictEqual(diagnostic!.category, 'contract-failure')
assert.strictEqual(diagnostic!.severity, 'error')
assert.ok(
diagnostic!.related.some((rel) =>
rel.message.includes('Predicate'),
),
)
assert.ok(
diagnostic!.fixHints.some((hint) =>
hint.includes('gap'),
),
)
})
it('indeterminate proofs produce indeterminate-result diagnostics', () => {
// Arrange: create an indeterminate failing proof
const proof: ProofLike = {
proofId: 'p1',
clauseId: 'c1',
outcome: 'fail',
truth: 'indeterminate',
witness: { envCaseId: 'env_1' },
}
// Act
const diagnostic = diagnosticFromProof(proof, {
idGen: () => 'd1',
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
messageForClause: () => 'Could not determine relation',
fixHintsForClause: () => [],
})
// Assert
assert.ok(diagnostic)
assert.strictEqual(diagnostic!.category, 'indeterminate-result')
})
it('passing proofs produce no diagnostics', () => {
// Arrange: create a passing proof
const proof: ProofLike = {
proofId: 'p1',
clauseId: 'c1',
outcome: 'pass',
truth: 'determinate',
}
// Act
const diagnostic = diagnosticFromProof(proof, {
idGen: () => 'd1',
codeForClause: () => 'IMH_RELATION_ABOVE_FAILED',
messageForClause: () => 'Should not be called',
fixHintsForClause: () => [],
})
// Assert
assert.strictEqual(diagnostic, null)
})
it('proof traces map to human-readable output', () => {
// Arrange: create diagnostic linked to a trace
const diagnostic = makeDiagnostic(
{
code: 'IMH_RELATION_LEFT_OF_FAILED',
category: 'contract-failure',
message: 'Button is not left of field',
traceRef: 't1',
position: {
start: { line: 3, column: 5, offset: 20 },
end: { line: 3, column: 40, offset: 55 },
},
},
{ idGen: () => 'd1' },
)
const traces = [
{
traceEventId: 't1',
phase: 'clause-evaluated' as const,
at: 1000,
refs: { clauseId: 'c1', diagnosticId: 'd1' },
payload: { status: 'fail' },
},
{
traceEventId: 't2',
phase: 'proof-created' as const,
at: 1001,
refs: { clauseId: 'c1', proofId: 'p1' },
payload: {},
},
]
// Act: render human report with traces
const report = renderHumanReport(
[diagnostic],
traces,
new Map(),
{ showTraces: true },
)
// Assert
assert.ok(report.includes('Button is not left of field'))
assert.ok(report.includes('clause-evaluated'))
assert.ok(report.includes('IMH_RELATION_LEFT_OF_FAILED'))
assert.ok(report.includes('line 3'))
})
it('trace builder emits events findable by clause id', () => {
// Arrange: create trace builder
const builder = createTraceBuilder({
idGen: () => 't1',
now: () => Date.now(),
})
// Act: emit events
builder.emit({
phase: 'clause-evaluated',
refs: { clauseId: 'c1' },
payload: { status: 'pass' },
})
builder.emit({
phase: 'clause-evaluated',
refs: { clauseId: 'c2' },
payload: { status: 'fail' },
})
builder.emit({
phase: 'proof-created',
refs: { clauseId: 'c1', proofId: 'p1' },
payload: {},
})
// Assert: findClauseTraces returns correct subset
const found = findClauseTraces(builder.events(), 'c1')
assert.strictEqual(found.length, 2)
assert.ok(
found.every((ev) => ev.refs.clauseId === 'c1'),
)
})
it('witness shrinking works end-to-end', () => {
// Arrange: create a witness with redundant env cases
const witness: Witness = {
proof: {
proofId: 'p1',
clauseId: 'c1',
outcome: 'fail',
truth: 'determinate',
},
envCases: ['env_1', 'env_2', 'env_3'],
snapshots: ['default', 'hover'],
subjects: [1, 2, 3],
facts: [100, 101, 102],
}
// Act: shrink witness — only env_2 and default snapshot are needed
const stillFails = (w: Witness) =>
w.envCases.includes('env_2') && w.snapshots.includes('default')
const result = shrinkWitness(witness, stillFails)
// Assert: witness was reduced
assert.strictEqual(result.reduced, true)
assert.deepStrictEqual(result.witness.envCases, ['env_2'])
assert.deepStrictEqual(result.witness.snapshots, ['default'])
assert.ok(result.axes.includes('env-case'))
assert.ok(result.axes.includes('snapshot'))
assert.ok(result.steps > 0)
})
it('shrink results render in JSON report', () => {
// Arrange: create a shrunk witness
const witness: Witness = {
proof: {
proofId: 'p1',
clauseId: 'c1',
outcome: 'fail',
truth: 'determinate',
},
envCases: ['env_1'],
snapshots: ['default'],
subjects: [1],
facts: [100],
}
const shrinkResult: ShrinkResult = {
reduced: true,
witness,
axes: ['env-case', 'subject'],
steps: 3,
}
const shrinkMap = new Map<string, ShrinkResult>()
shrinkMap.set('c1', shrinkResult)
const diagnostic = makeDiagnostic(
{
code: 'IMH_TEST',
category: 'contract-failure',
message: 'Fail',
clauseId: 'c1',
},
{ idGen: () => 'd1' },
)
// Act: build JSON report with shrink
const report = buildJsonReport(
[diagnostic],
[],
shrinkMap,
{ includeShrink: true },
)
// Assert
assert.strictEqual(report.summary.errorCount, 1)
assert.ok(report.shrinkResults)
assert.ok(report.shrinkResults!.c1)
assert.strictEqual(report.shrinkResults!.c1.reduced, true)
assert.ok(report.shrinkResults!.c1.axes.includes('env-case'))
// Act: stringify
const json = renderJsonReport(
[diagnostic],
[],
shrinkMap,
{ includeShrink: true, indent: 2 },
)
assert.ok(json.includes('shrinkResults'))
assert.ok(json.includes('env-case'))
})
it('solver diagnostics flow through to reporter formatting', () => {
// Arrange: create solver result with diagnostics
const solverResult = buildMockSolverResult({
clauseResults: [
{
clauseId: 'c1',
status: 'fail',
truth: 'determinate',
metrics: { observedGap: 4, minGap: 16 },
witness: { subjectId: 1, referenceId: 2 },
},
],
diagnostics: [
{
code: 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND',
severity: 'error',
category: 'internal-error',
message: 'Evaluation error',
},
],
})
// Act: convert solver diagnostics to reporter diagnostics
const diagnostics = solverResult.diagnostics.map((d) =>
makeDiagnostic(
{
code: d.code,
category: d.category as any,
message: d.message,
severity: d.severity as any,
},
{ idGen: () => 'd1' },
),
)
// Assert
assert.strictEqual(diagnostics.length, 1)
assert.strictEqual(diagnostics[0].code, 'IMH_INTERNAL_UNKNOWN_CLAUSE_KIND')
// Act: render compact
const compact = formatDiagnosticCompact(diagnostics[0])
assert.ok(compact.includes('IMH_INTERNAL_UNKNOWN_CLAUSE_KIND'))
})
})
@@ -0,0 +1,264 @@
/**
* State -> Solver integration tests.
*
* Verifies that state snapshots create distinct geometry worlds,
* that state comparison assertions evaluate correctly, and that
* transition sampling produces valid worlds.
*/
import { describe, it, before } from 'node:test'
import assert from 'node:assert'
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
import type { GeometryWorld } from 'imhotep-solver'
import {
compareSnapshots,
createSnapshotStore,
} from 'imhotep-state'
import type { GeometrySnapshot } from 'imhotep-state'
import {
buildMockGeometryWorld,
buildMockSnapshot,
buildClauseDescriptor,
} from './integration-mocks.js'
describe('State -> Solver', () => {
before(() => {
registerDefaultClauses()
})
it('state snapshots create distinct geometry worlds', () => {
// Arrange: build two worlds that differ only in box height
const defaultWorld = buildMockGeometryWorld({
snapshotId: 'snap_default',
subjects: [
{
id: 0,
selector: '.btn',
box: { top: 0, bottom: 30, left: 0, right: 100 },
},
],
})
const hoverWorld = buildMockGeometryWorld({
snapshotId: 'snap_hover',
subjects: [
{
id: 0,
selector: '.btn',
box: { top: 0, bottom: 32, left: 0, right: 100 },
},
],
})
// Act: wrap as snapshots
const defaultSnap = buildMockSnapshot({
id: 'snap_default',
stateKind: 'default',
world: defaultWorld,
})
const hoverSnap = buildMockSnapshot({
id: 'snap_hover',
stateKind: 'hover',
world: hoverWorld,
})
// Assert: snapshots are distinct
const diff = compareSnapshots(defaultSnap, hoverSnap)
assert.strictEqual(diff.identical, false)
assert.ok(
diff.differences.some((d) => d.includes('world content differs')),
)
})
it('state comparison assertions evaluate correctly across snapshots', () => {
// Arrange: default and hover worlds with different sizes
const defaultWorld = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.btn',
box: { top: 0, bottom: 30, left: 0, right: 100 },
},
],
})
const hoverWorld = buildMockGeometryWorld({
subjects: [
{
id: 0,
selector: '.btn',
box: { top: 0, bottom: 32, left: 0, right: 100 },
},
],
})
// Act: evaluate size assertion (height >= 31) on both worlds
const clauses = [
buildClauseDescriptor({
clauseId: 'c1',
clauseKind: 'size.atLeast',
subjectRef: 0,
bounds: { min: 31 },
options: { dimension: 'height' },
}),
]
const defaultResult = evaluate(defaultWorld, clauses)
const hoverResult = evaluate(hoverWorld, clauses)
// Assert: default fails (30 < 31), hover passes (32 >= 31)
assert.strictEqual(defaultResult.clauseResults[0].status, 'fail')
assert.strictEqual(hoverResult.clauseResults[0].status, 'pass')
})
it('snapshot store maintains distinct worlds per state', () => {
// Arrange: create store and add multiple state snapshots
const store = createSnapshotStore()
const defaultWorld = buildMockGeometryWorld({
snapshotId: 'snap_default',
subjects: [
{
id: 0,
selector: '.input',
box: { top: 0, bottom: 20, left: 0, right: 200 },
},
],
})
const focusWorld = buildMockGeometryWorld({
snapshotId: 'snap_focus',
subjects: [
{
id: 0,
selector: '.input',
box: { top: 0, bottom: 22, left: 0, right: 200 },
},
],
})
store.add(
buildMockSnapshot({
id: 'snap_default',
stateKind: 'default',
world: defaultWorld,
}),
)
store.add(
buildMockSnapshot({
id: 'snap_focus',
stateKind: 'focus',
world: focusWorld,
}),
)
// Act: retrieve snapshots
const snapshots = store.list()
// Assert
assert.strictEqual(snapshots.length, 2)
assert.strictEqual(snapshots[0].metadata.stateKind, 'default')
assert.strictEqual(snapshots[1].metadata.stateKind, 'focus')
// Assert: worlds are distinct
const diff = compareSnapshots(snapshots[0], snapshots[1])
assert.strictEqual(diff.identical, false)
})
it('transition sampling produces valid worlds for solver', () => {
// Arrange: create a store to hold transition samples
const store = createSnapshotStore()
// Simulate 3 transition samples with increasing vertical offset
for (let i = 0; i < 3; i++) {
const world = buildMockGeometryWorld({
snapshotId: `snap_transition_${i}`,
subjects: [
{
id: 0,
selector: '.box',
box: {
top: i * 10,
bottom: i * 10 + 20,
left: 0,
right: 50,
},
},
],
})
const snap = buildMockSnapshot({
id: `snap_transition_${i}`,
stateKind: 'default',
stateSource: 'synthetic',
world,
})
store.add(snap)
}
// Act: retrieve and evaluate each snapshot
const snapshots = store.list()
assert.strictEqual(snapshots.length, 3)
for (const snap of snapshots) {
const world = snap.world as GeometryWorld
const clauses = [
buildClauseDescriptor({
clauseId: 'c1',
clauseKind: 'size.atLeast',
subjectRef: 0,
bounds: { min: 20 },
options: { dimension: 'height' },
}),
]
const result = evaluate(world, clauses)
assert.strictEqual(
result.clauseResults[0].status,
'pass',
`Sample ${snap.id} should pass height >= 20`,
)
}
})
it('state worlds carry correct metadata for diagnostics', () => {
// Arrange: build snapshot with full metadata
const world = buildMockGeometryWorld({
snapshotId: 'snap_hover',
subjects: [
{
id: 0,
selector: '.tooltip',
box: { top: 0, bottom: 10, left: 0, right: 50 },
},
],
})
const snap = buildMockSnapshot({
id: 'snap_hover',
stateKind: 'hover',
stateSource: 'interaction',
selector: '.tooltip',
world,
})
// Assert: metadata is preserved
assert.strictEqual(snap.metadata.stateKind, 'hover')
assert.strictEqual(snap.metadata.stateSource, 'interaction')
assert.strictEqual(snap.metadata.selector, '.tooltip')
assert.strictEqual(snap.id, 'snap_hover')
// Act: evaluate a relation on the state world
const clauses = [
buildClauseDescriptor({
clauseId: 'c1',
clauseKind: 'size.atLeast',
subjectRef: 0,
bounds: { min: 5 },
options: { dimension: 'height' },
}),
]
const result = evaluate(snap.world as GeometryWorld, clauses)
assert.strictEqual(result.clauseResults[0].status, 'pass')
})
})
+221
View File
@@ -0,0 +1,221 @@
/**
* Semantic IR and Execution IR definitions for Imhotep.
*
* Semantic IR:
* Normalized graph of explicit obligations, frames, selectors, states,
* environments, and tolerances. Stored as tables keyed by stable id.
*
* Execution IR:
* Flattened, data-oriented arrays optimized for bulk evaluation against
* a geometry world. This is the hot-path representation.
*/
import type {
ImhotepId,
SourceOrigin,
LengthLiteral,
Environment,
StateSnapshot,
TimelineSnapshot,
Frame,
Subject,
} from './types.js'
// ---------------------------------------------------------------------------
// Semantic IR Tables
// ---------------------------------------------------------------------------
export interface SemanticIr {
subjects: Map<ImhotepId, SemanticSubject>
frames: Map<ImhotepId, SemanticFrame>
states: Map<ImhotepId, SemanticState>
timelines: Map<ImhotepId, SemanticTimeline>
tolerances: Map<ImhotepId, SemanticTolerance>
envGuards: Map<ImhotepId, SemanticEnvGuard>
clauses: Map<ImhotepId, SemanticClause>
groups: Map<ImhotepId, SemanticGroup>
diagnosticMetadata: Map<ImhotepId, SemanticDiagnosticMeta>
}
export interface SemanticSubject {
id: ImhotepId
selector: string
kind: Subject['kind']
origin: SourceOrigin
}
export interface SemanticFrame {
id: ImhotepId
kind: Frame['kind']
selector?: string
name?: string
originX: number
originY: number
writingMode: Frame['writingMode']
origin: SourceOrigin
}
export interface SemanticState {
id: ImhotepId
kind: StateSnapshot['kind']
name?: string
origin: SourceOrigin
}
export interface SemanticTimeline {
id: ImhotepId
mode: TimelineSnapshot['mode']
samples?: number[]
origin: SourceOrigin
}
export interface SemanticTolerance {
id: ImhotepId
value: number
unit: 'jnd' | 'px'
origin: SourceOrigin
}
export interface SemanticEnvGuard {
id: ImhotepId
expression: string
normalizedCases: Environment[]
origin: SourceOrigin
}
export interface SemanticClause {
id: ImhotepId
origin: SourceOrigin
subjectRef: ImhotepId
referenceRef?: ImhotepId
relation: string
frameRef: ImhotepId
stateRef: ImhotepId
timelineRef: ImhotepId
envGuardRef: ImhotepId
toleranceRef: ImhotepId
bounds: {
minGap?: LengthLiteral
maxGap?: LengthLiteral
}
}
export interface SemanticGroup {
id: ImhotepId
origin: SourceOrigin
operator: 'and' | 'or' | 'not' | 'quantifier'
quantifier?: {
kind: string
count?: number
}
clauseRefs: ImhotepId[]
groupRefs?: ImhotepId[]
}
export interface SemanticDiagnosticMeta {
id: ImhotepId
clauseRef?: ImhotepId
messageTemplate: string
origin: SourceOrigin
}
// ---------------------------------------------------------------------------
// Execution IR Tables
// ---------------------------------------------------------------------------
/**
* Execution IR stores clause data in parallel typed arrays for bulk
* evaluation. Every array is indexed by clauseIndex.
*/
export interface ExecutionIr {
clauseCount: number
// Clause classification and cross-references (all Uint32Array)
clauseType: Uint16Array
clauseSubject: Uint32Array
clauseReference: Uint32Array
clauseFrame: Uint32Array
clauseState: Uint32Array
clauseTimeline: Uint32Array
clauseTolerance: Uint32Array
clauseEnvGuard: Uint32Array
// Numeric bounds (Float64Array)
clauseArg0: Float64Array
clauseArg1: Float64Array
// Flags and origin (Uint32Array)
clauseFlags: Uint32Array
clauseOrigin: Uint32Array
}
export interface ExecutionIrBuilder {
addClause(descriptor: ExecutionClauseDescriptor): number
build(): ExecutionIr
}
export interface ExecutionClauseDescriptor {
clauseType: number
subjectIndex: number
referenceIndex: number
frameIndex: number
stateIndex: number
timelineIndex: number
toleranceIndex: number
envGuardIndex: number
arg0: number
arg1: number
flags: number
originIndex: number
}
// ---------------------------------------------------------------------------
// IR Lowering Pipeline Stages
// ---------------------------------------------------------------------------
export type LoweringStage =
| 'parse'
| 'validate'
| 'normalize'
| 'resolve-defaults'
| 'compile'
| 'derive-facts'
export interface LoweringPipeline {
stages: LoweringStage[]
currentStage: LoweringStage
diagnostics: unknown[]
}
// ---------------------------------------------------------------------------
// Clause Family Registry
// ---------------------------------------------------------------------------
export interface ClauseFamilyDescriptor {
clauseKind: string
version: number
requiredFacts: string[]
}
export interface ClauseFamilyRegistry {
register(descriptor: ClauseFamilyDescriptor): void
lookup(clauseKind: string): ClauseFamilyDescriptor | undefined
}
// ---------------------------------------------------------------------------
// Helper: Create empty Semantic IR
// ---------------------------------------------------------------------------
export function createEmptySemanticIr(): SemanticIr {
return {
subjects: new Map(),
frames: new Map(),
states: new Map(),
timelines: new Map(),
tolerances: new Map(),
envGuards: new Map(),
clauses: new Map(),
groups: new Map(),
diagnosticMetadata: new Map(),
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Pure first-order logic AST types for Imhotep V1.1.
*
* These nodes represent the deterministic scene logic layer:
* quantifiers, boolean connectives, predicate calls, and terms.
*
* All nodes follow the unist-style shape with source spans.
*/
import type { AstNode } from './ast.js'
import type { Position } from './types.js'
// ---------------------------------------------------------------------------
// Formula Union
// ---------------------------------------------------------------------------
export type FormulaNode =
| ForAllFormula
| ExistsFormula
| AndFormula
| OrFormula
| NotFormula
| ImpliesFormula
| PredicateCall
// ---------------------------------------------------------------------------
// Quantifier Formulas
// ---------------------------------------------------------------------------
export interface ForAllFormula extends AstNode {
type: 'FormulaNode'
kind: 'forall'
bindings: TupleBinding[]
body: FormulaNode
}
export interface ExistsFormula extends AstNode {
type: 'FormulaNode'
kind: 'exists'
bindings: TupleBinding[]
body: FormulaNode
}
// ---------------------------------------------------------------------------
// Boolean Connective Formulas
// ---------------------------------------------------------------------------
export interface AndFormula extends AstNode {
type: 'FormulaNode'
kind: 'and'
left: FormulaNode
right: FormulaNode
}
export interface OrFormula extends AstNode {
type: 'FormulaNode'
kind: 'or'
left: FormulaNode
right: FormulaNode
}
export interface NotFormula extends AstNode {
type: 'FormulaNode'
kind: 'not'
operand: FormulaNode
}
export interface ImpliesFormula extends AstNode {
type: 'FormulaNode'
kind: 'implies'
antecedent: FormulaNode
consequent: FormulaNode
}
// ---------------------------------------------------------------------------
// Atomic Formula: Predicate Call
// ---------------------------------------------------------------------------
export interface PredicateCall extends AstNode {
type: 'FormulaNode'
kind: 'predicate'
predicate: string
args: TermNode[]
}
// ---------------------------------------------------------------------------
// Terms
// ---------------------------------------------------------------------------
export type TermNode = VariableRef | DomainRef | AccessorTerm
export interface VariableRef extends AstNode {
type: 'VariableRef'
name: string
}
export interface DomainRef extends AstNode {
type: 'DomainRef'
domain: string
selector?: string
parentVar?: string
}
export interface AccessorTerm extends AstNode {
type: 'AccessorTerm'
variable: string
property: string
}
// ---------------------------------------------------------------------------
// Tuple Binding (for multi-variable quantification)
// ---------------------------------------------------------------------------
export interface TupleBinding extends AstNode {
type: 'TupleBinding'
variables: string[]
domain: DomainRef
}
// ---------------------------------------------------------------------------
// Property Run Blocks
// ---------------------------------------------------------------------------
export interface PropertyRunBlock extends AstNode {
type: 'PropertyRunBlock'
mode: 'sampled' | 'enumerated'
inputDomain: unknown
body: AstNode[]
}
export interface SampledRunBlock extends AstNode {
type: 'SampledRunBlock'
arbitrary: unknown
numRuns?: number
seed?: number
body: AstNode[]
}
// ---------------------------------------------------------------------------
// Type Guards
// ---------------------------------------------------------------------------
export function isForAllFormula(node: FormulaNode): node is ForAllFormula {
return node.kind === 'forall'
}
export function isExistsFormula(node: FormulaNode): node is ExistsFormula {
return node.kind === 'exists'
}
export function isAndFormula(node: FormulaNode): node is AndFormula {
return node.kind === 'and'
}
export function isOrFormula(node: FormulaNode): node is OrFormula {
return node.kind === 'or'
}
export function isNotFormula(node: FormulaNode): node is NotFormula {
return node.kind === 'not'
}
export function isImpliesFormula(node: FormulaNode): node is ImpliesFormula {
return node.kind === 'implies'
}
export function isPredicateCall(node: FormulaNode): node is PredicateCall {
return node.kind === 'predicate'
}
export function isVariableRef(node: AstNode): node is VariableRef {
return node.type === 'VariableRef'
}
export function isDomainRef(node: AstNode): node is DomainRef {
return node.type === 'DomainRef'
}
export function isAccessorTerm(node: AstNode): node is AccessorTerm {
return node.type === 'AccessorTerm'
}
export function isTupleBinding(node: AstNode): node is TupleBinding {
return node.type === 'TupleBinding'
}
+232
View File
@@ -0,0 +1,232 @@
/**
* Flattened, data-oriented execution IR for first-order logic formulas.
*
* The hot-path representation stores formulas in parallel typed arrays
* indexed by formulaId. No deep nesting exists at runtime; child
* relationships are expressed through numeric offsets into the same
* flat tables.
*
* Invariant: every formula referenced by a child pointer is stored in
* the same LogicIr instance. The root formula always has id 0.
*/
import type { ImhotepId, SourceOrigin } from './types.js'
// ---------------------------------------------------------------------------
// Formula Kinds (must fit in Uint8)
// ---------------------------------------------------------------------------
export const FORMULA_KIND_FORALL = 1
export const FORMULA_KIND_EXISTS = 2
export const FORMULA_KIND_AND = 3
export const FORMULA_KIND_OR = 4
export const FORMULA_KIND_NOT = 5
export const FORMULA_KIND_IMPLIES = 6
export const FORMULA_KIND_PREDICATE = 7
export type FormulaKind =
| typeof FORMULA_KIND_FORALL
| typeof FORMULA_KIND_EXISTS
| typeof FORMULA_KIND_AND
| typeof FORMULA_KIND_OR
| typeof FORMULA_KIND_NOT
| typeof FORMULA_KIND_IMPLIES
| typeof FORMULA_KIND_PREDICATE
// ---------------------------------------------------------------------------
// Logic IR Tables
// ---------------------------------------------------------------------------
export interface LogicIr {
/** Total number of formulas in this IR. */
formulaCount: number
// Formula classification (all Uint8Array)
formulaKind: Uint8Array
// Child pointers (Uint32Array, 0xFFFFFFFF means "none")
formulaLeft: Uint32Array
formulaRight: Uint32Array
// Binding region (for quantifiers)
// Each quantifier formula references a slice [bindingStart, bindingStart + bindingCount)
// in the flat binding table below.
bindingStart: Uint32Array
bindingCount: Uint8Array
// Predicate call region (for atomic formulas)
// Each predicate formula references a slice [argStart, argStart + argCount)
predicateId: Uint32Array
argStart: Uint32Array
argCount: Uint8Array
// Origin and proof tracking (Uint32Array)
originIndex: Uint32Array
}
// ---------------------------------------------------------------------------
// Flat Binding Table
// ---------------------------------------------------------------------------
export interface BindingTable {
/** Number of bindings. */
count: number
// Variable names (string table index)
variableNameId: Uint32Array
// Domain descriptor index
domainId: Uint32Array
}
// ---------------------------------------------------------------------------
// Flat Argument Table (for predicate calls)
// ---------------------------------------------------------------------------
export interface ArgTable {
/** Number of argument terms. */
count: number
// Term kind: 1 = variable, 2 = accessor, 3 = literal number
termKind: Uint8Array
// For variables: binding table index
// For accessors: variable binding index
// For literals: 0xFFFFFFFF (value stored in literalValue)
termRef: Uint32Array
// For accessors: string table index of property name
// For literals: the literal numeric value (reinterpreted as Uint32)
termAux: Uint32Array
}
// ---------------------------------------------------------------------------
// Predicate Registry Index
// ---------------------------------------------------------------------------
export interface PredicateIndexEntry {
predicateId: number
name: string
arity: number
domainSignature: number[]
requiredFacts: string[]
}
export interface PredicateIndex {
entries: PredicateIndexEntry[]
byName: Map<string, number>
}
// ---------------------------------------------------------------------------
// String Table (shared across IR tables)
// ---------------------------------------------------------------------------
export interface IrStringTable {
values: string[]
byValue: Map<string, number>
}
// ---------------------------------------------------------------------------
// Evaluation Request
// ---------------------------------------------------------------------------
export interface LogicEvaluationRequest {
logicIr: LogicIr
bindings: BindingTable
args: ArgTable
predicates: PredicateIndex
strings: IrStringTable
rootFormulaId: number
}
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
export interface LogicIrBuilder {
addFormula(descriptor: LogicFormulaDescriptor): number
addBinding(descriptor: LogicBindingDescriptor): number
addArg(descriptor: LogicArgDescriptor): number
build(): LogicIr
}
export interface LogicFormulaDescriptor {
kind: FormulaKind
left?: number
right?: number
bindingStart?: number
bindingCount?: number
predicateId?: number
argStart?: number
argCount?: number
originIndex?: number
}
export interface LogicBindingDescriptor {
variableNameId: number
domainId: number
}
export interface LogicArgDescriptor {
termKind: number
termRef: number
termAux: number
}
// ---------------------------------------------------------------------------
// Helper: Create empty Logic IR
// ---------------------------------------------------------------------------
export function createEmptyLogicIr(): LogicIr {
return {
formulaCount: 0,
formulaKind: new Uint8Array(0),
formulaLeft: new Uint32Array(0),
formulaRight: new Uint32Array(0),
bindingStart: new Uint32Array(0),
bindingCount: new Uint8Array(0),
predicateId: new Uint32Array(0),
argStart: new Uint32Array(0),
argCount: new Uint8Array(0),
originIndex: new Uint32Array(0),
}
}
export function createEmptyBindingTable(): BindingTable {
return {
count: 0,
variableNameId: new Uint32Array(0),
domainId: new Uint32Array(0),
}
}
export function createEmptyArgTable(): ArgTable {
return {
count: 0,
termKind: new Uint8Array(0),
termRef: new Uint32Array(0),
termAux: new Uint32Array(0),
}
}
export function createEmptyStringTable(): IrStringTable {
return {
values: [],
byValue: new Map(),
}
}
// ---------------------------------------------------------------------------
// Helper: Intern a string into the string table
// ---------------------------------------------------------------------------
export function internString(table: IrStringTable, value: string): number {
const existing = table.byValue.get(value)
if (existing !== undefined) {
return existing
}
const id = table.values.length
table.values.push(value)
table.byValue.set(value, id)
return id
}
+331
View File
@@ -0,0 +1,331 @@
/**
* Unit tests for Imhotep logic AST nodes, IR structure, and domain
* definitions.
*
* Covers Stream 1 contracts:
* - Formula node construction and type guards
* - Logic IR flat array layout
* - Domain descriptor and value factories
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import type {
FormulaNode,
ForAllFormula,
ExistsFormula,
AndFormula,
OrFormula,
NotFormula,
ImpliesFormula,
PredicateCall,
VariableRef,
DomainRef,
AccessorTerm,
TupleBinding,
PropertyRunBlock,
SampledRunBlock,
} from './logic-ast.js'
import {
isForAllFormula,
isExistsFormula,
isAndFormula,
isOrFormula,
isNotFormula,
isImpliesFormula,
isPredicateCall,
isVariableRef,
isDomainRef,
isAccessorTerm,
isTupleBinding,
} from './logic-ast.js'
import {
createEmptyLogicIr,
createEmptyBindingTable,
createEmptyArgTable,
createEmptyStringTable,
internString,
FORMULA_KIND_FORALL,
FORMULA_KIND_PREDICATE,
} from './logic-ir.js'
import {
createElementsDomain,
createDescendantsDomain,
createLineBoxesDomain,
createTextRunsDomain,
createFragmentsDomain,
createFramesDomain,
createDomainValue,
registerDomain,
lookupDomain,
listDomains,
clearDomainRegistry,
} from './domains.js'
// ---------------------------------------------------------------------------
// Formula AST
// ---------------------------------------------------------------------------
describe('Logic AST', () => {
it('constructs a ForAllFormula', () => {
const formula: ForAllFormula = {
type: 'FormulaNode',
kind: 'forall',
bindings: [
{
type: 'TupleBinding',
variables: ['btn'],
domain: {
type: 'DomainRef',
domain: 'elements',
selector: '.button',
},
},
],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'atLeast',
args: [
{ type: 'VariableRef', name: 'btn' },
],
},
}
assert.strictEqual(formula.kind, 'forall')
assert.strictEqual(formula.bindings.length, 1)
assert.strictEqual(formula.bindings[0].variables[0], 'btn')
assert.ok(isForAllFormula(formula))
assert.strictEqual(isExistsFormula(formula), false)
})
it('constructs an ExistsFormula', () => {
const formula: ExistsFormula = {
type: 'FormulaNode',
kind: 'exists',
bindings: [
{
type: 'TupleBinding',
variables: ['icon'],
domain: {
type: 'DomainRef',
domain: 'descendants',
parentVar: 'card',
selector: '.icon',
},
},
],
body: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'above',
args: [
{ type: 'VariableRef', name: 'icon' },
{ type: 'VariableRef', name: 'content' },
],
},
}
assert.ok(isExistsFormula(formula))
assert.strictEqual(formula.bindings[0].domain.domain, 'descendants')
})
it('constructs boolean connectives', () => {
const p: PredicateCall = {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'width',
args: [{ type: 'VariableRef', name: 'x' }],
}
const q: PredicateCall = {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'height',
args: [{ type: 'VariableRef', name: 'x' }],
}
const andF: AndFormula = {
type: 'FormulaNode',
kind: 'and',
left: p,
right: q,
}
const orF: OrFormula = {
type: 'FormulaNode',
kind: 'or',
left: p,
right: q,
}
const notF: NotFormula = {
type: 'FormulaNode',
kind: 'not',
operand: p,
}
const impliesF: ImpliesFormula = {
type: 'FormulaNode',
kind: 'implies',
antecedent: p,
consequent: q,
}
assert.ok(isAndFormula(andF))
assert.ok(isOrFormula(orF))
assert.ok(isNotFormula(notF))
assert.ok(isImpliesFormula(impliesF))
assert.ok(isPredicateCall(p))
})
it('type guards narrow correctly', () => {
const varRef: VariableRef = { type: 'VariableRef', name: 'btn' }
const domRef: DomainRef = { type: 'DomainRef', domain: 'elements', selector: '.btn' }
const accTerm: AccessorTerm = { type: 'AccessorTerm', variable: 'btn', property: 'width' }
assert.ok(isVariableRef(varRef))
assert.ok(isDomainRef(domRef))
assert.ok(isAccessorTerm(accTerm))
assert.strictEqual(isVariableRef(domRef), false)
})
it('constructs PropertyRunBlock and SampledRunBlock', () => {
const propBlock: PropertyRunBlock = {
type: 'PropertyRunBlock',
mode: 'enumerated',
inputDomain: { size: ['sm', 'md', 'lg'] },
body: [],
}
const sampledBlock: SampledRunBlock = {
type: 'SampledRunBlock',
arbitrary: {},
numRuns: 100,
seed: 42,
body: [],
}
assert.strictEqual(propBlock.mode, 'enumerated')
assert.strictEqual(sampledBlock.numRuns, 100)
})
})
// ---------------------------------------------------------------------------
// Logic IR
// ---------------------------------------------------------------------------
describe('Logic IR', () => {
it('creates empty Logic IR with zero-length arrays', () => {
const ir = createEmptyLogicIr()
assert.strictEqual(ir.formulaCount, 0)
assert.strictEqual(ir.formulaKind.length, 0)
assert.strictEqual(ir.formulaLeft.length, 0)
assert.strictEqual(ir.formulaRight.length, 0)
assert.strictEqual(ir.bindingStart.length, 0)
assert.strictEqual(ir.bindingCount.length, 0)
assert.strictEqual(ir.predicateId.length, 0)
assert.strictEqual(ir.argStart.length, 0)
assert.strictEqual(ir.argCount.length, 0)
assert.strictEqual(ir.originIndex.length, 0)
})
it('creates empty binding and arg tables', () => {
const bindings = createEmptyBindingTable()
assert.strictEqual(bindings.count, 0)
assert.strictEqual(bindings.variableNameId.length, 0)
const args = createEmptyArgTable()
assert.strictEqual(args.count, 0)
assert.strictEqual(args.termKind.length, 0)
})
it('interns strings into the string table', () => {
const table = createEmptyStringTable()
const id1 = internString(table, 'width')
const id2 = internString(table, 'height')
const id3 = internString(table, 'width')
assert.strictEqual(id1, 0)
assert.strictEqual(id2, 1)
assert.strictEqual(id3, 0)
assert.strictEqual(table.values.length, 2)
assert.strictEqual(table.values[id1], 'width')
})
it('exposes formula kind constants', () => {
assert.strictEqual(FORMULA_KIND_FORALL, 1)
assert.strictEqual(FORMULA_KIND_PREDICATE, 7)
})
})
// ---------------------------------------------------------------------------
// Domains
// ---------------------------------------------------------------------------
describe('Domains', () => {
it('creates elements domain descriptor', () => {
const d = createElementsDomain('dom_1', '.button')
assert.strictEqual(d.domainId, 'dom_1')
assert.strictEqual(d.kind, 'elements')
assert.strictEqual(d.selector, '.button')
})
it('creates descendants domain descriptor', () => {
const d = createDescendantsDomain('dom_2', '$card', '.title')
assert.strictEqual(d.kind, 'descendants')
assert.strictEqual(d.parentVar, '$card')
assert.strictEqual(d.selector, '.title')
})
it('creates line boxes domain descriptor', () => {
const d = createLineBoxesDomain('dom_3', 'text_1')
assert.strictEqual(d.kind, 'lineBoxes')
assert.strictEqual(d.selector, 'text_1')
})
it('creates text runs domain descriptor', () => {
const d = createTextRunsDomain('dom_4', 'text_1')
assert.strictEqual(d.kind, 'textRuns')
})
it('creates fragments domain descriptor', () => {
const d = createFragmentsDomain('dom_5', 'subj_1')
assert.strictEqual(d.kind, 'fragments')
})
it('creates frames domain descriptor', () => {
const d = createFramesDomain('dom_6', 'viewport')
assert.strictEqual(d.kind, 'frames')
assert.strictEqual(d.selector, 'viewport')
})
it('creates domain value with subject ids', () => {
const ids = new Uint32Array([10, 20, 30])
const dv = createDomainValue('dom_1', ids, 'elements(.button)', true)
assert.strictEqual(dv.domainId, 'dom_1')
assert.strictEqual(dv.subjectIds.length, 3)
assert.strictEqual(dv.closed, true)
assert.strictEqual(dv.provenance, 'elements(.button)')
})
it('registers and looks up domains', () => {
clearDomainRegistry()
const d = createElementsDomain('dom_test', '.test')
registerDomain(d)
const lookedUp = lookupDomain('dom_test')
assert.ok(lookedUp)
assert.strictEqual(lookedUp?.selector, '.test')
const all = listDomains()
assert.strictEqual(all.length, 1)
clearDomainRegistry()
assert.strictEqual(listDomains().length, 0)
})
})
+379
View File
@@ -0,0 +1,379 @@
// perf.test.ts - Performance regression tests for Imhotep core hot paths
// Uses Node.js built-in test runner and performance hooks.
// Targets (V1 developer guide):
// Unit tests < 10ms each
// Integration tests < 100ms each
// Compilation < 5ms per assertion
// Extraction < 50ms per page
// Solving < 1ms per clause
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { performance } from 'node:perf_hooks'
import { compile } from 'imhotep-dsl'
import {
normalizeSubjects,
normalizeBoxes,
normalizeTopology,
normalizeFrames,
buildTransformChain,
composeTransform2D,
clearTransformCaches,
} from 'imhotep-geometry'
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
describe('Performance regression — compilation', () => {
it('compiles 100 assertions in under 500ms', () => {
const assertions = generateAssertions(100)
const start = performance.now()
const ir = compile(assertions)
const duration = performance.now() - start
assert.strictEqual(
duration < 500,
true,
`Compilation took ${duration.toFixed(2)}ms`
)
})
it('compiles 10 assertions in under 50ms (5ms per assertion)', () => {
const assertions = generateAssertions(10)
const start = performance.now()
compile(assertions)
const duration = performance.now() - start
assert.strictEqual(
duration < 50,
true,
`Compilation took ${duration.toFixed(2)}ms`
)
})
})
describe('Performance regression — world creation', () => {
it('normalizes 1000 subjects in under 50ms', () => {
const raw = generateRawSubjects(1000)
const start = performance.now()
normalizeSubjects(raw)
const duration = performance.now() - start
assert.strictEqual(
duration < 50,
true,
`Subject normalization took ${duration.toFixed(2)}ms`
)
})
it('normalizes 1000 boxes in under 50ms', () => {
const raw = generateRawBoxes(1000)
const start = performance.now()
normalizeBoxes(raw)
const duration = performance.now() - start
assert.strictEqual(
duration < 50,
true,
`Box normalization took ${duration.toFixed(2)}ms`
)
})
it('normalizes topology in under 25ms', () => {
const raw = generateRawTopology(1000)
const start = performance.now()
normalizeTopology(raw, 1000)
const duration = performance.now() - start
assert.strictEqual(
duration < 25,
true,
`Topology normalization took ${duration.toFixed(2)}ms`
)
})
})
describe('Performance regression — frame resolution', () => {
it('resolves 100 transform chains in under 50ms', () => {
const matrices = generateTransformMatrices()
const start = performance.now()
for (let i = 0; i < 100; i++) {
buildTransformChain(matrices)
}
const duration = performance.now() - start
assert.strictEqual(
duration < 50,
true,
`Frame resolution took ${duration.toFixed(2)}ms`
)
})
it('composes 100 2D transforms in under 100ms', () => {
const start = performance.now()
for (let i = 0; i < 100; i++) {
composeTransform2D(0, 0, i, i, 0, 1, 1)
}
const duration = performance.now() - start
assert.strictEqual(
duration < 100,
true,
`Transform composition took ${duration.toFixed(2)}ms`
)
})
it('caches repeated transform chains (second pass under 1ms)', () => {
const matrices = generateTransformMatrices()
clearTransformCaches()
buildTransformChain(matrices) // warm cache
const start = performance.now()
for (let i = 0; i < 100; i++) {
buildTransformChain(matrices)
}
const duration = performance.now() - start
assert.strictEqual(
duration < 1,
true,
`Cached frame resolution took ${duration.toFixed(2)}ms`
)
})
})
describe('Performance regression — solving', () => {
it('evaluates 100 clauses in under 100ms (1ms per clause)', () => {
registerDefaultClauses()
const world = generateMinimalWorld()
const clauses = generateClauses(100)
const start = performance.now()
evaluate(world, clauses, { skipMissingEvaluators: true })
const duration = performance.now() - start
assert.strictEqual(
duration < 100,
true,
`Solving took ${duration.toFixed(2)}ms`
)
})
it('evaluates 10 clauses in under 10ms', () => {
registerDefaultClauses()
const world = generateMinimalWorld()
const clauses = generateClauses(10)
const start = performance.now()
evaluate(world, clauses, { skipMissingEvaluators: true })
const duration = performance.now() - start
assert.strictEqual(
duration < 10,
true,
`Solving took ${duration.toFixed(2)}ms`
)
})
})
// ---------------------------------------------------------------------------
// Mock data generators
// ---------------------------------------------------------------------------
function generateAssertions(n: number): Array<{
type: 'RelationAssertion'
relation: string
subject: { type: 'SelectorRef'; value: string }
reference: { type: 'SelectorRef'; value: string }
options: { type: 'RelationOptions' }
}> {
return Array.from({ length: n }, (_, i) => ({
type: 'RelationAssertion' as const,
relation: i % 2 === 0 ? 'leftOf' : 'above',
subject: { type: 'SelectorRef' as const, value: `.s-${i}` },
reference: { type: 'SelectorRef' as const, value: `.r-${i}` },
options: { type: 'RelationOptions' as const },
}))
}
function generateRawSubjects(n: number): Array<{
id: number
domNodeId: number
kind: string
primaryBoxId: number
firstFragmentId: number
fragmentCount: number
firstTextRunId: number
textRunCount: number
}> {
return Array.from({ length: n }, (_, i) => ({
id: i,
domNodeId: i,
kind: 'element',
primaryBoxId: i,
firstFragmentId: 0,
fragmentCount: 0,
firstTextRunId: 0,
textRunCount: 0,
}))
}
function generateRawBoxes(n: number): Array<{
id: number
subjectId: number
frameId: number
borderLeft: number
borderTop: number
borderRight: number
borderBottom: number
paddingLeft: number
paddingTop: number
paddingRight: number
paddingBottom: number
contentLeft: number
contentTop: number
contentRight: number
contentBottom: number
}> {
return Array.from({ length: n }, (_, i) => ({
id: i,
subjectId: i,
frameId: 0,
borderLeft: 0,
borderTop: 0,
borderRight: 10,
borderBottom: 10,
paddingLeft: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
contentLeft: 0,
contentTop: 0,
contentRight: 10,
contentBottom: 10,
}))
}
function generateRawTopology(n: number): {
containingBlockOf: number[]
nearestPositionedAncestorOf: number[]
scrollContainerOf: number[]
stackingContextOf: number[]
formattingContextOf: number[]
clippingRootOf: number[]
paintOrderBucket: number[]
paintOrderIndex: number[]
} {
return {
containingBlockOf: Array.from({ length: n }, () => 0),
nearestPositionedAncestorOf: Array.from({ length: n }, () => 0),
scrollContainerOf: Array.from({ length: n }, () => 0),
stackingContextOf: Array.from({ length: n }, () => 0),
formattingContextOf: Array.from({ length: n }, () => 0),
clippingRootOf: Array.from({ length: n }, () => 0),
paintOrderBucket: Array.from({ length: n }, () => 0),
paintOrderIndex: Array.from({ length: n }, () => 0),
}
}
function generateTransformMatrices(): Float64Array[] {
return [
new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 10, 0, 1]),
]
}
function generateMinimalWorld(): any {
return {
sceneId: 'scene_1',
snapshotId: 'snapshot_1',
env: {
viewportWidth: 1280,
viewportHeight: 720,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array([0, 1]),
domNodeId: new Uint32Array([0, 1]),
subjectKind: new Uint16Array([1, 1]),
primaryBoxId: new Uint32Array([0, 1]),
firstFragmentId: new Uint32Array([0, 0]),
fragmentCount: new Uint16Array([0, 0]),
},
boxes: {
boxId: new Uint32Array([0, 1]),
subjectId: new Uint32Array([0, 1]),
frameId: new Uint32Array([0, 0]),
borderLeft: new Float64Array([0, 0]),
borderTop: new Float64Array([0, 0]),
borderRight: new Float64Array([10, 10]),
borderBottom: new Float64Array([10, 10]),
paddingLeft: new Float64Array([0, 0]),
paddingTop: new Float64Array([0, 0]),
paddingRight: new Float64Array([0, 0]),
paddingBottom: new Float64Array([0, 0]),
contentLeft: new Float64Array([0, 0]),
contentTop: new Float64Array([0, 0]),
contentRight: new Float64Array([10, 10]),
contentBottom: new Float64Array([10, 10]),
},
rects: {
rectId: new Uint32Array([0, 1]),
left: new Float64Array([0, 5]),
top: new Float64Array([0, 5]),
right: new Float64Array([10, 15]),
bottom: new Float64Array([10, 15]),
},
topology: {
containingBlockOf: new Uint32Array([0, 0]),
nearestPositionedAncestorOf: new Uint32Array([0, 0]),
scrollContainerOf: new Uint32Array([0, 0]),
stackingContextOf: new Uint32Array([0, 0]),
formattingContextOf: new Uint32Array([0, 0]),
clippingRootOf: new Uint32Array([0, 0]),
paintOrderBucket: new Uint16Array([0, 0]),
paintOrderIndex: new Uint32Array([0, 1]),
},
scroll: {
containerId: new Uint32Array([0]),
scrollLeft: new Float64Array([0]),
scrollTop: new Float64Array([0]),
scrollWidth: new Float64Array([1280]),
scrollHeight: new Float64Array([720]),
clientWidth: new Float64Array([1280]),
clientHeight: new Float64Array([720]),
},
clipping: {
clipNodeId: new Uint32Array([0]),
subjectId: new Uint32Array([0]),
clipKind: new Uint16Array([1]),
clipLeft: new Float64Array([0]),
clipTop: new Float64Array([0]),
clipRight: new Float64Array([1280]),
clipBottom: new Float64Array([720]),
parentClipNodeId: new Uint32Array([0]),
},
visibility: {
subjectId: new Uint32Array([0, 1]),
isRendered: new Uint8Array([1, 1]),
isVisible: new Uint8Array([1, 1]),
visibleArea: new Float64Array([100, 100]),
clippedArea: new Float64Array([0, 0]),
},
}
}
function generateClauses(n: number): Array<{
clauseId: string
clauseKind: string
version: number
subjectRef: number
referenceRef?: number
}> {
const kinds = [
'relation.leftOf',
'relation.above',
'relation.overlaps',
'relation.inside',
]
return Array.from({ length: n }, (_, i) => ({
clauseId: `clause-${i}`,
clauseKind: kinds[i % kinds.length],
version: 1,
subjectRef: 0,
referenceRef: 1,
}))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
/**
* Property runner input domain contracts.
*
* These types define how render inputs are supplied to a property run:
* either as an explicit finite enumeration (determinate) or as a
* generated sampled domain (sampled). The mode is always explicit.
*
* Invariant 6: every InputDomain carries its mode at the type level so
* downstream code cannot silently switch from enumerated to generated.
*/
// ---------------------------------------------------------------------------
// InputDomain
// ---------------------------------------------------------------------------
export type InputDomain =
| { mode: 'enumerated'; values: unknown[] }
| { mode: 'generated'; arbitrary: unknown; seed?: number; numRuns?: number }
// ---------------------------------------------------------------------------
// Type Guards
// ---------------------------------------------------------------------------
export function isEnumeratedDomain(domain: InputDomain): domain is Extract<InputDomain, { mode: 'enumerated' }> {
return domain.mode === 'enumerated'
}
export function isGeneratedDomain(domain: InputDomain): domain is Extract<InputDomain, { mode: 'generated' }> {
return domain.mode === 'generated'
}
// ---------------------------------------------------------------------------
// Factory Helpers
// ---------------------------------------------------------------------------
export function enumeratedDomain(values: unknown[]): InputDomain {
return { mode: 'enumerated', values }
}
export function generatedDomain(arbitrary: unknown, options?: { seed?: number; numRuns?: number }): InputDomain {
return { mode: 'generated', arbitrary, seed: options?.seed, numRuns: options?.numRuns }
}
@@ -0,0 +1,85 @@
/**
* Property run result contracts.
*
* These interfaces materialize Invariant 6: every result must clearly say
* whether it is scene-determinate, scene-indeterminate, enumerated-determinate,
* or sampled. The mode field is a literal discriminant so switch-exhaustiveness
* checking works.
*/
import type { ImhotepId } from './types.js'
// ---------------------------------------------------------------------------
// SampledRunResult
// ---------------------------------------------------------------------------
export interface SampledRunResult {
mode: 'sampled'
seed: number
numRuns: number
passed: boolean
counterexample?: unknown
shrunkCounterexample?: unknown
failingScene?: unknown
diagnostics: unknown[]
// P1.3 Property-Run Reporting Upgrade
minimalFailingCase?: unknown
caseIndex?: number
durationMs?: number
replayPayload?: { props: unknown; seed: number; caseIndex: number }
}
// ---------------------------------------------------------------------------
// EnumeratedRunResult
// ---------------------------------------------------------------------------
export interface EnumeratedRunResult {
mode: 'enumerated-determinate'
totalCases: number
passed: boolean
failingCase?: unknown
diagnostics: unknown[]
// P1.3 Property-Run Reporting Upgrade
minimalFailingCase?: unknown
caseIndex?: number
seed?: number
durationMs?: number
replayPayload?: { props: unknown; seed: number; caseIndex: number }
}
// ---------------------------------------------------------------------------
// Scene Evaluation Results (for completeness in the taxonomy)
// ---------------------------------------------------------------------------
export interface DeterministicSceneResult {
mode: 'scene-determinate' | 'scene-indeterminate'
sceneId: ImhotepId
results: unknown[]
proofs: unknown[]
diagnostics: unknown[]
}
// ---------------------------------------------------------------------------
// Union type for any property or scene run result
// ---------------------------------------------------------------------------
export type PropertyRunResult =
| SampledRunResult
| EnumeratedRunResult
| DeterministicSceneResult
// ---------------------------------------------------------------------------
// Type Guards
// ---------------------------------------------------------------------------
export function isSampledRunResult(result: PropertyRunResult): result is SampledRunResult {
return result.mode === 'sampled'
}
export function isEnumeratedRunResult(result: PropertyRunResult): result is EnumeratedRunResult {
return result.mode === 'enumerated-determinate'
}
export function isDeterministicSceneResult(result: PropertyRunResult): result is DeterministicSceneResult {
return result.mode === 'scene-determinate' || result.mode === 'scene-indeterminate'
}
@@ -0,0 +1,223 @@
/**
* Unit tests for SceneTarget discriminated union, type guards, and matchers.
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import type { SceneTarget, RenderCase } from './scene-target.js'
import {
isSceneTarget,
isPageTarget,
isPlaywrightPageTarget,
isStorybookStoryTarget,
isReactComponentTarget,
isVueComponentTarget,
isCustomRendererTarget,
isFixtureTarget,
isComponentTarget,
isRendererTarget,
matchSceneTarget,
pageTarget,
playwrightPageTarget,
storybookStoryTarget,
reactComponentTarget,
vueComponentTarget,
customRendererTarget,
fixtureTarget,
createRenderCase,
} from './scene-target.js'
describe('SceneTarget type guards', () => {
it('accepts valid page target', () => {
const target = pageTarget('https://example.com')
assert.strictEqual(isSceneTarget(target), true)
assert.strictEqual(isPageTarget(target), true)
assert.strictEqual(isPlaywrightPageTarget(target), false)
assert.strictEqual(isComponentTarget(target), false)
assert.strictEqual(isRendererTarget(target), false)
})
it('accepts valid playwright-page target', () => {
const target = playwrightPageTarget('page-1', 'https://example.com')
assert.strictEqual(isSceneTarget(target), true)
assert.strictEqual(isPlaywrightPageTarget(target), true)
assert.strictEqual(isPageTarget(target), false)
})
it('accepts valid storybook-story target', () => {
const target = storybookStoryTarget('button--primary', 'http://localhost:6006')
assert.strictEqual(isSceneTarget(target), true)
assert.strictEqual(isStorybookStoryTarget(target), true)
assert.strictEqual(isRendererTarget(target), true)
assert.strictEqual(isComponentTarget(target), false)
})
it('accepts valid react-component target', () => {
const target = reactComponentTarget('react-1', 'Button')
assert.strictEqual(isSceneTarget(target), true)
assert.strictEqual(isReactComponentTarget(target), true)
assert.strictEqual(isComponentTarget(target), true)
assert.strictEqual(isRendererTarget(target), true)
})
it('accepts valid vue-component target', () => {
const target = vueComponentTarget('vue-1', 'Button')
assert.strictEqual(isSceneTarget(target), true)
assert.strictEqual(isVueComponentTarget(target), true)
assert.strictEqual(isComponentTarget(target), true)
})
it('accepts valid custom-renderer target', () => {
const target = customRendererTarget('custom-1', 'my-widget')
assert.strictEqual(isSceneTarget(target), true)
assert.strictEqual(isCustomRendererTarget(target), true)
assert.strictEqual(isComponentTarget(target), true)
})
it('accepts valid fixture target', () => {
const target = fixtureTarget('fixtures/button.html')
assert.strictEqual(isSceneTarget(target), true)
assert.strictEqual(isFixtureTarget(target), true)
assert.strictEqual(isRendererTarget(target), false)
})
it('rejects non-objects', () => {
assert.strictEqual(isSceneTarget(null), false)
assert.strictEqual(isSceneTarget(undefined), false)
assert.strictEqual(isSceneTarget('string'), false)
assert.strictEqual(isSceneTarget(42), false)
})
it('rejects objects without valid kind', () => {
assert.strictEqual(isSceneTarget({}), false)
assert.strictEqual(isSceneTarget({ kind: 'unknown' }), false)
})
it('rejects page target with missing url', () => {
assert.strictEqual(isSceneTarget({ kind: 'page' }), false)
})
})
describe('SceneTarget matchers', () => {
it('matches page target to correct branch', () => {
const target = pageTarget('https://example.com')
const result = matchSceneTarget<string>(target, {
page: (url) => `page:${url}`,
'playwright-page': (pageRef) => `playwright-page:${pageRef}`,
'storybook-story': (storyId) => `storybook-story:${storyId}`,
'react-component': (rendererId, componentId) => `react-component:${rendererId}:${componentId}`,
'vue-component': (rendererId, componentId) => `vue-component:${rendererId}:${componentId}`,
'custom-renderer': (rendererId, targetId) => `custom-renderer:${rendererId}:${targetId}`,
fixture: (fixtureId) => `fixture:${fixtureId}`,
})
assert.strictEqual(result, 'page:https://example.com')
})
it('matches react-component target to correct branch', () => {
const target = reactComponentTarget('react-1', 'Button')
const result = matchSceneTarget(target, {
page: () => 'page',
'playwright-page': () => 'playwright-page',
'storybook-story': () => 'storybook-story',
'react-component': (rendererId, componentId) => `react:${rendererId}:${componentId}`,
'vue-component': () => 'vue',
'custom-renderer': () => 'custom',
fixture: () => 'fixture',
})
assert.strictEqual(result, 'react:react-1:Button')
})
it('matches all target kinds exhaustively', () => {
const targets: SceneTarget[] = [
pageTarget('https://a.com'),
playwrightPageTarget('p1'),
storybookStoryTarget('s1', 'http://sb'),
reactComponentTarget('r1', 'C1'),
vueComponentTarget('v1', 'C2'),
customRendererTarget('c1', 't1'),
fixtureTarget('f1'),
]
for (const target of targets) {
const result = matchSceneTarget(target, {
page: () => 'page',
'playwright-page': () => 'playwright-page',
'storybook-story': () => 'storybook-story',
'react-component': () => 'react-component',
'vue-component': () => 'vue-component',
'custom-renderer': () => 'custom-renderer',
fixture: () => 'fixture',
})
assert.strictEqual(result, target.kind)
}
})
})
describe('SceneTarget factory functions', () => {
it('pageTarget creates correct shape', () => {
const target = pageTarget('https://example.com')
assert.deepStrictEqual(target, { kind: 'page', url: 'https://example.com' })
})
it('playwrightPageTarget creates correct shape with optional url', () => {
const withUrl = playwrightPageTarget('page-1', 'https://example.com')
assert.deepStrictEqual(withUrl, { kind: 'playwright-page', pageRef: 'page-1', url: 'https://example.com' })
const withoutUrl = playwrightPageTarget('page-1')
assert.deepStrictEqual(withoutUrl, { kind: 'playwright-page', pageRef: 'page-1', url: undefined })
})
it('storybookStoryTarget creates correct shape', () => {
const target = storybookStoryTarget('button--primary', 'http://localhost:6006')
assert.deepStrictEqual(target, {
kind: 'storybook-story',
storyId: 'button--primary',
storybookUrl: 'http://localhost:6006',
})
})
it('reactComponentTarget creates correct shape', () => {
const target = reactComponentTarget('react-dom', 'Button')
assert.deepStrictEqual(target, { kind: 'react-component', rendererId: 'react-dom', componentId: 'Button' })
})
it('vueComponentTarget creates correct shape', () => {
const target = vueComponentTarget('vue-3', 'Button')
assert.deepStrictEqual(target, { kind: 'vue-component', rendererId: 'vue-3', componentId: 'Button' })
})
it('customRendererTarget creates correct shape', () => {
const target = customRendererTarget('svelte', 'Widget')
assert.deepStrictEqual(target, { kind: 'custom-renderer', rendererId: 'svelte', targetId: 'Widget' })
})
it('fixtureTarget creates correct shape', () => {
const target = fixtureTarget('fixtures/card.html')
assert.deepStrictEqual(target, { kind: 'fixture', fixtureId: 'fixtures/card.html' })
})
})
describe('RenderCase', () => {
it('createRenderCase builds minimal case', () => {
const rc = createRenderCase('case-1', { size: 'md' })
assert.strictEqual(rc.caseId, 'case-1')
assert.deepStrictEqual(rc.input, { size: 'md' })
assert.strictEqual(rc.env, undefined)
assert.strictEqual(rc.metadata, undefined)
})
it('createRenderCase includes env and metadata when provided', () => {
const rc = createRenderCase('case-2', { disabled: true }, { viewportWidth: 800 }, { run: 3 })
assert.strictEqual(rc.caseId, 'case-2')
assert.deepStrictEqual(rc.input, { disabled: true })
assert.deepStrictEqual(rc.env, { viewportWidth: 800 })
assert.deepStrictEqual(rc.metadata, { run: 3 })
})
it('isSceneTarget rejects RenderCase', () => {
const rc = createRenderCase('case-1', {})
assert.strictEqual(isSceneTarget(rc), false)
})
})
+202
View File
@@ -0,0 +1,202 @@
/**
* SceneTarget discriminated union and type guards.
*
* The scene target is the stage. It must be possible to mount any component,
* any story, any page, and treat it as a deterministic scene. The adapter
* boundary is the moat that keeps renderer concerns out of the core.
*/
import type { Environment } from './types.js'
// ---------------------------------------------------------------------------
// SceneTarget Union
// ---------------------------------------------------------------------------
export type SceneTarget =
| { kind: 'page'; url: string }
| { kind: 'playwright-page'; pageRef: string; url?: string }
| { kind: 'storybook-story'; storyId: string; storybookUrl: string }
| { kind: 'react-component'; rendererId: string; componentId: string }
| { kind: 'vue-component'; rendererId: string; componentId: string }
| { kind: 'custom-renderer'; rendererId: string; targetId: string }
| { kind: 'fixture'; fixtureId: string }
// ---------------------------------------------------------------------------
// RenderCase Contract
// ---------------------------------------------------------------------------
export interface RenderCase {
caseId: string
input: unknown
env?: Partial<Environment>
metadata?: Record<string, unknown>
}
// ---------------------------------------------------------------------------
// Type Guards
// ---------------------------------------------------------------------------
export function isSceneTarget(value: unknown): value is SceneTarget {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
if (typeof obj.kind !== 'string') return false
switch (obj.kind) {
case 'page':
return typeof obj.url === 'string'
case 'playwright-page':
return typeof obj.pageRef === 'string' && (obj.url === undefined || typeof obj.url === 'string')
case 'storybook-story':
return typeof obj.storyId === 'string' && typeof obj.storybookUrl === 'string'
case 'react-component':
return typeof obj.rendererId === 'string' && typeof obj.componentId === 'string'
case 'vue-component':
return typeof obj.rendererId === 'string' && typeof obj.componentId === 'string'
case 'custom-renderer':
return typeof obj.rendererId === 'string' && typeof obj.targetId === 'string'
case 'fixture':
return typeof obj.fixtureId === 'string'
default:
return false
}
}
export function isPageTarget(target: SceneTarget): target is Extract<SceneTarget, { kind: 'page' }> {
return target.kind === 'page'
}
export function isPlaywrightPageTarget(
target: SceneTarget
): target is Extract<SceneTarget, { kind: 'playwright-page' }> {
return target.kind === 'playwright-page'
}
export function isStorybookStoryTarget(
target: SceneTarget
): target is Extract<SceneTarget, { kind: 'storybook-story' }> {
return target.kind === 'storybook-story'
}
export function isReactComponentTarget(
target: SceneTarget
): target is Extract<SceneTarget, { kind: 'react-component' }> {
return target.kind === 'react-component'
}
export function isVueComponentTarget(
target: SceneTarget
): target is Extract<SceneTarget, { kind: 'vue-component' }> {
return target.kind === 'vue-component'
}
export function isCustomRendererTarget(
target: SceneTarget
): target is Extract<SceneTarget, { kind: 'custom-renderer' }> {
return target.kind === 'custom-renderer'
}
export function isFixtureTarget(target: SceneTarget): target is Extract<SceneTarget, { kind: 'fixture' }> {
return target.kind === 'fixture'
}
export function isComponentTarget(
target: SceneTarget
): target is
| Extract<SceneTarget, { kind: 'react-component' }>
| Extract<SceneTarget, { kind: 'vue-component' }>
| Extract<SceneTarget, { kind: 'custom-renderer' }> {
return target.kind === 'react-component' || target.kind === 'vue-component' || target.kind === 'custom-renderer'
}
export function isRendererTarget(
target: SceneTarget
): target is
| Extract<SceneTarget, { kind: 'react-component' }>
| Extract<SceneTarget, { kind: 'vue-component' }>
| Extract<SceneTarget, { kind: 'storybook-story' }>
| Extract<SceneTarget, { kind: 'custom-renderer' }> {
return (
target.kind === 'react-component' ||
target.kind === 'vue-component' ||
target.kind === 'storybook-story' ||
target.kind === 'custom-renderer'
)
}
// ---------------------------------------------------------------------------
// Matchers
// ---------------------------------------------------------------------------
export function matchSceneTarget<R>(
target: SceneTarget,
cases: {
page: (url: string) => R
'playwright-page': (pageRef: string, url?: string) => R
'storybook-story': (storyId: string, storybookUrl: string) => R
'react-component': (rendererId: string, componentId: string) => R
'vue-component': (rendererId: string, componentId: string) => R
'custom-renderer': (rendererId: string, targetId: string) => R
fixture: (fixtureId: string) => R
}
): R {
switch (target.kind) {
case 'page':
return cases.page(target.url)
case 'playwright-page':
return cases['playwright-page'](target.pageRef, target.url)
case 'storybook-story':
return cases['storybook-story'](target.storyId, target.storybookUrl)
case 'react-component':
return cases['react-component'](target.rendererId, target.componentId)
case 'vue-component':
return cases['vue-component'](target.rendererId, target.componentId)
case 'custom-renderer':
return cases['custom-renderer'](target.rendererId, target.targetId)
case 'fixture':
return cases.fixture(target.fixtureId)
}
}
// ---------------------------------------------------------------------------
// Factory Functions
// ---------------------------------------------------------------------------
export function pageTarget(url: string): SceneTarget {
return { kind: 'page', url }
}
export function playwrightPageTarget(pageRef: string, url?: string): SceneTarget {
return { kind: 'playwright-page', pageRef, url }
}
export function storybookStoryTarget(storyId: string, storybookUrl: string): SceneTarget {
return { kind: 'storybook-story', storyId, storybookUrl }
}
export function reactComponentTarget(rendererId: string, componentId: string): SceneTarget {
return { kind: 'react-component', rendererId, componentId }
}
export function vueComponentTarget(rendererId: string, componentId: string): SceneTarget {
return { kind: 'vue-component', rendererId, componentId }
}
export function customRendererTarget(rendererId: string, targetId: string): SceneTarget {
return { kind: 'custom-renderer', rendererId, targetId }
}
export function fixtureTarget(fixtureId: string): SceneTarget {
return { kind: 'fixture', fixtureId }
}
// ---------------------------------------------------------------------------
// RenderCase Factory
// ---------------------------------------------------------------------------
export function createRenderCase(
caseId: string,
input: unknown,
env?: Partial<Environment>,
metadata?: Record<string, unknown>
): RenderCase {
return { caseId, input, env, metadata }
}
+216
View File
@@ -0,0 +1,216 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import type {
Point,
Position,
LengthLiteral,
ClauseStatus,
TruthValue,
ClauseResult,
Proof,
} from './types.js'
import type {
DiagnosticCategory,
DiagnosticCode,
} from './diagnostics.js'
import {
isAstNode,
isParentNode,
isProgram,
isRelationAssertion,
isCompoundAssertion,
type Program,
type RelationAssertion,
type CompoundAssertion,
} from './ast.js'
import { createEmptySemanticIr } from './ir.js'
import { createEmptyWorld } from './world.js'
import { createDiagnostic, isContractFailure, isSystemUseError } from './diagnostics.js'
describe('Core Types', () => {
it('constructs a valid Point', () => {
const p: Point = { line: 1, column: 1, offset: 0 }
assert.strictEqual(p.line, 1)
assert.strictEqual(p.column, 1)
assert.strictEqual(p.offset, 0)
})
it('constructs a valid Position', () => {
const pos: Position = {
start: { line: 2, column: 5, offset: 17 },
end: { line: 2, column: 51, offset: 63 },
}
assert.strictEqual(pos.start.line, 2)
assert.strictEqual(pos.end.offset, 63)
})
it('constructs a valid LengthLiteral', () => {
const len: LengthLiteral = { value: 16, unit: 'px' }
assert.strictEqual(len.value, 16)
assert.strictEqual(len.unit, 'px')
})
it('narrows ClauseStatus discriminated union', () => {
const statuses: ClauseStatus[] = ['pass', 'fail', 'error', 'skipped']
for (const s of statuses) {
assert.ok(['pass', 'fail', 'error', 'skipped'].includes(s))
}
})
it('narrows TruthValue discriminated union', () => {
const truths: TruthValue[] = ['determinate', 'indeterminate', 'approximate']
for (const t of truths) {
assert.ok(['determinate', 'indeterminate', 'approximate'].includes(t))
}
})
it('constructs a valid ClauseResult', () => {
const result: ClauseResult = {
clauseId: 'clause_42',
status: 'fail',
truth: 'determinate',
metrics: { observedGap: 11, minGap: 16, maxGap: 20 },
witness: { subjectId: 2, referenceId: 9, frameId: 1 },
diagnostics: [],
}
assert.strictEqual(result.status, 'fail')
assert.strictEqual(result.truth, 'determinate')
assert.strictEqual(result.metrics.observedGap, 11)
})
it('constructs a valid Proof', () => {
const proof: Proof = {
proofId: 'proof_99',
clauseId: 'clause_42',
outcome: 'fail',
truth: 'determinate',
usedFacts: [102, 103, 220],
derivedFacts: [501, 502],
failedPredicate: { op: '>=', left: 11, right: 24 },
witness: {
subjectId: 5,
referenceId: 8,
envCaseId: 'env_3',
snapshotId: 'hover',
},
}
assert.strictEqual(proof.usedFacts.length, 3)
assert.strictEqual(proof.failedPredicate?.op, '>=')
})
})
describe('AST Type Guards', () => {
it('identifies valid AstNode', () => {
assert.strictEqual(isAstNode({ type: 'Program' }), true)
assert.strictEqual(isAstNode(null), false)
assert.strictEqual(isAstNode({}), false)
})
it('identifies ParentNode via children property', () => {
const program: Program = {
type: 'Program',
children: [],
}
assert.strictEqual(isParentNode(program), true)
const relation: RelationAssertion = {
type: 'RelationAssertion',
relation: 'leftOf',
subject: { type: 'SelectorRef', value: '.a' },
reference: { type: 'SelectorRef', value: '.b' },
options: { type: 'RelationOptions' },
}
assert.strictEqual(isParentNode(relation), false)
})
it('narrows Program type', () => {
const node = { type: 'Program', children: [] } as const
assert.strictEqual(isProgram(node), true)
assert.strictEqual(isRelationAssertion(node), false)
})
it('narrows RelationAssertion type', () => {
const node: RelationAssertion = {
type: 'RelationAssertion',
relation: 'leftOf',
subject: { type: 'SelectorRef', value: '.a' },
reference: { type: 'SelectorRef', value: '.b' },
options: { type: 'RelationOptions' },
}
assert.strictEqual(isRelationAssertion(node), true)
assert.strictEqual(isProgram(node), false)
})
it('narrows CompoundAssertion type', () => {
const node: CompoundAssertion = {
type: 'CompoundAssertion',
quantifier: { type: 'Quantifier', kind: 'all' },
children: [],
}
assert.strictEqual(isCompoundAssertion(node), true)
})
})
describe('Semantic IR', () => {
it('creates empty Semantic IR with all tables', () => {
const ir = createEmptySemanticIr()
assert.ok(ir.subjects instanceof Map)
assert.ok(ir.frames instanceof Map)
assert.ok(ir.clauses instanceof Map)
assert.ok(ir.groups instanceof Map)
assert.ok(ir.tolerances instanceof Map)
assert.ok(ir.envGuards instanceof Map)
assert.ok(ir.states instanceof Map)
assert.ok(ir.timelines instanceof Map)
assert.ok(ir.diagnosticMetadata instanceof Map)
})
})
describe('Geometry World', () => {
it('creates empty world with correct scene/snapshot ids', () => {
const world = createEmptyWorld('scene_1', 'snapshot_default')
assert.strictEqual(world.sceneId, 'scene_1')
assert.strictEqual(world.snapshotId, 'snapshot_default')
assert.strictEqual(world.env.viewportWidth, 0)
assert.ok(world.subjects.ids instanceof Uint32Array)
assert.ok(world.boxes.boxId instanceof Uint32Array)
})
})
describe('Diagnostics', () => {
it('creates a diagnostic with correct category', () => {
const d = createDiagnostic(
'IMH_RELATION_LEFT_OF_FAILED',
'contract-failure',
'error',
'Expected .gallery to be left of .details.',
)
assert.strictEqual(d.code, 'IMH_RELATION_LEFT_OF_FAILED')
assert.strictEqual(d.category, 'contract-failure')
assert.strictEqual(d.severity, 'error')
assert.strictEqual(isContractFailure(d), true)
assert.strictEqual(isSystemUseError(d), false)
})
it('classifies system use errors correctly', () => {
const parseErr = createDiagnostic(
'IMH_PARSE_INVALID_SYNTAX',
'parse-error',
'error',
'Unexpected token',
)
assert.strictEqual(isSystemUseError(parseErr), true)
assert.strictEqual(isContractFailure(parseErr), false)
const resolutionErr = createDiagnostic(
'IMH_SELECTOR_ZERO_MATCHES',
'resolution-error',
'error',
'Selector matched zero elements.',
)
assert.strictEqual(isSystemUseError(resolutionErr), true)
})
})
+308
View File
@@ -0,0 +1,308 @@
/**
* Core type definitions for Imhotep.
*
* Imhotep uses three internal representations:
* 1. Authoring AST — rich tree, unist-style, source-positioned
* 2. Semantic IR — normalized graph with explicit obligations
* 3. Execution IR — flattened, data-oriented arrays for speed
*
* This file holds the foundational types shared across all stages.
*/
// ---------------------------------------------------------------------------
// Position and Source Origin
// ---------------------------------------------------------------------------
export interface Point {
/** 1-based line number in the source text */
line: number
/** 1-based column number in the source text */
column: number
/** 0-based character offset in the source text */
offset: number
}
export interface Position {
start: Point
end: Point
}
export interface SourceOrigin {
/** AST node ids that contributed to this IR node */
astNodeIds: string[]
/** Source spans traceable back to authoring syntax */
positions: Position[]
}
// ---------------------------------------------------------------------------
// Identifier and Reference Types
// ---------------------------------------------------------------------------
export type ImhotepId = string
export interface IdRef {
id: ImhotepId
}
// ---------------------------------------------------------------------------
// Literal Value Types
// ---------------------------------------------------------------------------
export type LengthUnit = 'px' | 'em' | 'rem' | 'ch' | 'jnd'
export interface LengthLiteral {
value: number
unit: LengthUnit
}
export interface RangeLiteral {
min: LengthLiteral
max: LengthLiteral
}
export interface ToleranceLiteral {
value: number
unit: 'jnd' | 'px'
}
export type LiteralValue =
| { kind: 'length'; value: number; unit: LengthUnit }
| { kind: 'range'; min: LengthLiteral; max: LengthLiteral }
| { kind: 'tolerance'; value: number; unit: 'jnd' | 'px' }
| { kind: 'string'; value: string }
| { kind: 'number'; value: number }
| { kind: 'boolean'; value: boolean }
// ---------------------------------------------------------------------------
// Environment and Snapshot
// ---------------------------------------------------------------------------
export type ColorScheme = 'light' | 'dark'
export type PointerType = 'coarse' | 'fine' | 'none'
export type ReducedMotion = 'reduce' | 'no-preference'
export type WritingMode = 'horizontal-tb' | 'vertical-rl' | 'vertical-lr'
export interface Environment {
viewportWidth: number
viewportHeight: number
deviceScaleFactor: number
colorScheme: ColorScheme
pointer: PointerType
hover: boolean
reducedMotion: ReducedMotion
locale: string
writingMode: WritingMode
}
export interface EnvCase {
id: ImhotepId
env: Environment
}
// ---------------------------------------------------------------------------
// State and Timeline
// ---------------------------------------------------------------------------
export type StateKind =
| 'default'
| 'hover'
| 'focus'
| 'focusVisible'
| 'active'
| 'disabled'
| 'checked'
| 'expanded'
| 'collapsed'
| 'selected'
| 'pressed'
| 'visited'
| 'custom'
export interface StateSnapshot {
id: ImhotepId
kind: StateKind
name?: string
}
export type TimelineMode = 'static' | 'keyframes' | 'range' | 'transition'
export interface TimelineSnapshot {
id: ImhotepId
mode: TimelineMode
samples?: number[]
}
// ---------------------------------------------------------------------------
// Frame Types
// ---------------------------------------------------------------------------
export type FrameKind =
| 'viewport'
| 'document'
| 'element'
| 'nearestPositionedAncestor'
| 'containingBlock'
| 'scrollContainer'
| 'namedGridArea'
| 'landmark'
| 'stackingContextRoot'
| 'custom'
export interface Frame {
id: ImhotepId
kind: FrameKind
selector?: string
name?: string
originX: number
originY: number
writingMode: WritingMode
}
// ---------------------------------------------------------------------------
// Subject Types
// ---------------------------------------------------------------------------
export type SubjectKind =
| 'element'
| 'pseudoElement'
| 'textRange'
| 'lineBox'
| 'fragmentBox'
| 'landmark'
| 'gridArea'
| 'scrollContainer'
| 'syntheticFrame'
export interface Subject {
id: ImhotepId
selector: string
kind: SubjectKind
}
// ---------------------------------------------------------------------------
// Assertion and Quantifier Types
// ---------------------------------------------------------------------------
export type RelationName =
| 'leftOf'
| 'rightOf'
| 'above'
| 'below'
| 'alignedWith'
| 'leftAlignedWith'
| 'rightAlignedWith'
| 'topAlignedWith'
| 'bottomAlignedWith'
| 'centeredWithin'
| 'inside'
| 'contains'
| 'overlaps'
| 'intersects'
| 'touches'
| 'separatedFrom'
| 'hasGap'
export type SizeProperty =
| 'width'
| 'height'
| 'minWidth'
| 'minHeight'
| 'maxWidth'
| 'maxHeight'
export type QuantifierKind =
| 'all'
| 'any'
| 'none'
| 'exactly'
| 'atLeast'
| 'atMost'
| 'ordered'
| 'pairwise'
export interface Quantifier {
kind: QuantifierKind
count?: number
}
// ---------------------------------------------------------------------------
// Clause Result and Truth
// ---------------------------------------------------------------------------
export type ClauseStatus = 'pass' | 'fail' | 'error' | 'skipped'
export type TruthValue = 'determinate' | 'indeterminate' | 'approximate'
export interface ClauseMetrics {
[key: string]: number | LengthLiteral | undefined
}
export interface ClauseWitness {
subjectId: number
referenceId?: number
frameId: number
envCaseId?: ImhotepId
snapshotId?: ImhotepId
}
export interface SourceReference {
/** 0-based index of the assertion in a fluent batch */
fluentIndex?: number
/** 1-based line number in the dense spec string */
specLine?: number
/** 1-based column number in the dense spec string */
specColumn?: number
}
export interface ClauseResult {
clauseId: ImhotepId
status: ClauseStatus
truth: TruthValue
metrics: ClauseMetrics
witness: ClauseWitness
diagnostics: ImhotepId[]
/** Traceability metadata linking this result to its authored source */
sourceRef?: SourceReference
/** Human-readable label derived from the normalized contract */
clauseLabel?: string
}
// ---------------------------------------------------------------------------
// Group Result
// ---------------------------------------------------------------------------
export type GroupOperator = 'and' | 'or' | 'not' | 'quantifier'
export interface GroupResult {
groupId: ImhotepId
operator: GroupOperator
clauseResults: ImhotepId[]
groupResults?: ImhotepId[]
status: ClauseStatus
truth: TruthValue
}
// ---------------------------------------------------------------------------
// Proof and Determination
// ---------------------------------------------------------------------------
export interface FailedPredicate {
op: string
left: number
right: number
}
export interface Proof {
proofId: ImhotepId
clauseId: ImhotepId
outcome: ClauseStatus
truth: TruthValue
usedFacts: number[]
derivedFacts: number[]
failedPredicate?: FailedPredicate
witness: {
subjectId: number
referenceId?: number
envCaseId: ImhotepId
snapshotId: ImhotepId
}
}
+574
View File
@@ -0,0 +1,574 @@
/**
* Geometry world schema types for Imhotep.
*
* The geometry world is the extracted and normalized scene model.
* It is immutable per snapshot, indexed by stable numeric ids for speed,
* and explicit about confidence and provenance.
*/
import type { ImhotepId, Environment } from './types.js'
// ---------------------------------------------------------------------------
// Coordinate Space
// ---------------------------------------------------------------------------
/**
* Coordinate space distinguishes between layout-local and visual-viewport
* coordinates. Layout coordinates are pre-transform (the local box model
* as computed by the browser). Visual coordinates are post-transform
* (the effective position after all CSS transforms are applied).
*
* Playwright boundingBox() returns visual coordinates.
* CDP DOM.getBoxModel returns layout coordinates.
*/
export enum CoordinateSpace {
LAYOUT = 0, // Local layout coordinates (pre-transform)
VISUAL = 1, // Visual viewport coordinates (post-transform)
}
// ---------------------------------------------------------------------------
// Top-Level World
// ---------------------------------------------------------------------------
export interface GeometryWorld {
sceneId: ImhotepId
snapshotId: ImhotepId
env: WorldEnvironment
source: WorldSource
strings: StringTable
subjects: SubjectsTable
dom: DomTable
frames: FramesTable
matrices: MatricesTable
rects: RectsTable
boxes: BoxesTable // Layout-space box geometry (CDP box model, pre-transform)
visualBoxes: BoxesTable // Visual-space box geometry (post-transform, viewport-relative)
fragments: FragmentsTable
transforms: TransformsTable
styles: StylesTable
text: TextTable
topology: TopologyTable
scroll: ScrollTable
clipping: ClippingTable
paint: PaintTable
visibility: VisibilityTable
provenance: ProvenanceTable
confidence: ConfidenceTable
}
// ---------------------------------------------------------------------------
// Environment and Source
// ---------------------------------------------------------------------------
export interface WorldEnvironment {
viewportWidth: number
viewportHeight: number
deviceScaleFactor: number
colorScheme: string
pointer: string
hover: boolean
reducedMotion: string
locale: string
writingMode: string
}
export interface WorldSource {
url: string
browserName: string
browserVersion: string
engine: 'chromium-cdp'
extractedAt: number
}
// ---------------------------------------------------------------------------
// String Table
// ---------------------------------------------------------------------------
export interface StringTable {
values: string[]
}
// ---------------------------------------------------------------------------
// Subjects
// ---------------------------------------------------------------------------
export interface SubjectsTable {
ids: Uint32Array
domNodeId: Uint32Array
subjectKind: Uint16Array
primaryBoxId: Uint32Array
firstFragmentId: Uint32Array
fragmentCount: Uint16Array
firstTextRunId: Uint32Array
textRunCount: Uint16Array
}
// ---------------------------------------------------------------------------
// DOM
// ---------------------------------------------------------------------------
export interface DomTable {
nodeId: Uint32Array
backendNodeId: Uint32Array
parentNodeId: Uint32Array
firstChildIndex: Uint32Array
childCount: Uint16Array
shadowRootKind: Uint8Array
tagNameStringId: Uint32Array
roleStringId: Uint32Array
ariaNameStringId: Uint32Array
}
// ---------------------------------------------------------------------------
// Frames
// ---------------------------------------------------------------------------
export enum FrameKind {
Viewport = 1,
ContainingBlock = 2,
NearestPositionedAncestor = 3,
ScrollContainer = 4,
Synthetic = 5,
}
export interface FramesTable {
frameId: Uint32Array
frameKind: Uint16Array
ownerSubjectId: Uint32Array
parentFrameId: Uint32Array
originX: Float64Array
originY: Float64Array
axisMatrixStart: Uint32Array
clipRectId: Uint32Array
scrollContainerId: Uint32Array
writingMode: Uint16Array
}
// ---------------------------------------------------------------------------
// Matrices
// ---------------------------------------------------------------------------
export interface MatricesTable {
values: Float64Array
}
// ---------------------------------------------------------------------------
// Rects
// ---------------------------------------------------------------------------
export interface RectsTable {
rectId: Uint32Array
left: Float64Array
top: Float64Array
right: Float64Array
bottom: Float64Array
}
// ---------------------------------------------------------------------------
// Boxes
// ---------------------------------------------------------------------------
export interface BoxesTable {
boxId: Uint32Array
subjectId: Uint32Array
frameId: Uint32Array
borderLeft: Float64Array
borderTop: Float64Array
borderRight: Float64Array
borderBottom: Float64Array
paddingLeft: Float64Array
paddingTop: Float64Array
paddingRight: Float64Array
paddingBottom: Float64Array
contentLeft: Float64Array
contentTop: Float64Array
contentRight: Float64Array
contentBottom: Float64Array
}
// ---------------------------------------------------------------------------
// Fragments
// ---------------------------------------------------------------------------
export enum FragmentKind {
Line = 1,
Column = 2,
Region = 3,
Page = 4,
}
export interface FragmentsTable {
fragmentId: Uint32Array
subjectId: Uint32Array
fragmentKind: Uint16Array
boxLeft: Float64Array
boxTop: Float64Array
boxRight: Float64Array
boxBottom: Float64Array
lineIndex: Int32Array
flowIndex: Int32Array
parentFragmentId: Uint32Array
}
// ---------------------------------------------------------------------------
// Transforms
// ---------------------------------------------------------------------------
export interface TransformsTable {
transformId: Uint32Array
subjectId: Uint32Array
matrixStart: Uint32Array
matrixLength: Uint16Array
originX: Float64Array
originY: Float64Array
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
export interface StylesTable {
subjectId: Uint32Array
display: Uint16Array
position: Uint16Array
zIndexKind: Uint8Array
zIndexValue: Int32Array
overflowX: Uint16Array
overflowY: Uint16Array
opacity: Float32Array
visibility: Uint16Array
containFlags: Uint32Array
pointerEvents: Uint16Array
lineHeight: Float64Array
fontFamilyStringId: Uint32Array
fontSize: Float64Array
fontWeight: Uint16Array
}
// ---------------------------------------------------------------------------
// Text
// ---------------------------------------------------------------------------
export interface TextTable {
runId: Uint32Array
subjectId: Uint32Array
contentStringId: Uint32Array
lineBoxId: Uint32Array
inkLeft: Float64Array
inkTop: Float64Array
inkRight: Float64Array
inkBottom: Float64Array
baselineY: Float64Array
capHeight: Float64Array
computedLineHeight: Float64Array
}
// ---------------------------------------------------------------------------
// Topology
// ---------------------------------------------------------------------------
export interface TopologyTable {
containingBlockOf: Uint32Array
nearestPositionedAncestorOf: Uint32Array
scrollContainerOf: Uint32Array
stackingContextOf: Uint32Array
formattingContextOf: Uint32Array
clippingRootOf: Uint32Array
paintOrderBucket: Uint16Array
paintOrderIndex: Uint32Array
}
// ---------------------------------------------------------------------------
// Scroll
// ---------------------------------------------------------------------------
export interface ScrollTable {
containerId: Uint32Array
scrollLeft: Float64Array
scrollTop: Float64Array
scrollWidth: Float64Array
scrollHeight: Float64Array
clientWidth: Float64Array
clientHeight: Float64Array
}
// ---------------------------------------------------------------------------
// Clipping
// ---------------------------------------------------------------------------
export interface ClippingTable {
clipNodeId: Uint32Array
subjectId: Uint32Array
clipKind: Uint16Array
clipLeft: Float64Array
clipTop: Float64Array
clipRight: Float64Array
clipBottom: Float64Array
parentClipNodeId: Uint32Array
}
// ---------------------------------------------------------------------------
// Paint
// ---------------------------------------------------------------------------
export interface PaintTable {
paintNodeId: Uint32Array
subjectId: Uint32Array
stackingContextId: Uint32Array
bucket: Uint16Array
localPaintIndex: Uint32Array
}
// ---------------------------------------------------------------------------
// Visibility
// ---------------------------------------------------------------------------
export interface VisibilityTable {
subjectId: Uint32Array
isRendered: Uint8Array
isVisible: Uint8Array
visibleArea: Float64Array
clippedArea: Float64Array
}
// ---------------------------------------------------------------------------
// Provenance
// ---------------------------------------------------------------------------
export interface ProvenanceTable {
factId: Uint32Array
extractionStepId: Uint32Array
sourceKind: Uint16Array
sourceRef: Uint32Array
}
// ---------------------------------------------------------------------------
// Confidence
// ---------------------------------------------------------------------------
export interface ConfidenceTable {
factId: Uint32Array
confidence: Float32Array
reasonCode: Uint16Array
}
// ---------------------------------------------------------------------------
// Fact Request Plan (fed to extractors)
// ---------------------------------------------------------------------------
export interface FactRequestPlan {
geometry: boolean
fragments: boolean
styles: string[] | boolean
topology: string[] | boolean
text: boolean
}
// ---------------------------------------------------------------------------
// Helper: Create empty world shell
// ---------------------------------------------------------------------------
export function createEmptyWorld(sceneId: ImhotepId, snapshotId: ImhotepId): GeometryWorld {
return {
sceneId,
snapshotId,
env: {
viewportWidth: 0,
viewportHeight: 0,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: 'no-preference',
locale: 'en',
writingMode: 'horizontal-tb',
},
source: {
url: '',
browserName: '',
browserVersion: '',
engine: 'chromium-cdp',
extractedAt: 0,
},
strings: { values: [] },
subjects: {
ids: new Uint32Array(0),
domNodeId: new Uint32Array(0),
subjectKind: new Uint16Array(0),
primaryBoxId: new Uint32Array(0),
firstFragmentId: new Uint32Array(0),
fragmentCount: new Uint16Array(0),
firstTextRunId: new Uint32Array(0),
textRunCount: new Uint16Array(0),
},
dom: {
nodeId: new Uint32Array(0),
backendNodeId: new Uint32Array(0),
parentNodeId: new Uint32Array(0),
firstChildIndex: new Uint32Array(0),
childCount: new Uint16Array(0),
shadowRootKind: new Uint8Array(0),
tagNameStringId: new Uint32Array(0),
roleStringId: new Uint32Array(0),
ariaNameStringId: new Uint32Array(0),
},
frames: {
frameId: new Uint32Array(0),
frameKind: new Uint16Array(0),
ownerSubjectId: new Uint32Array(0),
parentFrameId: new Uint32Array(0),
originX: new Float64Array(0),
originY: new Float64Array(0),
axisMatrixStart: new Uint32Array(0),
clipRectId: new Uint32Array(0),
scrollContainerId: new Uint32Array(0),
writingMode: new Uint16Array(0),
},
matrices: { values: new Float64Array(0) },
rects: {
rectId: new Uint32Array(0),
left: new Float64Array(0),
top: new Float64Array(0),
right: new Float64Array(0),
bottom: new Float64Array(0),
},
boxes: {
boxId: new Uint32Array(0),
subjectId: new Uint32Array(0),
frameId: new Uint32Array(0),
borderLeft: new Float64Array(0),
borderTop: new Float64Array(0),
borderRight: new Float64Array(0),
borderBottom: new Float64Array(0),
paddingLeft: new Float64Array(0),
paddingTop: new Float64Array(0),
paddingRight: new Float64Array(0),
paddingBottom: new Float64Array(0),
contentLeft: new Float64Array(0),
contentTop: new Float64Array(0),
contentRight: new Float64Array(0),
contentBottom: new Float64Array(0),
},
visualBoxes: {
boxId: new Uint32Array(0),
subjectId: new Uint32Array(0),
frameId: new Uint32Array(0),
borderLeft: new Float64Array(0),
borderTop: new Float64Array(0),
borderRight: new Float64Array(0),
borderBottom: new Float64Array(0),
paddingLeft: new Float64Array(0),
paddingTop: new Float64Array(0),
paddingRight: new Float64Array(0),
paddingBottom: new Float64Array(0),
contentLeft: new Float64Array(0),
contentTop: new Float64Array(0),
contentRight: new Float64Array(0),
contentBottom: new Float64Array(0),
},
fragments: {
fragmentId: new Uint32Array(0),
subjectId: new Uint32Array(0),
fragmentKind: new Uint16Array(0),
boxLeft: new Float64Array(0),
boxTop: new Float64Array(0),
boxRight: new Float64Array(0),
boxBottom: new Float64Array(0),
lineIndex: new Int32Array(0),
flowIndex: new Int32Array(0),
parentFragmentId: new Uint32Array(0),
},
transforms: {
transformId: new Uint32Array(0),
subjectId: new Uint32Array(0),
matrixStart: new Uint32Array(0),
matrixLength: new Uint16Array(0),
originX: new Float64Array(0),
originY: new Float64Array(0),
},
styles: {
subjectId: new Uint32Array(0),
display: new Uint16Array(0),
position: new Uint16Array(0),
zIndexKind: new Uint8Array(0),
zIndexValue: new Int32Array(0),
overflowX: new Uint16Array(0),
overflowY: new Uint16Array(0),
opacity: new Float32Array(0),
visibility: new Uint16Array(0),
containFlags: new Uint32Array(0),
pointerEvents: new Uint16Array(0),
lineHeight: new Float64Array(0),
fontFamilyStringId: new Uint32Array(0),
fontSize: new Float64Array(0),
fontWeight: new Uint16Array(0),
},
text: {
runId: new Uint32Array(0),
subjectId: new Uint32Array(0),
contentStringId: new Uint32Array(0),
lineBoxId: new Uint32Array(0),
inkLeft: new Float64Array(0),
inkTop: new Float64Array(0),
inkRight: new Float64Array(0),
inkBottom: new Float64Array(0),
baselineY: new Float64Array(0),
capHeight: new Float64Array(0),
computedLineHeight: new Float64Array(0),
},
topology: {
containingBlockOf: new Uint32Array(0),
nearestPositionedAncestorOf: new Uint32Array(0),
scrollContainerOf: new Uint32Array(0),
stackingContextOf: new Uint32Array(0),
formattingContextOf: new Uint32Array(0),
clippingRootOf: new Uint32Array(0),
paintOrderBucket: new Uint16Array(0),
paintOrderIndex: new Uint32Array(0),
},
scroll: {
containerId: new Uint32Array(0),
scrollLeft: new Float64Array(0),
scrollTop: new Float64Array(0),
scrollWidth: new Float64Array(0),
scrollHeight: new Float64Array(0),
clientWidth: new Float64Array(0),
clientHeight: new Float64Array(0),
},
clipping: {
clipNodeId: new Uint32Array(0),
subjectId: new Uint32Array(0),
clipKind: new Uint16Array(0),
clipLeft: new Float64Array(0),
clipTop: new Float64Array(0),
clipRight: new Float64Array(0),
clipBottom: new Float64Array(0),
parentClipNodeId: new Uint32Array(0),
},
paint: {
paintNodeId: new Uint32Array(0),
subjectId: new Uint32Array(0),
stackingContextId: new Uint32Array(0),
bucket: new Uint16Array(0),
localPaintIndex: new Uint32Array(0),
},
visibility: {
subjectId: new Uint32Array(0),
isRendered: new Uint8Array(0),
isVisible: new Uint8Array(0),
visibleArea: new Float64Array(0),
clippedArea: new Float64Array(0),
},
provenance: {
factId: new Uint32Array(0),
extractionStepId: new Uint32Array(0),
sourceKind: new Uint16Array(0),
sourceRef: new Uint32Array(0),
},
confidence: {
factId: new Uint32Array(0),
confidence: new Float32Array(0),
reasonCode: new Uint16Array(0),
},
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {},
"composite": false,
"noEmitOnError": false
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.test.ts",
"src/integration-*.ts"
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"composite": false,
"noEmit": true,
"noEmitOnError": false
},
"include": [
"src/**/*"
],
"exclude": []
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist-test",
"rootDir": "./src",
"paths": {},
"composite": false,
"noEmitOnError": false
},
"include": [
"src/**/*.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "imhotep-dsl",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"imhotep-core": "^1.0.0"
}
}
+997
View File
@@ -0,0 +1,997 @@
// Compiler: lowers AST -> Semantic IR -> Execution IR
// Pure function, no browser dependencies
import type {
RelationAssertion,
SemanticIr,
ExecutionIr,
SemanticClause,
SemanticSubject,
SemanticFrame,
SemanticState,
SemanticTimeline,
SemanticTolerance,
SemanticEnvGuard,
Diagnostic,
Position,
ImhotepId,
FormulaNode,
VariableRef,
DomainRef,
TupleBinding,
PredicateCall,
AssertionNode,
SizeAssertion,
TopologyAssertion,
CompoundAssertion,
SelectorRef,
} from 'imhotep-core'
import { createEmptySemanticIr, getDefaultContext } from 'imhotep-core'
import { parseTolerance, parseGap } from './validator.js'
// DSL grammar FOL types (distinct from solver FormulaNode imported above)
import type {
FormulaNode as DslFormulaNode,
ForAllFormula as DslForAll,
ExistsFormula as DslExists,
AndFormula as DslAnd,
OrFormula as DslOr,
NotFormula as DslNot,
ImpliesFormula as DslImplies,
PredicateCall as DslPredicateCall,
VariableRef as DslVariableRef,
DomainRef as DslDomainRef,
} from './logic-fluent.js'
// ---- Compiler Options (dependency injection) ----
export interface CompilerOptions {
// Injected id generator for determinism in tests
generateId?: (prefix: string) => string
// Default frame when none specified
defaultFrameKind?: string
// Default tolerance when none specified
defaultTolerance?: { value: number; unit: 'px' | 'jnd' }
}
// ---- Id Generator ----
function defaultNextId(prefix: string): string {
return `${prefix}_${getDefaultContext().idGenerator()}`
}
// ---- Option Value Normalization ----
// INVARIANT: Both fluent API and string DSL must lower to equivalent Execution IR.
// The string parser produces LiteralNode / ToleranceLiteralNode AST wrappers,
// while the fluent API stores raw numbers. These normalizers extract the scalar
// value from both formats so parseGap / parseTolerance receive consistent input.
//
// Bug fixed: Previously parseGap(LiteralNode) silently returned null because
// String(object) === "[object Object]", causing string DSL gap options to be
// lost in compilation. The extractGapValue helper now handles range literals.
function normalizeOptionValue(raw: unknown): number | string | undefined {
if (raw === undefined || raw === null) return undefined
if (typeof raw === 'number' || typeof raw === 'string') return raw
// String-parser/fluent path: LiteralNode or ToleranceLiteralNode
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>
if ('value' in obj && typeof obj.value === 'number') {
if ('unit' in obj && typeof obj.unit === 'string') {
return `${obj.value}${obj.unit}`
}
return obj.value
}
}
return undefined
}
// Extract a numeric gap value from fluent raw numbers, string-parser LiteralNodes,
// or string-parser range LiteralNodes ( picking min or max edge ).
function extractGapValue(raw: unknown, edge: 'min' | 'max'): number | undefined {
if (raw === undefined || raw === null) return undefined
if (typeof raw === 'number') return raw
if (typeof raw === 'string') {
const num = parseFloat(raw.trim())
if (!Number.isNaN(num)) return num
return undefined
}
if (typeof raw === 'object') {
const obj = raw as Record<string, unknown>
// Simple length literal: { type: 'Literal', kind: 'length', value: number }
if ('value' in obj && typeof obj.value === 'number') {
return obj.value
}
// Range literal: { type: 'Literal', kind: 'range', value: { min: { value }, max: { value } } }
if (obj.kind === 'range' && obj.value && typeof obj.value === 'object') {
const range = obj.value as Record<string, unknown>
const target = edge === 'min' ? range.min : range.max
if (target && typeof target === 'object') {
const targetObj = target as Record<string, unknown>
if (typeof targetObj.value === 'number') {
return targetObj.value
}
}
}
}
return undefined
}
// ---- Semantic Lowering ----
function toSemanticIr(
ast: RelationAssertion[],
opts: CompilerOptions,
): { semanticIr: SemanticIr; diagnostics: Diagnostic[] } {
const generateId = opts.generateId || defaultNextId
const ir = createEmptySemanticIr()
const diagnostics: Diagnostic[] = []
// Default frame
const defaultFrameId = generateId('frame')
ir.frames.set(defaultFrameId, {
id: defaultFrameId,
kind: (opts.defaultFrameKind || 'viewport') as SemanticFrame['kind'],
originX: 0,
originY: 0,
writingMode: 'horizontal-tb',
origin: { astNodeIds: [], positions: [] },
})
// Default state
const defaultStateId = generateId('state')
ir.states.set(defaultStateId, {
id: defaultStateId,
kind: 'default',
origin: { astNodeIds: [], positions: [] },
})
// Default timeline
const defaultTimelineId = generateId('timeline')
ir.timelines.set(defaultTimelineId, {
id: defaultTimelineId,
mode: 'static',
origin: { astNodeIds: [], positions: [] },
})
// Default tolerance
const defaultToleranceId = generateId('tolerance')
ir.tolerances.set(defaultToleranceId, {
id: defaultToleranceId,
value: opts.defaultTolerance?.value ?? 0,
unit: opts.defaultTolerance?.unit ?? 'px',
origin: { astNodeIds: [], positions: [] },
})
for (const assertion of ast) {
// Build origin from source span
const origin = buildOrigin(assertion.position)
// Access extended DSL properties via cast
const extended = assertion as unknown as Record<string, unknown>
// Subject
const subjectId = generateId('subject')
const subjectDef: SemanticSubject = {
id: subjectId,
selector: assertion.subject.value,
kind: 'element',
origin,
}
ir.subjects.set(subjectId, subjectDef)
// Reference
const referenceId = generateId('subject')
const referenceDef: SemanticSubject = {
id: referenceId,
selector: assertion.reference.value,
kind: 'element',
origin,
}
ir.subjects.set(referenceId, referenceDef)
// Frame
let frameId = defaultFrameId
const frameNode = extended.frame as { kind: string; selector?: string; name?: string } | undefined
if (frameNode) {
frameId = generateId('frame')
const frameDef: SemanticFrame = {
id: frameId,
kind: frameNode.kind as SemanticFrame['kind'],
selector: frameNode.selector,
name: frameNode.name,
originX: 0,
originY: 0,
writingMode: 'horizontal-tb',
origin,
}
ir.frames.set(frameId, frameDef)
}
// State
let stateId = defaultStateId
const stateNode = extended.state as { kind: string; name?: string } | undefined
if (stateNode) {
stateId = generateId('state')
const stateDef: SemanticState = {
id: stateId,
kind: stateNode.kind as SemanticState['kind'],
name: stateNode.name,
origin,
}
ir.states.set(stateId, stateDef)
}
// Tolerance
let toleranceId = defaultToleranceId
const rawOpts = assertion.options as unknown as Record<string, unknown> | undefined
const tol = parseTolerance(normalizeOptionValue(rawOpts?.tolerance))
if (tol) {
toleranceId = generateId('tolerance')
ir.tolerances.set(toleranceId, {
id: toleranceId,
value: tol.value,
unit: tol.unit,
origin,
})
}
// Environment guard
const envGuardId = generateId('guard')
const envGuard = extended.envGuard as { condition: string } | undefined
const guardDef: SemanticEnvGuard = {
id: envGuardId,
expression: envGuard?.condition || 'true',
normalizedCases: [],
origin,
}
ir.envGuards.set(envGuardId, guardDef)
// Bounds
const bounds: SemanticClause['bounds'] = {}
if (rawOpts) {
if ('minGap' in rawOpts) {
const minVal = extractGapValue(rawOpts.minGap, 'min')
if (minVal !== undefined) {
bounds.minGap = { value: minVal, unit: 'px' }
}
}
if ('maxGap' in rawOpts) {
const maxVal = extractGapValue(rawOpts.maxGap, 'max')
if (maxVal !== undefined) {
bounds.maxGap = { value: maxVal, unit: 'px' }
}
}
}
// Flags
let flags = 0
const quantifier = extended.quantifier as string | undefined
if (quantifier === 'all') flags |= 1
if (quantifier === 'any') flags |= 2
if (quantifier === 'none') flags |= 4
if (rawOpts?.inStackingContext) flags |= 8
if ((assertion as any).negated) flags |= 16
// Clause
const clauseId = generateId('clause')
const clause: SemanticClause = {
id: clauseId,
origin,
subjectRef: subjectId,
referenceRef: referenceId,
relation: assertion.relation,
frameRef: frameId,
stateRef: stateId,
timelineRef: defaultTimelineId,
envGuardRef: envGuardId,
toleranceRef: toleranceId,
bounds,
}
// Attach flags via cast since core SemanticClause doesn't have flags field yet
;(clause as unknown as Record<string, unknown>).flags = flags
ir.clauses.set(clauseId, clause)
}
return { semanticIr: ir, diagnostics }
}
function buildOrigin(position?: Position): { astNodeIds: string[]; positions: Position[] } {
if (position) {
return { astNodeIds: [], positions: [position] }
}
return { astNodeIds: [], positions: [] }
}
// ---- Execution IR Compilation ----
function toExecutionIr(semanticIr: SemanticIr): ExecutionIr {
const clauses = Array.from(semanticIr.clauses.values())
const count = clauses.length
if (count === 0) {
return {
clauseCount: 0,
clauseType: new Uint16Array(0),
clauseSubject: new Uint32Array(0),
clauseReference: new Uint32Array(0),
clauseFrame: new Uint32Array(0),
clauseState: new Uint32Array(0),
clauseTimeline: new Uint32Array(0),
clauseTolerance: new Uint32Array(0),
clauseEnvGuard: new Uint32Array(0),
clauseArg0: new Float64Array(0),
clauseArg1: new Float64Array(0),
clauseFlags: new Uint32Array(0),
clauseOrigin: new Uint32Array(0),
}
}
// Build lookup maps for numeric indices — iterate Maps directly to avoid Array.from().
const subjectIndex = new Map<ImhotepId, number>()
let idx = 0
for (const k of semanticIr.subjects.keys()) subjectIndex.set(k, idx++)
const frameIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.frames.keys()) frameIndex.set(k, idx++)
const stateIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.states.keys()) stateIndex.set(k, idx++)
const timelineIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.timelines.keys()) timelineIndex.set(k, idx++)
const toleranceIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.tolerances.keys()) toleranceIndex.set(k, idx++)
const guardIndex = new Map<ImhotepId, number>()
idx = 0
for (const k of semanticIr.envGuards.keys()) guardIndex.set(k, idx++)
// Encode relation as small integer
const relationCodes: Record<string, number> = {
leftOf: 1,
rightOf: 2,
above: 3,
below: 4,
alignedWith: 5,
leftAlignedWith: 6,
rightAlignedWith: 7,
topAlignedWith: 8,
bottomAlignedWith: 9,
centeredWithin: 10,
inside: 11,
contains: 12,
overlaps: 13,
separatedFrom: 14,
aspectRatioBetween: 15,
between: 16,
}
const clauseType = new Uint16Array(count)
const clauseSubject = new Uint32Array(count)
const clauseReference = new Uint32Array(count)
const clauseFrame = new Uint32Array(count)
const clauseState = new Uint32Array(count)
const clauseTimeline = new Uint32Array(count)
const clauseTolerance = new Uint32Array(count)
const clauseEnvGuard = new Uint32Array(count)
const clauseArg0 = new Float64Array(count)
const clauseArg1 = new Float64Array(count)
const clauseFlags = new Uint32Array(count)
const clauseOrigin = new Uint32Array(count)
for (let i = 0; i < count; i++) {
const c = clauses[i]
clauseType[i] = relationCodes[c.relation] || 0
clauseSubject[i] = subjectIndex.get(c.subjectRef) ?? 0
clauseReference[i] = c.referenceRef ? (subjectIndex.get(c.referenceRef) ?? 0) : 0
clauseFrame[i] = frameIndex.get(c.frameRef) ?? 0
clauseState[i] = stateIndex.get(c.stateRef) ?? 0
clauseTimeline[i] = timelineIndex.get(c.timelineRef) ?? 0
clauseTolerance[i] = toleranceIndex.get(c.toleranceRef) ?? 0
clauseEnvGuard[i] = guardIndex.get(c.envGuardRef) ?? 0
clauseOrigin[i] = i // origin index same as clause index for now
// Retrieve flags via cast
const flags = (c as unknown as Record<string, unknown>).flags as number | undefined
clauseFlags[i] = flags || 0
// Pack bounds into arg0/arg1 where applicable
if (c.bounds.minGap && c.bounds.maxGap) {
clauseArg0[i] = c.bounds.minGap.value
clauseArg1[i] = c.bounds.maxGap.value
} else if (c.bounds.minGap) {
clauseArg0[i] = c.bounds.minGap.value
clauseArg1[i] = Number.POSITIVE_INFINITY
} else if (c.bounds.maxGap) {
clauseArg0[i] = Number.NEGATIVE_INFINITY
clauseArg1[i] = c.bounds.maxGap.value
} else {
clauseArg0[i] = Number.NEGATIVE_INFINITY
clauseArg1[i] = Number.POSITIVE_INFINITY
}
}
return {
clauseCount: count,
clauseType,
clauseSubject,
clauseReference,
clauseFrame,
clauseState,
clauseTimeline,
clauseTolerance,
clauseEnvGuard,
clauseArg0,
clauseArg1,
clauseFlags,
clauseOrigin,
}
}
// ---- FOL Formula Compilation ----
const subjectVar: VariableRef = { type: 'VariableRef', name: '$subject' }
const referenceVar: VariableRef = { type: 'VariableRef', name: '$reference' }
function makeDomain(selector: string): DomainRef {
return { type: 'DomainRef', domain: 'elements', selector }
}
function makeBinding(varName: string, selector: string): TupleBinding {
return {
type: 'TupleBinding',
variables: [varName],
domain: makeDomain(selector),
}
}
function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion | TopologyAssertion): Record<string, unknown> {
const options: Record<string, unknown> = {}
if (assertion.type === 'RelationAssertion') {
const opts = assertion.options as unknown as Record<string, unknown>
if (opts?.minGap !== undefined) {
const v = normalizeOptionValue(opts.minGap)
if (v !== undefined) options.minGap = v
}
if (opts?.maxGap !== undefined) {
const v = normalizeOptionValue(opts.maxGap)
if (v !== undefined) options.maxGap = v
}
if (opts?.tolerance !== undefined) {
const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
if (tol) {
options.tolerance = tol.value
options.toleranceUnit = tol.unit
}
}
if (opts?.axis !== undefined) options.axis = opts.axis
if (opts?.inStackingContext === true) options.inStackingContext = true
}
if (assertion.type === 'SizeAssertion') {
const bounds = assertion.bounds as unknown as Record<string, unknown>
if (bounds?.min && typeof (bounds.min as any).value === 'number') {
options.min = (bounds.min as any).value
options.value = (bounds.min as any).value
}
if (bounds?.max && typeof (bounds.max as any).value === 'number') {
options.max = (bounds.max as any).value
}
if (bounds?.exact && typeof (bounds.exact as any).value === 'number') {
options.value = (bounds.exact as any).value
}
if (assertion.property) {
options.dimension = assertion.property
}
}
if (assertion.type === 'TopologyAssertion') {
const opts = assertion.options as unknown as Record<string, unknown>
if (opts?.tolerance !== undefined) {
const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
if (tol) {
options.tolerance = tol.value
options.toleranceUnit = tol.unit
}
}
}
return options
}
function compileSimpleAssertionToFormula(
assertion: RelationAssertion | SizeAssertion | TopologyAssertion,
): FormulaNode {
const options = buildOptionsFromAssertion(assertion)
const isUnary = assertion.type === 'SizeAssertion'
|| (assertion.type === 'RelationAssertion'
&& (assertion.relation === 'atLeast' || assertion.relation === 'atMost'))
let predicateName: string
let args: VariableRef[]
if (assertion.type === 'RelationAssertion') {
predicateName = assertion.relation
args = isUnary ? [subjectVar] : [subjectVar, referenceVar]
} else if (assertion.type === 'SizeAssertion') {
if (assertion.property === 'aspectRatio') {
predicateName = 'aspectRatio'
} else if (assertion.bounds.min && assertion.bounds.max) {
predicateName = 'between'
} else if (assertion.bounds.max) {
predicateName = 'atMost'
} else if (assertion.bounds.min) {
predicateName = 'atLeast'
} else {
predicateName = 'atLeast'
}
args = [subjectVar]
} else {
predicateName = assertion.predicate
args = assertion.reference ? [subjectVar, referenceVar] : [subjectVar]
}
let body: FormulaNode = {
type: 'FormulaNode',
kind: 'predicate',
predicate: predicateName,
args,
...(Object.keys(options).length > 0 ? { options } : {}),
} as FormulaNode
// Negation
if ((assertion as any).negated) {
body = {
type: 'FormulaNode',
kind: 'not',
operand: body,
}
}
// Bindings
const subjectBinding = makeBinding('$subject', assertion.subject.value)
const bindings: TupleBinding[] = [subjectBinding]
if (!isUnary && assertion.type !== 'TopologyAssertion') {
const ref = (assertion as RelationAssertion).reference
if (ref?.value) {
bindings.push(makeBinding('$reference', ref.value))
}
}
if (assertion.type === 'TopologyAssertion' && assertion.reference?.value) {
bindings.push(makeBinding('$reference', assertion.reference.value))
}
return {
type: 'FormulaNode',
kind: 'forall',
bindings,
body,
} as FormulaNode
}
function compileQuantifierToFormula(
assertion: CompoundAssertion,
body: FormulaNode,
): FormulaNode {
const quantifier = assertion.quantifier?.kind
const subject = (assertion.children[0] as any).subject as SelectorRef
const reference = (assertion.children[0] as any).reference as SelectorRef | undefined
const subjectBinding = makeBinding('$subject', subject.value)
if (quantifier === 'any') {
const refBinding = reference?.value
? makeBinding('$reference', reference.value)
: null
return {
type: 'FormulaNode',
kind: 'exists',
bindings: [subjectBinding],
body: refBinding
? {
type: 'FormulaNode',
kind: 'forall',
bindings: [refBinding],
body,
}
: body,
} as FormulaNode
}
if (quantifier === 'none') {
const refBinding = reference?.value
? makeBinding('$reference', reference.value)
: null
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [subjectBinding],
body: {
type: 'FormulaNode',
kind: 'not',
operand: refBinding
? {
type: 'FormulaNode',
kind: 'exists',
bindings: [refBinding],
body,
}
: body,
},
} as FormulaNode
}
// Default / 'all'
const bindings: TupleBinding[] = [subjectBinding]
if (reference?.value) {
bindings.push(makeBinding('$reference', reference.value))
}
return {
type: 'FormulaNode',
kind: 'forall',
bindings,
body,
} as FormulaNode
}
/**
* Compile a single DSL assertion AST node to a FOL FormulaNode.
* Handles RelationAssertion, SizeAssertion, TopologyAssertion,
* and CompoundAssertion (with operators and/or quantifiers).
*/
export function compileToFormula(assertion: AssertionNode): FormulaNode | null {
if (!assertion) return null
// Compound assertion: operator (and/or) or quantifier wrapper
if (assertion.type === 'CompoundAssertion') {
const compound = assertion as CompoundAssertion
if (compound.operator) {
// Logical compound: compile children and wrap in and/or
const children = (compound.children || [])
.map((child) => compileToFormula(child))
.filter((f): f is FormulaNode => f !== null)
if (children.length === 0) return null
if (children.length === 1) return children[0]
let result = children[0]
for (let i = 1; i < children.length; i++) {
result = {
type: 'FormulaNode',
kind: compound.operator,
left: result,
right: children[i],
} as FormulaNode
}
// If quantifier is present, wrap the whole compound
if (compound.quantifier) {
return compileQuantifierToFormula(compound, result)
}
return result
}
// Quantifier-only compound
const childFormulas = (compound.children || [])
.map((child) => compileToFormula(child))
.filter((f): f is FormulaNode => f !== null)
if (childFormulas.length === 0) return null
const body = childFormulas.length === 1
? childFormulas[0]
: childFormulas.reduce((left, right) => ({
type: 'FormulaNode',
kind: 'and',
left,
right,
} as FormulaNode))
return compileQuantifierToFormula(compound, body)
}
// Simple assertions
if (
assertion.type === 'RelationAssertion'
|| assertion.type === 'SizeAssertion'
|| assertion.type === 'TopologyAssertion'
) {
return compileSimpleAssertionToFormula(assertion as RelationAssertion | SizeAssertion | TopologyAssertion)
}
return null
}
// ---- Dense DSL FOL Compilation (bypasses canonical lowering) ----
/**
* Convert a DSL grammar FOL formula AST node to a solver FormulaNode.
*
* Dense DSL forall/exists formulas use a distinct AST shape from the solver's
* FormulaNode (e.g. type: 'ForAll' vs type: 'FormulaNode' kind: 'forall').
* This function bridges the two representations so that parsed dense FOL specs
* can be evaluated directly by evaluateLogic() without going through canonical
* clause descriptors.
*
* String arguments inside PredicateCall are lifted into implicit forall
* bindings because the solver only accepts VariableRef and AccessorTerm
* as predicate arguments.
*/
export function compileDenseFOLToFormula(dslFormula: DslFormulaNode): FormulaNode {
let freshVarCounter = 0
function makeFreshVar(): string {
freshVarCounter += 1
return `$str_${freshVarCounter}`
}
function compile(node: DslFormulaNode): FormulaNode {
switch (node.type) {
case 'ForAll': {
const fa = node as DslForAll
return {
type: 'FormulaNode',
kind: 'forall',
bindings: [makeTupleBinding(fa.variable, fa.domain)],
body: compile(fa.body),
} as FormulaNode
}
case 'Exists': {
const ex = node as DslExists
return {
type: 'FormulaNode',
kind: 'exists',
bindings: [makeTupleBinding(ex.variable, ex.domain)],
body: compile(ex.body),
} as FormulaNode
}
case 'And': {
const a = node as DslAnd
return {
type: 'FormulaNode',
kind: 'and',
left: compile(a.left),
right: compile(a.right),
} as FormulaNode
}
case 'Or': {
const o = node as DslOr
return {
type: 'FormulaNode',
kind: 'or',
left: compile(o.left),
right: compile(o.right),
} as FormulaNode
}
case 'Not': {
const n = node as DslNot
return {
type: 'FormulaNode',
kind: 'not',
operand: compile(n.operand),
} as FormulaNode
}
case 'Implies': {
const imp = node as DslImplies
return {
type: 'FormulaNode',
kind: 'implies',
antecedent: compile(imp.left),
consequent: compile(imp.right),
} as FormulaNode
}
case 'PredicateCall': {
const pc = node as DslPredicateCall
const implicitBindings: TupleBinding[] = []
const compiledArgs: VariableRef[] = []
for (const arg of pc.args) {
if (typeof arg === 'string') {
const varName = makeFreshVar()
implicitBindings.push({
type: 'TupleBinding',
variables: [varName],
domain: { type: 'DomainRef', domain: 'elements', selector: arg },
})
compiledArgs.push({ type: 'VariableRef', name: varName })
} else if (typeof arg === 'number') {
// Numbers are not supported as predicate args in the solver.
// Skip them — the caller should have encoded numeric values
// in predicate options instead.
continue
} else {
compiledArgs.push(convertTerm(arg) as VariableRef)
}
}
// Size predicates with comparison operators: width($x) >= 44 → size.atLeast
let predicateBody: FormulaNode
if (['width', 'height', 'size'].includes(pc.name) && pc.operator && pc.right !== undefined) {
const options: Record<string, unknown> = {}
if (pc.name === 'width') options.dimension = 'width'
else if (pc.name === 'height') options.dimension = 'height'
else options.dimension = 'width' // size defaults to width
let predicateName: string
switch (pc.operator) {
case '>=':
predicateName = 'atLeast'
options.min = pc.right
break
case '<=':
predicateName = 'atMost'
options.max = pc.right
break
case '==':
predicateName = 'between'
options.min = pc.right
options.max = pc.right
break
case '!=':
// Not-equal is compiled as negated equality
predicateBody = {
type: 'FormulaNode',
kind: 'not',
operand: {
type: 'FormulaNode',
kind: 'predicate',
predicate: 'between',
args: compiledArgs,
options: { ...options, min: pc.right, max: pc.right },
} as FormulaNode,
} as FormulaNode
// Wrap implicit bindings if any
return wrapInForAll(implicitBindings, predicateBody)
case '>':
predicateName = 'atLeast'
options.min = pc.right
break
case '<':
predicateName = 'atMost'
options.max = pc.right
break
default:
predicateName = pc.name
}
predicateBody = {
type: 'FormulaNode',
kind: 'predicate',
predicate: predicateName,
args: compiledArgs,
...(Object.keys(options).length > 0 ? { options } : {}),
} as FormulaNode
} else {
predicateBody = {
type: 'FormulaNode',
kind: 'predicate',
predicate: pc.name,
args: compiledArgs,
} as FormulaNode
}
// Wrap implicit forall bindings around the predicate body.
// String literals in predicate args are treated as universally
// quantified domains (same semantics as canonical clause compilation).
return wrapInForAll(implicitBindings, predicateBody)
}
case 'VariableRef':
case 'DomainRef':
// VariableRef and DomainRef are terms, not formulas. They should only
// appear as arguments inside PredicateCall, never as top-level formulas.
throw new Error(`DSL ${node.type} is a term, not a formula, and cannot be compiled standalone`)
default:
throw new Error(`Unknown DSL formula node type: ${(node as any).type}`)
}
}
return compile(dslFormula)
}
function wrapInForAll(bindings: TupleBinding[], body: FormulaNode): FormulaNode {
if (bindings.length === 0) return body
let result = body
// Wrap from last binding to first so that the outermost forall
// corresponds to the leftmost argument.
for (let i = bindings.length - 1; i >= 0; i--) {
result = {
type: 'FormulaNode',
kind: 'forall',
bindings: [bindings[i]],
body: result,
} as FormulaNode
}
return result
}
function makeTupleBinding(variable: DslVariableRef, domain: DslDomainRef): TupleBinding {
return {
type: 'TupleBinding',
variables: [variable.name],
domain: convertDomain(domain),
}
}
function convertDomain(domain: DslDomainRef): DomainRef {
const selectorFromVar = domain.variableSelector
? `$${domain.variableSelector.name}`
: undefined
const extraArgFromVar = domain.variableExtraArg
? `$${domain.variableExtraArg.name}`
: undefined
// Descendant domains use the first argument as parent and second as filter:
// descendants($card, '.title') => parentVar: '$card', selector: '.title'
if (domain.kind === 'descendants') {
const parentVar = selectorFromVar ?? domain.selector
const selector = extraArgFromVar ?? (domain as any).extraArg
return {
type: 'DomainRef',
domain: domain.kind,
selector,
parentVar,
}
}
// Default mapping for non-descendant domains.
const selector = selectorFromVar ?? domain.selector
const parentVar = extraArgFromVar ?? (domain as any).extraArg
return {
type: 'DomainRef',
domain: domain.kind,
selector,
parentVar,
}
}
function convertTerm(term: DslVariableRef | string | number): VariableRef | string | number {
if (typeof term === 'object' && term !== null && term.type === 'VariableRef') {
return { type: 'VariableRef', name: (term as DslVariableRef).name }
}
return term
}
// ---- Public Compiler ----
export interface CompileResult {
ast: { type: 'Program'; children: RelationAssertion[] }
semanticIr: SemanticIr
executionIr: ExecutionIr
diagnostics: Diagnostic[]
}
export function compile(
ast: RelationAssertion[],
options: CompilerOptions = {},
): CompileResult {
const programNode = {
type: 'Program' as const,
children: ast,
}
const { semanticIr, diagnostics } = toSemanticIr(ast, options)
const executionIr = toExecutionIr(semanticIr)
return {
ast: programNode,
semanticIr,
executionIr,
diagnostics,
}
}
+51
View File
@@ -0,0 +1,51 @@
// Global configuration and project defaults for Imhotep
// Uses dependency injection patterns: configure() sets runtime globals,
// project() sets persistent project-level metadata.
export interface ImhotepConfig {
defaultTolerance?: { value: number; unit: 'px' | 'jnd' }
defaultFrame?: string
perceptualModel?: string
[key: string]: unknown
}
let globalConfig: ImhotepConfig = {}
/** Sets or updates global Imhotep runtime configuration. */
export function configure(config: ImhotepConfig): void {
globalConfig = { ...globalConfig, ...config }
}
/** Returns a shallow copy of the current global config. */
export function getConfig(): ImhotepConfig {
return { ...globalConfig }
}
export interface ProjectConfig {
name?: string
fontCorpus?: string
adapters?: string[]
environments?: unknown[]
[key: string]: unknown
}
let projectConfig: ProjectConfig = {}
/** Sets or updates project-level defaults, font corpora, and adapters. */
export function project(config: ProjectConfig): void {
projectConfig = { ...projectConfig, ...config }
}
/** Returns a shallow copy of the current project config. */
export function getProjectConfig(): ProjectConfig {
return { ...projectConfig }
}
/** Environment guard for responsive contracts.
* Only executes the callback when the condition is truthy.
*/
export function when(condition: unknown, fn: () => void): void {
if (condition) {
fn()
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Tests for FOL fluent builder safety.
*
* Verifies:
* - forAll/exists callbacks collect returned assertions
* - Empty assertion quantifiers throw instead of vacuously passing
* - Assertions are properly lowered to formula AST
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
expect,
FluentAssertion,
FluentBinding,
FluentQuantifier,
} from './fluent.js'
describe('FOL fluent builder', () => {
it('forAll collects returned FluentRelation', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) =>
btn.expect().to.be.above('.label'),
)
assert.strictEqual(quantifier.kind, 'forAll')
assert.strictEqual(quantifier.bindings.length, 1)
const assertions = quantifier.bindings[0].getAssertions()
assert.strictEqual(assertions.length, 1)
assert.strictEqual(assertions[0].relation, 'above')
assert.strictEqual(assertions[0].subject.value, '.button')
assert.strictEqual(assertions[0].reference.value, '.label')
})
it('forAll collects multiple returned assertions via addAssertion', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) => {
const rel1 = btn.expect().to.be.above('.label')
btn.addAssertion(rel1.toAst())
const rel2 = btn.expect().to.be.leftOf('.icon')
btn.addAssertion(rel2.toAst())
})
const assertions = quantifier.bindings[0].getAssertions()
assert.strictEqual(assertions.length, 2)
assert.strictEqual(assertions[0].relation, 'above')
assert.strictEqual(assertions[1].relation, 'leftOf')
})
it('forAll with no assertions throws on toFormula', () => {
const quantifier = FluentAssertion.forAll('.button', (_btn) => {
// Intentionally empty — simulates builder misuse
})
assert.throws(() => quantifier.toFormula(), /produced no assertions/)
})
it('exists collects returned FluentRelation', () => {
const quantifier = FluentAssertion.exists('.tooltip', (tip) =>
tip.expect().to.be.inside('.container'),
)
assert.strictEqual(quantifier.kind, 'exists')
assert.strictEqual(quantifier.bindings.length, 1)
const assertions = quantifier.bindings[0].getAssertions()
assert.strictEqual(assertions.length, 1)
assert.strictEqual(assertions[0].relation, 'inside')
})
it('exists with no assertions throws on toFormula', () => {
const quantifier = FluentAssertion.exists('.tooltip', (_tip) => {
// Intentionally empty
})
assert.throws(() => quantifier.toFormula(), /produced no assertions/)
})
it('forAll over multiple selectors creates multiple bindings', () => {
const quantifier = FluentAssertion.forAll(['.a', '.b'], (el) =>
el.expect().to.be.leftOf('.c'),
)
assert.strictEqual(quantifier.bindings.length, 2)
assert.strictEqual(quantifier.bindings[0].selector, '.a')
assert.strictEqual(quantifier.bindings[1].selector, '.b')
})
it('forAll lowers to ForAll formula AST', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) =>
btn.expect().to.be.above('.label'),
)
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'ForAll')
assert.strictEqual((formula as any).variable.name, '$0')
assert.strictEqual((formula as any).domain.selector, '.button')
// Binary relations are wrapped in a reference forall
assert.strictEqual((formula as any).body.type, 'ForAll')
assert.strictEqual((formula as any).body.variable.name, '$ref')
assert.strictEqual((formula as any).body.body.type, 'PredicateCall')
assert.strictEqual((formula as any).body.body.name, 'above')
})
it('exists lowers to Exists formula AST', () => {
const quantifier = FluentAssertion.exists('.modal', (modal) =>
modal.expect().to.be.centeredWithin('.viewport'),
)
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'Exists')
// Binary relations are wrapped in a reference forall
assert.strictEqual((formula as any).body.type, 'ForAll')
assert.strictEqual((formula as any).body.body.type, 'PredicateCall')
assert.strictEqual((formula as any).body.body.name, 'centeredWithin')
})
it('forAll with multiple assertions lowers to conjunction', () => {
const quantifier = FluentAssertion.forAll('.button', (btn) => {
const rel1 = btn.expect().to.be.above('.label')
btn.addAssertion(rel1.toAst())
const rel2 = btn.expect().to.be.leftOf('.icon')
btn.addAssertion(rel2.toAst())
return undefined
})
const formula = quantifier.toFormula()
assert.strictEqual(formula.type, 'ForAll')
const body = (formula as any).body
assert.strictEqual(body.type, 'And')
// Each predicate is wrapped in a reference forall
assert.strictEqual(body.left.type, 'ForAll')
assert.strictEqual(body.left.body.type, 'PredicateCall')
assert.strictEqual(body.right.type, 'ForAll')
assert.strictEqual(body.right.body.type, 'PredicateCall')
})
})
+421
View File
@@ -0,0 +1,421 @@
// Unit tests for imhotep-dsl fluent API and compiler
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
expect,
assert as imhotepAssert,
frame,
state,
compile,
validateRelation,
validateProgram,
parseSpec,
parseTolerance,
parseGap,
} from './index.js'
// ---- Fluent Builder Tests ----
describe('fluent builder', () => {
it('creates a basic leftOf relation', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
assert.strictEqual(chain.assertion.getSubject(), '.a')
assert.strictEqual(chain.relation, 'leftOf')
assert.strictEqual(chain.referenceSelector, '.b')
assert.strictEqual(chain.options.minGap, 16)
})
it('chains frame attachment', () => {
const chain = expect('.tooltip')
.in(frame.nearestPositionedAncestor('.trigger'))
.to.be.rightOf('.trigger', { minGap: 8, maxGap: 16 })
assert.strictEqual(chain.assertion.getSubject(), '.tooltip')
assert.strictEqual(chain.assertion.getFrame()?.kind, 'nearestPositionedAncestor')
assert.strictEqual(chain.assertion.getFrame()?.selector, '.trigger')
assert.strictEqual(chain.relation, 'rightOf')
assert.deepStrictEqual(chain.options, { minGap: 8, maxGap: 16 })
})
it('chains state attachment', () => {
const chain = expect('.button')
.inState(state.hover())
.to.be.above('.label')
assert.strictEqual(chain.assertion.getState()?.kind, 'hover')
})
it('chains tolerance option', () => {
const chain = expect('.dialog').to.be.centeredWithin('.viewport', {
tolerance: 1,
})
assert.strictEqual(chain.options.tolerance, 1)
})
it('chains perceptual tolerance in jnd', () => {
const chain = expect('.icon').to.be.alignedWith('.label', {
tolerance: '0.5jnd',
})
assert.strictEqual(chain.options.tolerance, '0.5jnd')
})
it('chains quantifiers', () => {
const allChain = expect('.toolbar button').all.to.be.leftOf('.sidebar')
assert.strictEqual(allChain.assertion.getQuantifier(), 'all')
const anyChain = expect('.menu').any.to.be.below('.trigger')
assert.strictEqual(anyChain.assertion.getQuantifier(), 'any')
const noneChain = expect('.errors').none.to.be.above('.footer')
assert.strictEqual(noneChain.assertion.getQuantifier(), 'none')
})
it('chains environment guard', () => {
const chain = expect('.sidebar')
.when('width >= 768')
.to.be.leftOf('.content', { minGap: 24 })
assert.strictEqual(chain.assertion.getEnvGuard(), 'width >= 768')
})
it('produces a relation assertion AST node', () => {
const chain = expect('.nav-toggle')
.in(frame.viewport())
.to.be.leftOf('.search', { minGap: 16, maxGap: 20 })
const ast = chain.toAst()
assert.strictEqual(ast.type, 'RelationAssertion')
assert.strictEqual(ast.relation, 'leftOf')
assert.strictEqual(ast.subject.value, '.nav-toggle')
assert.strictEqual(ast.reference.value, '.search')
// Access extended properties via cast
const extended = ast as unknown as Record<string, unknown>
assert.strictEqual((extended.frame as { kind: string }).kind, 'viewport')
const opts = ast.options as unknown as Record<string, unknown>
assert.strictEqual(opts.minGap, 16)
assert.strictEqual(opts.maxGap, 20)
})
it('produces AST with source span', () => {
const span = {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 10, offset: 9 },
}
const chain = expect('.a', span).to.be.leftOf('.b')
const ast = chain.toAst()
assert.deepStrictEqual(ast.position, span)
})
it('supports lower-level assert helper', () => {
const chain = imhotepAssert('leftOf', '.a', '.b', { minGap: 8 }, frame.viewport(), state.default())
assert.strictEqual(chain.relation, 'leftOf')
assert.strictEqual(chain.assertion.getFrame()?.kind, 'viewport')
assert.strictEqual(chain.assertion.getState()?.kind, 'default')
})
// ---- Size Assertion Overloads ----
it('atLeast(44, "width") returns FluentRelation directly', () => {
const chain = expect('.box').to.be.atLeast(44, 'width')
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, 44)
assert.strictEqual(chain.options.mode, 'atLeast')
})
it('atLeast({ width: 44 }) returns FluentRelation directly', () => {
const chain = expect('.box').to.be.atLeast({ width: 44 })
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, 44)
assert.strictEqual(chain.options.mode, 'atLeast')
})
it('atLeast({ height: 88 }) returns height FluentRelation', () => {
const chain = expect('.box').to.be.atLeast({ height: 88 })
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.value, 88)
})
it('atMost(100, "height") returns FluentRelation directly', () => {
const chain = expect('.box').to.be.atMost(100, 'height')
assert.strictEqual(chain.relation, 'size.atMost')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.value, 100)
assert.strictEqual(chain.options.mode, 'atMost')
})
it('atMost({ width: 200 }) returns FluentRelation directly', () => {
const chain = expect('.box').to.be.atMost({ width: 200 })
assert.strictEqual(chain.relation, 'size.atMost')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, 200)
})
it('between(50, 100, "width") returns FluentRelation directly', () => {
const chain = expect('.box').to.be.between(50, 100, 'width')
assert.strictEqual(chain.relation, 'size.between')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.min, 50)
assert.strictEqual(chain.options.max, 100)
assert.strictEqual(chain.options.mode, 'between')
})
it('between({ width: [50, 100] }) returns FluentRelation directly', () => {
const chain = expect('.box').to.be.between({ width: [50, 100] })
assert.strictEqual(chain.relation, 'size.between')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.min, 50)
assert.strictEqual(chain.options.max, 100)
})
it('between({ height: [20, 80] }) returns height FluentRelation', () => {
const chain = expect('.box').to.be.between({ height: [20, 80] })
assert.strictEqual(chain.relation, 'size.between')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.min, 20)
assert.strictEqual(chain.options.max, 80)
})
// ---- Backward Compatibility ----
it('atLeast("44px").wide still returns SizeProxy (old syntax)', () => {
const proxy = expect('.box').to.be.atLeast('44px')
assert.ok(proxy)
assert.strictEqual(typeof proxy.wide, 'object')
const chain = proxy.wide
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, '44px')
})
it('atLeast("44px").tall still works (old syntax)', () => {
const chain = expect('.box').to.be.atLeast('44px').tall
assert.strictEqual(chain.relation, 'size.atLeast')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.value, '44px')
})
it('atMost("100px").wide still works (old syntax)', () => {
const chain = expect('.box').to.be.atMost('100px').wide
assert.strictEqual(chain.relation, 'size.atMost')
assert.strictEqual(chain.options.dimension, 'width')
assert.strictEqual(chain.options.value, '100px')
})
it('between("50px", "100px").tall still works (old syntax)', () => {
const chain = expect('.box').to.be.between('50px', '100px').tall
assert.strictEqual(chain.relation, 'size.between')
assert.strictEqual(chain.options.dimension, 'height')
assert.strictEqual(chain.options.min, '50px')
assert.strictEqual(chain.options.max, '100px')
})
it('new overloads produce correct AST via toAst()', () => {
const chain = expect('.box').to.be.atLeast(44, 'width')
const ast = chain.toAst()
assert.strictEqual(ast.relation, 'size.atLeast')
const opts = ast.options as unknown as Record<string, unknown>
assert.strictEqual(opts.dimension, 'width')
assert.strictEqual(opts.value, 44)
assert.strictEqual(opts.mode, 'atLeast')
})
})
// ---- Validator Tests ----
describe('validator', () => {
it('accepts a valid leftOf with minGap', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, true)
assert.strictEqual(result.diagnostics.length, 0)
})
it('rejects unknown relation', () => {
// Manually construct an invalid AST
const ast = expect('.a').to.be.leftOf('.b').toAst()
ast.relation = 'unknownRelation'
const result = validateRelation(ast)
assert.strictEqual(result.valid, false)
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_ILLEGAL_RELATION_OPTION')
})
it('rejects invalid option for relation', () => {
const chain = expect('.a').to.be.leftOf('.b', { axis: 'centerY' })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, false)
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_ILLEGAL_RELATION_OPTION')
})
it('rejects contradictory gap bounds', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 30, maxGap: 10 })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, false)
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_CONTRADICTION')
})
it('rejects negative gap', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: -5 })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, false)
assert.strictEqual(result.diagnostics[0].code, 'IMH_VALID_INVALID_UNIT')
})
it('accepts valid tolerance in px', () => {
const chain = expect('.a').to.be.centeredWithin('.b', { tolerance: 2 })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, true)
})
it('accepts valid tolerance in jnd', () => {
const chain = expect('.a').to.be.centeredWithin('.b', { tolerance: '1jnd' })
const result = validateRelation(chain.toAst())
assert.strictEqual(result.valid, true)
})
it('parses tolerance correctly', () => {
assert.deepStrictEqual(parseTolerance(1), { value: 1, unit: 'px' })
assert.deepStrictEqual(parseTolerance('0.5jnd'), { value: 0.5, unit: 'jnd' })
assert.strictEqual(parseTolerance('bad'), null)
assert.strictEqual(parseTolerance(undefined), null)
})
it('parses gap correctly', () => {
assert.deepStrictEqual(parseGap(16), { value: 16, unit: 'px' })
assert.deepStrictEqual(parseGap('24'), { value: 24, unit: 'px' })
assert.strictEqual(parseGap(-1)?.value, -1)
})
it('validates batch of assertions', () => {
const assertions = [
expect('.a').to.be.leftOf('.b', { minGap: 16 }).toAst(),
expect('.c').to.be.above('.d', { maxGap: 10 }).toAst(),
]
const result = validateProgram(assertions)
assert.strictEqual(result.valid, true)
})
})
// ---- Compiler Tests ----
describe('compiler', () => {
it('compiles a single relation to semantic IR', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16 })
const result = compile([chain.toAst()])
assert.strictEqual(result.ast.type, 'Program')
assert.strictEqual(result.semanticIr.clauses.size, 1)
assert.strictEqual(result.diagnostics.length, 0)
const clause = Array.from(result.semanticIr.clauses.values())[0]
assert.strictEqual(clause.relation, 'leftOf')
assert.strictEqual(clause.bounds.minGap?.value, 16)
assert.strictEqual(clause.bounds.minGap?.unit, 'px')
assert.ok(clause.frameRef)
})
it('compiles frame attachment into semantic IR', () => {
const chain = expect('.tooltip')
.in(frame.nearestPositionedAncestor('.trigger'))
.to.be.rightOf('.trigger', { minGap: 8 })
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const frameDef = result.semanticIr.frames.get(clause.frameRef)
assert.strictEqual(frameDef?.kind, 'nearestPositionedAncestor')
assert.strictEqual(frameDef?.selector, '.trigger')
})
it('compiles state attachment into semantic IR', () => {
const chain = expect('.button')
.inState(state.hover())
.to.be.above('.label')
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const stateDef = result.semanticIr.states.get(clause.stateRef)
assert.strictEqual(stateDef?.kind, 'hover')
})
it('compiles quantifier into flags', () => {
const chain = expect('.toolbar button').all.to.be.leftOf('.sidebar')
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const flags = (clause as unknown as Record<string, unknown>).flags as number
assert.ok(flags & 1) // all flag
})
it('compiles tolerance into semantic IR', () => {
const chain = expect('.dialog').to.be.centeredWithin('.viewport', {
tolerance: '1jnd',
})
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const tolDef = result.semanticIr.tolerances.get(clause.toleranceRef)
assert.strictEqual(tolDef?.value, 1)
assert.strictEqual(tolDef?.unit, 'jnd')
})
it('compiles environment guard into semantic IR', () => {
const chain = expect('.sidebar')
.when('width >= 768')
.to.be.leftOf('.content')
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
const guardDef = result.semanticIr.envGuards.get(clause.envGuardRef)
assert.strictEqual(guardDef?.expression, 'width >= 768')
})
it('lowers semantic IR to execution IR', () => {
const chain = expect('.a').to.be.leftOf('.b', { minGap: 16, maxGap: 20 })
const result = compile([chain.toAst()])
assert.strictEqual(result.executionIr.clauseCount, 1)
assert.strictEqual(result.executionIr.clauseType[0], 1) // leftOf code
assert.strictEqual(result.executionIr.clauseArg0[0], 16)
assert.strictEqual(result.executionIr.clauseArg1[0], 20)
assert.strictEqual(result.executionIr.clauseFlags[0], 0)
})
it('handles empty assertion list', () => {
const result = compile([])
assert.strictEqual(result.semanticIr.clauses.size, 0)
assert.strictEqual(result.executionIr.clauseCount, 0)
})
it('attaches source spans in semantic IR', () => {
const span = {
start: { line: 2, column: 5, offset: 17 },
end: { line: 2, column: 51, offset: 63 },
}
const chain = expect('.a', span).to.be.leftOf('.b')
const result = compile([chain.toAst()])
const clause = Array.from(result.semanticIr.clauses.values())[0]
assert.deepStrictEqual(clause.origin.positions[0], span)
})
})
// ---- Parser Placeholder Tests ----
describe('parser placeholder', () => {
it('returns empty AST for empty input', () => {
const result = parseSpec('')
assert.strictEqual(result.ast.children.length, 0)
assert.strictEqual(result.diagnostics.length, 0)
})
it('parses dense DSL input', () => {
const result = parseSpec(`in viewport:\n '.a' leftOf '.b'`)
assert.strictEqual(result.ast.children.length, 1)
assert.strictEqual(result.diagnostics.length, 0)
const block = result.ast.children[0] as any
assert.strictEqual(block.type, 'FrameBlock')
assert.strictEqual(block.children[0].type, 'RelationAssertion')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,465 @@
// FOL Dense DSL Combination Tests
// Systematically tests every First-Order Logic combination in dense DSL syntax
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { parseSpec } from './parser.js'
import { tokenize } from './lexer.js'
import { parseGrammar } from './grammar.js'
import { lowerFormula, lowerProgram } from './logic-compiler.js'
import { validateFormula, validateProgram } from './logic-validator.js'
import { compileDenseFOLToFormula } from './compiler.js'
import { extractFOLFormulas } from './lower-to-canonical.js'
import type { FormulaNode } from './logic-fluent.js'
// ---------------------------------------------------------------------------
// Test Helpers
// ---------------------------------------------------------------------------
function parseDense(source: string) {
const tokens = tokenize(source)
const ast = parseGrammar(tokens)
return ast
}
function getFormula(ast: any): FormulaNode | null {
const formulas = ast.children.filter(
(child: any) => child.type === 'ForAll' || child.type === 'Exists' || child.type === 'And' || child.type === 'Or' || child.type === 'Not' || child.type === 'Implies'
)
return formulas.length > 0 ? formulas[0] : null
}
function testParses(name: string, source: string) {
it(`parses: ${name}`, () => {
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
const formula = getFormula(result.ast)
assert.ok(formula, `Expected formula node in AST, got children: ${result.ast.children.map((c: any) => c.type).join(', ')}`)
})
}
function testLowers(name: string, source: string, expectedType: string) {
it(`lowers: ${name}`, () => {
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula, 'Expected formula')
const ir = lowerFormula(formula!)
assert.strictEqual(ir.type, expectedType, `Expected ${expectedType} but got ${ir.type}`)
})
}
function testValidates(name: string, source: string, expectedValid: boolean) {
it(`validates: ${name}`, () => {
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula, 'Expected formula')
const validation = validateFormula(formula!)
assert.strictEqual(validation.valid, expectedValid, `Expected valid=${expectedValid} but got: ${validation.diagnostics.map(d => d.message).join(', ')}`)
})
}
// ---------------------------------------------------------------------------
// Dense DSL FOL Combination Tests
// ---------------------------------------------------------------------------
describe('FOL Dense DSL - Simple Quantifiers', () => {
testParses('forall with simple predicate',
`forall $btn in elements('.button'):\n width($btn) >= 44`)
testParses('exists with simple predicate',
`exists $title in elements('.title'):\n above($title, '.content')`)
testParses('forall with elements domain',
`forall $btn in elements('.button'):\n leftOf($btn, '.label')`)
testParses('forall with descendants domain',
`forall $item in descendants('.card', '.item'):\n inside($item, '.container')`)
testParses('exists with lineBoxes domain',
`exists $box in lineBoxes('.text'):\n overlaps($box, '.highlight')`)
})
describe('FOL Dense DSL - Boolean Connectives', () => {
testParses('forall + and with spatial predicates',
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') and above($item, '.footer')`)
testParses('forall + or',
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') or above($item, '.footer')`)
testParses('forall + implies',
`forall $modal in elements('.modal'):\n visible($modal) implies centeredWithin($modal, 'viewport')`)
testParses('exists + and with spatial predicates',
`exists $item in elements('.item'):\n leftOf($item, '.sidebar') and above($item, '.footer')`)
testParses('exists + or',
`exists $item in elements('.item'):\n leftOf($item, '.sidebar') or above($item, '.footer')`)
testParses('forall + nested and',
`forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c') and inside($a, '.d')`)
testParses('forall + nested or',
`forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c') or inside($a, '.d')`)
it('forall + and with size predicates (width/height) parses successfully', () => {
// width/height are now recognized as predicate keywords
const source = `forall $btn in elements('.button'):\n width($btn) >= 44 and height($btn) >= 44`
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
const formula = getFormula(result.ast)
assert.ok(formula, 'Expected formula')
assert.strictEqual(formula?.type, 'ForAll')
})
})
describe('FOL Dense DSL - Negation', () => {
testParses('forall + not prefix',
`forall $el in elements('.error'):\n not overlaps($el, '.success')`)
testParses('exists + not prefix',
`exists $el in elements('.error'):\n not overlaps($el, '.success')`)
testParses('not + forall',
`not (forall $btn in elements('.button'):\n leftOf($btn, '.label'))`)
testParses('not + exists',
`not (exists $btn in elements('.button'):\n leftOf($btn, '.label'))`)
testParses('forall + not + and',
`forall $a in elements('.a'):\n not leftOf($a, '.b') and not above($a, '.c')`)
testParses('not + forall + and',
`not (forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c'))`)
})
describe('FOL Dense DSL - Nested Quantifiers', () => {
testParses('forall + exists with string selectors',
`forall $card in elements('.card'):\n exists $title in descendants('.card', '.title'):\n above($title, '.content')`)
testParses('exists + forall with string selectors',
`exists $container in elements('.container'):\n forall $item in descendants('.container', '.item'):\n inside($item, '.container')`)
testParses('forall + forall',
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n leftOf($a, $b)`)
testParses('exists + exists',
`exists $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`)
testParses('triple nesting',
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n exists $c in elements('.c'):\n leftOf($a, $c) and above($b, $c)`)
it('descendants($var, selector) syntax is supported', () => {
// Variable as first arg to descendants now supported
const source = `forall $card in elements('.card'):\n exists $title in descendants($card, '.title'):\n above($title, '.content')`
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0, `Expected no diagnostics but got: ${result.diagnostics.map(d => d.message).join(', ')}`)
const formula = getFormula(result.ast)
assert.ok(formula, 'Expected formula')
// Verify nested quantifier with variable domain is present
assert.strictEqual(formula?.type, 'ForAll')
})
it('GAP: variable reference as second arg in predicate not supported', () => {
// Using $container as second arg
const source = `exists $container in elements('.container'):\n forall $item in elements('.item'):\n inside($item, $container)`
const result = parseSpec(source)
// This might parse or might fail depending on grammar
// Document actual behavior
const formula = getFormula(result.ast)
if (!formula) {
assert.ok(result.diagnostics.length > 0)
}
})
})
describe('FOL Dense DSL - Complex Combinations', () => {
testParses('forall + exists + and + or',
`forall $a in elements('.a'):\n exists $b in elements('.b'):\n (leftOf($a, $b) or above($a, $b)) and not overlaps($a, $b)`)
testParses('forall + implies + exists',
`forall $btn in elements('.button'):\n visible($btn) implies exists $label in elements('.label'):\n above($label, $btn)`)
testParses('forall + or + not',
`forall $item in elements('.item'):\n leftOf($item, '.sidebar') or not overlaps($item, '.overlay')`)
testParses('exists + and + implies',
`exists $modal in elements('.modal'):\n visible($modal) and (focused($modal) implies centeredWithin($modal, 'viewport'))`)
testParses('forall + complex nested',
`forall $a in elements('.a'):\n forall $b in elements('.b'):\n (leftOf($a, $b) and above($a, $b)) implies not overlaps($a, $b)`)
testParses('not + exists + and',
`not (exists $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c'))`)
})
describe('FOL Dense DSL - Lowering Verification', () => {
testLowers('forall lowers to LogicForAll',
`forall $btn in elements('.button'):\n leftOf($btn, '.label')`,
'LogicForAll')
testLowers('exists lowers to LogicExists',
`exists $title in elements('.title'):\n above($title, '.content')`,
'LogicExists')
testLowers('and lowers to LogicAnd',
`forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c')`,
'LogicForAll')
testLowers('or lowers to LogicOr (inside body)',
`forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c')`,
'LogicForAll')
testLowers('not lowers to LogicNot',
`forall $a in elements('.a'):\n not leftOf($a, '.b')`,
'LogicForAll')
testLowers('implies lowers to LogicImplies',
`forall $a in elements('.a'):\n leftOf($a, '.b') implies above($a, '.c')`,
'LogicForAll')
testLowers('nested forall+exists',
`forall $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`,
'LogicForAll')
})
describe('FOL Dense DSL - Validation Verification', () => {
it('validates: closed forall formula', () => {
const source = `forall $btn in elements('.button'):\n leftOf($btn, '.label')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const validation = validateFormula(formula)
assert.strictEqual(validation.valid, true)
})
it('validates: free variable detected', () => {
// forall $btn but uses $bad inside
const source = `forall $btn in elements('.button'):\n leftOf($bad, '.label')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const validation = validateFormula(formula)
assert.strictEqual(validation.valid, false)
assert.ok(validation.diagnostics.some(d => d.message.includes('Free variable') || d.message.includes('free variable')))
})
it('validates: nested quantifiers with correct scoping', () => {
const source = `forall $card in elements('.card'):\n exists $title in descendants('.card', '.title'):\n above($title, '.content')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const validation = validateFormula(formula)
assert.strictEqual(validation.valid, true)
})
it('validates: nested quantifiers with variable capture', () => {
// $card used in inner scope where it should be accessible (capture is correct)
const source = `forall $card in elements('.card'):\n exists $title in elements('.title'):\n above($title, $card)`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const validation = validateFormula(formula)
assert.strictEqual(validation.valid, true)
})
})
describe('FOL Dense DSL - Edge Cases', () => {
it('parses: forall with predicate taking no arguments', () => {
const source = `forall $btn in elements('.button'):\n focused()`
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula)
})
it('parses: forall with predicate taking string argument', () => {
const source = `forall $btn in elements('.button'):\n matchesSelector($btn, '.primary')`
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula)
})
it('parses: forall with predicate taking numeric argument', () => {
const source = `forall $btn in elements('.button'):\n minWidth($btn, 44)`
const result = parseSpec(source)
const formula = getFormula(result.ast)
assert.ok(formula)
})
it('parses: multiple independent quantifiers in program', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b')\n\nexists $c in elements('.c'):\n above($c, '.d')`
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0)
const formulas = result.ast.children.filter(
(child: any) => child.type === 'ForAll' || child.type === 'Exists'
)
assert.strictEqual(formulas.length, 2)
})
})
// ---------------------------------------------------------------------------
// End-to-End Lowering: DSL AST -> Solver FormulaNode
// ---------------------------------------------------------------------------
describe('FOL Dense DSL - Solver Formula Lowering', () => {
it('compileDenseFOLToFormula: forall -> solver forall', () => {
const source = `forall $btn in elements('.button'):\n leftOf($btn, '.label')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual(solverFormula.type, 'FormulaNode')
assert.strictEqual(solverFormula.kind, 'forall')
assert.ok(Array.isArray(solverFormula.bindings))
assert.strictEqual(solverFormula.bindings.length, 1)
assert.strictEqual(solverFormula.bindings[0].type, 'TupleBinding')
assert.deepStrictEqual(solverFormula.bindings[0].variables, ['btn'])
assert.strictEqual(solverFormula.bindings[0].domain.type, 'DomainRef')
assert.strictEqual(solverFormula.bindings[0].domain.domain, 'elements')
assert.strictEqual(solverFormula.bindings[0].domain.selector, '.button')
})
it('compileDenseFOLToFormula: exists -> solver exists', () => {
const source = `exists $title in elements('.title'):\n above($title, '.content')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual(solverFormula.type, 'FormulaNode')
assert.strictEqual(solverFormula.kind, 'exists')
assert.strictEqual(solverFormula.bindings[0].variables[0], 'title')
})
it('compileDenseFOLToFormula: and -> solver and', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') and above($a, '.c')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual(solverFormula.type, 'FormulaNode')
assert.strictEqual(solverFormula.kind, 'forall')
const andBody = (solverFormula as any).body
assert.strictEqual(andBody.type, 'FormulaNode')
assert.strictEqual(andBody.kind, 'and')
})
it('compileDenseFOLToFormula: or -> solver or', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') or above($a, '.c')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual((solverFormula as any).body.kind, 'or')
})
it('compileDenseFOLToFormula: not -> solver not', () => {
const source = `forall $a in elements('.a'):\n not leftOf($a, '.b')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual((solverFormula as any).body.kind, 'not')
})
it('compileDenseFOLToFormula: implies -> solver implies', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b') implies above($a, '.c')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
const impliesBody = (solverFormula as any).body
assert.strictEqual(impliesBody.kind, 'implies')
assert.ok(impliesBody.antecedent)
assert.ok(impliesBody.consequent)
})
it('compileDenseFOLToFormula: nested forall+exists', () => {
const source = `forall $a in elements('.a'):\n exists $b in elements('.b'):\n leftOf($a, $b)`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula)
assert.strictEqual(solverFormula.kind, 'forall')
const existsBody = (solverFormula as any).body
assert.strictEqual(existsBody.kind, 'exists')
const predBody = existsBody.body
assert.strictEqual(predBody.kind, 'predicate')
assert.strictEqual(predBody.predicate, 'leftOf')
assert.strictEqual(predBody.args.length, 2)
assert.strictEqual(predBody.args[0].type, 'VariableRef')
assert.strictEqual(predBody.args[0].name, 'a')
assert.strictEqual(predBody.args[1].type, 'VariableRef')
assert.strictEqual(predBody.args[1].name, 'b')
})
it('compileDenseFOLToFormula: descendants($var, selector) maps selector + parentVar correctly', () => {
const source = `forall $card in elements('.card'):\n exists $title in descendants($card, '.title'):\n above($title, '.content')`
const result = parseSpec(source)
const formula = getFormula(result.ast)!
const solverFormula = compileDenseFOLToFormula(formula) as any
assert.strictEqual(solverFormula.kind, 'forall')
const existsBody = solverFormula.body
assert.strictEqual(existsBody.kind, 'exists')
assert.strictEqual(existsBody.bindings[0].domain.domain, 'descendants')
assert.strictEqual(existsBody.bindings[0].domain.selector, '.title')
assert.strictEqual(existsBody.bindings[0].domain.parentVar, '$card')
})
it('extractFOLFormulas returns formulas from mixed AST', () => {
const source = `forall $a in elements('.a'):\n leftOf($a, '.b')\n\n'[data-testid="x"]' above '[data-testid="y"]'`
const result = parseSpec(source)
const formulas = extractFOLFormulas(result.ast)
assert.strictEqual(formulas.length, 1)
assert.strictEqual(formulas[0].type, 'ForAll')
})
it('extractFOLFormulas ignores non-FOL assertions', () => {
const source = `'[data-testid="x"]' above '[data-testid="y"]'`
const result = parseSpec(source)
const formulas = extractFOLFormulas(result.ast)
assert.strictEqual(formulas.length, 0)
})
it('extractFOLFormulas finds formulas nested in frame blocks', () => {
const source = `in viewport:\n forall $cat in elements('.showroom-category'):\n width($cat) >= 200`
const result = parseSpec(source)
const formulas = extractFOLFormulas(result.ast)
assert.strictEqual(formulas.length, 1)
assert.strictEqual(formulas[0].type, 'ForAll')
})
})
// ---------------------------------------------------------------------------
// Gap Reporting Summary
// ---------------------------------------------------------------------------
describe('FOL Dense DSL - Gap Detection', () => {
// These tests document known gaps in the dense DSL FOL support
it('GAP: dense DSL does not support size between assertions', () => {
// between is only in fluent API, not dense DSL grammar
const source = `forall $btn in elements('.button'):\n between($btn, 44, 100)`
const result = parseSpec(source)
// May parse as predicate call or fail
assert.ok(result.diagnostics.length > 0 || result.ast.children.length === 0,
'Expected parse failure or no formula for between in dense DSL')
})
it('GAP: dense DSL does not support separatedFrom relation', () => {
const source = `forall $a in elements('.a'):\n separatedFrom($a, '.b')`
const result = parseSpec(source)
// separatedFrom may not be in keyword list
assert.ok(result.diagnostics.length >= 0)
})
it('GAP: dense DSL does not support contains relation', () => {
// contains exists in fluent but may not parse in dense
const source = `forall $a in elements('.a'):\n contains($a, '.b')`
const result = parseSpec(source)
// This might parse as generic predicate
const formula = getFormula(result.ast)
if (formula) {
assert.ok(true, 'contains parsed as generic predicate')
}
})
})

Some files were not shown because too many files have changed in this diff Show More