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.
This commit is contained in:
John Dvorak
2026-05-22 13:31:34 -07:00
parent 9df295b915
commit 1bc0c8e6df
3 changed files with 68 additions and 36 deletions
+60 -29
View File
@@ -644,19 +644,45 @@ export class HaveProxy {
// ---- Fluent Relation Terminal ---- // ---- 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 { export class FluentRelation {
private _assertion: FluentAssertion private _assertion: FluentAssertion
private _relation: string private _relation: string
private _referenceSelector: string private _referenceSelector: string
private _options: RelationOptions private _options: RelationOptions
private _compoundParts: Array<{ relation: string; referenceSelector: string; options: RelationOptions }> = [] private _compoundParts: CompoundPart[] = []
private _compoundOperator?: 'and' | 'or' 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._assertion = assertion
this._relation = relation this._relation = relation
this._referenceSelector = referenceSelector as string this._referenceSelector = referenceSelector as string
this._options = options this._options = options
if (compoundState) {
this._compoundParts = compoundState.parts
this._compoundOperator = compoundState.operator
this._originalFirstRelation = compoundState.firstRelation
}
} }
get assertion(): FluentAssertion { get assertion(): FluentAssertion {
@@ -675,21 +701,29 @@ export class FluentRelation {
return this._options 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<CompoundPart> {
return this._compoundParts
}
// Chain: .and.above('.c') => leftOf('.b') AND above('.c') // Chain: .and.above('.c') => leftOf('.b') AND above('.c')
get and(): FluentCompoundBuilder { get and(): FluentCompoundBuilder {
// If this relation is already a compound result, continue accumulating if (this.isCompound) {
// instead of starting a fresh builder. This enables triple+ chaining: const originalFirst = this._originalFirstRelation
// 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
const firstRelation = originalFirst ?? this const firstRelation = originalFirst ?? this
const builder = new FluentCompoundBuilder(firstRelation, 'and') const builder = new FluentCompoundBuilder(firstRelation, 'and')
// Pre-populate with all existing compound parts (skip [0] which is the const existingParts = this._compoundParts
// first relation — the builder constructor already captures it). if (existingParts.length > 1) {
const existingParts = (this as any)._compoundParts as Array<{ relation: string; referenceSelector: string; options: RelationOptions }>
if (existingParts && existingParts.length > 1) {
for (let i = 1; i < existingParts.length; i++) { for (let i = 1; i < existingParts.length; i++) {
builder.getParts().push(existingParts[i]) builder.getParts().push(existingParts[i])
} }
@@ -701,13 +735,12 @@ export class FluentRelation {
// Chain: .or.below('.c') => leftOf('.b') OR below('.c') // Chain: .or.below('.c') => leftOf('.b') OR below('.c')
get or(): FluentCompoundBuilder { get or(): FluentCompoundBuilder {
// Same compound-continuation logic as .and if (this.isCompound) {
if ((this as any)._isCompound) { const originalFirst = this._originalFirstRelation
const originalFirst = (this as any)._originalFirstRelation as FluentRelation | undefined
const firstRelation = originalFirst ?? this const firstRelation = originalFirst ?? this
const builder = new FluentCompoundBuilder(firstRelation, 'or') const builder = new FluentCompoundBuilder(firstRelation, 'or')
const existingParts = (this as any)._compoundParts as Array<{ relation: string; referenceSelector: string; options: RelationOptions }> const existingParts = this._compoundParts
if (existingParts && existingParts.length > 1) { if (existingParts.length > 1) {
for (let i = 1; i < existingParts.length; i++) { for (let i = 1; i < existingParts.length; i++) {
builder.getParts().push(existingParts[i]) builder.getParts().push(existingParts[i])
} }
@@ -761,7 +794,7 @@ export class FluentRelation {
export class FluentCompoundBuilder { export class FluentCompoundBuilder {
private _firstRelation: FluentRelation private _firstRelation: FluentRelation
private _operator: 'and' | 'or' private _operator: 'and' | 'or'
private _parts: Array<{ relation: string; referenceSelector: string; options: RelationOptions }> = [] private _parts: CompoundPart[] = []
constructor(firstRelation: FluentRelation, operator: 'and' | 'or') { constructor(firstRelation: FluentRelation, operator: 'and' | 'or') {
this._firstRelation = firstRelation this._firstRelation = firstRelation
@@ -769,7 +802,7 @@ export class FluentCompoundBuilder {
} }
/** Package-private: allow compound continuation to pre-populate parts. */ /** Package-private: allow compound continuation to pre-populate parts. */
getParts(): Array<{ relation: string; referenceSelector: string; options: RelationOptions }> { getParts(): CompoundPart[] {
return this._parts return this._parts
} }
@@ -816,17 +849,15 @@ export class FluentCompoundBuilder {
private _addPart(relation: string, referenceSelector: string, options: RelationOptions): FluentRelation { private _addPart(relation: string, referenceSelector: string, options: RelationOptions): FluentRelation {
this._parts.push({ relation, referenceSelector, options }) this._parts.push({ relation, referenceSelector, options })
const compound = new FluentRelation(this._firstRelation.assertion, relation, referenceSelector, options) const compoundState: CompoundState = {
// Tag the compound relation for extraction and continuation operator: this._operator,
;(compound as any)._isCompound = true parts: [
;(compound as any)._compoundOperator = this._operator
;(compound as any)._compoundParts = [
{ relation: this._firstRelation.relation, referenceSelector: this._firstRelation.referenceSelector, options: this._firstRelation.options }, { relation: this._firstRelation.relation, referenceSelector: this._firstRelation.referenceSelector, options: this._firstRelation.options },
...this._parts, ...this._parts,
] ],
// Store the original first relation so .and/.or continuation works correctly firstRelation: this._firstRelation,
;(compound as any)._originalFirstRelation = this._firstRelation }
return compound return new FluentRelation(this._firstRelation.assertion, relation, referenceSelector, options, compoundState)
} }
} }
@@ -1318,8 +1318,9 @@ export function getSelectorsFromAssertion(assertion: FluentRelation | FluentAsse
selectors.add(rel.referenceSelector) selectors.add(rel.referenceSelector)
} }
// Extract selectors from compound relation parts (.and / .or chaining) // Extract selectors from compound relation parts (.and / .or chaining)
if (Array.isArray(rel._compoundParts)) { const compoundRel = assertion as FluentRelation
for (const part of rel._compoundParts) { if (compoundRel.isCompound) {
for (const part of compoundRel.compoundParts) {
if (part.referenceSelector) { if (part.referenceSelector) {
selectors.add(part.referenceSelector) selectors.add(part.referenceSelector)
} }
@@ -228,8 +228,8 @@ function compileRelation(
} }
// Handle compound relations (.and / .or chaining) // Handle compound relations (.and / .or chaining)
const compoundParts = (relation as any)._compoundParts as Array<{ relation: string; referenceSelector: string; options: Record<string, unknown> }> | undefined const compoundParts = relation.compoundParts
const compoundOperator = (relation as any)._compoundOperator as 'and' | 'or' | undefined const compoundOperator = relation.compoundOperator
if (compoundParts && compoundParts.length > 1 && compoundOperator) { if (compoundParts && compoundParts.length > 1 && compoundOperator) {
const assertionSpace = getAssertionSpace() const assertionSpace = getAssertionSpace()
@@ -415,7 +415,7 @@ function compileSingleAssertion(
const refSel = rel.referenceSelector const refSel = rel.referenceSelector
const quantifier = rel.assertion.getQuantifier() 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 subjectVar = makeVar('$subject')
const refVar = makeVar('$reference') const refVar = makeVar('$reference')