Compare commits
10 Commits
e78ffe3419
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4142027f9b | |||
| 2bdda12030 | |||
| 8f823d959b | |||
| 3b7be0aaf0 | |||
| ce04b2b3de | |||
| 066ef9f677 | |||
| 6c2471052f | |||
| 6a98d8ce9e | |||
| 6a3a101ef4 | |||
| 8ac69254d4 |
@@ -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.
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) ----
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ----
|
||||||
|
|||||||
@@ -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}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"asAny": 98,
|
||||||
|
"emptyCatch": 17,
|
||||||
|
"nullishZero": 61
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
Executable
+84
@@ -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
|
||||||
Reference in New Issue
Block a user