From 7f40224f0f6c908e31108252f66816b6cd0b57a1 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 22 May 2026 13:46:54 -0700 Subject: [PATCH] fix: propagate compound state through toAst() and canonical lowering ast.ts: Add optional compoundOperator and compoundParts fields to RelationAssertion, allowing compound fluent assertions to carry their .and / .or structure through the AST layer. fluent.ts: toAst() now emits compoundOperator/compoundParts when isCompound, closing the gap where compound state was silently dropped in canonical extraction, preset building, and validation. lower-to-canonical.ts: Handle RelationAssertion with compound metadata by lowering each part as a separate grouped clause (same groupId, same compoundOperator), matching DSL parser compound behavior. validator.ts: Validate options on each compound part, closing the false-positive validation gap where only the final relation was checked. 3 lossy code paths (extractCanonicalFromAssertion, presets.toPresetResult, validateRelation) now preserve compound structure. Primary FOL evaluation path was already correct (fol-compiler reads compoundParts directly). 658 tests pass. --- packages/imhotep-core/src/ast.ts | 4 ++++ packages/imhotep-dsl/src/fluent.ts | 10 +++++++++ .../imhotep-dsl/src/lower-to-canonical.ts | 19 +++++++++++++++- packages/imhotep-dsl/src/validator.ts | 22 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/imhotep-core/src/ast.ts b/packages/imhotep-core/src/ast.ts index b6d5575..d3885f0 100644 --- a/packages/imhotep-core/src/ast.ts +++ b/packages/imhotep-core/src/ast.ts @@ -110,6 +110,10 @@ export interface RelationAssertion extends AstNode { subject: SelectorRef reference: SelectorRef options: RelationOptions + /** If set, this assertion is part of a compound (.and / .or) chain. */ + compoundOperator?: 'and' | 'or' + /** Sub-relation parts forming the compound chain (including this one). */ + compoundParts?: Array<{ relation: string; referenceSelector: string; options: RelationOptions }> } export interface RelationOptions extends AstNode { diff --git a/packages/imhotep-dsl/src/fluent.ts b/packages/imhotep-dsl/src/fluent.ts index e6c54ff..3837c99 100644 --- a/packages/imhotep-dsl/src/fluent.ts +++ b/packages/imhotep-dsl/src/fluent.ts @@ -783,6 +783,16 @@ export class FluentRelation { opts.space = space } + // Emit compound metadata so canonical lowering preserves .and / .or chains + if (this.isCompound) { + base.compoundOperator = this._compoundOperator + base.compoundParts = this._compoundParts.map(p => ({ + relation: p.relation, + referenceSelector: p.referenceSelector, + options: p.options as unknown as import('imhotep-core').RelationOptions, + })) + } + return base } } diff --git a/packages/imhotep-dsl/src/lower-to-canonical.ts b/packages/imhotep-dsl/src/lower-to-canonical.ts index ac280e7..8987ce3 100644 --- a/packages/imhotep-dsl/src/lower-to-canonical.ts +++ b/packages/imhotep-dsl/src/lower-to-canonical.ts @@ -156,7 +156,24 @@ export function lowerToCanonical(ast: RelationAssertion | Program): CanonicalCla lowerBlock(child as any, descriptors, {}, undefined, () => { compoundGroupId += 1; return compoundGroupId }) } } else { - descriptors.push(lowerAssertionToCanonical(ast, {})) + // Check for compound metadata on a RelationAssertion (from fluent API toAst()) + const rel = ast as RelationAssertion + if (rel.type === 'RelationAssertion' && rel.compoundOperator && rel.compoundParts && rel.compoundParts.length > 1) { + // Lower each part as a separate clause with the same compound group ID + const groupId = compoundGroupId += 1 + for (const part of rel.compoundParts) { + const partAst: RelationAssertion = { ...rel, relation: part.relation, options: part.options } + if (part.referenceSelector) { + partAst.reference = { ...rel.reference, value: part.referenceSelector } + } + const desc = lowerAssertionToCanonical(partAst, {}) + desc.compoundOperator = rel.compoundOperator + desc.compoundGroupId = groupId + descriptors.push(desc) + } + } else { + descriptors.push(lowerAssertionToCanonical(ast, {})) + } } return descriptors diff --git a/packages/imhotep-dsl/src/validator.ts b/packages/imhotep-dsl/src/validator.ts index 44274d5..f41b8ef 100644 --- a/packages/imhotep-dsl/src/validator.ts +++ b/packages/imhotep-dsl/src/validator.ts @@ -262,6 +262,28 @@ export function validateRelation(assertion: RelationAssertion): ValidationResult ) } + // 6. Compound parts: validate each part's relation name and options + if (assertion.compoundOperator && assertion.compoundParts && assertion.compoundParts.length > 1) { + for (let i = 0; i < assertion.compoundParts.length; i++) { + const part = assertion.compoundParts[i] + const partAllowed = getValidOptionsForRelation(part.relation) + const partOpts = part.options as unknown as Record + const partProvided = Object.keys(partOpts).filter(k => k !== 'type') + for (const key of partProvided) { + if (!partAllowed.has(key)) { + diagnostics.push( + makeDiag( + 'IMH_VALID_ILLEGAL_RELATION_OPTION', + 'validation-error', + `Option "${key}" is not valid for compound part "${part.relation}". Allowed options: ${Array.from(partAllowed).join(', ') || 'none'}.`, + assertion.position, + ), + ) + } + } + } + } + return { valid: diagnostics.length === 0, diagnostics,