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:
@@ -110,6 +110,10 @@ export interface RelationAssertion extends AstNode {
|
|||||||
subject: SelectorRef
|
subject: SelectorRef
|
||||||
reference: SelectorRef
|
reference: SelectorRef
|
||||||
options: RelationOptions
|
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 {
|
export interface RelationOptions extends AstNode {
|
||||||
|
|||||||
@@ -783,6 +783,16 @@ export class FluentRelation {
|
|||||||
opts.space = space
|
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
|
return base
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,24 @@ export function lowerToCanonical(ast: RelationAssertion | Program): CanonicalCla
|
|||||||
lowerBlock(child as any, descriptors, {}, undefined, () => { compoundGroupId += 1; return compoundGroupId })
|
lowerBlock(child as any, descriptors, {}, undefined, () => { compoundGroupId += 1; return compoundGroupId })
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
return descriptors
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
valid: diagnostics.length === 0,
|
valid: diagnostics.length === 0,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
|||||||
Reference in New Issue
Block a user