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.
This commit is contained in:
John Dvorak
2026-05-22 13:46:54 -07:00
parent a49d6008a4
commit 7f40224f0f
4 changed files with 54 additions and 1 deletions
+10
View File
@@ -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
}
}
+18 -1
View File
@@ -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
+22
View File
@@ -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<string, unknown>
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,