From 1bc0c8e6df05a7a55c1794e8359eebdfe8376437 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 22 May 2026 13:31:34 -0700 Subject: [PATCH] refactor: replace smuggled compound properties with typed FluentRelation API fluent.ts: Add public getters isCompound, compoundOperator, compoundParts to FluentRelation. Add optional CompoundState to constructor. Add CompoundPart/CompoundState interfaces. Replace 4 (this as any)._xxx property smuggles in FluentCompoundBuilder._addPart() with properly typed constructor initialization via CompoundState. fol-compiler.ts: Replace 3 (relation as any)._compoundParts / ._compoundOperator / ._isCompound duck-type reads with direct relation.compoundParts / relation.compoundOperator / relation.isCompound. extraction.ts: Replace (rel as any)._compoundParts read in getSelectorsFromAssertion with typed compoundRel.isCompound / compoundRel.compoundParts access. Eliminates 7 (as any) casts across 3 production files. Zero remaining _compoundParts/_compoundOperator/_isCompound/_originalFirstRelation smuggled references in any production source file. 598 SDK + 3 conformance + 57 E2E = 658 tests pass. --- packages/imhotep-dsl/src/fluent.ts | 93 ++++++++++++------- packages/imhotep-playwright/src/extraction.ts | 5 +- .../imhotep-playwright/src/fol-compiler.ts | 6 +- 3 files changed, 68 insertions(+), 36 deletions(-) diff --git a/packages/imhotep-dsl/src/fluent.ts b/packages/imhotep-dsl/src/fluent.ts index 38c85e1..e6c54ff 100644 --- a/packages/imhotep-dsl/src/fluent.ts +++ b/packages/imhotep-dsl/src/fluent.ts @@ -644,19 +644,45 @@ export class HaveProxy { // ---- Fluent Relation Terminal ---- +// --------------------------------------------------------------------------- +// Compound relation types (replaces smuggled _compoundParts / _compoundOperator) +// --------------------------------------------------------------------------- + +export interface CompoundPart { + relation: string + referenceSelector: string + options: RelationOptions +} + +export interface CompoundState { + operator: 'and' | 'or' + parts: CompoundPart[] + firstRelation: FluentRelation +} + +// --------------------------------------------------------------------------- +// Fluent Relation +// --------------------------------------------------------------------------- + export class FluentRelation { private _assertion: FluentAssertion private _relation: string private _referenceSelector: string private _options: RelationOptions - private _compoundParts: Array<{ relation: string; referenceSelector: string; options: RelationOptions }> = [] + private _compoundParts: CompoundPart[] = [] private _compoundOperator?: 'and' | 'or' + private _originalFirstRelation?: FluentRelation - constructor(assertion: FluentAssertion, relation: string, referenceSelector: string | SubjectReference, options: RelationOptions) { + constructor(assertion: FluentAssertion, relation: string, referenceSelector: string | SubjectReference, options: RelationOptions, compoundState?: CompoundState) { this._assertion = assertion this._relation = relation this._referenceSelector = referenceSelector as string this._options = options + if (compoundState) { + this._compoundParts = compoundState.parts + this._compoundOperator = compoundState.operator + this._originalFirstRelation = compoundState.firstRelation + } } get assertion(): FluentAssertion { @@ -675,21 +701,29 @@ export class FluentRelation { return this._options } + /** Whether this relation represents a compound (.and / .or) chain. */ + get isCompound(): boolean { + return this._compoundParts.length > 1 && this._compoundOperator !== undefined + } + + /** Logical operator for compound chains (undefined for simple relations). */ + get compoundOperator(): 'and' | 'or' | undefined { + return this._compoundOperator + } + + /** Sub-relations that form the compound chain (empty for simple relations). */ + get compoundParts(): ReadonlyArray { + return this._compoundParts + } + // Chain: .and.above('.c') => leftOf('.b') AND above('.c') get and(): FluentCompoundBuilder { - // If this relation is already a compound result, continue accumulating - // instead of starting a fresh builder. This enables triple+ chaining: - // expect('.a').to.be.leftOf('.b').and.above('.c').and.inside('.d') - if ((this as any)._isCompound) { - // Use the original first relation, not this compound relation (whose - // _relation is the last-added relation, e.g. 'above'). - const originalFirst = (this as any)._originalFirstRelation as FluentRelation | undefined + if (this.isCompound) { + const originalFirst = this._originalFirstRelation const firstRelation = originalFirst ?? this const builder = new FluentCompoundBuilder(firstRelation, 'and') - // Pre-populate with all existing compound parts (skip [0] which is the - // first relation — the builder constructor already captures it). - const existingParts = (this as any)._compoundParts as Array<{ relation: string; referenceSelector: string; options: RelationOptions }> - if (existingParts && existingParts.length > 1) { + const existingParts = this._compoundParts + if (existingParts.length > 1) { for (let i = 1; i < existingParts.length; i++) { builder.getParts().push(existingParts[i]) } @@ -701,13 +735,12 @@ export class FluentRelation { // Chain: .or.below('.c') => leftOf('.b') OR below('.c') get or(): FluentCompoundBuilder { - // Same compound-continuation logic as .and - if ((this as any)._isCompound) { - const originalFirst = (this as any)._originalFirstRelation as FluentRelation | undefined + if (this.isCompound) { + const originalFirst = this._originalFirstRelation const firstRelation = originalFirst ?? this const builder = new FluentCompoundBuilder(firstRelation, 'or') - const existingParts = (this as any)._compoundParts as Array<{ relation: string; referenceSelector: string; options: RelationOptions }> - if (existingParts && existingParts.length > 1) { + const existingParts = this._compoundParts + if (existingParts.length > 1) { for (let i = 1; i < existingParts.length; i++) { builder.getParts().push(existingParts[i]) } @@ -761,7 +794,7 @@ export class FluentRelation { export class FluentCompoundBuilder { private _firstRelation: FluentRelation private _operator: 'and' | 'or' - private _parts: Array<{ relation: string; referenceSelector: string; options: RelationOptions }> = [] + private _parts: CompoundPart[] = [] constructor(firstRelation: FluentRelation, operator: 'and' | 'or') { this._firstRelation = firstRelation @@ -769,7 +802,7 @@ export class FluentCompoundBuilder { } /** Package-private: allow compound continuation to pre-populate parts. */ - getParts(): Array<{ relation: string; referenceSelector: string; options: RelationOptions }> { + getParts(): CompoundPart[] { return this._parts } @@ -816,17 +849,15 @@ export class FluentCompoundBuilder { private _addPart(relation: string, referenceSelector: string, options: RelationOptions): FluentRelation { this._parts.push({ relation, referenceSelector, options }) - const compound = new FluentRelation(this._firstRelation.assertion, relation, referenceSelector, options) - // Tag the compound relation for extraction and continuation - ;(compound as any)._isCompound = true - ;(compound as any)._compoundOperator = this._operator - ;(compound as any)._compoundParts = [ - { relation: this._firstRelation.relation, referenceSelector: this._firstRelation.referenceSelector, options: this._firstRelation.options }, - ...this._parts, - ] - // Store the original first relation so .and/.or continuation works correctly - ;(compound as any)._originalFirstRelation = this._firstRelation - return compound + const compoundState: CompoundState = { + operator: this._operator, + parts: [ + { relation: this._firstRelation.relation, referenceSelector: this._firstRelation.referenceSelector, options: this._firstRelation.options }, + ...this._parts, + ], + firstRelation: this._firstRelation, + } + return new FluentRelation(this._firstRelation.assertion, relation, referenceSelector, options, compoundState) } } diff --git a/packages/imhotep-playwright/src/extraction.ts b/packages/imhotep-playwright/src/extraction.ts index c82adbb..86df4df 100644 --- a/packages/imhotep-playwright/src/extraction.ts +++ b/packages/imhotep-playwright/src/extraction.ts @@ -1318,8 +1318,9 @@ export function getSelectorsFromAssertion(assertion: FluentRelation | FluentAsse selectors.add(rel.referenceSelector) } // Extract selectors from compound relation parts (.and / .or chaining) - if (Array.isArray(rel._compoundParts)) { - for (const part of rel._compoundParts) { + const compoundRel = assertion as FluentRelation + if (compoundRel.isCompound) { + for (const part of compoundRel.compoundParts) { if (part.referenceSelector) { selectors.add(part.referenceSelector) } diff --git a/packages/imhotep-playwright/src/fol-compiler.ts b/packages/imhotep-playwright/src/fol-compiler.ts index ed0ffc3..67c8920 100644 --- a/packages/imhotep-playwright/src/fol-compiler.ts +++ b/packages/imhotep-playwright/src/fol-compiler.ts @@ -228,8 +228,8 @@ function compileRelation( } // Handle compound relations (.and / .or chaining) - const compoundParts = (relation as any)._compoundParts as Array<{ relation: string; referenceSelector: string; options: Record }> | undefined - const compoundOperator = (relation as any)._compoundOperator as 'and' | 'or' | undefined + const compoundParts = relation.compoundParts + const compoundOperator = relation.compoundOperator if (compoundParts && compoundParts.length > 1 && compoundOperator) { const assertionSpace = getAssertionSpace() @@ -415,7 +415,7 @@ function compileSingleAssertion( const refSel = rel.referenceSelector const quantifier = rel.assertion.getQuantifier() - const isCompound = !!(rel as any)._compoundParts && (rel as any)._compoundParts.length > 1 + const isCompound = rel.isCompound const subjectVar = makeVar('$subject') const refVar = makeVar('$reference')