diff --git a/.changeset/verify-routed-transition-evidence.md b/.changeset/verify-routed-transition-evidence.md new file mode 100644 index 0000000..beea4f6 --- /dev/null +++ b/.changeset/verify-routed-transition-evidence.md @@ -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`. diff --git a/src/Machine.ts b/src/Machine.ts index 92454b8..5f47c1a 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -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 } diff --git a/src/internal/testing/machine/verification.ts b/src/internal/testing/machine/verification.ts index 461717c..ac58d41 100644 --- a/src/internal/testing/machine/verification.ts +++ b/src/internal/testing/machine/verification.ts @@ -1538,11 +1538,24 @@ export const verify = ( return direction === "entry" ? leftOrder - rightOrder : rightOrder - leftOrder }) + const transitionBranch = ( + transition: Microstep["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["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) @@ -1554,7 +1567,7 @@ export const verify = ( `retained transition from "${transition.source}" has no public definition`, transition.source ) - return + return undefined } if ( !Number.isSafeInteger(transition.branchIndex) || transition.branchIndex < 0 || @@ -1566,7 +1579,7 @@ export const verify = ( `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)) { @@ -1581,6 +1594,141 @@ export const verify = ( transition.target === undefined ? transition.source : String(transition.target) ) } + return branch + } + + const validateTransitionResolutions = ( + transitions: Microstep["transitions"], + branches: ReadonlyArray, + 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() + 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 = [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 = ( @@ -1735,7 +1883,8 @@ export const verify = ( } } } - 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 = ( @@ -1773,11 +1922,17 @@ export const verify = ( 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 ) diff --git a/src/testing/MachineTest.ts b/src/testing/MachineTest.ts index 29250ec..1c5879a 100644 --- a/src/testing/MachineTest.ts +++ b/src/testing/MachineTest.ts @@ -2050,6 +2050,7 @@ export type VerificationLaw = | "definitions.transition" | "definitions.branchIndex" | "definitions.selection" + | "definitions.resolution" /** * One independently observed violation in a planner trace. @@ -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 diff --git a/test/machine/Choice.test.ts b/test/machine/Choice.test.ts index 7b3f730..d8fbd83 100644 --- a/test/machine/Choice.test.ts +++ b/test/machine/Choice.test.ts @@ -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", () => @@ -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", () => diff --git a/test/testing/Verification.test.ts b/test/testing/Verification.test.ts index b301533..7188890 100644 --- a/test/testing/Verification.test.ts +++ b/test/testing/Verification.test.ts @@ -14,6 +14,7 @@ class Counter extends Schema.TaggedClass("Counter")("Counter", { class Increment extends Schema.TaggedClass("Increment")("Increment", {}) {} class Noop extends Schema.TaggedClass("Noop")("Noop", {}) {} class Restart extends Schema.TaggedClass("Restart")("Restart", {}) {} +class Route extends Schema.TaggedClass("VerificationRoute")("Route", {}) {} class Select extends Schema.TaggedClass