Skip to content

feat(journeys,core): wildcard transitions with shared exit contracts#35

Merged
kibertoad merged 5 commits intomainfrom
claude/wildcard-journey-transitions-b3L1a
May 8, 2026
Merged

feat(journeys,core): wildcard transitions with shared exit contracts#35
kibertoad merged 5 commits intomainfrom
claude/wildcard-journey-transitions-b3L1a

Conversation

@kibertoad
Copy link
Copy Markdown
Owner

@kibertoad kibertoad commented May 7, 2026

Summary

Adds JourneyDefinition.wildcardTransitions so cross-cutting outcomes (cancelled, error, back, ...) can be declared once instead of repeated under every transitions[mod][entry] block, plus defineExitContract for cross-module shape consistency and optional Standard Schema runtime validation.

  • Two wildcard tiers on JourneyDefinition.wildcardTransitions:

    • byEntryAndExit[entry][exit] — module unknown, entry + exit known
    • byExit[exit] — module + entry both unknown

    Resolution is exact → byEntryAndExitbyExit, with the more specific handler always preempting the less specific. Encoded by lookup order in dispatchExit — no flag, no merge step.

  • defineExitContract<T>(kind, schema?) in @modular-react/core. When several modules reference the same contract value as the schema for an exit, the wildcard handler's output collapses to the contract's T (instead of the intersection across modules), and validateJourneyContracts enforces identity consistency at registration. The optional schema arg accepts any StandardSchemaV1 (Zod, Valibot, ArkType, ...) — payloads are validated at every exit() emit; failures abort with exit-payload-invalid, async schemas with exit-payload-invalid-async. Both reasons flow through the existing isJourneySystemAbort predicate.

  • Validation at registration time covers: dead keys (no registered module emits the named exit / entry+exit pair), contract identity mismatch across modules sharing a wildcard slot, and a console.warn when both tiers declare the same exit (the broader one is unreachable from the matching entry).

  • Documentation: two new README pattern sections (wildcard transitions and shared exit contracts) plus API-reference entries for defineExitContract / isExitContract / ExitContract.

Test plan

  • 374 journeys tests pass (+21 new): 4 precedence, 1 entry-name disambiguation, 4 schema-validation, 6 validator, 4 behavior (state propagation, next advance, onTransition, multi-step), plus 4 in wildcard-transitions.test-d.ts for type narrowing.
  • All existing tests pass — 43 packages green via pnpm test.
  • Full workspace pnpm build succeeds.
  • pnpm lint clean (zero new warnings; only 5 pre-existing in unrelated files).

https://claude.ai/code/session_01A2WdMdGoofyjzbjwzZJcMA


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Shared exit contracts for defining/validating exit payloads; handlers receive coerced/validated payloads.
    • Wildcard transitions as fallback handlers with exact → byEntryAndExit → byExit precedence.
    • Registration-time validation and runtime spot-checks that warn on unreachable handlers or contract drift.
    • Runtime abort reasons for schema failures, including rejection of async schema refinements.
  • Documentation

    • Expanded docs and API reference for wildcard transitions and exit contracts.
  • Tests

    • Added type-level and runtime tests for wildcard behavior, validation, and drift warnings.
  • Chores

    • Package manifest updated to add a runtime schema dependency.

Cross-cutting outcomes (cancelled, error, back, ...) tend to be emitted
by many modules and handled the same way. wildcardTransitions lets a
journey declare those handlers once instead of repeating them under
every transitions[mod][entry] block.

- New JourneyDefinition.wildcardTransitions with two precision tiers:
  byEntryAndExit[entry][exit] (module unknown, entry+exit known) and
  byExit[exit] (module + entry both unknown). Resolution is exact >
  byEntryAndExit > byExit; more specific always wins.
- defineExitContract<T>(kind) shares an exit contract across modules.
  Wildcard handlers receive the contract's typed output instead of an
  intersection across modules. Optional StandardSchemaV1 (Zod / Valibot
  / ArkType / ...) drives runtime payload validation at every emit;
  failures abort with exit-payload-invalid, async schemas with
  exit-payload-invalid-async — both flow through isJourneySystemAbort.
- validateJourneyContracts checks: live-key (some registered module
  emits the exit), contract identity consistency across modules, and
  warns on byEntryAndExit / byExit overlap.
- 21 new tests (precedence, schema validation, validator rules,
  multi-step, observability) plus a TSD suite for type narrowing.
- README pattern + API reference entries for wildcardTransitions and
  defineExitContract.

https://claude.ai/code/session_01A2WdMdGoofyjzbjwzZJcMA
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f870940-72d1-4f89-a144-0698936e0c24

📥 Commits

Reviewing files that changed from the base of the PR and between f2102b9 and d761a38.

📒 Files selected for processing (6)
  • packages/core/src/entry-exit.ts
  • packages/core/src/types.ts
  • packages/journeys/README.md
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/validation.ts
  • packages/journeys/src/wildcard-transitions.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/journeys/src/validation.ts
  • packages/journeys/README.md
  • packages/journeys/src/wildcard-transitions.test.ts

📝 Walkthrough

Walkthrough

Adds typed shared ExitContract (with optional synchronous StandardSchema validation), wildcard transition typing and registration-time/runtime validation, precedence-based handler resolution (exact → byEntryAndExit → byExit), package export updates, docs, and comprehensive tests.

Changes

Exit Contracts & Wildcard Transitions

Layer / File(s) Summary
Types & Contract Definitions
packages/core/src/types.ts, packages/core/src/journey-contracts.ts
Adds ExitContract<TOutput>, StandardSchemaLike/result/issue types, wildcard transition types (WildcardEntryNamesOf, WildcardExitNamesOf, ExitNamesPairedWithEntry, WildcardExitOutputForEntry, WildcardExitOutputOf, WildcardEntryInputOf, EntryExitWildcardMap, ExitOnlyWildcardMap, WildcardTransitionMap), and new abort reason codes exit-payload-invalid / exit-payload-invalid-async.
Contract Helpers & Detection
packages/core/src/entry-exit.ts
Implements defineExitContract overloads (type-only and schema-backed) returning ExitContract with kind identity and optional schema; adds isExitContract type guard.
Core Package Exports & Dependency
packages/core/package.json, packages/core/src/index.ts
Adds runtime dependency @standard-schema/spec and re-exports new types and helpers (ExitContract, StandardSchemaLike, StandardSchemaResult, StandardSchemaIssue, defineExitContract, isExitContract) and wildcard utilities from journey-contracts.
Journey Definition & Authoring
packages/journeys/src/types.ts, packages/journeys/src/index.ts
Adds optional wildcardTransitions?: WildcardTransitionMap to JourneyDefinition and re-exports wildcard-related types for authoring; documents precedence rules (exact → byEntryAndExit → byExit).
Runtime Exit Dispatch & Validation
packages/journeys/src/runtime.ts
dispatchExit validates payloads synchronously against an active module's ExitContract schema when present (aborts on throws, Promise results, or failures with issues), coerces validated output for handlers, caches missing-module warnings and spot-checks contract identity drift, and resolves handlers with exact→byEntryAndExit→byExit precedence.
Registration-time Wildcard Validation
packages/journeys/src/validation.ts
validateJourneyContracts now validates wildcardTransitions shapes, ensures handler functions and reachable entry/exit targets, enforces contract identity consistency across modules sharing a wildcard slot (helper validateContractConsistency), and warns when byExit is shadowed by byEntryAndExit.
Documentation & Tests
packages/journeys/README.md, packages/journeys/src/wildcard-transitions.test-d.ts, packages/journeys/src/wildcard-transitions.test.ts
Adds README sections on wildcard transitions and shared exit contracts; updates API table; adds type-level tests for narrowing and an extensive runtime/validation test suite covering precedence, schema validation (sync/async/no-schema), contract drift warnings, deduped missing-module warnings, state/history propagation, and observability.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

major

Suggested reviewers

  • diogomiguel
  • casamitjana

Poem

🐰 I nibble through contracts, neat and spry,
Wildcards scatter like stars in the sky—
Payloads snug in a validated hat,
Handlers chosen, no fallbacks fall flat.
Hop on, dear journeys, let's leap and try!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(journeys,core): wildcard transitions with shared exit contracts' accurately summarizes the main changes: adding wildcard transition support and shared exit contract functionality across the core and journeys packages.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/wildcard-journey-transitions-b3L1a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/journeys/src/runtime.ts`:
- Around line 1354-1356: The current exit-contract validation silently no-ops
when moduleMap or options.modules are not provided: inside createJourneyRuntime,
detect when options.modules (and therefore moduleMap) are missing or when
moduleMap[step.moduleId] is undefined and the step references a moduleId, and
either log a clear warning or throw to fail-fast instead of silently skipping
validation; specifically update the block that reads stepModule =
moduleMap[step.moduleId], exitSchema = stepModule?.exitPoints?.[exitName] and
the isExitContract check to first assert moduleMap exists and stepModule is
found (or report the step.moduleId) and then proceed to evaluate exitSchema and
(ExitContract).schema so that validation cannot be bypassed when modules are
omitted.

In `@packages/journeys/src/validation.ts`:
- Around line 148-203: The code silently ignores malformed wildcard transition
containers; add explicit validation that pushes errors into the issues array
when wildcardTransitions is not an object, when
wildcardTransitions.byEntryAndExit exists but is not an object, and when
wildcardTransitions.byExit exists but is not an object (use the same error style
referencing def.id and the property path, e.g. `journey "${def.id}" has
malformed wildcardTransitions` / `...wildcardTransitions.byEntryAndExit` /
`...wildcardTransitions.byExit`), then only proceed with the existing
loops/logic when those values are objects; reference the existing symbols
wildcardTransitions, byEntryAndExit, byExit, modules, issues and keep existing
handler/function checks and validateContractConsistency calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9ffc1532-8cee-4a95-a2f5-40ae8bd72373

📥 Commits

Reviewing files that changed from the base of the PR and between d1235a2 and d626893.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • packages/core/package.json
  • packages/core/src/entry-exit.ts
  • packages/core/src/index.ts
  • packages/core/src/journey-contracts.ts
  • packages/core/src/types.ts
  • packages/journeys/README.md
  • packages/journeys/src/index.ts
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/types.ts
  • packages/journeys/src/validation.ts
  • packages/journeys/src/wildcard-transitions.test-d.ts
  • packages/journeys/src/wildcard-transitions.test.ts

Comment thread packages/journeys/src/runtime.ts Outdated
Comment thread packages/journeys/src/validation.ts Outdated
@kibertoad kibertoad added the minor label May 7, 2026
@kibertoad kibertoad requested a review from diogomiguel May 7, 2026 17:21
- runtime.ts: warn (debug-only, deduped per moduleId) when dispatchExit
  can't resolve the active step's module from the runtime's modules
  map. Without this, ExitContract schema validation silently no-ops if
  createJourneyRuntime is called without { modules: ... }.
- validation.ts: report malformed wildcardTransitions / byEntryAndExit
  / byExit (non-object values) as JourneyValidationError issues
  instead of silently skipping them.
- Tests: cover both new behaviors (3 added).

https://claude.ai/code/session_01A2WdMdGoofyjzbjwzZJcMA
@kibertoad kibertoad requested a review from casamitjana May 8, 2026 09:02
Comment thread packages/journeys/src/validation.ts Outdated
Comment thread packages/journeys/src/validation.ts Outdated
Address the snapshot-staleness concern on validateJourneyContracts:
the registration-time validator only sees modules registered with the
registry at resolve time. Realistic dynamic flows (plugins, route-
driven loading, HMR re-bundles, fresh test setups) re-trigger
resolve() and therefore re-validate, but bypass paths (direct
createJourneyRuntime use, plugin composition that adds modules
post-validation, test mocks swapping descriptors) can leave drift
uncaught.

- Runtime now lazily walks its own moduleMap on the first dispatch of
  each contract-based exit and emits a dev-mode warning if two modules
  declare the same exit via different ExitContract instances. Cached
  per exit name (`spotCheckedExits` Set) so the cost is bounded —
  O(modules) once per unique exit fired, then amortized free.
- Documented the lifecycle + spot-check in the README's contract
  pattern.
- 2 new tests: drift triggers warn (and dedupes across instances);
  consistent contracts stay silent.

https://claude.ai/code/session_01A2WdMdGoofyjzbjwzZJcMA
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/journeys/src/wildcard-transitions.test.ts (1)

765-773: ⚡ Quick win

Order-dependent regex may silently invert if the validator reorders its error accumulation.

The assertion:

.toThrowError(
  /malformed wildcardTransitions\.byEntryAndExit.*malformed wildcardTransitions\.byExit/s,
);

requires byEntryAndExit to appear before byExit in the thrown message. If validateJourneyContracts ever processes byExit first (alphabetically or by code change), the regex silently fails to match and the test regresses without a clear signal.

Splitting into two independent assertions removes the ordering dependency:

🛡️ Proposed fix
- expect(() =>
-   validateJourneyContracts(
-     [{ definition: def, options: undefined } as RegisteredJourney],
-     [profileModule, billingModule],
-   ),
- ).toThrowError(
-   /malformed wildcardTransitions\.byEntryAndExit.*malformed wildcardTransitions\.byExit/s,
- );
+ let thrown: unknown;
+ try {
+   validateJourneyContracts(
+     [{ definition: def, options: undefined } as RegisteredJourney],
+     [profileModule, billingModule],
+   );
+ } catch (e) {
+   thrown = e;
+ }
+ expect(thrown).toBeInstanceOf(JourneyValidationError);
+ expect(String((thrown as Error).message)).toMatch(/malformed wildcardTransitions\.byEntryAndExit/);
+ expect(String((thrown as Error).message)).toMatch(/malformed wildcardTransitions\.byExit/);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/journeys/src/wildcard-transitions.test.ts` around lines 765 - 773,
The test currently asserts the thrown message contains "malformed
wildcardTransitions.byEntryAndExit" before "malformed
wildcardTransitions.byExit" which makes it order-dependent; change the single
ordered regex assertion into two independent assertions that each check for one
substring so order doesn't matter: call expect(() => validateJourneyContracts([{
definition: def, options: undefined } as RegisteredJourney], [profileModule,
billingModule])).toThrowError(/malformed wildcardTransitions\.byEntryAndExit/)
and separately expect(...).toThrowError(/malformed
wildcardTransitions\.byExit/), referencing validateJourneyContracts and the
wildcardTransitions.byEntryAndExit / wildcardTransitions.byExit substrings to
locate the check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/journeys/src/wildcard-transitions.test.ts`:
- Around line 192-199: The test named "ignores the exit (warn) when no tier
matches" claims to verify a warning but uses freshRuntime with debug: false so
no warning is emitted; either rename the test to remove "(warn)" or add a new
test that creates the runtime with debug: true (via freshRuntime({ debug: true
}) or equivalent), spy on console.warn before calling rt.start/fireExit, assert
the spy was called with the expected "no matching tier" warning, and restore the
spy after the test; reference the existing test name and the
freshRuntime/rt.start/fireExit helpers to locate where to change or add the new
test.

---

Nitpick comments:
In `@packages/journeys/src/wildcard-transitions.test.ts`:
- Around line 765-773: The test currently asserts the thrown message contains
"malformed wildcardTransitions.byEntryAndExit" before "malformed
wildcardTransitions.byExit" which makes it order-dependent; change the single
ordered regex assertion into two independent assertions that each check for one
substring so order doesn't matter: call expect(() => validateJourneyContracts([{
definition: def, options: undefined } as RegisteredJourney], [profileModule,
billingModule])).toThrowError(/malformed wildcardTransitions\.byEntryAndExit/)
and separately expect(...).toThrowError(/malformed
wildcardTransitions\.byExit/), referencing validateJourneyContracts and the
wildcardTransitions.byEntryAndExit / wildcardTransitions.byExit substrings to
locate the check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 28fe829e-7d40-41db-ac47-cefd9300a49f

📥 Commits

Reviewing files that changed from the base of the PR and between d626893 and f2102b9.

📒 Files selected for processing (4)
  • packages/journeys/README.md
  • packages/journeys/src/runtime.ts
  • packages/journeys/src/validation.ts
  • packages/journeys/src/wildcard-transitions.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/journeys/src/validation.ts
  • packages/journeys/README.md
  • packages/journeys/src/runtime.ts

Comment thread packages/journeys/src/wildcard-transitions.test.ts Outdated
Copy link
Copy Markdown
Collaborator

@casamitjana casamitjana left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes based on the inline comments. Main concern is the wildcard validation/runtime checks using the app-wide module set; that either needs to be the explicit API semantics or should be scoped to the journey module set.

Comment thread packages/journeys/src/runtime.ts
Comment thread packages/journeys/src/validation.ts Outdated
Comment thread packages/journeys/src/validation.ts Outdated
@casamitjana casamitjana dismissed their stale review May 8, 2026 09:46

Changing this review to non-blocking. Follow-up comments are severity-labelled inline.

claude added 2 commits May 8, 2026 10:17
- runtime.ts: gate spotCheckContractDrift call site on debug flag so
  prod doesn't pay the function-call cost. Re-scope the spot-check to
  the dispatching journey's own modules (those keyed under
  def.transitions) — previously walked the runtime's full moduleMap,
  which could surface false positives from unrelated modules. Cache
  key is now [journeyId, exitName] so two journeys sharing an exit
  name each get their own first-fire check.
- runtime.ts: attach a no-op .catch() to the dropped Promise from an
  async ExitContract schema. Without it, a rejecting thenable would
  surface as unhandledRejection after dispatchExit returns; we've
  already aborted the journey, the rejection value is irrelevant.
- validation.ts: add isPlainObject guard — typeof === "object" alone
  admits null and arrays, both of which slipped through the earlier
  malformed-shape checks. Reject both with a descriptive
  "(got null|array|<typeof>)" suffix.
- validation.ts: scope contract-consistency check to the journey's own
  declared modules (Object.keys(def.transitions)). The live-key check
  stays app-wide so typo-detection isn't weakened. Documented the
  asymmetric scopes in the new validateWildcardTransitions header.
- validation.ts: extract validateWildcardTransitions / parseWildcardSlot
  / validateByEntryAndExit / validateByExit so the main registration
  loop reads cleanly. Each helper has a focused responsibility.
- Tests: rename misleading "(warn)" test, add a sibling debug-warn
  test, add coverage for null/array shape guards, journey-scoped vs
  unrelated-module drift, and rejecting-async-schema unhandled-
  rejection suppression. 383 / 383 passing.

https://claude.ai/code/session_01A2WdMdGoofyjzbjwzZJcMA
Round-2 review feedback shifted some semantics that the README
described loosely:
- The validator's two scope axes (live-key = app-wide, contract
  consistency = journey-scoped) are now spelled out as a per-check
  table.
- The runtime spot-check description previously said "walks its own
  moduleMap" — wrong since e892472, which scoped the walk to the
  dispatching journey's own modules. Updated to "walks the dispatching
  journey's own modules (those keyed under def.transitions,
  intersected with the runtime's module map)" and clarified the
  debug-only gating ("production builds pay nothing").
- Added the plain-object shape rejection (rejects null and arrays) to
  the validation table.

refactor(core): drop ExitContract.__contract sentinel

`kind: string` is required on ExitContract and absent on plain
ExitPointSchema (which defineExit() returns as `{}`), so the kind
field already serves as the structural discriminator that
isExitContract checks for. The extra __contract: true marker only
defended against someone hand-constructing { kind: "x" } and passing
it as an exit schema — an antipattern not worth optimizing for.
Removing it gives a cleaner type with one fewer required field; the
type predicate now keys off `typeof obj.kind === "string"`. 383
tests still pass.

https://claude.ai/code/session_01A2WdMdGoofyjzbjwzZJcMA
@kibertoad kibertoad merged commit b044f1f into main May 8, 2026
12 checks passed
@kibertoad kibertoad deleted the claude/wildcard-journey-transitions-b3L1a branch May 8, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants