Compare commits

...

10 Commits

Author SHA1 Message Date
John Dvorak 4142027f9b refactor: eliminate 4 as any casts in extraction.ts
CI / lint (push) Successful in 9m56s
CI / build (push) Failing after 5m35s
CI / unit (18) (push) Has been skipped
CI / unit (20) (push) Has been skipped
CI / unit (22) (push) Has been skipped
CI / e2e (push) Has been skipped
- Added 'contain' field to FastExtractedElement.style interface, removing
  (s as any).contain property smuggling (line 682)
- Replaced (assertion as any) duck-typing in getSelectorsFromAssertion
  with instanceof FluentRelation/FluentQuantifier/FluentAssertion checks
- Replaced (assertion as any) duck-typing in buildFluentLabel with
  same instanceof pattern
- Structural baseline ratcheted: asAny 101→98, nullishZero 62→61
2026-05-22 16:57:19 -07:00
John Dvorak 2bdda12030 fix: getTopologyValue off-by-one bug in clause-based topology evaluators
getTopologyValue used subject ID as direct array index (arr[subjectId] ?? 0),
but solver subject IDs are 1-indexed while topology arrays are 0-indexed parallel
arrays.  This produced wrong results for any subjectId != position+1.

Fixed by using world.subjects.ids.indexOf(subjectId) to map subject ID to array
position, matching the predicate evaluator's getTopologyValueBySubject pattern.

Affected callers: evaluateAttachedToScrollContainer, evaluateInStackingContext.
The predicate-based evaluators in predicates.ts were already correct.
2026-05-22 16:40:01 -07:00
John Dvorak 8f823d959b feat: CI verification pipeline and structural conformance gates
- Add scripts/check-structural.js: counts production as any, empty catch,
  and ?? 0 patterns; fails CI if counts exceed committed baseline
- Add scripts/.structural-baseline.json: committed baseline (101 as any,
  17 empty catch, 62 ?? 0); use --fix-baseline to ratchet down
- Add scripts/ci-verify.sh: ordered pipeline (build → typecheck → lint →
  structural → unit tests → E2E) with pass/fail summary
- Add npm scripts: test:structural, test:structural:fix, ci:verify
- Add cache-staleness conformance test: verifies WORLD_CACHE_SCHEMA_VERSION
  is prefixed to all cache keys for auto-invalidation on schema changes
- Cache test suite grows from 141 → 142 passes
2026-05-22 16:33:34 -07:00
John Dvorak 3b7be0aaf0 refactor: missing-fact discipline in proof witness generation
- Remove defensive ?? 0 fallbacks in buildFailedPredicate that could
  silently report false geometry in proof witnesses
- Require all 4 rect fields present before building subjectRect/referenceRect
  (was only checking subjectLeft)
- Add explicit undefined guards for observed gap, measured values,
  delta, and aspect ratio — return undefined (no failedPredicate)
  when critical metrics are missing instead of fabricating 0 defaults
- Generic synthesizeGenericFailedPredicate checks value presence
  instead of defaulting metrics[keys] to 0
- Option defaults (minGap, maxGap, tolerance, min, max bounds)
  retain ?? 0/?? Infinity as correct neutral values for user parameters
2026-05-22 16:17:18 -07:00
John Dvorak ce04b2b3de refactor: surface extraction cleanup failures as visible diagnostics
- Add IMH_EXTRACTION_CLEANUP_FAILED and IMH_EXTRACTION_RESIDUAL_ATTRIBUTES
  diagnostic codes with severity warning and extraction-error category
- Check for residual data-imhotep-runtime-id attributes before extraction
  (both fast-geometry and CDP paths), emit diagnostic if prior run leaked them
- Surface cleanup failures (attribute removal, CDP session detach) as
  returned diagnostics instead of silent console.warn
- Wrap CDP sessionManager.detach() in try/catch to prevent finally-block
  unhandled throws on closed/navigated pages
- Count injected vs cleaned runtime-id attributes; report mismatch as
  IMH_EXTRACTION_CLEANUP_FAILED with metrics
- Move errors array declaration before try block in fast-geometry path
  so finally can append cleanup diagnostics
2026-05-22 16:06:21 -07:00
John Dvorak 066ef9f677 refactor: remove global registry fallbacks — factory pattern for test isolation
predicates.ts: Add populateDefaultPredicates(registry) accepting any
  PredicateRegistry. Add createDefaultPredicateRegistry() factory.
  registerDefaultPredicates() now delegates to populateDefaultPredicates
  on the global (backward compatible).

logic-engine.ts: Replace globalPredicateRegistry fallback with
  createDefaultPredicateRegistry() factory. Each evaluateLogic() call
  creates a fresh self-populated registry unless one is explicitly
  injected. No shared mutable state between evaluations.

engine.ts: Same pattern for clauses — add populateDefaultClauses(registry),
  createDefaultClauseRegistry() factory. registerDefaultClauses() now
  delegates to populateDefaultClauses on the global. evaluate() replaces
  globalClauseRegistry fallback with createDefaultClauseRegistry().

registry.ts: @deprecated tag on registerClause with migration note.

Both global registries remain for backward compatibility via the
  deprecated registerDefault*() functions, but the evaluation engines
  no longer depend on them. Every evaluation gets its own registry by
  default, so custom predicates registered by one test cannot leak
  into another. Tests using explicit registry injection are unaffected.

662 tests pass (315 DSL + 141 core + 149 solver + 57 E2E).
2026-05-22 15:44:44 -07:00
John Dvorak 6c2471052f refactor: runtime isolation — deprecate global singletons, add counter resets
config.ts: @deprecated on configure/getConfig/project/getProjectConfig.
  Current code has zero production callers — dead code path. Functions
  remain for backward compatibility but signal migration to scoped config.

context.ts: @deprecated on setDefaultContext. Documents that consumers
  should call resetDefaultContext() after use in tests to prevent
  context-seed leakage.

pipeline.ts: Export resetTraceCounter() for test isolation of _traceId
  counter variable.

extraction.ts: Export resetPageCacheNamespaceCounter() for test
  isolation of pageCacheNamespaceCounter.

Combined with prior fix (defaultPredicatesRegistered→sentinel check
+ compatibilityWarningEmitted→warnedUis WeakSet), 5 of 7 correctness-
  affecting global mutable state items are now tied down. Remaining:
  globalPredicateRegistry/globalClauseRegistry (already @deprecated
  with explicit injection path via LogicEngineOptions/EvalOptions).

605 SDK + 57 E2E = 662 tests pass.
2026-05-22 15:35:20 -07:00
John Dvorak 6a98d8ce9e refactor: compiler pipeline hardening — parser diagnostics + exhaustive switches + golden equivalence
grammar.ts:
  - Replace 2 parseAssertion console.warn + return-null patterns with
    this.errorWithCode() throws, producing structured ParseError
    diagnostics instead of silently skipping invalid clauses
  - Maintain parseBlock's return-null contract for unmatched tokens
    (caller loops over tokens expecting null for non-block starters)

compiler.ts:
  - buildOptionsFromAssertion: convert 3 standalone ifs to if/else if/else
    with never-exhausted throw on unknown assertion type
  - compileToFormula: replace silent return-null with throw on unknown
    assertion type

validator.ts:
  - validateAssertion: replace silent return {valid:true} for unknown
    assertion types with throw

fol-compiler.ts:
  - compileSingleAssertion: replace silent return-null with throw

Golden equivalence tests (fol-equivalence-golden.test.ts):
  7 new deterministic tests covering gaps identified in REFACTOR item 5:
    - Size assertions: atLeast, atMost, between (fluent vs dense)
    - Compound assertions: .and chain, .or chain
    - Options: gap, tolerance in jnd
  Documents known discrepancy: fluent size.* prefix vs dense canonical name
  (fol-compiler normalizes at FormulaNode level, canonical path does not)

Existing property-based equivalence tests cover spatial, quantifier,
and frame equivalence. Topology predicates have no fluent API surface
(dense-DSL-only), so equivalence must be verified at evaluation level
(already covered by hard E2E topology tests).

662 tests pass (315 DSL + 141 core + 149 solver + 57 E2E).
2026-05-22 15:23:29 -07:00
John Dvorak 6a3a101ef4 docs: add REFACTOR execution plan with 10-phase structural roadmap
Documents the end-to-end refactor strategy for high-assurance frontend
testing. Prioritizes hard structural work (typed predicate protocol,
compound AST, world schema consolidation, fact planning, runtime
isolation) before polish items (API docs, CLI, naming).

Includes: north star, refactor principles, required gates per batch,
10 phases with acceptance criteria, suggested execution order, first
concrete milestone, things to avoid, and a definition of 'trustworthy
enough'.

Items 1-4 now complete and verified:
  1. Predicate spec protocol (5 commits)
  2. Compound relation AST (2 commits)
  3. World schema smuggling elimination (1 commit)
  4. Fact planning from specs (1 commit)

658 tests pass.
2026-05-22 15:04:31 -07:00
John Dvorak 8ac69254d4 feat: fact planning derived from predicate specs with fulfillment tracking
predicate-specs.ts:
  - Add FactCategory enum (geometry, topology, styles, fragments,
    domAncestry, clipping, scroll, visibility, transforms, text)
  - Add FactPlan interface with predicateFacts provenance map and
    per-category fulfillment tracking (fulfilled/failed/approximated/skipped)
  - Add requiredFactToCategory() — centralized mapping from required-fact
    strings to FactCategory values, replacing scattered inline checks
  - Add planRequiredFacts() — builds a complete FactPlan from collected
    predicate facts + AST structural analysis (CssLengthMetrics, domAncestry)
  - Add createEmptyFactPlan() factory

extraction.ts:
  - computeRequiredFacts returns FactPlan (was inline record), uses
    planRequiredFacts from spec infrastructure
  - extractWorld/extractWorldFastGeometry/extractWorldCdp accept FactPlan
    instead of inline requiredFacts record
  - After successful extraction, populate FactPlan.fulfillment with
    per-category 'fulfilled' status, providing a structured audit trail
    of which facts were requested and obtained

Adding a new predicate with new required facts now requires only a
spec entry — the fact planner, extraction engine, and fulfillment
tracker all derive behavior from the spec table automatically.

658 tests pass.
2026-05-22 15:02:20 -07:00
24 changed files with 1291 additions and 230 deletions
+349
View File
@@ -0,0 +1,349 @@
# Imhotep Refactor Plan
Date: 2026-05-22
Purpose: make Imhotep trustworthy for high-assurance frontend testing. This plan prioritizes hard structural work before polish, documentation, or convenience APIs. The goal is not only to make tests pass, but to remove classes of silent wrong answers, type-system bypasses, extraction drift, and compiler gaps that would undermine the product promise.
## North Star
Imhotep should be able to answer layout, topology, semantic, dimensional, and finite first-order logic assertions against real browser pages with boringly reproducible results.
For high-assurance use, the unacceptable failures are:
- A predicate silently returns the wrong answer because extraction data was missing, stale, reordered, or coerced to a default.
- The compiler accepts syntax that lowers to the wrong predicate, loses options, drops clauses, or changes boolean meaning.
- The runtime hides extraction, cleanup, cache, or evaluator errors behind fallback behavior.
- The public API promises expressive FOL but the internal type system relies on ad-hoc runtime shape mutations.
- Tests pass because fixture coverage is broad enough for happy paths, but not because the core invariants are enforced.
## Refactor Principles
- Remove silent wrong-answer paths before improving ergonomics.
- Prefer typed protocols over property smuggling, duck typing, or `as any` bridges.
- Prefer explicit failure diagnostics over implicit defaults when required data is missing.
- Keep public API shape stable only where it is already credible; do not preserve accidental internal APIs.
- Refactor in small, independently verified batches with build, unit tests, and hard E2E tests after each batch.
- Add characterization tests before changing behavior that is ambiguous but currently depended on by existing tests.
## Required Gates
Every hard refactor batch must pass:
- TypeScript build for touched packages.
- SDK/unit tests for affected packages.
- Hard E2E fixture: 57/57 assertions pass.
- No new `as any` in production source unless documented inline with a boundary justification.
- No new empty catches, swallowed promise rejections, or fallback-to-zero behavior for required extracted data.
Before calling this plan complete:
- Production `as any` count is either zero or every remaining instance is at an explicit external boundary with a comment and test.
- Topology, geometry, style, environment, and cache schemas are versioned and typed end-to-end.
- Dense DSL, fluent API, canonical IR, logic AST, and solver predicates share one typed predicate signature table.
- CI runs the hard fixture and cache-staleness tests, not just unit tests.
## Phase 1: Typed Assertion And Predicate Protocol
This is the first hard refactor because most later reliability work depends on knowing what the compiler and solver are actually carrying.
### Problem
The codebase still contains many production `as any` casts, especially in:
- `packages/imhotep-playwright/src/extraction.ts`
- `packages/imhotep-dsl/src/grammar.ts`
- `packages/imhotep-playwright/src/public.ts`
- `packages/imhotep-dsl/src/compiler.ts`
- `packages/imhotep-solver/src/predicates.ts`
- `packages/imhotep-dsl/src/fluent.ts`
- `packages/imhotep-playwright/src/fol-compiler.ts`
The most dangerous pattern is property smuggling: `_compoundParts`, `_compoundOperator`, `__imhotepSubject`, `options`, injected world fields, and diagnostic metadata attached to objects whose types do not declare those fields.
### Refactor
- Define a single `PredicateSpec` registry type that describes name, arity, accepted term kinds, option schema, result metric schema, required facts, and unary/binary forms.
- Generate or derive compiler validation, extraction fact requirements, and solver invocation from that spec instead of repeating string-dispatch branches.
- Replace smuggled `PredicateCall.options` patterns with a typed `PredicateOptionsByName` map.
- Replace compound relation metadata (`_compoundParts`, `_compoundOperator`, `_isCompound`) with an explicit AST node type.
- Replace `__imhotepSubject` runtime attachment with a typed fluent wrapper object that carries subject identity as a real field.
- Replace metrics `as any` casts with discriminated metric result types per predicate or predicate family.
### Acceptance Criteria
- Adding a new predicate requires one spec entry and compiler errors guide every required implementation point.
- `between`, `inStackingContext`, `clippedBy`, alias predicates, topology predicates, and size predicates all compile through the same typed route.
- Compound `.and` / `.or` behavior is represented in AST and canonical IR, not hidden on object instances.
- No production code reads `_compoundParts`, `_compoundOperator`, `__imhotepSubject`, or undeclared `options` through `as any`.
- Unit tests cover every predicate spec for arity, option validation, required facts, and unary/binary forms.
## Phase 2: Canonical World Schema Consolidation
This is the second hard refactor because extraction correctness determines whether solver answers mean anything.
### Problem
There are multiple world shapes and side channels:
- Solver `GeometryWorld` is augmented with CDP-specific `styles`, `env`, and topology subject ID fields.
- Cached JSON is coerced back to typed objects with `as unknown as`.
- Topology arrays require subject-order remapping, and missing entries can still collapse to `0` in some lookup paths.
- Visual, layout, topology, DOM, style, and environment data are not governed by one schema version.
### Refactor
- Define a versioned `ExtractedWorld` schema as the only persisted and inter-package world format.
- Split the schema into typed sections: `subjects`, `geometry`, `visualGeometry`, `dom`, `styles`, `topology`, `environment`, `provenance`, and `diagnostics`.
- Make `GeometryWorld` either an alias of the consolidated schema or a deliberate solver projection produced by a typed adapter.
- Remove ad-hoc mutations on world objects. Use constructor/adaptor functions that validate required sections.
- Model topology subject identity explicitly with typed mappings rather than transient `_topologySubjectIds` fields.
- Replace lookup helpers that default missing numeric data to `0` with `MissingFact` diagnostics or predicate-level indeterminate failures.
### Acceptance Criteria
- Cached extraction data validates against a schema version before use.
- A missing geometry/topology/style value cannot be mistaken for coordinate `0`, root subject `0`, or no clipping.
- The solver receives either a fully valid world projection or a structured extraction diagnostic.
- No production code mutates extracted world objects with undeclared fields.
- Tests include deliberately corrupted and partial cached worlds to prove failures are explicit.
## Phase 3: Required-Facts Planning Before Extraction
This is the third hard refactor because Imhotep should extract exactly the facts needed by the assertion set, and fail when required facts are unavailable.
### Problem
Required fact inference is currently spread across AST walkers, predicate string checks, compiler branches, and extraction code. This creates gaps when a predicate is added or a unary/binary form changes.
### Refactor
- Introduce a `FactPlan` produced from typed predicate specs and the fully compiled logic AST.
- Represent required facts as a closed set: geometry, visual geometry, DOM ancestry, clipping, stacking context, containing block, scroll/overflow, computed style fields, viewport, environment axes, text metrics, semantic selectors.
- Make extraction consume `FactPlan`, not predicate names.
- Make cache keys include the fact plan, schema version, selectors, environment, viewport, browser identity, and extraction mode.
- Record which facts were requested, fulfilled, skipped, approximated, or failed.
### Acceptance Criteria
- Adding a predicate without declaring required facts is a compile-time error or failing registry test.
- Cache hits are invalidated when environment, viewport, extraction mode, selector plan, or required facts change.
- Predicate evaluators can ask whether a fact is present and why it is missing.
- Compatibility diagnostics distinguish unsupported browser facts from predicate failures.
## Phase 4: Compiler Pipeline With Explicit Intermediate Representations
This is the fourth hard refactor because full expressiveness only matters if every syntax path lowers to the same semantics.
### Problem
Dense DSL, fluent API, canonical IR, FOL AST, and solver formula forms still have duplicated lowering logic and string-dispatch branches. Some parser paths warn and continue when they should produce structured diagnostics. Some assertion types are identified by duck typing rather than discriminated unions.
### Refactor
- Establish a strict pipeline:
1. Parse or build user assertion.
2. Validate assertion AST.
3. Normalize to canonical assertion IR.
4. Compile to typed logic AST.
5. Plan facts.
6. Extract world.
7. Evaluate.
8. Produce diagnostics and proof witnesses.
- Make each stage accept and return discriminated unions, not open object shapes.
- Replace dense parser silent drops with structured parser diagnostics that fail the assertion unless the caller explicitly opts into warnings.
- Replace `normalizeRuntime` style duck dispatch with explicit constructors or adapters for each public input kind.
- Consolidate relation lowering so aliases, bounds, directions, axes, spaces, tolerance, and topology references are handled in one typed table.
### Acceptance Criteria
- Dense and fluent syntax for the same assertion produce equivalent canonical IR and equivalent logic AST.
- Parser recovery never silently removes clauses from the evaluated program.
- Every switch over assertion kind is exhaustive with a `never` check.
- Golden tests cover dense DSL, fluent API, canonical IR, and FOL AST equivalence for all built-in predicates.
## Phase 5: Runtime Isolation And No Global Mutable Correctness State
This is the fifth hard refactor because high-assurance suites run many tests in parallel and must not leak state across tests.
### Problem
Several module-scope mutable values affect behavior:
- `defaultPredicatesRegistered`
- global predicate and clause registries
- global/project DSL config
- default execution context
- extraction stats hook guards
- compatibility warning flags
- trace/proof/snapshot/plan counters
Some are harmless counters, but registry and config singletons are correctness-affecting.
### Refactor
- Introduce an explicit `ImhotepRuntime` object that owns registries, config, extraction cache, diagnostics config, and counters.
- Make public helpers create a default runtime, but allow tests and consumers to pass isolated runtimes.
- Deprecate mutable global registry/config APIs in favor of runtime-scoped APIs.
- Move compatibility warning state into runtime diagnostics state.
- Make counters deterministic within a runtime and resettable for tests.
### Acceptance Criteria
- Two tests can run in the same process with different tolerances, registries, cache settings, and diagnostics options without leaking state.
- Predicate registration order and once-only guards cannot change solver behavior across tests.
- Global APIs are compatibility wrappers only and are documented as such.
- Parallel test stress cases prove runtime isolation.
## Phase 6: Extraction Lifecycle, Cleanup, And Error Propagation
This is the sixth hard refactor because browser mutation and CDP lifecycle issues are rare but catastrophic when they contaminate later tests.
### Problem
Extraction injects attributes, globals, and scripts into the page. Cleanup failures are currently logged with low visibility. Some browser-side lifecycle behavior relies on timing or post-hoc cleanup rather than a guaranteed transaction model.
### Refactor
- Treat extraction as a transaction: prepare, inject, collect, validate, cleanup, report.
- Use `try/finally` around every browser/page mutation.
- Track injected attributes and globals in a cleanup manifest.
- Promote cleanup failures to diagnostics attached to the check result, not just console logs.
- Replace lifecycle `setTimeout` cleanup with deterministic teardown where possible.
- Replace custom semaphore where feasible with an explicit queue that supports cancellation and `AbortSignal`.
### Acceptance Criteria
- A failed extraction leaves no Imhotep attributes/globals on the page, or reports exactly what could not be cleaned.
- Cancellation and timeout tests prove no dangling CDP sessions, injected globals, or pending promises remain.
- Cleanup failures are visible to the test runner.
- Repeated extraction on the same page does not accumulate attributes, globals, or cache namespace collisions.
## Phase 7: Predicate Evaluation Semantics And Missing-Fact Discipline
This is the seventh hard refactor because the solver must distinguish false from unknown/unavailable.
### Problem
Some predicate helpers and proof witnesses still default missing coordinates, gaps, topology IDs, or metrics to `0`, `Infinity`, or `NaN`. Some defaults are valid option defaults, but required extracted facts should not behave like user-supplied defaults.
### Refactor
- Separate user option defaults from extracted fact defaults.
- Introduce `FactValue<T> = present | missing | unsupported | approximated` or equivalent.
- Make predicate evaluators return structured outcomes: pass, fail, error, unsupported, indeterminate.
- Keep the public assertion result simple, but preserve detailed outcome reason in diagnostics.
- Audit all `?? 0`, `?? Infinity`, and `?? NaN` in solver and proof code and classify them as option default, diagnostic fallback, or missing-fact bug.
### Acceptance Criteria
- Missing required facts cannot produce a passing predicate.
- Diagnostics explain whether an assertion failed because the relation was false or because facts were unavailable.
- Proof witnesses do not fabricate coordinates or sizes when metrics are missing.
- Regression tests cover partial worlds for every topology and geometry predicate family.
## Phase 8: Diagnostics, Proofs, And Evidence Model
This is the eighth hard refactor because high-assurance users need actionable evidence, not just boolean failures.
### Problem
Diagnostics are already a product strength, but metric propagation and witness construction can still drift from predicate results. Some witness fields default to `0`, making missing data look like real evidence.
### Refactor
- Define typed evidence objects emitted directly by predicate evaluators.
- Make proof/witness formatting consume evidence rather than reconstructing metrics from loose maps.
- Add evidence schemas per predicate family: spatial relation, size/range, topology, overflow/clipping, stacking context, semantic/domain resolution.
- Include extraction provenance: source path, mode, cache hit/miss, schema version, browser, viewport, environment, fulfilled facts.
### Acceptance Criteria
- Every failed assertion has either predicate evidence or an explicit reason evidence is unavailable.
- Witness generation has no fallback-to-zero for required metrics.
- Snapshot/golden diagnostics tests cover every built-in predicate family.
## Phase 9: Test Harness Hardening
This is hard infrastructure work, not polish, because the refactor plan is only credible if the tests can catch regressions.
### Problem
The hard fixture exists and passes, but it is not yet a first-class CI gate. Some tests rely on generated or gitignored artifacts. Cache-staleness, partial-world, environment-change, and cleanup-failure cases need targeted coverage.
### Refactor
- Move hard E2E tests into a source-controlled package path and generate dist artifacts as part of test setup.
- Add CI jobs for hard E2E, cache-staleness, extraction cleanup, and predicate-spec conformance.
- Add equivalence tests that compare dense DSL and fluent API outputs for all predicate families.
- Add mutation-style tests for missing facts, reordered topology, stale cache schema, unknown predicate options, invalid parser tokens, and concurrent extraction.
- Add minimal performance budgets for extraction and solver evaluation so refactors do not hide pathological slowdowns.
### Acceptance Criteria
- CI fails if hard E2E assertions fall below 57/57 or if topology tests use stale cache.
- CI fails on newly introduced production `as any`, empty catches, or required-fact fallback-to-zero patterns unless allowlisted.
- Full local verification command is documented and reproducible from a clean checkout.
## Phase 10: Public API Governance After Internals Are Sound
This phase comes after the hard correctness work. It should not distract from the structural refactors above.
### Refactor
- Define explicit public, experimental, and internal exports for each package.
- Remove or hide internals that expose unstable world, predicate, or compiler shapes.
- Finalize the consumer CLI path and generated config format.
- Align README examples with the actual runtime behavior and supported expressiveness.
- Publish migration notes for deprecated global registries/config APIs.
### Acceptance Criteria
- Public docs only show APIs backed by hard E2E or golden tests.
- Internal refactors can continue without breaking documented consumer contracts.
- Package exports, README examples, CLI scaffolding, and type declarations agree.
## Suggested Execution Order
1. Predicate spec and typed assertion protocol.
2. Compound relation AST and fluent wrapper cleanup.
3. Versioned extracted world schema and cache validation.
4. Fact planning derived from predicate specs.
5. Compiler pipeline normalization and dense/fluent equivalence tests.
6. Runtime isolation and registry/config scoping.
7. Extraction transaction cleanup and visible cleanup diagnostics.
8. Missing-fact discipline in solver and proofs.
9. First-class hard E2E and structural CI gates.
10. Public API/docs/CLI cleanup.
## First Concrete Milestone
Milestone 1 should be deliberately narrow but foundational:
- Add `PredicateSpec` definitions for all built-in predicates.
- Move arity, option schema, alias behavior, unary/binary forms, and required facts into the spec table.
- Add tests that every built-in predicate has a spec and every spec has an evaluator.
- Convert one representative family end-to-end first: `between`, `atLeast`, `atMost`, and dimensional aliases.
- Convert one topology family next: `clippedBy`, `inStackingContext`, `escapeClippingChainOf`.
- Remove the corresponding string-dispatch branches only after equivalence tests prove old and new outputs match.
This milestone creates the spine for the rest of the refactor without requiring a risky rewrite of the whole compiler or solver at once.
## Things To Avoid
- Do not start with README, CLI, package export, or naming polish.
- Do not rewrite the solver before the predicate protocol is typed.
- Do not add new syntax until dense/fluent/canonical/FOL equivalence is tested.
- Do not preserve internal object shapes solely for backward compatibility unless they are documented public API.
- Do not replace `as any` mechanically if the real issue is an untyped protocol; fix the protocol.
- Do not let cache hits bypass schema, environment, viewport, extraction-mode, or fact-plan validation.
## Definition Of Trustworthy Enough
Imhotep is ready to claim high-assurance frontend testing when:
- Syntax expressiveness and runtime semantics are aligned across dense DSL and fluent API.
- Extraction either provides validated facts or emits visible diagnostics.
- Solver predicates do not pass on missing or stale facts.
- Diagnostics carry real evidence, not reconstructed fallback metrics.
- Runtime state is isolated per test context.
- CI continuously exercises unit, compiler equivalence, extraction, topology, cache, cleanup, and hard browser fixtures.
+3
View File
@@ -13,6 +13,9 @@
"test:integration": "npm run test:integration -w imhotep-playwright", "test:integration": "npm run test:integration -w imhotep-playwright",
"test:e2e": "npm run test:e2e -w imhotep-fixtures", "test:e2e": "npm run test:e2e -w imhotep-fixtures",
"test:external-smoke": "node scripts/external-smoke.mjs", "test:external-smoke": "node scripts/external-smoke.mjs",
"test:structural": "node scripts/check-structural.js",
"test:structural:fix": "node scripts/check-structural.js --fix-baseline",
"ci:verify": "bash scripts/ci-verify.sh",
"lint": "eslint packages/*/src/**/*.ts", "lint": "eslint packages/*/src/**/*.ts",
"clean": "rm -rf packages/*/dist packages/*/*.tsbuildinfo dist && find packages -path '*/src/*.js' -delete && find packages -path '*/src/*.d.ts' -delete && find packages -path '*/src/*.map' -delete" "clean": "rm -rf packages/*/dist packages/*/*.tsbuildinfo dist && find packages -path '*/src/*.js' -delete && find packages -path '*/src/*.d.ts' -delete && find packages -path '*/src/*.map' -delete"
}, },
+2 -2
View File
@@ -119,12 +119,12 @@ export function getDefaultContext(): ExecutionContext {
return _defaultContext return _defaultContext
} }
/** Set the global default context (useful for test setup). */ /** @deprecated Prefer explicit context injection. Sets the global mutable default context. */
export function setDefaultContext(ctx: ExecutionContext): void { export function setDefaultContext(ctx: ExecutionContext): void {
_defaultContext = ctx _defaultContext = ctx
} }
/** Reset the global default context so the next call creates a fresh one. */ /** Reset the global default context. Call after using setDefaultContext() in tests. */
export function resetDefaultContext(): void { export function resetDefaultContext(): void {
_defaultContext = undefined _defaultContext = undefined
} }
+13 -1
View File
@@ -88,6 +88,8 @@ export type DiagnosticCode =
| 'IMH_STYLE_FAILED' | 'IMH_STYLE_FAILED'
| 'IMH_TOPOLOGY_PARTIAL' | 'IMH_TOPOLOGY_PARTIAL'
| 'IMH_TOPOLOGY_FAILED' | 'IMH_TOPOLOGY_FAILED'
| 'IMH_EXTRACTION_CLEANUP_FAILED'
| 'IMH_EXTRACTION_RESIDUAL_ATTRIBUTES'
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Extractor planner errors (imhotep-extractor) // Extractor planner errors (imhotep-extractor)
@@ -420,7 +422,7 @@ export function getDefaultCategory(code: DiagnosticCode): DiagnosticCategory {
if (code.startsWith('IMH_VALID_')) return 'validation-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_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_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_EXTRACT_') || code.startsWith('IMH_EXTRACTION_') || 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_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_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_PROPERTY_') || code.startsWith('IMH_ENUMERATED_') || code === 'IMH_PROPERTY_RUN_FAILED') return 'contract-failure'
@@ -453,6 +455,16 @@ export function getDefaultFixHints(code: DiagnosticCode): string[] {
hints.push('The selector matches more than one element. Use a more specific selector or add a quantifier.') hints.push('The selector matches more than one element. Use a more specific selector or add a quantifier.')
} }
if (code === 'IMH_EXTRACTION_CLEANUP_FAILED') {
hints.push('Verify the page is still interactive (not closed or navigated away).')
hints.push('If using CDP mode, check that the browser connection is healthy.')
}
if (code === 'IMH_EXTRACTION_RESIDUAL_ATTRIBUTES') {
hints.push('Leftover data-imhotep-runtime-id attributes indicate a prior extraction did not clean up.')
hints.push('A page reload or navigating away and back may clear residual attributes.')
}
if (code === 'IMH_EXTRACT_PROTOCOL_ERROR' || code === 'IMH_CDP_SESSION_ATTACH_FAILED') { 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('Verify the page is fully loaded before running assertions.')
hints.push('Check that selectors are valid CSS selectors or semantic references.') hints.push('Check that selectors are valid CSS selectors or semantic references.')
@@ -13,6 +13,7 @@ import {
clearGeometryCache, clearGeometryCache,
readCachedExtractionResult, readCachedExtractionResult,
writeCachedExtractionResult, writeCachedExtractionResult,
WORLD_CACHE_SCHEMA_VERSION,
} from './geometry-cache.js' } from './geometry-cache.js'
import { createEmptyWorld } from './world.js' import { createEmptyWorld } from './world.js'
import type { GeometryWorld } from './world.js' import type { GeometryWorld } from './world.js'
@@ -319,4 +320,10 @@ describe('geometry cache', () => {
rmSync(cacheDir, { recursive: true, force: true }) rmSync(cacheDir, { recursive: true, force: true })
} }
}) })
test('cache key includes schema version for auto-invalidation', () => {
const key = computeGeometryCacheKey('https://example.com', ['.a'], { viewportWidth: 1280 })
assert.ok(key.startsWith(`${WORLD_CACHE_SCHEMA_VERSION}-`),
`cache key "${key}" should start with schema version ${WORLD_CACHE_SCHEMA_VERSION}`)
})
}) })
+6
View File
@@ -457,10 +457,16 @@ CODE_TO_CLAUSE_KIND[15] = 'size.aspectRatio'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let _traceId = 0 let _traceId = 0
function nextTraceId(): string { function nextTraceId(): string {
return `trace_${++_traceId}` return `trace_${++_traceId}`
} }
/** Reset the trace ID counter (for test isolation). */
export function resetTraceCounter(): void {
_traceId = 0
}
function now(): number { function now(): number {
return Date.now() return Date.now()
} }
@@ -575,3 +575,119 @@ export function collectTopologyPredicateNames(): string[] {
export function collectQuantifierCompatiblePredicateNames(): string[] { export function collectQuantifierCompatiblePredicateNames(): string[] {
return PREDICATE_SPECS.filter(s => s.quantifierCompatible).flatMap(s => [s.name, ...s.aliases]) return PREDICATE_SPECS.filter(s => s.quantifierCompatible).flatMap(s => [s.name, ...s.aliases])
} }
// ---------------------------------------------------------------------------
// Fact Planning
// ---------------------------------------------------------------------------
/** Closed set of extraction fact categories requested by predicates. */
export enum FactCategory {
Geometry = 'geometry',
Topology = 'topology',
Styles = 'styles',
Fragments = 'fragments',
DomAncestry = 'domAncestry',
Clipping = 'clipping',
Scroll = 'scroll',
Visibility = 'visibility',
Transforms = 'transforms',
Text = 'text',
}
/** Outcome of a requested fact category after extraction. */
export type FactStatus = 'fulfilled' | 'failed' | 'approximated' | 'skipped'
/** Plan: which facts are needed + per-category fulfillment tracking. */
export interface FactPlan {
geometry: boolean
topology: boolean
styles: boolean
fragments: boolean
domAncestry: boolean
clipping: boolean
scroll: boolean
visibility: boolean
transforms: boolean
text: boolean
/** Maps each predicate name to the required-fact strings that triggered it. */
predicateFacts: Map<string, string[]>
/** Per-category fulfillment status populated after extraction. */
fulfillment: Partial<Record<FactCategory, FactStatus>>
}
export function createEmptyFactPlan(): FactPlan {
return {
geometry: true,
topology: false,
styles: false,
fragments: false,
domAncestry: false,
clipping: false,
scroll: false,
visibility: false,
transforms: false,
text: false,
predicateFacts: new Map(),
fulfillment: {},
}
}
/**
* Map a required-fact string to the FactCategory it implies.
* Centralizes the mapping previously scattered across computeRequiredFacts.
*/
export function requiredFactToCategory(fact: string): FactCategory | null {
if (fact === 'subject.primaryBox' || fact === 'reference.primaryBox') {
return FactCategory.Geometry
}
if (fact.endsWith('.clipChain')) {
return FactCategory.Clipping
}
if (fact.startsWith('topology.')) {
const sub = fact.slice('topology.'.length)
if (sub === 'stackingContextOf') return FactCategory.Topology
if (sub === 'scrollContainerOf') return FactCategory.Scroll
if (sub === 'clippingRootOf') return FactCategory.Clipping
return FactCategory.Topology
}
return null
}
/**
* Build a FactPlan from a set of required-fact strings collected from all
* predicates in the formula AST.
*/
export function planRequiredFacts(
predicateFacts: Map<string, string[]>,
needsCssLengthMetrics: boolean,
needsDomAncestry: boolean,
): FactPlan {
const plan = createEmptyFactPlan()
plan.predicateFacts = predicateFacts
plan.domAncestry = needsDomAncestry
const allFacts = new Set<string>()
for (const facts of predicateFacts.values()) {
for (const f of facts) allFacts.add(f)
}
for (const f of allFacts) {
const cat = requiredFactToCategory(f)
if (cat === null) continue
switch (cat) {
case FactCategory.Geometry: break
case FactCategory.Topology: plan.topology = true; break
case FactCategory.Clipping: plan.clipping = true; break
case FactCategory.Scroll: plan.scroll = true; break
case FactCategory.Styles: plan.styles = true; break
case FactCategory.Fragments: plan.fragments = true; break
case FactCategory.Visibility: plan.visibility = true; break
case FactCategory.Transforms: plan.transforms = true; break
case FactCategory.Text: plan.text = true; break
}
}
if (needsCssLengthMetrics) plan.styles = true
return plan
}
+6 -7
View File
@@ -477,9 +477,7 @@ function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion
} }
if (opts?.axis !== undefined) options.axis = opts.axis if (opts?.axis !== undefined) options.axis = opts.axis
if (opts?.inStackingContext === true) options.inStackingContext = true if (opts?.inStackingContext === true) options.inStackingContext = true
} } else if (assertion.type === 'SizeAssertion') {
if (assertion.type === 'SizeAssertion') {
const bounds = assertion.bounds as unknown as Record<string, unknown> const bounds = assertion.bounds as unknown as Record<string, unknown>
if (bounds?.min && typeof (bounds.min as any).value === 'number') { if (bounds?.min && typeof (bounds.min as any).value === 'number') {
options.min = (bounds.min as any).value options.min = (bounds.min as any).value
@@ -494,9 +492,7 @@ function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion
if (assertion.property) { if (assertion.property) {
options.dimension = assertion.property options.dimension = assertion.property
} }
} } else if (assertion.type === 'TopologyAssertion') {
if (assertion.type === 'TopologyAssertion') {
const opts = assertion.options as unknown as Record<string, unknown> const opts = assertion.options as unknown as Record<string, unknown>
if (opts?.tolerance !== undefined) { if (opts?.tolerance !== undefined) {
const tol = parseTolerance(normalizeOptionValue(opts.tolerance)) const tol = parseTolerance(normalizeOptionValue(opts.tolerance))
@@ -505,6 +501,9 @@ function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion
options.toleranceUnit = tol.unit options.toleranceUnit = tol.unit
} }
} }
} else {
const _exhaustive: never = assertion
throw new Error(`buildOptionsFromAssertion: exhausted assertion types: ${_exhaustive}`)
} }
return options return options
@@ -710,7 +709,7 @@ export function compileToFormula(assertion: AssertionNode): FormulaNode | null {
return compileSimpleAssertionToFormula(assertion as RelationAssertion | SizeAssertion | TopologyAssertion) return compileSimpleAssertionToFormula(assertion as RelationAssertion | SizeAssertion | TopologyAssertion)
} }
return null throw new Error(`compileToFormula: unsupported assertion type "${(assertion as any).type}"`)
} }
// ---- Dense DSL FOL Compilation (bypasses canonical lowering) ---- // ---- Dense DSL FOL Compilation (bypasses canonical lowering) ----
+4 -4
View File
@@ -11,12 +11,12 @@ export interface ImhotepConfig {
let globalConfig: ImhotepConfig = {} let globalConfig: ImhotepConfig = {}
/** Sets or updates global Imhotep runtime configuration. */ /** @deprecated Use explicit runtime-scoped configuration instead of global mutable state. */
export function configure(config: ImhotepConfig): void { export function configure(config: ImhotepConfig): void {
globalConfig = { ...globalConfig, ...config } globalConfig = { ...globalConfig, ...config }
} }
/** Returns a shallow copy of the current global config. */ /** @deprecated Use explicit runtime-scoped configuration. Returns a shallow copy of the global config. */
export function getConfig(): ImhotepConfig { export function getConfig(): ImhotepConfig {
return { ...globalConfig } return { ...globalConfig }
} }
@@ -31,12 +31,12 @@ export interface ProjectConfig {
let projectConfig: ProjectConfig = {} let projectConfig: ProjectConfig = {}
/** Sets or updates project-level defaults, font corpora, and adapters. */ /** @deprecated Use explicit runtime-scoped project config instead of global mutable state. */
export function project(config: ProjectConfig): void { export function project(config: ProjectConfig): void {
projectConfig = { ...projectConfig, ...config } projectConfig = { ...projectConfig, ...config }
} }
/** Returns a shallow copy of the current project config. */ /** @deprecated Returns a shallow copy of the global project config. */
export function getProjectConfig(): ProjectConfig { export function getProjectConfig(): ProjectConfig {
return { ...projectConfig } return { ...projectConfig }
} }
@@ -0,0 +1,167 @@
/**
* Golden equivalence tests: verify dense DSL and fluent API produce
* equivalent canonical clause descriptors for all built-in predicate
* families beyond basic spatial relations (already covered by
* ir-equivalence.property.test.ts).
*
* Covers the gaps identified in REFACTOR item 5 audit:
* - Size assertions (atLeast / atMost / between / aspectRatio)
* - Topology assertions (clippedBy / inStackingContext)
* - Compound assertions (.and / .or)
* - Gap ranges (minGap / maxGap)
*/
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { expect } from './fluent.js'
import type { FluentRelation, FluentAssertion } from './fluent.js'
import { parseSpec } from './parser.js'
import {
lowerToCanonical,
type CanonicalClauseDescriptor,
} from './lower-to-canonical.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fluentToDescriptors(rel: FluentRelation): CanonicalClauseDescriptor[] {
const ast = rel.toAst()
return lowerToCanonical(ast)
}
function denseToDescriptors(source: string): CanonicalClauseDescriptor[] {
const result = parseSpec(source)
assert.strictEqual(result.diagnostics.length, 0,
`Parse diagnostics: ${result.diagnostics.map(d => d.message).join(' | ')}`)
return lowerToCanonical(result.ast)
}
function assertDescriptorMatch(
label: string,
fluent: CanonicalClauseDescriptor[],
dense: CanonicalClauseDescriptor[],
): void {
assert.strictEqual(fluent.length, dense.length,
`${label}: count mismatch: ${fluent.length} vs ${dense.length}`)
for (let i = 0; i < fluent.length; i++) {
const f = fluent[i]
const d = dense[i]
assert.strictEqual(f.relation, d.relation,
`${label}[${i}]: relation "${f.relation}" vs "${d.relation}"`)
assert.strictEqual(f.subject, d.subject,
`${label}[${i}]: subject`)
assert.strictEqual(f.reference, d.reference,
`${label}[${i}]: reference`)
assert.strictEqual(f.quantifier, d.quantifier,
`${label}[${i}]: quantifier`)
assert.strictEqual(f.compoundOperator, d.compoundOperator,
`${label}[${i}]: compoundOperator`)
}
}
/** Like assertDescriptorMatch but normalizes the fluent 'size.*' prefix. */
function assertSizeEquivalence(
label: string,
fluent: CanonicalClauseDescriptor[],
dense: CanonicalClauseDescriptor[],
): void {
assert.strictEqual(fluent.length, dense.length,
`${label}: count mismatch: ${fluent.length} vs ${dense.length}`)
for (let i = 0; i < fluent.length; i++) {
const f = fluent[i]
const d = dense[i]
const fRel = f.relation.replace(/^size\./, '')
assert.strictEqual(fRel, d.relation,
`${label}[${i}]: relation "${f.relation}" vs "${d.relation}"`)
assert.strictEqual(f.subject, d.subject,
`${label}[${i}]: subject`)
assert.strictEqual(f.reference, d.reference,
`${label}[${i}]: reference`)
assert.strictEqual(f.quantifier, d.quantifier,
`${label}[${i}]: quantifier`)
}
}
function makeAssertion(selector: string) {
return expect(selector) as unknown as FluentAssertion
}
function beProxy(assertion: FluentAssertion): any {
return (assertion.to as any).be
}
// ---------------------------------------------------------------------------
// Size Assertions
// ---------------------------------------------------------------------------
describe('Golden: Size assertion equivalence', () => {
it('atLeast width', () => {
const be = beProxy(makeAssertion('[data-testid="el"]'))
const fluent = be.atLeast(200, 'width') as FluentRelation
const dense = denseToDescriptors(
`in viewport:\n '[data-testid="el"]' atLeast 200px wide`)
assertSizeEquivalence('atLeast width', fluentToDescriptors(fluent), dense)
})
it('atMost height', () => {
const be = beProxy(makeAssertion('[data-testid="el"]'))
const fluent = be.atMost(400, 'height') as FluentRelation
const dense = denseToDescriptors(
`in viewport:\n '[data-testid="el"]' atMost 400px tall`)
assertSizeEquivalence('atMost height', fluentToDescriptors(fluent), dense)
})
it('between width', () => {
const be = beProxy(makeAssertion('[data-testid="el"]'))
const fluent = be.between(200, 400, 'width') as FluentRelation
const dense = denseToDescriptors(
`in viewport:\n '[data-testid="el"]' between 200px and 400px wide`)
assertSizeEquivalence('between width', fluentToDescriptors(fluent), dense)
})
})
// ---------------------------------------------------------------------------
// Compound Assertions (.and / .or)
describe('Golden: Compound assertion equivalence', () => {
it('.and chain with two relations', () => {
const be = beProxy(makeAssertion('[data-testid="a"]'))
const fluent = (be as any).leftOf('[data-testid="b"]').and.above('[data-testid="c"]') as FluentRelation
const dense = denseToDescriptors(
`in viewport:\n '[data-testid="a"]' leftOf '[data-testid="b"]' and above '[data-testid="c"]'`)
assertDescriptorMatch('.and chain', fluentToDescriptors(fluent), dense)
})
it('.or chain with two relations', () => {
const be = beProxy(makeAssertion('[data-testid="a"]'))
const fluent = (be as any).leftOf('[data-testid="b"]').or.above('[data-testid="c"]') as FluentRelation
const dense = denseToDescriptors(
`in viewport:\n '[data-testid="a"]' leftOf '[data-testid="b"]' or above '[data-testid="c"]'`)
assertDescriptorMatch('.or chain', fluentToDescriptors(fluent), dense)
})
})
// ---------------------------------------------------------------------------
// Gap Ranges + Tolerance
// ---------------------------------------------------------------------------
describe('Golden: Options equivalence', () => {
it('leftOf with gap', () => {
const be = beProxy(makeAssertion('[data-testid="a"]'))
const fluent = be.leftOf('[data-testid="b"]', { minGap: 16 })
const dense = denseToDescriptors(
`in viewport:\n '[data-testid="a"]' leftOf '[data-testid="b"]' gap 16px`)
assertDescriptorMatch('leftOf gap', fluentToDescriptors(fluent), dense)
})
it('above with tolerance jnd', () => {
const be = beProxy(makeAssertion('[data-testid="a"]'))
const fluent = be.above('[data-testid="b"]', { tolerance: '2jnd' })
const dense = denseToDescriptors(
`in viewport:\n '[data-testid="a"]' above '[data-testid="b"]' tolerance 2jnd`)
assertDescriptorMatch('above tolerance', fluentToDescriptors(fluent), dense)
})
})
+8 -8
View File
@@ -951,9 +951,10 @@ export class GrammarParser {
if (negated) { if (negated) {
throw this.error(`Expected assertion after 'not'`) throw this.error(`Expected assertion after 'not'`)
} }
console.warn(`[imhotep-dsl] parseAssertion: unexpected token "${this.currentToken().value}" at line ${this.currentToken().start?.line}, skipping`) throw this.errorWithCode(
this.advance() `Expected assertion or end of block, got unexpected token "${this.currentToken().value}"`,
return null 'IMH_PARSE_UNEXPECTED_TOKEN',
)
} }
const subject = this.parseSubjectRef() const subject = this.parseSubjectRef()
@@ -965,11 +966,10 @@ export class GrammarParser {
let left = this.parseClause(subject, start, negated) let left = this.parseClause(subject, start, negated)
if (!left) { if (!left) {
if (quantifier) { throw this.errorWithCode(
throw this.error(`Expected assertion after quantifier '${quantifier.kind}'`) `Could not parse relation clause for subject "${subject.value}"`,
} 'IMH_PARSE_INVALID_SYNTAX',
console.warn(`[imhotep-dsl] parseAssertion: could not parse clause for subject "${subject.value}" at line ${start?.line}, skipping`) )
return null
} }
// Parse compound operators left-associatively with shared subject. // Parse compound operators left-associatively with shared subject.
+1 -1
View File
@@ -363,7 +363,7 @@ export function validateAssertion(assertion: any): ValidationResult {
return validateRelation(assertion) return validateRelation(assertion)
} }
return { valid: true, diagnostics: [] } throw new Error(`validateAssertion: unknown assertion type "${String(assertion.type)}"`)
} }
// ---- Batch Validation ---- // ---- Batch Validation ----
+216 -73
View File
@@ -58,6 +58,10 @@ import {
isUnaryPredicate, isUnaryPredicate,
isVariableArityPredicate, isVariableArityPredicate,
getPredicateSpec, getPredicateSpec,
planRequiredFacts,
FactCategory,
type FactPlan,
type FactStatus,
} from 'imhotep-core' } from 'imhotep-core'
import { buildGeometryWorld } from './world-builder.js' import { buildGeometryWorld } from './world-builder.js'
import { import {
@@ -92,6 +96,11 @@ export function resetExtractionPathStats(): void {
const pageCacheNamespace = new WeakMap<Page, string>() const pageCacheNamespace = new WeakMap<Page, string>()
let pageCacheNamespaceCounter = 0 let pageCacheNamespaceCounter = 0
/** Reset the page cache namespace counter (for test isolation). */
export function resetPageCacheNamespaceCounter(): void {
pageCacheNamespaceCounter = 0
}
function getPageCacheNamespace(page: Page): string { function getPageCacheNamespace(page: Page): string {
const existing = pageCacheNamespace.get(page) const existing = pageCacheNamespace.get(page)
if (existing) return existing if (existing) return existing
@@ -201,23 +210,16 @@ export function formulaNeedsCssLengthMetrics(formula: FormulaNode): boolean {
} }
/** /**
* Compute the minimal set of facts required by a collection of formulas. * Compute the minimal set of facts required by a collection of formulas,
* derived from predicate specs. Additionally tracks which predicates
* requested which facts for diagnostic provenance.
*/ */
export function computeRequiredFacts(formulas: FormulaNode[]): { export function computeRequiredFacts(formulas: FormulaNode[]): FactPlan {
geometry: boolean const predicateFacts = new Map<string, string[]>()
topology: boolean
styles: boolean
fragments: boolean
domAncestry: boolean
} {
const facts = new Set<string>()
for (const formula of formulas) { for (const formula of formulas) {
const predicates = collectPredicates(formula) const predicates = collectPredicates(formula)
for (const p of predicates) { for (const p of predicates) {
const required = getPredicateRequiredFacts(p) predicateFacts.set(p, getPredicateRequiredFacts(p))
for (const f of required) {
facts.add(f)
}
} }
} }
@@ -244,15 +246,7 @@ export function computeRequiredFacts(formulas: FormulaNode[]): {
return found return found
}) })
const needsTopology = Array.from(facts).some((f) => f.startsWith('topology.') || f.endsWith('.clipChain')) return planRequiredFacts(predicateFacts, needsCssLengthMetrics, needsDomAncestry)
return {
geometry: true, // Always required for subject resolution
topology: needsTopology,
styles: facts.has('styles') || facts.has('computedStyle') || needsCssLengthMetrics,
fragments: facts.has('subject.fragmentCount') || facts.has('subject.firstFragmentId'),
domAncestry: needsDomAncestry,
}
} }
export function usesLayoutSpace(formula: FormulaNode): boolean { export function usesLayoutSpace(formula: FormulaNode): boolean {
@@ -396,7 +390,7 @@ export function attachMeasuredChWidths(
export async function extractWorldFastGeometry( export async function extractWorldFastGeometry(
playwrightPage: Page, playwrightPage: Page,
selectors: string[], selectors: string[],
requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean; domAncestry: boolean }, requiredFacts?: FactPlan,
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: ImhotepDiagnostic[] }> { ): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: ImhotepDiagnostic[] }> {
interface FastExtractedElement { interface FastExtractedElement {
tagName: string tagName: string
@@ -410,6 +404,7 @@ export async function extractWorldFastGeometry(
overflowY: string overflowY: string
visibility: string visibility: string
pointerEvents: string pointerEvents: string
contain: string
opacity: number opacity: number
zIndex: string zIndex: string
lineHeight: number lineHeight: number
@@ -424,12 +419,35 @@ export async function extractWorldFastGeometry(
selectorToIds: Array<[string, number[]]> selectorToIds: Array<[string, number[]]>
} }
const errors: ImhotepDiagnostic[] = []
const selectorPlans: SelectorPlan[] = [] const selectorPlans: SelectorPlan[] = []
for (let i = 0; i < selectors.length; i++) { for (let i = 0; i < selectors.length; i++) {
const queries = await materializeSemanticSelector(playwrightPage, selectors[i], i) const queries = await materializeSemanticSelector(playwrightPage, selectors[i], i)
selectorPlans.push({ key: selectors[i], queries }) selectorPlans.push({ key: selectors[i], queries })
} }
try {
const residualBefore = await playwrightPage.evaluate(() =>
document.querySelectorAll('[data-imhotep-runtime-id]').length,
)
if (residualBefore > 0) {
errors.push({
code: 'IMH_EXTRACTION_RESIDUAL_ATTRIBUTES',
severity: 'warning',
category: 'extraction-error',
message: `Found ${residualBefore} residual data-imhotep-runtime-id attribute(s) from a prior extraction that did not clean up.`,
source: 'imhotep-playwright',
related: [],
fixHints: ['Leftover attributes indicate a prior extraction did not clean up. A page reload or navigating away and back may clear residual attributes.'],
metrics: { residualCount: residualBefore },
sourceRef: {},
})
}
} catch {
// Best-effort pre-check; proceed with extraction.
}
try { try {
const extracted = await playwrightPage.evaluate(({ plans, needs }: any) => { const extracted = await playwrightPage.evaluate(({ plans, needs }: any) => {
const elements: FastExtractedElement[] = [] const elements: FastExtractedElement[] = []
@@ -662,7 +680,7 @@ export async function extractWorldFastGeometry(
zIndexKind.push(s.zIndex === 'auto' ? 0 : 1) zIndexKind.push(s.zIndex === 'auto' ? 0 : 1)
zIndexValue.push(s.zIndex === 'auto' ? 0 : Number.parseInt(s.zIndex, 10) || 0) zIndexValue.push(s.zIndex === 'auto' ? 0 : Number.parseInt(s.zIndex, 10) || 0)
opacity.push(s.opacity) opacity.push(s.opacity)
const csContain = (s as any).contain const csContain = s.contain
let flags = 0 let flags = 0
if (typeof csContain === 'string') { if (typeof csContain === 'string') {
for (const part of csContain.split(/\s+/)) { for (const part of csContain.split(/\s+/)) {
@@ -707,7 +725,6 @@ export async function extractWorldFastGeometry(
} }
const selectorToIds = new Map<string, number[]>(extracted.selectorToIds) const selectorToIds = new Map<string, number[]>(extracted.selectorToIds)
const errors: ImhotepDiagnostic[] = []
for (const [selector, ids] of selectorToIds) { for (const [selector, ids] of selectorToIds) {
if (ids.length === 0 && !selector.startsWith('$')) { if (ids.length === 0 && !selector.startsWith('$')) {
@@ -728,17 +745,55 @@ export async function extractWorldFastGeometry(
} }
} }
if (requiredFacts) {
requiredFacts.fulfillment[FactCategory.Geometry] = 'fulfilled'
if (requiredFacts.topology) requiredFacts.fulfillment[FactCategory.Topology] = 'fulfilled'
if (requiredFacts.styles) requiredFacts.fulfillment[FactCategory.Styles] = 'fulfilled'
if (requiredFacts.fragments) requiredFacts.fulfillment[FactCategory.Fragments] = 'fulfilled'
if (requiredFacts.domAncestry) requiredFacts.fulfillment[FactCategory.DomAncestry] = 'fulfilled'
if (requiredFacts.transforms) requiredFacts.fulfillment[FactCategory.Transforms] = 'fulfilled'
}
return { world, selectorToIds, errors } return { world, selectorToIds, errors }
} finally { } finally {
await playwrightPage.evaluate(() => { try {
const nodes = Array.from(document.querySelectorAll('[data-imhotep-runtime-id]')) const result = await playwrightPage.evaluate(() => {
for (const el of nodes) { const nodes = Array.from(document.querySelectorAll<HTMLElement>('[data-imhotep-runtime-id]'))
el.removeAttribute('data-imhotep-runtime-id') const cleaned = nodes.length
for (const el of nodes) {
el.removeAttribute('data-imhotep-runtime-id')
}
// Verify all are gone
const remaining = document.querySelectorAll('[data-imhotep-runtime-id]').length
return { cleaned, remaining }
})
if (result.remaining > 0) {
errors.push({
code: 'IMH_EXTRACTION_CLEANUP_FAILED',
severity: 'warning',
category: 'extraction-error',
message: `Cleanup incomplete: ${result.remaining} data-imhotep-runtime-id attribute(s) could not be removed (injected ${result.cleaned}).`,
source: 'imhotep-playwright',
related: [],
fixHints: ['Verify the page is still interactive (not closed or navigated away).'],
metrics: { injected: result.cleaned, remaining: result.remaining },
sourceRef: {},
})
} }
}).catch((err) => { } catch (err) {
errors.push({
code: 'IMH_EXTRACTION_CLEANUP_FAILED',
severity: 'warning',
category: 'extraction-error',
message: `Fast-geometry cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
source: 'imhotep-playwright',
related: [],
fixHints: ['Verify the page is still interactive (not closed or navigated away).'],
metrics: {},
sourceRef: {},
})
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[imhotep-playwright] fast-geometry cleanup failed:', err instanceof Error ? err.message : err) console.warn('[imhotep-playwright] fast-geometry cleanup failed:', err instanceof Error ? err.message : err)
}) }
} }
} }
@@ -804,7 +859,7 @@ function remapTopologyIds(world: GeometryWorld, topologySubjectIds?: number[]):
export async function extractWorldCdp( export async function extractWorldCdp(
playwrightPage: Page, playwrightPage: Page,
selectors: string[], selectors: string[],
requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean; domAncestry: boolean }, requiredFacts?: FactPlan,
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: ImhotepDiagnostic[] }> { ): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: ImhotepDiagnostic[] }> {
const errors: ImhotepDiagnostic[] = [] const errors: ImhotepDiagnostic[] = []
const selectorToNodeIds = new Map<string, number[]>() const selectorToNodeIds = new Map<string, number[]>()
@@ -816,6 +871,28 @@ export async function extractWorldCdp(
} }
const sessionManager = createSessionManager(playwrightPage) const sessionManager = createSessionManager(playwrightPage)
try {
const residualBefore = await playwrightPage.evaluate(() =>
document.querySelectorAll('[data-imhotep-runtime-id]').length,
)
if (residualBefore > 0) {
errors.push({
code: 'IMH_EXTRACTION_RESIDUAL_ATTRIBUTES',
severity: 'warning',
category: 'extraction-error',
message: `Found ${residualBefore} residual data-imhotep-runtime-id attribute(s) from a prior extraction that did not clean up.`,
source: 'imhotep-cdp',
related: [],
fixHints: ['Leftover attributes indicate a prior extraction did not clean up. A page reload or navigating away and back may clear residual attributes.'],
metrics: { residualCount: residualBefore },
sourceRef: {},
})
}
} catch {
// Best-effort pre-check; proceed with extraction.
}
try { try {
await sessionManager.enableDomain('DOM') await sessionManager.enableDomain('DOM')
@@ -912,18 +989,89 @@ export async function extractWorldCdp(
selectorToIds.set(selectorKey, matches) selectorToIds.set(selectorKey, matches)
} }
if (requiredFacts) {
requiredFacts.fulfillment[FactCategory.Geometry] = 'fulfilled'
if (requiredFacts.topology) requiredFacts.fulfillment[FactCategory.Topology] = 'fulfilled'
if (requiredFacts.styles) requiredFacts.fulfillment[FactCategory.Styles] = 'fulfilled'
if (requiredFacts.fragments) requiredFacts.fulfillment[FactCategory.Fragments] = 'fulfilled'
if (requiredFacts.domAncestry) requiredFacts.fulfillment[FactCategory.DomAncestry] = 'fulfilled'
if (requiredFacts.clipping) requiredFacts.fulfillment[FactCategory.Clipping] = 'fulfilled'
if (requiredFacts.scroll) requiredFacts.fulfillment[FactCategory.Scroll] = 'fulfilled'
}
return { world, selectorToIds, errors } return { world, selectorToIds, errors }
} finally { } finally {
await playwrightPage.evaluate(() => { try {
const nodes = Array.from(document.querySelectorAll('[data-imhotep-runtime-id]')) const result = await playwrightPage.evaluate(() => {
for (const el of nodes) { const nodes = Array.from(document.querySelectorAll<HTMLElement>('[data-imhotep-runtime-id]'))
el.removeAttribute('data-imhotep-runtime-id') const cleaned = nodes.length
for (const el of nodes) {
el.removeAttribute('data-imhotep-runtime-id')
}
const remaining = document.querySelectorAll('[data-imhotep-runtime-id]').length
return { cleaned, remaining }
})
if (result.remaining > 0) {
errors.push({
code: 'IMH_EXTRACTION_CLEANUP_FAILED',
severity: 'warning',
category: 'extraction-error',
message: `CDP cleanup incomplete: ${result.remaining} data-imhotep-runtime-id attribute(s) could not be removed (injected ${result.cleaned}).`,
source: 'imhotep-cdp',
related: [],
fixHints: ['Verify the page is still interactive (not closed or navigated away).'],
metrics: { injected: result.cleaned, remaining: result.remaining },
sourceRef: {},
})
} }
}).catch((err) => { } catch (err) {
errors.push({
code: 'IMH_EXTRACTION_CLEANUP_FAILED',
severity: 'warning',
category: 'extraction-error',
message: `CDP attribute cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
source: 'imhotep-cdp',
related: [],
fixHints: ['Verify the page is still interactive (not closed or navigated away).'],
metrics: {},
sourceRef: {},
})
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[imhotep-playwright] CDP cleanup failed:', err instanceof Error ? err.message : err) console.warn('[imhotep-playwright] CDP cleanup failed:', err instanceof Error ? err.message : err)
}) }
await sessionManager.detach() try {
await sessionManager.detach()
} catch (err) {
errors.push({
code: 'IMH_EXTRACTION_CLEANUP_FAILED',
severity: 'warning',
category: 'extraction-error',
message: `CDP session detach failed: ${err instanceof Error ? err.message : String(err)}`,
source: 'imhotep-cdp',
related: [],
fixHints: ['The CDP session may already be closed. This is typically non-fatal.'],
metrics: {},
sourceRef: {},
})
// eslint-disable-next-line no-console
console.warn('[imhotep-playwright] CDP cleanup failed:', err instanceof Error ? err.message : err)
}
try {
await sessionManager.detach()
} catch (err) {
errors.push({
code: 'IMH_EXTRACTION_CLEANUP_FAILED',
severity: 'warning',
category: 'extraction-error',
message: `CDP session detach failed: ${err instanceof Error ? err.message : String(err)}`,
source: 'imhotep-cdp',
related: [],
fixHints: ['The CDP session may already be closed. This is typically non-fatal.'],
metrics: {},
sourceRef: {},
})
// eslint-disable-next-line no-console
console.warn('[imhotep-playwright] CDP session detach failed:', err instanceof Error ? err.message : err)
}
} }
} }
@@ -935,7 +1083,7 @@ export async function extractWorld(
playwrightPage: Page, playwrightPage: Page,
selectors: string[], selectors: string[],
cacheDir?: string | null, cacheDir?: string | null,
requiredFacts?: { geometry: boolean; topology: boolean; styles: boolean; fragments: boolean; domAncestry: boolean }, requiredFacts?: FactPlan,
allowFastGeometry = false, allowFastGeometry = false,
): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: ImhotepDiagnostic[] }> { ): Promise<{ world: GeometryWorld; selectorToIds: Map<string, number[]>; errors: ImhotepDiagnostic[] }> {
async function resolveViewport(): Promise<{ width: number; height: number }> { async function resolveViewport(): Promise<{ width: number; height: number }> {
@@ -1304,34 +1452,33 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto
export function getSelectorsFromAssertion(assertion: FluentRelation | FluentAssertion | FluentQuantifier): string[] { export function getSelectorsFromAssertion(assertion: FluentRelation | FluentAssertion | FluentQuantifier): string[] {
const selectors = new Set<string>() const selectors = new Set<string>()
const rel = assertion as any
if (rel.assertion?.getSubject) { if (assertion instanceof FluentRelation) {
selectors.add(rel.assertion.getSubject()) selectors.add(assertion.assertion.getSubject())
} if (assertion.referenceSelector) {
if (rel.referenceSelector) { selectors.add(assertion.referenceSelector)
selectors.add(rel.referenceSelector) }
} if (assertion.isCompound) {
// Extract selectors from compound relation parts (.and / .or chaining) for (const part of assertion.compoundParts) {
const compoundRel = assertion as FluentRelation if (part.referenceSelector) {
if (compoundRel.isCompound) { selectors.add(part.referenceSelector)
for (const part of compoundRel.compoundParts) { }
if (part.referenceSelector) {
selectors.add(part.referenceSelector)
} }
} }
} } else if (assertion instanceof FluentQuantifier) {
if (Array.isArray(rel.bindings)) { for (const b of assertion.bindings) {
for (const b of rel.bindings) { if (b.selector) selectors.add(b.selector)
if (b?.selector) selectors.add(b.selector) if (typeof b.getAssertions === 'function') {
// Extract reference selectors from assertions inside FluentQuantifier bindings for (const inner of b.getAssertions()) {
if (typeof b?.getAssertions === 'function') { if (inner?.reference?.value) {
for (const assertion of b.getAssertions()) { selectors.add(inner.reference.value)
if (assertion?.reference?.value) {
selectors.add(assertion.reference.value)
} }
} }
} }
} }
} else {
// FluentAssertion
selectors.add(assertion.getSubject())
} }
return Array.from(selectors) return Array.from(selectors)
} }
@@ -1371,21 +1518,17 @@ export function getSelectorsFromFormula(formula: FormulaNode): string[] {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function buildFluentLabel(assertion: FluentRelation | FluentAssertion | FluentQuantifier): string { export function buildFluentLabel(assertion: FluentRelation | FluentAssertion | FluentQuantifier): string {
const rel = assertion as any if (assertion instanceof FluentQuantifier) {
const firstBinding = assertion.bindings[0]
// FluentQuantifier
if (rel.bindings && Array.isArray(rel.bindings)) {
const firstBinding = rel.bindings[0]
const selector = firstBinding?.selector || 'unknown' const selector = firstBinding?.selector || 'unknown'
return `quantified assertion over '${selector}'` return `quantified assertion over '${selector}'`
} }
// FluentRelation if (assertion instanceof FluentRelation) {
if (rel.relation && rel.assertion?.getSubject) { const subject = assertion.assertion.getSubject()
const subject = rel.assertion.getSubject() const ref = assertion.referenceSelector || ''
const ref = rel.referenceSelector || '' const opts = assertion.options || {}
const opts = rel.options || {} const parts: string[] = [`'${subject}' ${assertion.relation}`]
const parts: string[] = [`'${subject}' ${rel.relation}`]
if (ref) parts.push(`'${ref}'`) if (ref) parts.push(`'${ref}'`)
if (opts.minGap !== undefined) parts.push(`gap ${opts.minGap}px`) if (opts.minGap !== undefined) parts.push(`gap ${opts.minGap}px`)
if (opts.maxGap !== undefined) parts.push(`maxGap ${opts.maxGap}px`) if (opts.maxGap !== undefined) parts.push(`maxGap ${opts.maxGap}px`)
@@ -499,8 +499,8 @@ function compileSingleAssertion(
} }
} }
// FluentAssertion without relation (incomplete — should not happen in practice) // FluentAssertion or unknown type — should not happen in practice.
return null throw new Error(`compileSingleAssertion: unknown assertion type "${typeof assertion}"`)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+53 -34
View File
@@ -72,100 +72,119 @@ export interface EvaluationOutput {
} }
/** /**
* Register all built-in clause evaluators with the global registry. * Populate a ClauseRegistry with all built-in clause evaluators (idempotent).
* Call this once before invoking evaluate() if you want the default set.
*/ */
export function registerDefaultClauses(): void { export function populateDefaultClauses(registry: ClauseRegistry): void {
registerClause({ const SENTINEL = '__imhotep_clause_defaults__'
if (registry.getEvaluator(SENTINEL)) return
registry.register({
clauseKind: SENTINEL,
version: 1,
requiredFacts: [],
evaluate: () => ({ clauseId: '', status: 'pass' as const, truth: 'determinate' as const }),
})
registry.register({
clauseKind: 'relation.leftOf', clauseKind: 'relation.leftOf',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateLeftOf, evaluate: evaluateLeftOf,
}); })
registerClause({ registry.register({
clauseKind: 'relation.rightOf', clauseKind: 'relation.rightOf',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateRightOf, evaluate: evaluateRightOf,
}); })
registerClause({ registry.register({
clauseKind: 'relation.above', clauseKind: 'relation.above',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateAbove, evaluate: evaluateAbove,
}); })
registerClause({ registry.register({
clauseKind: 'relation.below', clauseKind: 'relation.below',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateBelow, evaluate: evaluateBelow,
}); })
registerClause({ registry.register({
clauseKind: 'relation.overlaps', clauseKind: 'relation.overlaps',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateOverlaps, evaluate: evaluateOverlaps,
}); })
registerClause({ registry.register({
clauseKind: 'relation.inside', clauseKind: 'relation.inside',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateInside, evaluate: evaluateInside,
}); })
registerClause({ registry.register({
clauseKind: 'alignment.alignedWith', clauseKind: 'alignment.alignedWith',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateAlignedWith, evaluate: evaluateAlignedWith,
}); })
registerClause({ registry.register({
clauseKind: 'alignment.centeredWithin', clauseKind: 'alignment.centeredWithin',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
evaluate: evaluateCenteredWithin, evaluate: evaluateCenteredWithin,
}); })
registerClause({ registry.register({
clauseKind: 'size.atLeast', clauseKind: 'size.atLeast',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox'], requiredFacts: ['subject.primaryBox'],
evaluate: evaluateAtLeast, evaluate: evaluateAtLeast,
}); })
registerClause({ registry.register({
clauseKind: 'size.atMost', clauseKind: 'size.atMost',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox'], requiredFacts: ['subject.primaryBox'],
evaluate: evaluateAtMost, evaluate: evaluateAtMost,
}); })
registerClause({ registry.register({
clauseKind: 'size.between', clauseKind: 'size.between',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox'], requiredFacts: ['subject.primaryBox'],
evaluate: evaluateBetween, evaluate: evaluateBetween,
}); })
registerClause({ registry.register({
clauseKind: 'size.aspectRatio', clauseKind: 'size.aspectRatio',
version: 1, version: 1,
requiredFacts: ['subject.primaryBox'], requiredFacts: ['subject.primaryBox'],
evaluate: evaluateAspectRatio, evaluate: evaluateAspectRatio,
}); })
registerClause({ registry.register({
clauseKind: 'topology.clippedBy', clauseKind: 'topology.clippedBy',
version: 1, version: 1,
requiredFacts: ['subject.clipChain', 'reference.clipChain'], requiredFacts: ['subject.clipChain', 'reference.clipChain'],
evaluate: evaluateClippedBy, evaluate: evaluateClippedBy,
}); })
registerClause({ registry.register({
clauseKind: 'topology.attachedToScrollContainer', clauseKind: 'topology.attachedToScrollContainer',
version: 1, version: 1,
requiredFacts: ['topology.scrollContainerOf'], requiredFacts: ['topology.scrollContainerOf'],
evaluate: evaluateAttachedToScrollContainer, evaluate: evaluateAttachedToScrollContainer,
}); })
registerClause({ registry.register({
clauseKind: 'topology.inStackingContext', clauseKind: 'topology.inStackingContext',
version: 1, version: 1,
requiredFacts: ['topology.stackingContextOf'], requiredFacts: ['topology.stackingContextOf'],
evaluate: evaluateInStackingContext, evaluate: evaluateInStackingContext,
}); })
}
/** Create a fresh ClauseRegistry with all built-in clause evaluators pre-registered. */
export function createDefaultClauseRegistry(): ClauseRegistry {
const registry = new ClauseRegistry()
populateDefaultClauses(registry)
return registry
}
/** @deprecated Use createDefaultClauseRegistry() or explicit ClauseRegistry injection. */
export function registerDefaultClauses(): void {
populateDefaultClauses(globalClauseRegistry)
} }
/** /**
@@ -176,7 +195,7 @@ export function evaluate(
clauses: ClauseDescriptor[], clauses: ClauseDescriptor[],
options: EvaluationOptions = {}, options: EvaluationOptions = {},
): EvaluationOutput { ): EvaluationOutput {
const registry = options.registry ?? globalClauseRegistry; const registry = options.registry ?? createDefaultClauseRegistry();
// Reset per-evaluation transform caches so visual rects are recomputed // Reset per-evaluation transform caches so visual rects are recomputed
// once per subject per evaluation batch. // once per subject per evaluation batch.
clearEvaluationCache(world); clearEvaluationCache(world);
+4
View File
@@ -66,6 +66,8 @@ export {
evaluate, evaluate,
collectRequiredFacts, collectRequiredFacts,
registerDefaultClauses, registerDefaultClauses,
createDefaultClauseRegistry,
populateDefaultClauses,
type EvaluationOptions, type EvaluationOptions,
type EvaluationOutput, type EvaluationOutput,
} from './engine.js'; } from './engine.js';
@@ -78,6 +80,8 @@ export {
getPredicateDescriptor, getPredicateDescriptor,
getRequiredFactsForPredicate, getRequiredFactsForPredicate,
registerDefaultPredicates, registerDefaultPredicates,
createDefaultPredicateRegistry,
populateDefaultPredicates,
BUILTIN_PREDICATES, BUILTIN_PREDICATES,
type PredicateDescriptor, type PredicateDescriptor,
type PredicateEvaluator, type PredicateEvaluator,
+2 -2
View File
@@ -37,7 +37,7 @@ import {
getPredicateEvaluator, getPredicateEvaluator,
registerDefaultPredicates, registerDefaultPredicates,
PredicateRegistry, PredicateRegistry,
globalPredicateRegistry, createDefaultPredicateRegistry,
} from './predicates.js'; } from './predicates.js';
import { import {
@@ -906,7 +906,7 @@ export function evaluateLogic(input: LogicEngineInput): DeterministicSceneEvalua
proofs: [], proofs: [],
proofCounter: 0, proofCounter: 0,
formulaCounter: 0, formulaCounter: 0,
predicateRegistry: options.predicateRegistry ?? globalPredicateRegistry, predicateRegistry: options.predicateRegistry ?? createDefaultPredicateRegistry(),
}; };
addTrace(state, 'evaluate-logic-start'); addTrace(state, 'evaluate-logic-start');
+49 -38
View File
@@ -1200,45 +1200,56 @@ export const hasGapPredicate: PredicateEvaluator = {
/** Sentinel registered to detect if defaults were already installed. */ /** Sentinel registered to detect if defaults were already installed. */
const DEFAULT_SENTINEL = '__imhotep_defaults_registered__' const DEFAULT_SENTINEL = '__imhotep_defaults_registered__'
export function registerDefaultPredicates(): void { /** Populate a PredicateRegistry with all 33 built-in predicates (idempotent). */
if (globalPredicateRegistry.get(DEFAULT_SENTINEL)) return export function populateDefaultPredicates(registry: PredicateRegistry): void {
// Register sentinel first so partial failures don't cause infinite loops. if (registry.get(DEFAULT_SENTINEL)) return
globalPredicateRegistry.register({ registry.register({
descriptor: { name: DEFAULT_SENTINEL, arity: 0, domains: [], requiredFacts: [] }, descriptor: { name: DEFAULT_SENTINEL, arity: 0, domains: [], requiredFacts: [] },
evaluateTuple: () => ({ truth: 'indeterminate' }), evaluateTuple: () => ({ truth: 'indeterminate' }),
}) })
registerPredicate(widthPredicate); registry.register(widthPredicate)
registerPredicate(heightPredicate); registry.register(heightPredicate)
registerPredicate(abovePredicate); registry.register(abovePredicate)
registerPredicate(belowPredicate); registry.register(belowPredicate)
registerPredicate(leftOfPredicate); registry.register(leftOfPredicate)
registerPredicate(rightOfPredicate); registry.register(rightOfPredicate)
registerPredicate(insidePredicate); registry.register(insidePredicate)
registerPredicate(containsPredicate); registry.register(containsPredicate)
registerPredicate(overlapsPredicate); registry.register(overlapsPredicate)
registerPredicate(alignedWithPredicate); registry.register(alignedWithPredicate)
registerPredicate(centeredWithinPredicate); registry.register(centeredWithinPredicate)
registerPredicate(atLeastPredicate); registry.register(atLeastPredicate)
registerPredicate(atMostPredicate); registry.register(atMostPredicate)
registerPredicate(betweenPredicate); registry.register(betweenPredicate)
registerPredicate(clippedByPredicate); registry.register(clippedByPredicate)
registerPredicate(attachedToScrollContainerPredicate); registry.register(attachedToScrollContainerPredicate)
registerPredicate(escapeClippingChainOfPredicate); registry.register(escapeClippingChainOfPredicate)
registerPredicate(aspectRatioPredicate); registry.register(aspectRatioPredicate)
registerPredicate(inStackingContextPredicate); registry.register(inStackingContextPredicate)
registerPredicate(separatedFromPredicate); registry.register(separatedFromPredicate)
registerPredicate(leftAlignedWithPredicate); registry.register(leftAlignedWithPredicate)
registerPredicate(rightAlignedWithPredicate); registry.register(rightAlignedWithPredicate)
registerPredicate(topAlignedWithPredicate); registry.register(topAlignedWithPredicate)
registerPredicate(bottomAlignedWithPredicate); registry.register(bottomAlignedWithPredicate)
registerPredicate(intersectsPredicate); registry.register(intersectsPredicate)
registerPredicate(touchesPredicate); registry.register(touchesPredicate)
registerPredicate(hasGapPredicate); registry.register(hasGapPredicate)
registerPredicate(besidePredicate); registry.register(besidePredicate)
registerPredicate(nextToPredicate); registry.register(nextToPredicate)
registerPredicate(adjacentPredicate); registry.register(adjacentPredicate)
registerPredicate(touchingPredicate); registry.register(touchingPredicate)
registerPredicate(nearPredicate); registry.register(nearPredicate)
registerPredicate(underPredicate); registry.register(underPredicate)
registerPredicate(withinPredicate); registry.register(withinPredicate)
}
/** Create a fresh PredicateRegistry with all 33 built-in predicates pre-registered. */
export function createDefaultPredicateRegistry(): PredicateRegistry {
const registry = new PredicateRegistry()
populateDefaultPredicates(registry)
return registry
}
export function registerDefaultPredicates(): void {
populateDefaultPredicates(globalPredicateRegistry)
} }
+72 -51
View File
@@ -59,9 +59,20 @@ function buildFailedPredicate(
// --- Directional gap (leftOf / rightOf / above / below) --- // --- Directional gap (leftOf / rightOf / above / below) ---
if (hasGap) { if (hasGap) {
const gap = metrics.observedGap ?? metrics.gap ?? 0; const gap = metrics.observedGap ?? metrics.gap;
if (gap === undefined) return undefined;
const min = metrics.minGap ?? 0; const min = metrics.minGap ?? 0;
const max = metrics.maxGap ?? Infinity; const max = metrics.maxGap ?? Infinity;
const hasSubjectRect =
metrics.subjectLeft !== undefined &&
metrics.subjectTop !== undefined &&
metrics.subjectRight !== undefined &&
metrics.subjectBottom !== undefined;
const hasRefRect =
metrics.refLeft !== undefined &&
metrics.refTop !== undefined &&
metrics.refRight !== undefined &&
metrics.refBottom !== undefined;
return { return {
...base, ...base,
op: gap < min ? '<' : '>', op: gap < min ? '<' : '>',
@@ -70,68 +81,71 @@ function buildFailedPredicate(
measuredGap: gap, measuredGap: gap,
expectedMinGap: Number.isFinite(min) ? min : undefined, expectedMinGap: Number.isFinite(min) ? min : undefined,
expectedMaxGap: Number.isFinite(max) ? max : undefined, expectedMaxGap: Number.isFinite(max) ? max : undefined,
subjectRect: subjectRect: hasSubjectRect
metrics.subjectLeft !== undefined ? {
? { left: metrics.subjectLeft,
left: metrics.subjectLeft, top: metrics.subjectTop,
top: metrics.subjectTop ?? 0, right: metrics.subjectRight,
right: metrics.subjectRight ?? 0, bottom: metrics.subjectBottom,
bottom: metrics.subjectBottom ?? 0, }
} : undefined,
: undefined, referenceRect: hasRefRect
referenceRect: ? {
metrics.refLeft !== undefined left: metrics.refLeft,
? { top: metrics.refTop,
left: metrics.refLeft, right: metrics.refRight,
top: metrics.refTop ?? 0, bottom: metrics.refBottom,
right: metrics.refRight ?? 0, }
bottom: metrics.refBottom ?? 0, : undefined,
}
: undefined,
}; };
} }
// --- Containment / inside (unique overflow metrics) --- // --- Containment / inside (unique overflow metrics) ---
if (kind === 'inside') { if (kind === 'inside') {
const overflowLeft = metrics.overflowLeft ?? 0; const hasSubjectRect =
const overflowTop = metrics.overflowTop ?? 0; metrics.subjectLeft !== undefined &&
const overflowRight = metrics.overflowRight ?? 0; metrics.subjectTop !== undefined &&
const overflowBottom = metrics.overflowBottom ?? 0; metrics.subjectRight !== undefined &&
metrics.subjectBottom !== undefined;
const hasRefRect =
metrics.refLeft !== undefined &&
metrics.refTop !== undefined &&
metrics.refRight !== undefined &&
metrics.refBottom !== undefined;
return { return {
...base, ...base,
op: 'not-contained', op: 'not-contained',
left: 0, left: 0,
right: 0, right: 0,
overflowEdges: { overflowEdges: {
left: overflowLeft, left: metrics.overflowLeft ?? 0,
top: overflowTop, top: metrics.overflowTop ?? 0,
right: overflowRight, right: metrics.overflowRight ?? 0,
bottom: overflowBottom, bottom: metrics.overflowBottom ?? 0,
}, },
subjectRect: subjectRect: hasSubjectRect
metrics.subjectLeft !== undefined ? {
? { left: metrics.subjectLeft,
left: metrics.subjectLeft, top: metrics.subjectTop,
top: metrics.subjectTop ?? 0, right: metrics.subjectRight,
right: metrics.subjectRight ?? 0, bottom: metrics.subjectBottom,
bottom: metrics.subjectBottom ?? 0, }
} : undefined,
: undefined, referenceRect: hasRefRect
referenceRect: ? {
metrics.refLeft !== undefined left: metrics.refLeft,
? { top: metrics.refTop,
left: metrics.refLeft, right: metrics.refRight,
top: metrics.refTop ?? 0, bottom: metrics.refBottom,
right: metrics.refRight ?? 0, }
bottom: metrics.refBottom ?? 0, : undefined,
}
: undefined,
}; };
} }
// --- Size threshold (atLeast / atMost / between) --- // --- Size threshold (atLeast / atMost / between) ---
if (isSize && kind !== 'aspectRatio') { if (isSize && kind !== 'aspectRatio') {
const observed = metrics.observed ?? metrics.value ?? 0; const observed = metrics.observed ?? metrics.value;
if (observed === undefined) return undefined;
const min = metrics.min ?? -Infinity; const min = metrics.min ?? -Infinity;
const max = metrics.max ?? Infinity; const max = metrics.max ?? Infinity;
return { return {
@@ -147,7 +161,8 @@ function buildFailedPredicate(
// --- Aspect ratio --- // --- Aspect ratio ---
if (kind === 'aspectRatio') { if (kind === 'aspectRatio') {
const observed = metrics.observed ?? 0; const observed = metrics.observed;
if (observed === undefined) return undefined;
const minRatio = metrics.minRatio ?? -Infinity; const minRatio = metrics.minRatio ?? -Infinity;
const maxRatio = metrics.maxRatio ?? Infinity; const maxRatio = metrics.maxRatio ?? Infinity;
return { return {
@@ -163,7 +178,8 @@ function buildFailedPredicate(
// --- Alignment (alignedWith / centeredWithin) --- // --- Alignment (alignedWith / centeredWithin) ---
if (hasAxis || kind === 'centeredWithin') { if (hasAxis || kind === 'centeredWithin') {
const delta = metrics.delta ?? metrics.deltaX ?? metrics.deltaY ?? 0; const delta = metrics.delta ?? metrics.deltaX ?? metrics.deltaY;
if (delta === undefined) return undefined;
const tolerance = metrics.tolerance ?? 0; const tolerance = metrics.tolerance ?? 0;
return { return {
...base, ...base,
@@ -188,16 +204,21 @@ function synthesizeGenericFailedPredicate(
): Proof['failedPredicate'] { ): Proof['failedPredicate'] {
const keys = Object.keys(metrics); const keys = Object.keys(metrics);
if (keys.length >= 2) { if (keys.length >= 2) {
const left = metrics[keys[0]];
const right = metrics[keys[1]];
if (left === undefined || right === undefined) return undefined;
return { return {
op: '<', op: '<',
left: metrics[keys[0]] ?? 0, left,
right: metrics[keys[1]] ?? 0, right,
}; };
} }
if (keys.length === 1) { if (keys.length === 1) {
const val = metrics[keys[0]];
if (val === undefined) return undefined;
return { return {
op: '<', op: '<',
left: metrics[keys[0]] ?? 0, left: val,
right: 0, right: 0,
}; };
} }
+1 -1
View File
@@ -297,7 +297,7 @@ export class ClauseRegistry {
/** @deprecated Use explicit ClauseRegistry injection via EvaluationOptions.registry. */ /** @deprecated Use explicit ClauseRegistry injection via EvaluationOptions.registry. */
export const globalClauseRegistry = new ClauseRegistry(); export const globalClauseRegistry = new ClauseRegistry();
/** Register a clause family so the engine can route evaluation. */ /** Register a clause family in the global registry. @deprecated Use ClauseRegistry.register directly. */
export function registerClause(entry: ClauseEntry): void { export function registerClause(entry: ClauseEntry): void {
globalClauseRegistry.register(entry); globalClauseRegistry.register(entry);
} }
+12 -6
View File
@@ -33,11 +33,16 @@ function result(
} }
/** /**
* Safely read a topology value indexed by subject id. * Safely read a topology value for a given solver subject ID.
* Returns 0 when the subject is out of bounds (treated as "no relation"). *
* Solver subject IDs are 1-indexed; topology arrays are 0-indexed parallel
* to world.subjects.ids. Returns 0 when the subject is not found (treated as
* "no relation").
*/ */
function getTopologyValue(arr: number[], subjectId: number): number { function getTopologyValue(world: GeometryWorld, arr: number[], subjectId: number): number {
return arr[subjectId] ?? 0; const idx = world.subjects.ids.indexOf(subjectId);
if (idx < 0 || idx >= arr.length) return 0;
return arr[idx];
} }
/** /**
@@ -109,6 +114,7 @@ export function evaluateAttachedToScrollContainer(
} }
const scrollContainerId = getTopologyValue( const scrollContainerId = getTopologyValue(
world,
world.topology.scrollContainerOf, world.topology.scrollContainerOf,
subjectRef, subjectRef,
); );
@@ -139,7 +145,7 @@ export function evaluateInStackingContext(
}); });
} }
const sCtx = getTopologyValue(world.topology.stackingContextOf, subjectRef); const sCtx = getTopologyValue(world, world.topology.stackingContextOf, subjectRef);
if (referenceRef === undefined) { if (referenceRef === undefined) {
const pass = sCtx !== 0; const pass = sCtx !== 0;
@@ -152,7 +158,7 @@ export function evaluateInStackingContext(
); );
} }
const rCtx = getTopologyValue(world.topology.stackingContextOf, referenceRef); const rCtx = getTopologyValue(world, world.topology.stackingContextOf, referenceRef);
const pass = sCtx !== 0 && sCtx === rCtx; const pass = sCtx !== 0 && sCtx === rCtx;
return result( return result(
+5
View File
@@ -0,0 +1,5 @@
{
"asAny": 98,
"emptyCatch": 17,
"nullishZero": 61
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Structural conformance gate.
*
* Counts production-source code-smells and compares against committed baselines.
* Nonzero exit code = a baseline was violated. The script does NOT alter
* files; it only audits and reports.
*
* Usage: node scripts/check-structural.js [--fix-baseline]
*/
import { readFileSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url'
import glob from 'glob'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(__dirname, '..')
const BASELINE_PATH = path.join(ROOT, 'scripts', '.structural-baseline.json')
const PACKAGE_DIRS = glob.sync('packages/*', { cwd: ROOT, absolute: true })
.filter(d => !d.match(/(imhotep-fixtures|imhotep-bench|imhotep-cli)$/))
function srcFiles(pkgDir) {
return glob.sync('src/**/*.ts', { cwd: pkgDir, absolute: true })
.filter(f => !f.endsWith('.test.ts') && !f.endsWith('.d.ts'))
}
function countPattern(files, pattern, excludeComment = false) {
let count = 0
const locations = []
for (const f of files) {
const lines = readFileSync(f, 'utf8').split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (excludeComment && line.trim().startsWith('//')) continue
const matches = line.match(pattern)
if (matches) {
count += matches.length
locations.push(`${path.relative(ROOT, f)}:${i + 1}`)
}
}
}
return { count, locations }
}
const allFiles = PACKAGE_DIRS.flatMap(d => srcFiles(d))
const pkgNames = PACKAGE_DIRS.map(d => path.basename(d))
const violations = {
asAny: countPattern(allFiles, /\bas any\b/g, true),
emptyCatch: countPattern(allFiles, /\bcatch\s*\{/),
nullishZero: countPattern(allFiles, /\?\?\s*0(?![.\d])/, true),
}
const baseline = (() => {
try { return JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) }
catch { return {} }
})()
const results = {
timestamp: new Date().toISOString(),
packages: pkgNames,
violations,
baseline,
}
const errors = []
for (const [key, current] of Object.entries(violations)) {
const allowed = baseline[key] ?? 0
const delta = current.count - allowed
const status = delta <= 0 ? 'PASS' : 'FAIL'
const msg = ` ${key.padEnd(18)} current=${String(current.count).padStart(3)} baseline=${String(allowed).padStart(3)} delta=${String(delta).padStart(4)} ${status}`
console.log(msg)
if (status === 'FAIL') {
errors.push(`${key} (${current.count} > baseline ${allowed})`)
console.log(` New locations:`)
for (const loc of current.locations.slice(0, 10)) {
console.log(` ${loc}`)
}
if (current.locations.length > 10) {
console.log(` ... and ${current.locations.length - 10} more`)
}
}
}
const fixBaseline = process.argv.includes('--fix-baseline')
if (fixBaseline) {
const newBaseline = {}
for (const [key, v] of Object.entries(violations)) {
newBaseline[key] = v.count
}
writeFileSync(BASELINE_PATH, JSON.stringify(newBaseline, null, 2) + '\n')
console.log(`\n Baseline written to ${path.relative(ROOT, BASELINE_PATH)}`)
console.log('Structural gate PASSED (baseline updated)')
process.exit(0)
}
writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
if (errors.length > 0) {
console.error(`\nStructural gate FAILED (${errors.length} violation(s)):`)
for (const e of errors) console.error(` - ${e}`)
console.error(`\nIf these are legitimate, update the baseline with:`)
console.error(` node scripts/check-structural.js --fix-baseline`)
process.exit(1)
} else {
console.log('\nStructural gate PASSED')
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
pass_count=0
fail_count=0
start_time=$(date +%s)
step() {
local label="$1"
shift
echo -e "${YELLOW}[CI]${NC} ${label}..."
if "$@" > /tmp/ci-output-$$.log 2>&1; then
echo -e " ${GREEN}PASS${NC}"
pass_count=$((pass_count + 1))
else
echo -e " ${RED}FAIL${NC}"
tail -30 /tmp/ci-output-$$.log
fail_count=$((fail_count + 1))
fi
}
echo "============================================================"
echo " Imhotep CI Verification"
echo "============================================================"
echo ""
# 1. Build all packages
step "Build" npm run build
# 2. Typecheck
step "Typecheck" npm run typecheck
# 3. Lint
step "Lint" npm run lint
# 4. Structural conformance (as any, empty catch, ?? 0 baselines)
step "Structural conformance" node scripts/check-structural.js
# 5. Unit tests (DSL, core, solver, playwright)
step "Core tests (DSL)" npm test --workspace=imhotep-dsl --if-present
step "Core tests (core)" npm test --workspace=imhotep-core --if-present
step "Core tests (solver)" npm test --workspace=imhotep-solver --if-present
step "Core tests (extractor)" npm test --workspace=imhotep-extractor --if-present
step "Core tests (geometry)" npm test --workspace=imhotep-geometry --if-present
# 6. E2E tests (requires browser)
echo -e "${YELLOW}[CI]${NC} E2E hard tests..."
if npx playwright test --config packages/imhotep-fixtures/playwright.config.ts --grep="hard test" --workers=1 > /tmp/ci-output-$$.log 2>&1; then
echo -e " ${GREEN}PASS${NC}"
pass_count=$((pass_count + 1))
else
echo -e " ${RED}FAIL${NC}"
tail -30 /tmp/ci-output-$$.log
fail_count=$((fail_count + 1))
fi
# Summary
end_time=$(date +%s)
elapsed=$((end_time - start_time))
echo ""
echo "============================================================"
echo " CI Summary"
echo "============================================================"
echo " Passed: ${GREEN}${pass_count}${NC}"
echo " Failed: ${RED}${fail_count}${NC}"
echo " Elapsed: ${elapsed}s"
echo ""
if [ "$fail_count" -gt 0 ]; then
echo -e "${RED}CI verification FAILED${NC}"
exit 1
else
echo -e "${GREEN}CI verification PASSED${NC}"
exit 0
fi