Skip to content

Commit 64094df

Browse files
Verify routed transition evidence (#130)
1 parent 4554c4d commit 64094df

7 files changed

Lines changed: 499 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Make `MachineTest.verify` accept startup roots reached through exact retained initial-choice routes and reject retained targets whose choice, initial, or history resolution is inconsistent with their selected static branch. Resolution failures are reported as `definitions.resolution`.

src/Machine.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3249,9 +3249,12 @@ export declare namespace Machine {
32493249
readonly reenter: boolean
32503250
/** Zero-based index of the selected static branch. */
32513251
readonly branchIndex: number
3252-
/** Path returned by the handler, including a history pseudo-state. */
3252+
/** Path returned by the handler, including a choice or history pseudo-state. */
32533253
readonly target: TargetPath | undefined
3254-
/** Concrete path used after resolving history, otherwise equal to `target`. */
3254+
/**
3255+
* Concrete path used after resolving choice, initial, or history routing.
3256+
* Choice microsteps retain each intermediate pseudo-state edge separately.
3257+
*/
32553258
readonly resolvedTarget: TargetPath | undefined
32563259
}
32573260

src/internal/testing/machine/verification.ts

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,11 +1538,24 @@ export const verify = <M extends AnyMachine>(
15381538
return direction === "entry" ? leftOrder - rightOrder : rightOrder - leftOrder
15391539
})
15401540

1541+
const transitionBranch = (
1542+
transition: Microstep<M>["transitions"][number]
1543+
): Machine.Machine.TransitionBranch | undefined => {
1544+
const definition = definitions.find((candidate) =>
1545+
candidate.source === transition.source && candidate.reenter === transition.reenter &&
1546+
sameTransitionTrigger(candidate.trigger, transition.trigger)
1547+
)
1548+
return definition === undefined || !Number.isSafeInteger(transition.branchIndex) || transition.branchIndex < 0 ||
1549+
transition.branchIndex >= definition.branches.length
1550+
? undefined
1551+
: definition.branches[transition.branchIndex]
1552+
}
1553+
15411554
const validateTransitionDefinition = (
15421555
transition: Microstep<M>["transitions"][number],
15431556
location: VerificationLocation
1544-
): void => {
1545-
if (!selected.has("definitions")) return
1557+
): Machine.Machine.TransitionBranch | undefined => {
1558+
if (!selected.has("definitions")) return undefined
15461559
const definition = definitions.find((candidate) =>
15471560
candidate.source === transition.source && candidate.reenter === transition.reenter &&
15481561
sameTransitionTrigger(candidate.trigger, transition.trigger)
@@ -1554,7 +1567,7 @@ export const verify = <M extends AnyMachine>(
15541567
`retained transition from "${transition.source}" has no public definition`,
15551568
transition.source
15561569
)
1557-
return
1570+
return undefined
15581571
}
15591572
if (
15601573
!Number.isSafeInteger(transition.branchIndex) || transition.branchIndex < 0 ||
@@ -1566,7 +1579,7 @@ export const verify = <M extends AnyMachine>(
15661579
`retained transition from "${transition.source}" selected invalid branch index ${transition.branchIndex}`,
15671580
transition.source
15681581
)
1569-
return
1582+
return undefined
15701583
}
15711584
const branch = definition.branches[transition.branchIndex]!
15721585
if (!targetWithinSelection(transition.target, branch, byPath)) {
@@ -1581,6 +1594,141 @@ export const verify = <M extends AnyMachine>(
15811594
transition.target === undefined ? transition.source : String(transition.target)
15821595
)
15831596
}
1597+
return branch
1598+
}
1599+
1600+
const validateTransitionResolutions = (
1601+
transitions: Microstep<M>["transitions"],
1602+
branches: ReadonlyArray<Machine.Machine.TransitionBranch | undefined>,
1603+
location: VerificationLocation
1604+
): void => {
1605+
if (!selected.has("definitions")) return
1606+
1607+
const choiceResolution = (
1608+
start: string,
1609+
afterIndex: number,
1610+
nested: boolean
1611+
): { readonly found: boolean; readonly target: string | undefined } => {
1612+
const seen = new Set<string>()
1613+
let choice = start
1614+
let cursor = afterIndex + 1
1615+
let first = true
1616+
while (!seen.has(choice)) {
1617+
seen.add(choice)
1618+
let choiceIndex = -1
1619+
for (let index = cursor; index < transitions.length; index++) {
1620+
const candidate = transitions[index]!
1621+
if (
1622+
candidate.trigger.type === "choice" &&
1623+
(candidate.source === choice || first && nested && isDescendantOrSelf(String(candidate.source), choice))
1624+
) {
1625+
choiceIndex = index
1626+
break
1627+
}
1628+
}
1629+
if (choiceIndex === -1) return { found: false, target: undefined }
1630+
const transition = transitions[choiceIndex]!
1631+
if (branches[choiceIndex] === undefined) return { found: false, target: undefined }
1632+
const target = transition.target === undefined ? undefined : String(transition.target)
1633+
if (target === undefined) return { found: true, target: undefined }
1634+
const targetNode = byPath.get(target)
1635+
if (targetNode?.type === "choice") {
1636+
choice = target
1637+
cursor = choiceIndex + 1
1638+
first = false
1639+
continue
1640+
}
1641+
return {
1642+
found: true,
1643+
target: targetNode?.type === "history" ? targetNode.parent : target
1644+
}
1645+
}
1646+
return { found: false, target: undefined }
1647+
}
1648+
1649+
transitions.forEach((transition, index) => {
1650+
if (branches[index] === undefined) return
1651+
const target = transition.target === undefined ? undefined : String(transition.target)
1652+
const resolvedTarget = transition.resolvedTarget === undefined ? undefined : String(transition.resolvedTarget)
1653+
const targetNode = target === undefined ? undefined : byPath.get(target)
1654+
let expected = target
1655+
let explanation = target === undefined ? "an unresolved targetless transition" : `target "${target}"`
1656+
1657+
if (transition.trigger.type === "choice") {
1658+
explanation = target === undefined ? "a targetless choice edge" : `choice edge target "${target}"`
1659+
} else if (targetNode?.type === "history") {
1660+
expected = targetNode.parent
1661+
explanation = `history owner "${String(targetNode.parent)}"`
1662+
} else if (targetNode?.type === "choice") {
1663+
const resolution = choiceResolution(targetNode.path, index, false)
1664+
if (!resolution.found) {
1665+
add(
1666+
"definitions.resolution",
1667+
location,
1668+
`transition from "${transition.source}" targets choice "${target}" without an exact retained route`,
1669+
target
1670+
)
1671+
return
1672+
}
1673+
expected = resolution.target
1674+
explanation = expected === undefined ?
1675+
`targetless route from choice "${target}"` :
1676+
`choice route from "${target}" to "${expected}"`
1677+
} else if (
1678+
resolvedTarget !== target &&
1679+
(targetNode?.type === "compound" || targetNode?.type === "parallel")
1680+
) {
1681+
const resolution = choiceResolution(targetNode.path, index, true)
1682+
if (resolution.found) {
1683+
expected = resolution.target
1684+
explanation = expected === undefined ?
1685+
`targetless nested choice route from "${target}"` :
1686+
`nested choice route from "${target}" to "${expected}"`
1687+
}
1688+
}
1689+
1690+
if (resolvedTarget !== expected) {
1691+
add(
1692+
"definitions.resolution",
1693+
location,
1694+
`transition branch ${transition.branchIndex} from "${transition.source}" resolved to ` +
1695+
`"${String(transition.resolvedTarget)}" instead of ${explanation}`,
1696+
resolvedTarget ?? target ?? String(transition.source)
1697+
)
1698+
}
1699+
})
1700+
}
1701+
1702+
const initialChoiceRouteRoot = (): string | undefined => {
1703+
const routing = trace.initial.plan.microsteps[0]
1704+
if (
1705+
routing === undefined || routing.changed ||
1706+
routing.transitions.length === 0 ||
1707+
!routing.transitions.every((transition) => transition.trigger.type === "choice")
1708+
) {
1709+
return undefined
1710+
}
1711+
1712+
const reachableScopes: Array<string> = [initialDefinition.target]
1713+
let terminalRoot: string | undefined
1714+
for (const transition of routing.transitions) {
1715+
const source = String(transition.source)
1716+
const branch = transitionBranch(transition)
1717+
if (
1718+
branch === undefined || !targetWithinSelection(transition.target, branch, byPath) ||
1719+
!reachableScopes.some((scope) => isDescendantOrSelf(source, scope))
1720+
) {
1721+
return undefined
1722+
}
1723+
const target = transition.target === undefined ? undefined : String(transition.target)
1724+
if (target === undefined) return undefined
1725+
const targetNode = byPath.get(target)
1726+
const scope = targetNode?.type === "history" ? targetNode.parent : target
1727+
if (scope === undefined) return undefined
1728+
reachableScopes.push(scope)
1729+
terminalRoot = ancestors(scope)[0]
1730+
}
1731+
return terminalRoot
15841732
}
15851733

15861734
const validateMicrostep = (
@@ -1735,7 +1883,8 @@ export const verify = <M extends AnyMachine>(
17351883
}
17361884
}
17371885
}
1738-
for (const transition of microstep.transitions) validateTransitionDefinition(transition, location)
1886+
const branches = microstep.transitions.map((transition) => validateTransitionDefinition(transition, location))
1887+
validateTransitionResolutions(microstep.transitions, branches, location)
17391888
}
17401889

17411890
const validatePlanCompletion = (
@@ -1773,11 +1922,17 @@ export const verify = <M extends AnyMachine>(
17731922
const starting = inspectSnapshot(trace.initial.startingState, initialLocation, "initial starting state")
17741923
if (selected.has("definitions")) {
17751924
const startingRoots = starting.paths.filter((path) => byPath.get(path)?.parent === undefined)
1776-
if (startingRoots.length !== 1 || startingRoots[0] !== initialDefinition.target) {
1925+
const routedRoot = startingRoots.length === 1 && startingRoots[0] !== initialDefinition.target
1926+
? initialChoiceRouteRoot()
1927+
: undefined
1928+
if (
1929+
startingRoots.length !== 1 ||
1930+
startingRoots[0] !== initialDefinition.target && startingRoots[0] !== routedRoot
1931+
) {
17771932
add(
17781933
"definitions.initial",
17791934
initialLocation,
1780-
`initial starting state selected roots [${startingRoots.join(", ")}] instead of ` +
1935+
`initial starting state selected roots [${startingRoots.join(", ")}] without an exact route from ` +
17811936
`declared root "${initialDefinition.target}"`,
17821937
startingRoots[0] ?? initialDefinition.target
17831938
)

src/testing/MachineTest.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2050,6 +2050,7 @@ export type VerificationLaw =
20502050
| "definitions.transition"
20512051
| "definitions.branchIndex"
20522052
| "definitions.selection"
2053+
| "definitions.resolution"
20532054

20542055
/**
20552056
* One independently observed violation in a planner trace.
@@ -2087,8 +2088,9 @@ export interface VerifyOptions {
20872088
/**
20882089
* Verifies an executed trace using only public machine inspection and raw
20892090
* snapshot data. Retained transitions are checked against their exact static
2090-
* branch and target selection, and startup is checked against the declared
2091-
* initial root. The verifier deliberately does not reuse planner
2091+
* branch, target selection, and resolved route. Startup is checked against
2092+
* the declared initial root or an exact retained initial-choice route. The
2093+
* verifier deliberately does not reuse planner
20922094
* normalization, encoding, finality, or other internal helpers.
20932095
*
20942096
* Every selected law is evaluated and returned in one structured error so a

test/machine/Choice.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,8 @@ describe("Machine choice pseudo-states", () => {
501501
assert.strictEqual(resumed.next.path, "Flow")
502502
if (resumed.next.path === "Flow") assert.strictEqual(resumed.next.state.path, "Flow.Active")
503503
assert.deepStrictEqual(resumed.microsteps[0]?.transitions.map(({ trigger }) => trigger.type), ["event", "choice"])
504+
const trace = yield* MachineTest.run(history, { events: [new Leave({}), new Resume({})] })
505+
yield* MachineTest.verify(history, trace, { laws: ["definitions"] })
504506
}))
505507

506508
it.effect("uses a history default when an initial choice targets history", () =>
@@ -548,6 +550,8 @@ describe("Machine choice pseudo-states", () => {
548550

549551
const plan = yield* Machine.planInitial(initialHistory)
550552
assert.strictEqual(plan.state.state.path, "Flow.Active")
553+
const trace = yield* MachineTest.run(initialHistory, { events: [] })
554+
yield* MachineTest.verify(initialHistory, trace, { laws: ["definitions"] })
551555
}))
552556

553557
it.effect("resolves a nested choice inside a first-use history fallback", () =>

0 commit comments

Comments
 (0)