Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/verify-routed-transition-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@typeonce/effect-machine": minor
---

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`.
7 changes: 5 additions & 2 deletions src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3249,9 +3249,12 @@ export declare namespace Machine {
readonly reenter: boolean
/** Zero-based index of the selected static branch. */
readonly branchIndex: number
/** Path returned by the handler, including a history pseudo-state. */
/** Path returned by the handler, including a choice or history pseudo-state. */
readonly target: TargetPath | undefined
/** Concrete path used after resolving history, otherwise equal to `target`. */
/**
* Concrete path used after resolving choice, initial, or history routing.
* Choice microsteps retain each intermediate pseudo-state edge separately.
*/
readonly resolvedTarget: TargetPath | undefined
}

Expand Down
169 changes: 162 additions & 7 deletions src/internal/testing/machine/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1538,11 +1538,24 @@ export const verify = <M extends AnyMachine>(
return direction === "entry" ? leftOrder - rightOrder : rightOrder - leftOrder
})

const transitionBranch = (
transition: Microstep<M>["transitions"][number]
): Machine.Machine.TransitionBranch | undefined => {
const definition = definitions.find((candidate) =>
candidate.source === transition.source && candidate.reenter === transition.reenter &&
sameTransitionTrigger(candidate.trigger, transition.trigger)
)
return definition === undefined || !Number.isSafeInteger(transition.branchIndex) || transition.branchIndex < 0 ||
transition.branchIndex >= definition.branches.length
? undefined
: definition.branches[transition.branchIndex]
}

const validateTransitionDefinition = (
transition: Microstep<M>["transitions"][number],
location: VerificationLocation
): void => {
if (!selected.has("definitions")) return
): Machine.Machine.TransitionBranch | undefined => {
if (!selected.has("definitions")) return undefined
const definition = definitions.find((candidate) =>
candidate.source === transition.source && candidate.reenter === transition.reenter &&
sameTransitionTrigger(candidate.trigger, transition.trigger)
Expand All @@ -1554,7 +1567,7 @@ export const verify = <M extends AnyMachine>(
`retained transition from "${transition.source}" has no public definition`,
transition.source
)
return
return undefined
}
if (
!Number.isSafeInteger(transition.branchIndex) || transition.branchIndex < 0 ||
Expand All @@ -1566,7 +1579,7 @@ export const verify = <M extends AnyMachine>(
`retained transition from "${transition.source}" selected invalid branch index ${transition.branchIndex}`,
transition.source
)
return
return undefined
}
const branch = definition.branches[transition.branchIndex]!
if (!targetWithinSelection(transition.target, branch, byPath)) {
Expand All @@ -1581,6 +1594,141 @@ export const verify = <M extends AnyMachine>(
transition.target === undefined ? transition.source : String(transition.target)
)
}
return branch
}

const validateTransitionResolutions = (
transitions: Microstep<M>["transitions"],
branches: ReadonlyArray<Machine.Machine.TransitionBranch | undefined>,
location: VerificationLocation
): void => {
if (!selected.has("definitions")) return

const choiceResolution = (
start: string,
afterIndex: number,
nested: boolean
): { readonly found: boolean; readonly target: string | undefined } => {
const seen = new Set<string>()
let choice = start
let cursor = afterIndex + 1
let first = true
while (!seen.has(choice)) {
seen.add(choice)
let choiceIndex = -1
for (let index = cursor; index < transitions.length; index++) {
const candidate = transitions[index]!
if (
candidate.trigger.type === "choice" &&
(candidate.source === choice || first && nested && isDescendantOrSelf(String(candidate.source), choice))
) {
choiceIndex = index
break
}
}
if (choiceIndex === -1) return { found: false, target: undefined }
const transition = transitions[choiceIndex]!
if (branches[choiceIndex] === undefined) return { found: false, target: undefined }
const target = transition.target === undefined ? undefined : String(transition.target)
if (target === undefined) return { found: true, target: undefined }
const targetNode = byPath.get(target)
if (targetNode?.type === "choice") {
choice = target
cursor = choiceIndex + 1
first = false
continue
}
return {
found: true,
target: targetNode?.type === "history" ? targetNode.parent : target
}
}
return { found: false, target: undefined }
}

transitions.forEach((transition, index) => {
if (branches[index] === undefined) return
const target = transition.target === undefined ? undefined : String(transition.target)
const resolvedTarget = transition.resolvedTarget === undefined ? undefined : String(transition.resolvedTarget)
const targetNode = target === undefined ? undefined : byPath.get(target)
let expected = target
let explanation = target === undefined ? "an unresolved targetless transition" : `target "${target}"`

if (transition.trigger.type === "choice") {
explanation = target === undefined ? "a targetless choice edge" : `choice edge target "${target}"`
} else if (targetNode?.type === "history") {
expected = targetNode.parent
explanation = `history owner "${String(targetNode.parent)}"`
} else if (targetNode?.type === "choice") {
const resolution = choiceResolution(targetNode.path, index, false)
if (!resolution.found) {
add(
"definitions.resolution",
location,
`transition from "${transition.source}" targets choice "${target}" without an exact retained route`,
target
)
return
}
expected = resolution.target
explanation = expected === undefined ?
`targetless route from choice "${target}"` :
`choice route from "${target}" to "${expected}"`
} else if (
resolvedTarget !== target &&
(targetNode?.type === "compound" || targetNode?.type === "parallel")
) {
const resolution = choiceResolution(targetNode.path, index, true)
if (resolution.found) {
expected = resolution.target
explanation = expected === undefined ?
`targetless nested choice route from "${target}"` :
`nested choice route from "${target}" to "${expected}"`
}
}

if (resolvedTarget !== expected) {
add(
"definitions.resolution",
location,
`transition branch ${transition.branchIndex} from "${transition.source}" resolved to ` +
`"${String(transition.resolvedTarget)}" instead of ${explanation}`,
resolvedTarget ?? target ?? String(transition.source)
)
}
})
}

const initialChoiceRouteRoot = (): string | undefined => {
const routing = trace.initial.plan.microsteps[0]
if (
routing === undefined || routing.changed ||
routing.transitions.length === 0 ||
!routing.transitions.every((transition) => transition.trigger.type === "choice")
) {
return undefined
}

const reachableScopes: Array<string> = [initialDefinition.target]
let terminalRoot: string | undefined
for (const transition of routing.transitions) {
const source = String(transition.source)
const branch = transitionBranch(transition)
if (
branch === undefined || !targetWithinSelection(transition.target, branch, byPath) ||
!reachableScopes.some((scope) => isDescendantOrSelf(source, scope))
) {
return undefined
}
const target = transition.target === undefined ? undefined : String(transition.target)
if (target === undefined) return undefined
const targetNode = byPath.get(target)
const scope = targetNode?.type === "history" ? targetNode.parent : target
if (scope === undefined) return undefined
reachableScopes.push(scope)
terminalRoot = ancestors(scope)[0]
}
return terminalRoot
}

const validateMicrostep = (
Expand Down Expand Up @@ -1735,7 +1883,8 @@ export const verify = <M extends AnyMachine>(
}
}
}
for (const transition of microstep.transitions) validateTransitionDefinition(transition, location)
const branches = microstep.transitions.map((transition) => validateTransitionDefinition(transition, location))
validateTransitionResolutions(microstep.transitions, branches, location)
}

const validatePlanCompletion = (
Expand Down Expand Up @@ -1773,11 +1922,17 @@ export const verify = <M extends AnyMachine>(
const starting = inspectSnapshot(trace.initial.startingState, initialLocation, "initial starting state")
if (selected.has("definitions")) {
const startingRoots = starting.paths.filter((path) => byPath.get(path)?.parent === undefined)
if (startingRoots.length !== 1 || startingRoots[0] !== initialDefinition.target) {
const routedRoot = startingRoots.length === 1 && startingRoots[0] !== initialDefinition.target
? initialChoiceRouteRoot()
: undefined
if (
startingRoots.length !== 1 ||
startingRoots[0] !== initialDefinition.target && startingRoots[0] !== routedRoot
) {
add(
"definitions.initial",
initialLocation,
`initial starting state selected roots [${startingRoots.join(", ")}] instead of ` +
`initial starting state selected roots [${startingRoots.join(", ")}] without an exact route from ` +
`declared root "${initialDefinition.target}"`,
startingRoots[0] ?? initialDefinition.target
)
Expand Down
6 changes: 4 additions & 2 deletions src/testing/MachineTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2050,6 +2050,7 @@ export type VerificationLaw =
| "definitions.transition"
| "definitions.branchIndex"
| "definitions.selection"
| "definitions.resolution"

/**
* One independently observed violation in a planner trace.
Expand Down Expand Up @@ -2087,8 +2088,9 @@ export interface VerifyOptions {
/**
* Verifies an executed trace using only public machine inspection and raw
* snapshot data. Retained transitions are checked against their exact static
* branch and target selection, and startup is checked against the declared
* initial root. The verifier deliberately does not reuse planner
* branch, target selection, and resolved route. Startup is checked against
* the declared initial root or an exact retained initial-choice route. The
* verifier deliberately does not reuse planner
* normalization, encoding, finality, or other internal helpers.
*
* Every selected law is evaluated and returned in one structured error so a
Expand Down
4 changes: 4 additions & 0 deletions test/machine/Choice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@ describe("Machine choice pseudo-states", () => {
assert.strictEqual(resumed.next.path, "Flow")
if (resumed.next.path === "Flow") assert.strictEqual(resumed.next.state.path, "Flow.Active")
assert.deepStrictEqual(resumed.microsteps[0]?.transitions.map(({ trigger }) => trigger.type), ["event", "choice"])
const trace = yield* MachineTest.run(history, { events: [new Leave({}), new Resume({})] })
yield* MachineTest.verify(history, trace, { laws: ["definitions"] })
}))

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

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

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