@afokapu/atdd-bun 0.10.58 → 0.10.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +4 -0
  2. package/detectors/bun_interlocking_coverage/_shared/interlocking.mjs +258 -36
  3. package/detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs +6 -2
  4. package/detectors/bun_interlocking_coverage/checks/interlocking_route_coverage.mjs +10 -1
  5. package/detectors/bun_interlocking_coverage/checks/interlocking_smoke_coverage.mjs +11 -10
  6. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/e2e/smoke/resolve-match.smoke.test.ts +8 -0
  7. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/plan/_trains/_interlockings/match.yaml +9 -0
  8. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/server.ts +10 -0
  9. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/interlocking.ts +12 -0
  10. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/journey.ts +8 -0
  11. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/runner.ts +6 -0
  12. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/scenario-compilation/compile.routes.test.ts +14 -0
  13. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/session-replay/replay.routes.test.ts +14 -0
  14. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/source-corpus-admission/refuse.routes.test.ts +14 -0
  15. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/smoke/runtime-station-master.test.ts +44 -0
  16. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/support/production-train.ts +8 -0
  17. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/create-world-pack.yaml +18 -0
  18. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/play-world-scenario.yaml +24 -0
  19. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/replay-world-session.yaml +18 -0
  20. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/scenario-compilation.yaml +14 -0
  21. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/session-replay.yaml +14 -0
  22. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/source-corpus-admission.yaml +14 -0
  23. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/server.ts +85 -0
  24. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/interlocking.ts +64 -0
  25. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/journey.ts +71 -0
  26. package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/runner.ts +28 -0
  27. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/e2e/smoke/resolve-match.smoke.test.ts +8 -0
  28. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/plan/_trains/_interlockings/match.yaml +9 -0
  29. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/server.ts +21 -0
  30. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/interlocking.ts +12 -0
  31. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/journey.ts +8 -0
  32. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/runner.ts +6 -0
  33. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/e2e/smoke/resolve-match.smoke.test.ts +8 -0
  34. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/plan/_trains/_interlockings/match.yaml +9 -0
  35. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/server.ts +11 -0
  36. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/interlocking.ts +12 -0
  37. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/journey.test.ts +8 -0
  38. package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/runner.ts +6 -0
  39. package/detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs +5 -0
  40. package/detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs +5 -1
  41. package/integrity.json +44 -10
  42. package/lib/journey-chain.mjs +677 -0
  43. package/package.json +3 -2
  44. package/src/hooks.ts +52 -4
  45. package/src/ratchet.ts +2 -1
@@ -0,0 +1,8 @@
1
+ // Supplies test-only wagon handlers for production runner topology proofs.
2
+ import type { WagonHandlerRegistry } from "../../src/trains/runner.ts";
3
+
4
+ /** Builds an inert test handler for the declared train without composing domain services. */
5
+ export async function testTrainContext(trainPath: string): Promise<Readonly<{ seed: Record<string, unknown>; handlers: WagonHandlerRegistry }>> {
6
+ const wagon = trainPath.replace(/^plan\/_trains\/|\.yaml$/g, "");
7
+ return { seed: {}, handlers: { [wagon]: () => ({ inert: true }) } };
8
+ }
@@ -0,0 +1,18 @@
1
+ schema_version: 1.0.0
2
+ journey_id: journey:create-world-pack
3
+ title: Create World Pack
4
+ status: draft
5
+ entrypoint:
6
+ interlocking_id: interlocking:source-corpus-admission
7
+ exposed: true
8
+ actions:
9
+ - create_world
10
+ reason: null
11
+ surfaces:
12
+ - backend
13
+ continuations: []
14
+ terminals:
15
+ - from:
16
+ interlocking_id: interlocking:source-corpus-admission
17
+ route_id: refuse
18
+ outcome: unsupported source refused
@@ -0,0 +1,24 @@
1
+ schema_version: 1.0.0
2
+ journey_id: journey:play-world-scenario
3
+ title: Play World Scenario
4
+ status: draft
5
+ entrypoint:
6
+ interlocking_id: interlocking:scenario-compilation
7
+ exposed: true
8
+ actions:
9
+ - create_scenario
10
+ reason: null
11
+ surfaces:
12
+ - backend
13
+ continuations:
14
+ - from:
15
+ interlocking_id: interlocking:scenario-compilation
16
+ route_id: compile
17
+ artifact: world:scenario:package
18
+ to:
19
+ interlocking_id: interlocking:play-turn-routing
20
+ terminals:
21
+ - from:
22
+ interlocking_id: interlocking:scenario-compilation
23
+ route_id: refuse
24
+ outcome: invalid Scenario refused
@@ -0,0 +1,18 @@
1
+ schema_version: 1.0.0
2
+ journey_id: journey:replay-world-session
3
+ title: Replay World Session
4
+ status: draft
5
+ entrypoint:
6
+ interlocking_id: interlocking:session-replay
7
+ exposed: true
8
+ actions:
9
+ - replay_session
10
+ reason: null
11
+ surfaces:
12
+ - backend
13
+ continuations: []
14
+ terminals:
15
+ - from:
16
+ interlocking_id: interlocking:session-replay
17
+ route_id: replay
18
+ outcome: recorded session replayed
@@ -0,0 +1,14 @@
1
+ interlocking_id: interlocking:scenario-compilation
2
+ entrypoint:
3
+ exposed: true
4
+ actions:
5
+ - create_scenario
6
+ route_resolution:
7
+ strategy: fail_on_multiple_match
8
+ routes:
9
+ - route_id: compile
10
+ train_id: train:scenario:compile
11
+ train_path: plan/_trains/train:scenario:compile.yaml
12
+ guard:
13
+ id: guard:compile
14
+ input: scenario
@@ -0,0 +1,14 @@
1
+ interlocking_id: interlocking:session-replay
2
+ entrypoint:
3
+ exposed: true
4
+ actions:
5
+ - replay_session
6
+ route_resolution:
7
+ strategy: fail_on_multiple_match
8
+ routes:
9
+ - route_id: replay
10
+ train_id: train:session:replay
11
+ train_path: plan/_trains/train:session:replay.yaml
12
+ guard:
13
+ id: guard:replay
14
+ input: replay
@@ -0,0 +1,14 @@
1
+ interlocking_id: interlocking:source-corpus-admission
2
+ entrypoint:
3
+ exposed: true
4
+ actions:
5
+ - create_world
6
+ route_resolution:
7
+ strategy: fail_on_multiple_match
8
+ routes:
9
+ - route_id: refuse
10
+ train_id: train:source:refuse
11
+ train_path: plan/_trains/train:source:refuse.yaml
12
+ guard:
13
+ id: guard:refuse
14
+ input: source
@@ -0,0 +1,85 @@
1
+ import { JourneyRunner } from "./trains/journey.ts";
2
+ import { UnavailableWagonError, type WagonHandlerRegistry } from "./trains/runner.ts";
3
+
4
+ type StationRoute = Readonly<{ journeyId: string; path: string; interlockingId: string }>;
5
+
6
+ export const JOURNEY_MAP: Readonly<Record<string, StationRoute>> = {
7
+ create_world: {
8
+ journeyId: "journey:create-world-pack",
9
+ path: "plan/_journeys/create-world-pack.yaml",
10
+ interlockingId: "interlocking:source-corpus-admission",
11
+ },
12
+ create_scenario: {
13
+ journeyId: "journey:play-world-scenario",
14
+ path: "plan/_journeys/play-world-scenario.yaml",
15
+ interlockingId: "interlocking:scenario-compilation",
16
+ },
17
+ replay_session: {
18
+ journeyId: "journey:replay-world-session",
19
+ path: "plan/_journeys/replay-world-session.yaml",
20
+ interlockingId: "interlocking:session-replay",
21
+ },
22
+ };
23
+
24
+ const unavailableWagonHandlers: WagonHandlerRegistry = {};
25
+
26
+ // Source admission is the one composition with production handlers; it reports what it refused.
27
+ function sourceWagonHandlers(supported: boolean) {
28
+ let refusal: string | undefined;
29
+ return {
30
+ handlers: {
31
+ "train:source:refuse": () => {
32
+ if (!supported) refusal = "UNSUPPORTED_SOURCE_FORMAT";
33
+ return { refused: !supported };
34
+ },
35
+ } as WagonHandlerRegistry,
36
+ result: () => (refusal ? { refusal } : { admitted: true }),
37
+ };
38
+ }
39
+
40
+ /** Production Station Master entrypoint for declared runtime journey actions. */
41
+ export async function executeAction(action: keyof typeof JOURNEY_MAP, inputs: Record<string, unknown>) {
42
+ const mapping = JOURNEY_MAP[action];
43
+ if (!mapping) throw new Error(`Station Master action is not declared: ${action}`);
44
+ const sourceRun = action === "create_world"
45
+ ? sourceWagonHandlers(Boolean((inputs.source as { required_supported?: boolean } | undefined)?.required_supported))
46
+ : undefined;
47
+ try {
48
+ const execution = await new JourneyRunner(mapping.path).execute(action, inputs, {
49
+ initialArtifacts: action === "create_world" ? { "world:source:submission": inputs.source_submission } : {},
50
+ handlers: sourceRun?.handlers ?? unavailableWagonHandlers,
51
+ // Semantic continuations stay explicit deployment work; source remains bounded.
52
+ continueOnContinuation: false,
53
+ });
54
+ return sourceRun ? { ...execution, source: sourceRun.result() } : execution;
55
+ } catch (error) {
56
+ if (error instanceof UnavailableWagonError)
57
+ return Object.freeze({
58
+ disposition: "INCOMPLETE_CONTINUATION" as const,
59
+ status: error.status,
60
+ trainId: error.trainId,
61
+ wagon: error.wagon,
62
+ });
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ /** HTTP-shaped Station Master for source admission. */
68
+ export async function stationMaster(command: { submission: { id: string }; supported: boolean }): Promise<Response> {
69
+ const execution = await executeAction("create_world", {
70
+ source: { required_supported: command.supported },
71
+ source_submission: command.submission,
72
+ });
73
+ if ("disposition" in execution)
74
+ return Response.json(execution, { status: execution.status });
75
+ const sourceResult = "source" in execution ? execution.source : undefined;
76
+ const refused = Boolean(sourceResult && typeof sourceResult === "object" && "refusal" in sourceResult);
77
+ return Response.json(sourceResult, {
78
+ status: refused ? 422 : 201,
79
+ headers: {
80
+ "x-journey-disposition": "TERMINAL",
81
+ "x-train-output-artifact": execution.terminal.artifact,
82
+ "x-train-wagons": execution.wagons.join(","),
83
+ },
84
+ });
85
+ }
@@ -0,0 +1,64 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { TrainRunner, type ExecutedTrain, type WagonHandlerRegistry } from "./runner.ts";
3
+
4
+ export type InterlockingResolution = Readonly<{
5
+ interlockingId: string;
6
+ routeId: string;
7
+ trainId: string;
8
+ trainPath: string;
9
+ category: string;
10
+ guardId: string;
11
+ resolutionStrategy: string;
12
+ reason: string;
13
+ }>;
14
+ export type InterlockedExecution = Readonly<{ resolution: InterlockingResolution; executed: ExecutedTrain }>;
15
+
16
+ type Route = Readonly<{ route_id: string; train_id: string; train_path: string; guard: Readonly<{ id: string; input: string }> }>;
17
+
18
+ /** Resolves one route from the parsed declaration without transcribing route or train values. */
19
+ export class InterlockingRunner {
20
+ constructor(private readonly interlockingYamlPath: string) {}
21
+
22
+ async resolveTrain(action: string, inputs: Record<string, unknown>): Promise<InterlockingResolution> {
23
+ const declared = Bun.YAML.parse(readFileSync(this.interlockingYamlPath, "utf8")) as {
24
+ interlocking_id: string;
25
+ entrypoint: { actions: readonly string[] };
26
+ route_resolution: { strategy: string };
27
+ routes: readonly Route[];
28
+ };
29
+ if (!declared.entrypoint.actions.includes(action))
30
+ throw new Error(`action ${action} is not declared by ${declared.interlocking_id}`);
31
+ const matching = declared.routes.filter((route) => Boolean(inputs[route.guard.input]));
32
+ if (matching.length !== 1)
33
+ throw new Error(`${declared.interlocking_id} ${declared.route_resolution.strategy} selected ${matching.length} routes`);
34
+ const selected = matching[0]!;
35
+ return Object.freeze({
36
+ interlockingId: declared.interlocking_id,
37
+ routeId: selected.route_id,
38
+ trainId: selected.train_id,
39
+ trainPath: selected.train_path,
40
+ category: "declared",
41
+ resolutionStrategy: declared.route_resolution.strategy,
42
+ guardId: selected.guard.id,
43
+ reason: `guard ${selected.guard.id} held`,
44
+ });
45
+ }
46
+
47
+ /** Selects exactly one declared train, then delegates its wagon execution. */
48
+ async execute(
49
+ action: string,
50
+ inputs: Record<string, unknown>,
51
+ initialArtifacts: Record<string, unknown>,
52
+ handlers: WagonHandlerRegistry,
53
+ ): Promise<InterlockedExecution> {
54
+ const selected = await this.resolveTrain(action, inputs);
55
+ // Selection occurs once and the selected YAML path determines execution.
56
+ return Object.freeze({
57
+ resolution: selected,
58
+ executed: await new TrainRunner(selected.trainPath, handlers).execute(
59
+ selected,
60
+ initialArtifacts,
61
+ ),
62
+ });
63
+ }
64
+ }
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { InterlockingRunner } from "./interlocking.ts";
3
+ import type { ExecutedTrain, WagonHandlerRegistry } from "./runner.ts";
4
+
5
+ type Transition = Readonly<{
6
+ from: Readonly<{ interlocking_id: string; route_id: string }>;
7
+ to?: Readonly<{ interlocking_id: string }>;
8
+ artifact?: string;
9
+ outcome?: string;
10
+ }>;
11
+ type JourneyDeclaration = Readonly<{
12
+ entrypoint: Readonly<{ interlocking_id: string; actions: readonly string[] }>;
13
+ continuations: readonly Transition[];
14
+ terminals: readonly Transition[];
15
+ }>;
16
+ type ExecutionContext = Readonly<{
17
+ initialArtifacts: Record<string, unknown>;
18
+ handlers: WagonHandlerRegistry;
19
+ continueOnContinuation?: boolean;
20
+ }>;
21
+ export type JourneyExecution = ExecutedTrain & Readonly<{ journey: Readonly<Record<string, unknown>> }>;
22
+
23
+ const pathFor = (interlockingId: string) =>
24
+ `plan/_trains/_interlockings/${interlockingId.replace("interlocking:", "")}.yaml`;
25
+
26
+ /** Delegates each declared journey boundary to InterlockingRunner. */
27
+ export class JourneyRunner {
28
+ constructor(private readonly journeyYamlPath: string) {}
29
+
30
+ /** Executes each declared route until the journey reaches one declared terminal. */
31
+ async execute(action: string, inputs: Record<string, unknown>, context: ExecutionContext): Promise<JourneyExecution> {
32
+ const declared = Bun.YAML.parse(readFileSync(this.journeyYamlPath, "utf8")) as JourneyDeclaration;
33
+ if (!declared?.entrypoint?.interlocking_id || !declared.entrypoint.actions?.includes(action))
34
+ throw new Error(`invalid journey entrypoint: ${this.journeyYamlPath}`);
35
+ let current = declared.entrypoint.interlocking_id;
36
+ let seed = context.initialArtifacts;
37
+ const traversed = [] as string[];
38
+ for (let hops = 0; hops < 32; hops++) {
39
+ const path = pathFor(current);
40
+ const run = await new InterlockingRunner(path).execute(action, inputs, seed, context.handlers);
41
+ const key = `${run.resolution.interlockingId}/${run.resolution.routeId}`;
42
+ traversed.push(run.resolution.interlockingId);
43
+ const continuation = declared.continuations.find(
44
+ (item) => item.from.interlocking_id === run.resolution.interlockingId && item.from.route_id === run.resolution.routeId,
45
+ );
46
+ const terminal = declared.terminals.find(
47
+ (item) => item.from.interlocking_id === run.resolution.interlockingId && item.from.route_id === run.resolution.routeId,
48
+ );
49
+ if (terminal) return Object.freeze({
50
+ ...run.executed,
51
+ journey: Object.freeze({ terminal, outcome: terminal.outcome, interlockings: Object.freeze(traversed) }),
52
+ });
53
+ if (continuation && !context.continueOnContinuation)
54
+ // Deployment composition can stop at a declared boundary without fabricating its successor.
55
+ return Object.freeze({
56
+ ...run.executed,
57
+ journey: Object.freeze({ continuation, interlockings: Object.freeze(traversed) }),
58
+ });
59
+ if (continuation) {
60
+ if (!continuation.to?.interlocking_id || !continuation.artifact)
61
+ throw new Error(`invalid journey continuation at ${key}`);
62
+ current = continuation.to.interlocking_id;
63
+ // The next train receives only the planner-declared handoff from the prior execution.
64
+ seed = { [continuation.artifact]: run.executed.terminal.value };
65
+ continue;
66
+ }
67
+ throw new Error(`journey route is not declared as a continuation or terminal: ${key}`);
68
+ }
69
+ throw new Error(`journey continuation limit exceeded: ${this.journeyYamlPath}`);
70
+ }
71
+ }
@@ -0,0 +1,28 @@
1
+ export type ExecutedTrain = Readonly<{
2
+ selectedTrainId: string;
3
+ terminal: Readonly<{ artifact: string; value: unknown }>;
4
+ wagons: readonly string[];
5
+ }>;
6
+ export type WagonHandlerRegistry = Readonly<Record<string, (seed: Record<string, unknown>) => unknown>>;
7
+
8
+ /** A declared wagon whose handler this deployment has not composed. */
9
+ export class UnavailableWagonError extends Error {
10
+ readonly status = 202;
11
+ constructor(readonly trainId: string, readonly wagon: string) {
12
+ super(`wagon ${wagon} of ${trainId} is unavailable`);
13
+ }
14
+ }
15
+
16
+ export class TrainRunner {
17
+ constructor(private readonly trainPath: string, private readonly handlers: WagonHandlerRegistry) {}
18
+ async execute(resolution: Readonly<{ trainId: string }>, seed: Record<string, unknown>): Promise<ExecutedTrain> {
19
+ const wagon = this.trainPath.replace(/^plan\/_trains\/|\.yaml$/g, "");
20
+ const handler = this.handlers[wagon];
21
+ if (!handler) throw new UnavailableWagonError(resolution.trainId, wagon);
22
+ return Object.freeze({
23
+ selectedTrainId: resolution.trainId,
24
+ terminal: Object.freeze({ artifact: `${wagon}:result`, value: handler(seed) }),
25
+ wagons: Object.freeze([wagon]),
26
+ });
27
+ }
28
+ }
@@ -0,0 +1,8 @@
1
+ // Phase: SMOKE
2
+ import { expect, test } from "bun:test";
3
+ import { dispatch } from "../../server";
4
+
5
+ test("resolve_match traverses the exported journey dispatch", () => {
6
+ const result = dispatch("resolve_match", { voted: true }, { handlers: {}, seed: {} });
7
+ expect(result.selectedTrainId).toBe("train:match:nominal");
8
+ });
@@ -0,0 +1,9 @@
1
+ interlocking_id: interlocking:match
2
+ entrypoint:
3
+ exposed: true
4
+ actions:
5
+ - resolve_match
6
+ routes:
7
+ - route_id: nominal
8
+ train_id: train:match:nominal
9
+ train_path: plan/_trains/train:match:nominal.yaml
@@ -0,0 +1,21 @@
1
+ import { JourneyRunner } from "./src/trains/journey";
2
+ import { InterlockingRunner } from "./src/trains/interlocking";
3
+ import { TrainRunner } from "./src/trains/runner";
4
+
5
+ export const JOURNEY_MAP = {
6
+ resolve_match: { journeyId: "journey:match", path: "plan/_journeys/match.yaml" },
7
+ };
8
+
9
+ // The production chain is importable and even exercised by a module-local helper, but the
10
+ // exported action returns business data directly instead of the JourneyRunner execution.
11
+ const runner = new InterlockingRunner("plan/_trains/_interlockings/match.yaml");
12
+ function inertProductionHelper(action: string, inputs: object, context: { handlers: object; seed: object }) {
13
+ const resolution = runner.resolveTrain(action, inputs);
14
+ return new TrainRunner(resolution.trainPath, context.handlers).execute(resolution, context.seed);
15
+ }
16
+
17
+ export function dispatch(_action: keyof typeof JOURNEY_MAP, _inputs: object, _context: { handlers: object; seed: object }) {
18
+ void JourneyRunner;
19
+ void inertProductionHelper;
20
+ return { selectedTrainId: "train:match:nominal" };
21
+ }
@@ -0,0 +1,12 @@
1
+ import { TrainRunner } from "./runner";
2
+
3
+ export class InterlockingRunner {
4
+ constructor(private readonly interlockingPath: string) {}
5
+ resolveTrain(_action: string, _inputs: object) {
6
+ return { routeId: "nominal", trainId: "train:match:nominal", trainPath: "plan/_trains/train:match:nominal.yaml" };
7
+ }
8
+ execute(action: string, inputs: object, context: { handlers: object; seed: object }) {
9
+ const resolution = this.resolveTrain(action, inputs);
10
+ return new TrainRunner(resolution.trainPath, context.handlers).execute(resolution, context.seed);
11
+ }
12
+ }
@@ -0,0 +1,8 @@
1
+ import { InterlockingRunner } from "./interlocking";
2
+
3
+ export class JourneyRunner {
4
+ constructor(private readonly journeyPath: string) {}
5
+ execute(action: string, inputs: object, context: { handlers: object; seed: object }) {
6
+ return new InterlockingRunner(this.journeyPath).execute(action, inputs, context);
7
+ }
8
+ }
@@ -0,0 +1,6 @@
1
+ export class TrainRunner {
2
+ constructor(private readonly trainPath: string, private readonly handlers: object = {}) {}
3
+ execute(resolution: { trainId: string }, seed: object) {
4
+ return { selectedTrainId: resolution.trainId, trainPath: this.trainPath, handlers: this.handlers, seed };
5
+ }
6
+ }
@@ -0,0 +1,8 @@
1
+ // Phase: SMOKE
2
+ import { expect, test } from "bun:test";
3
+ import { dispatch } from "../../server";
4
+
5
+ test("resolve_match traverses the exported journey dispatch", () => {
6
+ const result = dispatch("resolve_match", { voted: true }, { handlers: {}, seed: {} });
7
+ expect(result.selectedTrainId).toBe("train:match:nominal");
8
+ });
@@ -0,0 +1,9 @@
1
+ interlocking_id: interlocking:match
2
+ entrypoint:
3
+ exposed: true
4
+ actions:
5
+ - resolve_match
6
+ routes:
7
+ - route_id: nominal
8
+ train_id: train:match:nominal
9
+ train_path: plan/_trains/train:match:nominal.yaml
@@ -0,0 +1,11 @@
1
+ import { JourneyRunner } from "./src/trains/journey.test";
2
+
3
+ export const JOURNEY_MAP = {
4
+ resolve_match: { journeyId: "journey:match", path: "plan/_journeys/match.yaml" },
5
+ };
6
+
7
+ // Identical to the clean dispatch except that JourneyRunner resolves to a test-local module.
8
+ export function dispatch(action: keyof typeof JOURNEY_MAP, inputs: object, context: { handlers: object; seed: object }) {
9
+ const mapping = JOURNEY_MAP[action];
10
+ return new JourneyRunner(mapping.path).execute(action, inputs, context);
11
+ }
@@ -0,0 +1,12 @@
1
+ import { TrainRunner } from "./runner";
2
+
3
+ export class InterlockingRunner {
4
+ constructor(private readonly interlockingPath: string) {}
5
+ resolveTrain(_action: string, _inputs: object) {
6
+ return { routeId: "nominal", trainId: "train:match:nominal", trainPath: "plan/_trains/train:match:nominal.yaml" };
7
+ }
8
+ execute(action: string, inputs: object, context: { handlers: object; seed: object }) {
9
+ const resolution = this.resolveTrain(action, inputs);
10
+ return new TrainRunner(resolution.trainPath, context.handlers).execute(resolution, context.seed);
11
+ }
12
+ }
@@ -0,0 +1,8 @@
1
+ import { InterlockingRunner } from "./interlocking";
2
+
3
+ export class JourneyRunner {
4
+ constructor(private readonly journeyPath: string) {}
5
+ execute(action: string, inputs: object, context: { handlers: object; seed: object }) {
6
+ return new InterlockingRunner(this.journeyPath).execute(action, inputs, context);
7
+ }
8
+ }
@@ -0,0 +1,6 @@
1
+ export class TrainRunner {
2
+ constructor(private readonly trainPath: string, private readonly handlers: object = {}) {}
3
+ execute(resolution: { trainId: string }, seed: object) {
4
+ return { selectedTrainId: resolution.trainId, trainPath: this.trainPath, handlers: this.handlers, seed };
5
+ }
6
+ }
@@ -16,6 +16,7 @@ import {
16
16
  mk,
17
17
  writeReport,
18
18
  } from "../_shared/interlocking.mjs";
19
+ import { provenJourneyActions } from "../../../lib/journey-chain.mjs";
19
20
 
20
21
  const RULE = "coder.bun.station-master-interlocking-routing";
21
22
  const roots = parseJsonEnv("ATDD_SCAN_ROOTS", []);
@@ -37,6 +38,10 @@ for (const scanRoot of roots) {
37
38
  const text = readText(app);
38
39
  const jm = journeyMap(text);
39
40
  if (!jm.hasInterlocking) continue; // pure direct-train Station Master carries no obligation.
41
+ // A journey-mediated Station Master delegates to the runners through JourneyRunner; when an exported
42
+ // action provably returns JourneyRunner -> InterlockingRunner -> TrainRunner execution (bound
43
+ // imports, live returns, no overrides), it need not reference the lower runners itself.
44
+ if (provenJourneyActions(text, app, croot).length > 0) continue;
40
45
 
41
46
  const line = jm.interlockingLine || 1;
42
47
  if (!referencesToken(text, "InterlockingRunner")) {
@@ -15,6 +15,7 @@ import {
15
15
  maskComments,
16
16
  PLAN_ROOT,
17
17
  } from "../_shared/interlocking.mjs";
18
+ import { provenJourneyActions } from "../../../lib/journey-chain.mjs";
18
19
 
19
20
  const RULE = "coder.bun.station-master-journey-routing";
20
21
  const roots = parseJsonEnv("ATDD_SCAN_ROOTS", []);
@@ -98,7 +99,10 @@ for (const scanRoot of roots) {
98
99
  "journey-station-unlinked: exposed journeys exist but Station Master never references JourneyRunner",
99
100
  lineAt(text, 1),
100
101
  ));
101
- if (referencesToken(text, "JourneyRunner") && !dispatchInvokesMappedJourneyRunner(text)) violations.push(mk(
102
+ // Any exported action (not only `dispatch`) may carry the mapped JourneyRunner execution, provided
103
+ // the whole chain is proven on the AST (lib/journey-chain.mjs).
104
+ if (referencesToken(text, "JourneyRunner") && !dispatchInvokesMappedJourneyRunner(text) &&
105
+ provenJourneyActions(text, app, croot).length === 0) violations.push(mk(
102
106
  RULE,
103
107
  appPath,
104
108
  1,