@afokapu/atdd-bun 0.10.54 → 0.10.56
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.
- package/detectors/bun_interlocking_binding/checks/interlocking_runtime_executes.mjs +46 -1
- package/detectors/bun_interlocking_binding/detect.mjs +2 -2
- package/detectors/bun_interlocking_binding/fixtures/clean/server.ts +2 -2
- package/detectors/bun_interlocking_binding/fixtures/clean/src/trains/runner.ts +21 -3
- package/detectors/bun_interlocking_binding/fixtures/clean_flow_style_actions/server.ts +2 -2
- package/detectors/bun_interlocking_binding/fixtures/clean_flow_style_actions/src/trains/runner.ts +21 -3
- package/detectors/bun_interlocking_coverage/_shared/interlocking.mjs +181 -2
- package/detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs +9 -4
- package/detectors/bun_interlocking_coverage/checks/interlocking_route_coverage.mjs +4 -4
- package/detectors/bun_interlocking_coverage/checks/interlocking_smoke_coverage.mjs +10 -3
- package/detectors/bun_interlocking_coverage/checks/interlocking_trace_binds.mjs +7 -5
- package/detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs +8 -13
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_production_runner/convex/trains/interlocking.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_production_runner/convex/trains/runner.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_route_coverage/convex/trains/interlocking.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_route_coverage/convex/trains/runner.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_smoke_coverage/convex/trains/interlocking.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_smoke_coverage/convex/trains/runner.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_smoke_coverage_module/convex/trains/interlocking.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_smoke_coverage_module/convex/trains/runner.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_trace_binds/convex/trains/interlocking.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/interlocking_trace_binds/convex/trains/runner.ts +1 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_inert_module/e2e/smoke/resolve-match.smoke.test.ts +12 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_inert_module/plan/_trains/_interlockings/match-resolution.yaml +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_inert_module/src/server.ts +13 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_mismatched_action/e2e/smoke/resolve-match.smoke.test.ts +12 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_mismatched_action/plan/_trains/_interlockings/match-resolution.yaml +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_mismatched_action/src/server.ts +9 -0
- package/detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs +19 -0
- package/detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs +27 -0
- package/detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs +30 -0
- package/detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs +112 -12
- package/detectors/bun_interlocking_infrastructure/fixtures/clean/journey_runner_boundary/src/trains/journey.ts +17 -4
- package/detectors/bun_interlocking_infrastructure/fixtures/clean/journey_station_routing/src/trains/journey.ts +16 -3
- package/detectors/bun_interlocking_infrastructure/fixtures/clean/journey_topology_traversal/plan/_journeys/checkout.yaml +19 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/clean/journey_topology_traversal/server.ts +13 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/clean/journey_topology_traversal/src/trains/journey.ts +36 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/clean/wagon_contract_honoured/src/trains/runner.ts +5 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_continuation_dead_read/plan/_journeys/checkout.yaml +14 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_continuation_dead_read/src/trains/journey.ts +15 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_continuation_returns_early/plan/_journeys/checkout.yaml +19 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_continuation_returns_early/src/trains/journey.ts +16 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_continuation_self_loop/plan/_journeys/checkout.yaml +14 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_continuation_self_loop/src/trains/journey.ts +18 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_station_inert_runner/plan/_journeys/public.yaml +14 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_station_inert_runner/server.ts +16 -0
- package/detectors/bun_interlocking_infrastructure/fixtures/dirty/journey_station_inert_runner/src/trains/journey.ts +9 -0
- package/integrity.json +48 -19
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Phase: SMOKE
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { dispatch } from "../../src/server";
|
|
4
|
+
import { InterlockingRunner } from "../../src/trains/interlocking";
|
|
5
|
+
import { TrainRunner } from "../../src/trains/runner";
|
|
6
|
+
|
|
7
|
+
test("resolve_match target token cannot cover resolve_other dispatch", async () => {
|
|
8
|
+
const result = await dispatch("resolve_other", { allPlayersVoted: true });
|
|
9
|
+
expect(result.selectedTrainId).toBe("train:match:match-resolution-standard");
|
|
10
|
+
expect(InterlockingRunner).toBeDefined();
|
|
11
|
+
expect(TrainRunner).toBeDefined();
|
|
12
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { InterlockingRunner } from "./trains/interlocking";
|
|
2
|
+
import { TrainRunner } from "./trains/runner";
|
|
3
|
+
|
|
4
|
+
const runner = new InterlockingRunner("plan/_trains/_interlockings/match-resolution.yaml");
|
|
5
|
+
|
|
6
|
+
export async function dispatch(action: string, inputs: object) {
|
|
7
|
+
const resolution = runner.resolveTrain(action, inputs);
|
|
8
|
+
return await new TrainRunner(resolution.trainId).execute({});
|
|
9
|
+
}
|
|
@@ -19,6 +19,15 @@ import {
|
|
|
19
19
|
|
|
20
20
|
const RULE = "coder.bun.station-master-interlocking-routing";
|
|
21
21
|
const roots = parseJsonEnv("ATDD_SCAN_ROOTS", []);
|
|
22
|
+
|
|
23
|
+
function dispatchDelegatesThroughInterlocking(text) {
|
|
24
|
+
const match = /\b(?:export\s+)?function\s+dispatch\s*\([^)]*\)\s*\{([\s\S]*)\}\s*$/.exec(text);
|
|
25
|
+
if (!match) return false;
|
|
26
|
+
const body = match[1];
|
|
27
|
+
if (/\breturn\s+new\s+InterlockingRunner\s*\([^)]*\)\s*\.\s*execute\s*\(/.test(body)) return true;
|
|
28
|
+
const runner = /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+InterlockingRunner\s*\(/.exec(body)?.[1];
|
|
29
|
+
return Boolean(runner && new RegExp(`\\breturn\\s+(?:await\\s+)?${runner}\\s*\\.\\s*execute\\s*\\(`).test(body));
|
|
30
|
+
}
|
|
22
31
|
const violations = [];
|
|
23
32
|
|
|
24
33
|
for (const scanRoot of roots) {
|
|
@@ -56,6 +65,16 @@ for (const scanRoot of roots) {
|
|
|
56
65
|
),
|
|
57
66
|
);
|
|
58
67
|
}
|
|
68
|
+
if (!dispatchDelegatesThroughInterlocking(text)) {
|
|
69
|
+
violations.push(mk(
|
|
70
|
+
RULE,
|
|
71
|
+
rel(app, croot),
|
|
72
|
+
line,
|
|
73
|
+
0,
|
|
74
|
+
"station-master-dispatch-bypasses-runners: mapped interlocking dispatch must return InterlockingRunner execution; inert runner references or direct business data do not execute the selected train",
|
|
75
|
+
lineAt(text, line),
|
|
76
|
+
));
|
|
77
|
+
}
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
|
|
@@ -56,6 +56,25 @@ function loadsJourneyDeclaration(text) {
|
|
|
56
56
|
return readsJourneyPath && parsesDeclaration;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
function consumesDeclaredTopology(text) {
|
|
60
|
+
const masked = maskComments(text);
|
|
61
|
+
const declaration = /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*Bun\.YAML\.parse\s*\(/.exec(masked);
|
|
62
|
+
if (!declaration) return false;
|
|
63
|
+
const name = declaration[1].replace(/[$]/g, "\\$");
|
|
64
|
+
const reads = (field) => new RegExp("\\b" + name + "\\s*\\.\\s*" + field + "\\s*\\.\\s*find\\s*\\(").test(masked);
|
|
65
|
+
// A runner must retain the interlocking result long enough to decide whether the
|
|
66
|
+
// declared route continues or terminates. Returning the first execute() directly
|
|
67
|
+
// is the characteristic skipped-continuation mutation.
|
|
68
|
+
const retainsResolution = /\b(?:const|let)\s+[A-Za-z_$][\w$]*\s*=\s*await\s+(?:new\s+InterlockingRunner\s*\([^)]*\)|[A-Za-z_$][\w$]*)\s*\.\s*execute\s*\(/.test(masked);
|
|
69
|
+
const readsEntrypoint = new RegExp("\\b" + name + "\\s*\\.\\s*entrypoint\\b").test(masked);
|
|
70
|
+
// Reading both lists is only discovery. A continuation must alter control flow to
|
|
71
|
+
// another declared interlocking and a terminal must decide the returned outcome;
|
|
72
|
+
// otherwise an early `return resolution` leaves the journey topology inert.
|
|
73
|
+
const followsContinuation = /\bif\s*\(\s*continuation\s*\)\s*\{[\s\S]{0,280}?\bcurrent\s*=\s*continuation\s*\.\s*to\s*\.\s*(?:interlocking_id|interlockingId)\b[\s\S]{0,280}?\bcontinue\b/.test(masked);
|
|
74
|
+
const returnsTerminal = /\bif\s*\(\s*terminal\s*\)\s*(?:\{[\s\S]{0,280}?)?\breturn\b[\s\S]{0,240}?\bterminal\s*\.\s*outcome\b/.test(masked);
|
|
75
|
+
return readsEntrypoint && reads("continuations") && reads("terminals") && retainsResolution && followsContinuation && returnsTerminal;
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
function hiddenTopologyLiterals(text) {
|
|
60
79
|
const masked = maskComments(text);
|
|
61
80
|
const hits = [];
|
|
@@ -114,6 +133,14 @@ for (const scanRoot of roots) {
|
|
|
114
133
|
"journey-runtime-transcription: JourneyRunner does not read and parse a journey declaration; topology may be hardcoded",
|
|
115
134
|
lineAt(text, line),
|
|
116
135
|
));
|
|
136
|
+
if (loadsDeclaration && !consumesDeclaredTopology(text)) violations.push(mk(
|
|
137
|
+
RULE,
|
|
138
|
+
rel(file, croot),
|
|
139
|
+
line,
|
|
140
|
+
0,
|
|
141
|
+
"journey-runtime-continuation-ignored: JourneyRunner reads a continuation-bearing declaration but returns the entrypoint InterlockingRunner result without consuming continuations and terminals",
|
|
142
|
+
lineAt(text, line),
|
|
143
|
+
));
|
|
117
144
|
for (const hit of hardcodedInterlockingConstruction(text)) violations.push(mk(
|
|
118
145
|
RULE,
|
|
119
146
|
rel(file, croot),
|
|
@@ -56,6 +56,28 @@ function actionMapping(text, action) {
|
|
|
56
56
|
return { body: match[1], line: lineOfIndex(masked, match.index) };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
function matchingBrace(text, open) {
|
|
60
|
+
let depth = 0;
|
|
61
|
+
for (let i = open; i < text.length; i++) {
|
|
62
|
+
if (text[i] === "{") depth++;
|
|
63
|
+
else if (text[i] === "}" && --depth === 0) return i;
|
|
64
|
+
}
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dispatchInvokesMappedJourneyRunner(text) {
|
|
69
|
+
const masked = maskComments(text);
|
|
70
|
+
const dispatch = /\b(?:async\s+)?function\s+dispatch\s*\([^)]*\)\s*\{/.exec(masked);
|
|
71
|
+
if (!dispatch) return false;
|
|
72
|
+
const open = dispatch.index + dispatch[0].length - 1;
|
|
73
|
+
const close = matchingBrace(masked, open);
|
|
74
|
+
const body = masked.slice(open + 1, close < 0 ? masked.length : close);
|
|
75
|
+
const direct = /\breturn\s+(?:await\s+)?new\s+JourneyRunner\s*\(\s*[A-Za-z_$][\w$]*\s*\.\s*path\s*\)\s*\.\s*execute\s*\(/.test(body);
|
|
76
|
+
const constructed = /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+JourneyRunner\s*\(\s*[A-Za-z_$][\w$]*\s*\.\s*path\s*\)/.exec(body);
|
|
77
|
+
const delegated = constructed && new RegExp("\\breturn\\s+(?:await\\s+)?" + constructed[1] + "\\s*\\.\\s*execute\\s*\\(").test(body);
|
|
78
|
+
return direct || delegated;
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
for (const scanRoot of roots) {
|
|
60
82
|
for (const croot of findConsumerRoots(scanRoot)) {
|
|
61
83
|
const journeys = journeyDocuments(croot).filter(item => item.data?.entrypoint?.exposed === true);
|
|
@@ -76,6 +98,14 @@ for (const scanRoot of roots) {
|
|
|
76
98
|
"journey-station-unlinked: exposed journeys exist but Station Master never references JourneyRunner",
|
|
77
99
|
lineAt(text, 1),
|
|
78
100
|
));
|
|
101
|
+
if (referencesToken(text, "JourneyRunner") && !dispatchInvokesMappedJourneyRunner(text)) violations.push(mk(
|
|
102
|
+
RULE,
|
|
103
|
+
appPath,
|
|
104
|
+
1,
|
|
105
|
+
0,
|
|
106
|
+
"journey-station-dispatch-bypasses-runner: Station Master dispatch must return a JourneyRunner execution for the mapped exposed journey",
|
|
107
|
+
lineAt(text, 1),
|
|
108
|
+
));
|
|
79
109
|
|
|
80
110
|
for (const journey of journeys) {
|
|
81
111
|
const id = String(journey.data.journey_id ?? "");
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// CONTRACT (v1.1): reads ATDD_SCAN_ROOTS / ATDD_SCAN_EXCLUDES, writes RAW violations to
|
|
25
25
|
// ATDD_VIOLATIONS_REPORT, exits 0 regardless of count.
|
|
26
26
|
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
27
|
-
import { join, sep } from "node:path";
|
|
27
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
28
28
|
|
|
29
29
|
const RULE = "coder.bun.wagon-honours-its-contract";
|
|
30
30
|
const EXCLUDES = ["node_modules", "dist", "build", ".next", ".git", "_generated"];
|
|
@@ -101,6 +101,95 @@ export function maskComments(text) {
|
|
|
101
101
|
.replace(/(^|[^:\\])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
105
|
+
|
|
106
|
+
function closingBrace(text, open) {
|
|
107
|
+
let depth = 0;
|
|
108
|
+
for (let i = open; i < text.length; i++) {
|
|
109
|
+
if (text[i] === "{") depth++;
|
|
110
|
+
else if (text[i] === "}" && --depth === 0) return i;
|
|
111
|
+
}
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function handlerBody(text, name) {
|
|
116
|
+
const escaped = escape(name);
|
|
117
|
+
const forms = [
|
|
118
|
+
new RegExp(`\\b(?:export\\s+)?(?:async\\s+)?function\\s+${escaped}\\s*\\([^)]*\\)\\s*(?::[^={]+)?\\{`),
|
|
119
|
+
new RegExp(`\\b(?:export\\s+)?(?:const|let)\\s+${escaped}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{`),
|
|
120
|
+
];
|
|
121
|
+
for (const form of forms) {
|
|
122
|
+
const match = form.exec(text);
|
|
123
|
+
if (!match) continue;
|
|
124
|
+
const open = match.index + match[0].lastIndexOf("{");
|
|
125
|
+
const close = closingBrace(text, open);
|
|
126
|
+
if (close >= 0) return text.slice(open + 1, close);
|
|
127
|
+
}
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function localConstants(text) {
|
|
132
|
+
const values = new Map();
|
|
133
|
+
const re = /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*["']([^"']+)["']/g;
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = re.exec(text)) !== null) values.set(match[1], match[2]);
|
|
136
|
+
return values;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function artifactArgument(argument, constants) {
|
|
140
|
+
const literal = /^\s*["']([^"']+)["']\s*$/.exec(argument);
|
|
141
|
+
if (literal) return literal[1];
|
|
142
|
+
const identifier = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(argument);
|
|
143
|
+
return identifier ? constants.get(identifier[1]) ?? null : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cargoMoves(handler, module) {
|
|
147
|
+
const constants = localConstants(module), reads = new Set(), writes = new Set();
|
|
148
|
+
for (const [method, target] of [["get", reads], ["put", writes]]) {
|
|
149
|
+
const re = new RegExp(`\\bcargo\\s*\\.\\s*${method}\\s*\\(\\s*([^,)]*)`, "g");
|
|
150
|
+
let match;
|
|
151
|
+
while ((match = re.exec(handler)) !== null) {
|
|
152
|
+
const artifact = artifactArgument(match[1], constants);
|
|
153
|
+
if (artifact) target.add(artifact);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { reads, writes };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function importedHandlers(runner) {
|
|
160
|
+
const handlers = [];
|
|
161
|
+
const re = /^\s*import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/gm;
|
|
162
|
+
let match;
|
|
163
|
+
while ((match = re.exec(runner.text)) !== null) {
|
|
164
|
+
if (!match[2].startsWith(".")) continue;
|
|
165
|
+
const target = resolve(dirname(runner.file), match[2]);
|
|
166
|
+
const file = [target, `${target}.ts`, `${target}.tsx`, join(target, "index.ts")]
|
|
167
|
+
.find((candidate) => runner.sources.has(candidate));
|
|
168
|
+
if (!file) continue;
|
|
169
|
+
for (const entry of match[1].split(",")) {
|
|
170
|
+
const [imported, local = imported] = entry.trim().split(/\s+as\s+/);
|
|
171
|
+
if (imported && local && imported !== "Cargo") handlers.push({ name: local.trim(), file });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return handlers;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function executedHandlers(wagon, runners, sources) {
|
|
178
|
+
const handlers = [];
|
|
179
|
+
const wagonLiteral = escape(wagon);
|
|
180
|
+
for (const runner of runners) {
|
|
181
|
+
const imports = importedHandlers({ ...runner, sources });
|
|
182
|
+
for (const handler of imports) {
|
|
183
|
+
const branch = new RegExp(
|
|
184
|
+
`\\b(?:if\\s*\\(\\s*step\\s*={2,3}\\s*["']${wagonLiteral}["']\\s*\\)|case\\s*["']${wagonLiteral}["']\\s*:)` +
|
|
185
|
+
`[\\s\\S]{0,240}?\\b${escape(handler.name)}\\s*\\(\\s*cargo\\b`,
|
|
186
|
+
);
|
|
187
|
+
if (branch.test(runner.text)) handlers.push(handler);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return handlers;
|
|
191
|
+
}
|
|
192
|
+
|
|
104
193
|
const reportPath = process.env.ATDD_VIOLATIONS_REPORT;
|
|
105
194
|
if (!reportPath) {
|
|
106
195
|
process.stderr.write("bun-infra: ATDD_VIOLATIONS_REPORT not set\n");
|
|
@@ -126,26 +215,37 @@ for (const root of parseJsonEnv("ATDD_SCAN_ROOTS", [])) {
|
|
|
126
215
|
if (!contracts.length) continue; // no declared contract
|
|
127
216
|
|
|
128
217
|
const sources = walk(croot, (p) => TS.test(p) && !TEST.test(p), excludes)
|
|
129
|
-
.map((
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
218
|
+
.map((file) => ({ file, text: maskComments(read(file)) }));
|
|
219
|
+
// Do not turn a plan-only repository into a contract failure. Once this consumer
|
|
220
|
+
// does have Cargo-shaped source, however, each carried wagon must bind its own
|
|
221
|
+
// handler into the train path; a global source blob is not evidence of that.
|
|
222
|
+
if (!sources.some((s) => /\bCargo\b|\bcargo\s*\.\s*(?:put|get)\s*\(/.test(s.text))) continue;
|
|
223
|
+
const sourceByFile = new Map(sources.map((source) => [source.file, source.text]));
|
|
224
|
+
const trainRoot = join(croot, "src", "trains") + sep;
|
|
225
|
+
const runners = sources.filter((source) => source.file.startsWith(trainRoot));
|
|
137
226
|
for (const c of contracts) {
|
|
138
227
|
if (c.wagon && !carried.has(c.wagon)) continue; // no train carries this wagon yet
|
|
228
|
+
const handlers = c.wagon ? executedHandlers(c.wagon, runners, sourceByFile) : [];
|
|
229
|
+
const reads = new Set(), writes = new Set();
|
|
230
|
+
for (const handler of handlers) {
|
|
231
|
+
const module = sourceByFile.get(handler.file) ?? "";
|
|
232
|
+
const body = handlerBody(module, handler.name);
|
|
233
|
+
const moves = cargoMoves(body, module);
|
|
234
|
+
for (const artifact of moves.reads) reads.add(artifact);
|
|
235
|
+
for (const artifact of moves.writes) writes.add(artifact);
|
|
236
|
+
}
|
|
139
237
|
for (const [kind, names] of [["produce", c.produce], ["consume", c.consume]]) {
|
|
140
238
|
for (const name of names) {
|
|
141
|
-
|
|
239
|
+
const moved = kind === "produce" ? writes : reads;
|
|
240
|
+
if (moved.has(name)) continue;
|
|
142
241
|
const rel = c.file.startsWith(croot + sep) ? c.file.slice(croot.length + 1) : c.file;
|
|
143
242
|
violations.push({
|
|
144
243
|
rule_id: RULE, file: rel, line: 1, col: 0,
|
|
145
244
|
evidence:
|
|
146
245
|
`the wagon declares it ${kind === "produce" ? "produces" : "consumes"} ` +
|
|
147
|
-
`"${name}",
|
|
148
|
-
|
|
246
|
+
`"${name}", but no handler reachable from that wagon's train step ` +
|
|
247
|
+
`${kind === "produce" ? "writes" : "reads"} it through Cargo; artifact constants ` +
|
|
248
|
+
`or unrelated Cargo code do not honour the wagon contract`,
|
|
149
249
|
source_line: "",
|
|
150
250
|
});
|
|
151
251
|
}
|
|
@@ -6,10 +6,23 @@ export class JourneyRunner {
|
|
|
6
6
|
async execute(action: string, inputs: Record<string, unknown>, state?: unknown) {
|
|
7
7
|
const declaration = Bun.YAML.parse(await Bun.file(this.journeyYamlPath).text()) as {
|
|
8
8
|
entrypoint: { interlocking_id: string };
|
|
9
|
+
continuations: Array<{ from: { interlocking_id: string; route_id: string }; to: { interlocking_id: string } }>;
|
|
10
|
+
terminals: Array<{ from: { interlocking_id: string; route_id: string }; outcome: string }>;
|
|
9
11
|
};
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
let current = declaration.entrypoint.interlocking_id;
|
|
13
|
+
for (;;) {
|
|
14
|
+
const id = current.replace("interlocking:", "");
|
|
15
|
+
const path = `plan/_trains/_interlockings/${id}.yaml`;
|
|
16
|
+
const runner = new InterlockingRunner(path);
|
|
17
|
+
const resolution = await runner.execute(action, inputs, state);
|
|
18
|
+
const continuation = declaration.continuations.find(edge => edge.from.interlocking_id === current && edge.from.route_id === resolution.routeId);
|
|
19
|
+
if (continuation) {
|
|
20
|
+
current = continuation.to.interlocking_id;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const terminal = declaration.terminals.find(edge => edge.from.interlocking_id === current && edge.from.route_id === resolution.routeId);
|
|
24
|
+
if (terminal) return { resolution, outcome: terminal.outcome };
|
|
25
|
+
throw new Error(`Journey topology has no outcome for ${current}`);
|
|
26
|
+
}
|
|
14
27
|
}
|
|
15
28
|
}
|
|
@@ -6,9 +6,22 @@ export class JourneyRunner {
|
|
|
6
6
|
async execute(action: string, inputs: Record<string, unknown>, state?: unknown) {
|
|
7
7
|
const declaration = Bun.YAML.parse(await Bun.file(this.journeyYamlPath).text()) as {
|
|
8
8
|
entrypoint: { interlocking_id: string };
|
|
9
|
+
continuations: Array<{ from: { interlocking_id: string; route_id: string }; to: { interlocking_id: string } }>;
|
|
10
|
+
terminals: Array<{ from: { interlocking_id: string; route_id: string }; outcome: string }>;
|
|
9
11
|
};
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
let current = declaration.entrypoint.interlocking_id;
|
|
13
|
+
for (;;) {
|
|
14
|
+
const id = current.replace("interlocking:", "");
|
|
15
|
+
const path = `plan/_trains/_interlockings/${id}.yaml`;
|
|
16
|
+
const resolution = await new InterlockingRunner(path).execute(action, inputs, state);
|
|
17
|
+
const continuation = declaration.continuations.find(edge => edge.from.interlocking_id === current && edge.from.route_id === resolution.routeId);
|
|
18
|
+
if (continuation) {
|
|
19
|
+
current = continuation.to.interlocking_id;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const terminal = declaration.terminals.find(edge => edge.from.interlocking_id === current && edge.from.route_id === resolution.routeId);
|
|
23
|
+
if (terminal) return { resolution, outcome: terminal.outcome };
|
|
24
|
+
throw new Error(`Journey topology has no outcome for ${current}`);
|
|
25
|
+
}
|
|
13
26
|
}
|
|
14
27
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
schema_version: 1.0.0
|
|
2
|
+
journey_id: journey:checkout
|
|
3
|
+
title: Checkout
|
|
4
|
+
status: checked
|
|
5
|
+
entrypoint:
|
|
6
|
+
interlocking_id: interlocking:cart
|
|
7
|
+
exposed: true
|
|
8
|
+
actions: [checkout]
|
|
9
|
+
reason: null
|
|
10
|
+
surfaces: [backend]
|
|
11
|
+
continuations:
|
|
12
|
+
- from: { interlocking_id: interlocking:cart, route_id: ready }
|
|
13
|
+
artifact: order:prepared
|
|
14
|
+
to: { interlocking_id: interlocking:payment }
|
|
15
|
+
terminals:
|
|
16
|
+
- from: { interlocking_id: interlocking:cart, route_id: empty }
|
|
17
|
+
outcome: empty-cart
|
|
18
|
+
- from: { interlocking_id: interlocking:payment, route_id: paid }
|
|
19
|
+
outcome: completed
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { JourneyRunner } from "./src/trains/journey";
|
|
2
|
+
|
|
3
|
+
export const JOURNEY_MAP = {
|
|
4
|
+
checkout: {
|
|
5
|
+
journeyId: "journey:checkout",
|
|
6
|
+
path: "plan/_journeys/checkout.yaml",
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function dispatch(action: string, inputs: Record<string, unknown>, state?: unknown) {
|
|
11
|
+
const mapping = JOURNEY_MAP[action as keyof typeof JOURNEY_MAP];
|
|
12
|
+
return new JourneyRunner(mapping.path).execute(action, inputs, state);
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { InterlockingRunner } from "@app/interlocking-runtime";
|
|
2
|
+
|
|
3
|
+
type Topology = {
|
|
4
|
+
entrypoint: { interlocking_id: string };
|
|
5
|
+
continuations: Array<{
|
|
6
|
+
from: { interlocking_id: string; route_id: string };
|
|
7
|
+
artifact: string;
|
|
8
|
+
to: { interlocking_id: string };
|
|
9
|
+
}>;
|
|
10
|
+
terminals: Array<{
|
|
11
|
+
from: { interlocking_id: string; route_id: string };
|
|
12
|
+
outcome: string;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class JourneyRunner {
|
|
17
|
+
constructor(private readonly journeyYamlPath: string) {}
|
|
18
|
+
|
|
19
|
+
async execute(action: string, inputs: Record<string, unknown>, state?: unknown) {
|
|
20
|
+
const declaration = Bun.YAML.parse(await Bun.file(this.journeyYamlPath).text()) as Topology;
|
|
21
|
+
let current = declaration.entrypoint.interlocking_id;
|
|
22
|
+
for (;;) {
|
|
23
|
+
const id = current.replace("interlocking:", "");
|
|
24
|
+
const path = `plan/_trains/_interlockings/${id}.yaml`;
|
|
25
|
+
const resolution = await new InterlockingRunner(path).execute(action, inputs, state);
|
|
26
|
+
const continuation = declaration.continuations.find(edge => edge.from.interlocking_id === current && edge.from.route_id === resolution.routeId);
|
|
27
|
+
if (continuation) {
|
|
28
|
+
current = continuation.to.interlocking_id;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const terminal = declaration.terminals.find(edge => edge.from.interlocking_id === current && edge.from.route_id === resolution.routeId);
|
|
32
|
+
if (terminal) return { resolution, outcome: terminal.outcome };
|
|
33
|
+
throw new Error(`Journey topology has no outcome for ${current}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
schema_version: 1.0.0
|
|
2
|
+
journey_id: journey:checkout
|
|
3
|
+
entrypoint:
|
|
4
|
+
interlocking_id: interlocking:cart
|
|
5
|
+
exposed: false
|
|
6
|
+
actions: []
|
|
7
|
+
surfaces: [backend]
|
|
8
|
+
continuations:
|
|
9
|
+
- from: { interlocking_id: interlocking:cart, route_id: ready }
|
|
10
|
+
artifact: order:prepared
|
|
11
|
+
to: { interlocking_id: interlocking:payment }
|
|
12
|
+
terminals:
|
|
13
|
+
- from: { interlocking_id: interlocking:payment, route_id: paid }
|
|
14
|
+
outcome: completed
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { InterlockingRunner } from "@app/interlocking-runtime";
|
|
2
|
+
|
|
3
|
+
export class JourneyRunner {
|
|
4
|
+
constructor(private readonly journeyYamlPath: string) {}
|
|
5
|
+
|
|
6
|
+
async execute(action: string, inputs: Record<string, unknown>, state?: unknown) {
|
|
7
|
+
const declaration = Bun.YAML.parse(await Bun.file(this.journeyYamlPath).text()) as any;
|
|
8
|
+
const id = declaration.entrypoint.interlocking_id.replace("interlocking:", "");
|
|
9
|
+
const path = `plan/_trains/_interlockings/${id}.yaml`;
|
|
10
|
+
const resolution = await new InterlockingRunner(path).execute(action, inputs, state);
|
|
11
|
+
declaration.continuations.find((edge: any) => edge.from.route_id === resolution.routeId);
|
|
12
|
+
declaration.terminals.find((edge: any) => edge.from.route_id === resolution.routeId);
|
|
13
|
+
return resolution;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
schema_version: 1.0.0
|
|
2
|
+
journey_id: journey:checkout
|
|
3
|
+
title: Checkout
|
|
4
|
+
status: checked
|
|
5
|
+
entrypoint:
|
|
6
|
+
interlocking_id: interlocking:cart
|
|
7
|
+
exposed: false
|
|
8
|
+
actions: []
|
|
9
|
+
reason: internal-transition-only
|
|
10
|
+
surfaces: [backend]
|
|
11
|
+
continuations:
|
|
12
|
+
- from: { interlocking_id: interlocking:cart, route_id: ready }
|
|
13
|
+
artifact: order:prepared
|
|
14
|
+
to: { interlocking_id: interlocking:payment }
|
|
15
|
+
terminals:
|
|
16
|
+
- from: { interlocking_id: interlocking:cart, route_id: empty }
|
|
17
|
+
outcome: empty-cart
|
|
18
|
+
- from: { interlocking_id: interlocking:payment, route_id: paid }
|
|
19
|
+
outcome: completed
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { InterlockingRunner } from "@app/interlocking-runtime";
|
|
2
|
+
|
|
3
|
+
export class JourneyRunner {
|
|
4
|
+
constructor(private readonly journeyYamlPath: string) {}
|
|
5
|
+
|
|
6
|
+
async execute(action: string, inputs: Record<string, unknown>, state?: unknown) {
|
|
7
|
+
const declaration = Bun.YAML.parse(await Bun.file(this.journeyYamlPath).text()) as {
|
|
8
|
+
entrypoint: { interlocking_id: string };
|
|
9
|
+
continuations: unknown[];
|
|
10
|
+
terminals: unknown[];
|
|
11
|
+
};
|
|
12
|
+
const id = declaration.entrypoint.interlocking_id.replace("interlocking:", "");
|
|
13
|
+
const path = `plan/_trains/_interlockings/${id}.yaml`;
|
|
14
|
+
return new InterlockingRunner(path).execute(action, inputs, state);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
schema_version: 1.0.0
|
|
2
|
+
journey_id: journey:checkout
|
|
3
|
+
entrypoint:
|
|
4
|
+
interlocking_id: interlocking:cart
|
|
5
|
+
exposed: false
|
|
6
|
+
actions: []
|
|
7
|
+
surfaces: [backend]
|
|
8
|
+
continuations:
|
|
9
|
+
- from: { interlocking_id: interlocking:cart, route_id: ready }
|
|
10
|
+
artifact: order:prepared
|
|
11
|
+
to: { interlocking_id: interlocking:payment }
|
|
12
|
+
terminals:
|
|
13
|
+
- from: { interlocking_id: interlocking:payment, route_id: paid }
|
|
14
|
+
outcome: completed
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { InterlockingRunner } from "@app/interlocking-runtime";
|
|
2
|
+
|
|
3
|
+
export class JourneyRunner {
|
|
4
|
+
constructor(private readonly journeyYamlPath: string) {}
|
|
5
|
+
|
|
6
|
+
async execute(action: string, inputs: Record<string, unknown>, state?: unknown) {
|
|
7
|
+
const declaration = Bun.YAML.parse(await Bun.file(this.journeyYamlPath).text()) as any;
|
|
8
|
+
let current = declaration.entrypoint.interlocking_id;
|
|
9
|
+
for (;;) {
|
|
10
|
+
const id = current.replace("interlocking:", "");
|
|
11
|
+
const resolution = await new InterlockingRunner(`plan/_trains/_interlockings/${id}.yaml`).execute(action, inputs, state);
|
|
12
|
+
const continuation = declaration.continuations.find((edge: any) => edge.from.route_id === resolution.routeId);
|
|
13
|
+
if (continuation) { current = current; continue; }
|
|
14
|
+
const terminal = declaration.terminals.find((edge: any) => edge.from.route_id === resolution.routeId);
|
|
15
|
+
if (terminal) return resolution;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
schema_version: 1.0.0
|
|
2
|
+
journey_id: journey:public
|
|
3
|
+
title: Public journey
|
|
4
|
+
status: checked
|
|
5
|
+
entrypoint:
|
|
6
|
+
interlocking_id: interlocking:first
|
|
7
|
+
exposed: true
|
|
8
|
+
actions: [start_journey]
|
|
9
|
+
reason: null
|
|
10
|
+
surfaces: [backend]
|
|
11
|
+
continuations: []
|
|
12
|
+
terminals:
|
|
13
|
+
- from: { interlocking_id: interlocking:first, route_id: done }
|
|
14
|
+
outcome: completed
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { JourneyRunner } from "./src/trains/journey";
|
|
2
|
+
import { createOrder } from "./src/orders/create";
|
|
3
|
+
|
|
4
|
+
export const JOURNEY_MAP = {
|
|
5
|
+
start_journey: {
|
|
6
|
+
journeyId: "journey:public",
|
|
7
|
+
path: "plan/_journeys/public.yaml",
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function dispatch(action: string, inputs: Record<string, unknown>) {
|
|
12
|
+
const mapping = JOURNEY_MAP[action as keyof typeof JOURNEY_MAP];
|
|
13
|
+
void JourneyRunner;
|
|
14
|
+
void mapping;
|
|
15
|
+
return createOrder(inputs);
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { InterlockingRunner } from "@app/interlocking-runtime";
|
|
2
|
+
|
|
3
|
+
export class JourneyRunner {
|
|
4
|
+
constructor(private readonly journeyYamlPath: string) {}
|
|
5
|
+
|
|
6
|
+
execute(action: string, inputs: Record<string, unknown>, state?: unknown) {
|
|
7
|
+
return new InterlockingRunner(this.journeyYamlPath).execute(action, inputs, state);
|
|
8
|
+
}
|
|
9
|
+
}
|