@afokapu/atdd-bun 0.10.58 → 0.10.59
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_coverage/_shared/interlocking.mjs +258 -36
- package/detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs +6 -2
- package/detectors/bun_interlocking_coverage/checks/interlocking_route_coverage.mjs +10 -1
- package/detectors/bun_interlocking_coverage/checks/interlocking_smoke_coverage.mjs +11 -10
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/e2e/smoke/resolve-match.smoke.test.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/plan/_trains/_interlockings/match.yaml +9 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/server.ts +10 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/interlocking.ts +12 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/journey.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/runner.ts +6 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/scenario-compilation/compile.routes.test.ts +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/session-replay/replay.routes.test.ts +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/source-corpus-admission/refuse.routes.test.ts +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/smoke/runtime-station-master.test.ts +44 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/support/production-train.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/create-world-pack.yaml +18 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/play-world-scenario.yaml +24 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/replay-world-session.yaml +18 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/scenario-compilation.yaml +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/session-replay.yaml +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/source-corpus-admission.yaml +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/server.ts +85 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/interlocking.ts +64 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/journey.ts +71 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/runner.ts +28 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/e2e/smoke/resolve-match.smoke.test.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/plan/_trains/_interlockings/match.yaml +9 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/server.ts +21 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/interlocking.ts +12 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/journey.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/runner.ts +6 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/e2e/smoke/resolve-match.smoke.test.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/plan/_trains/_interlockings/match.yaml +9 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/server.ts +11 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/interlocking.ts +12 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/journey.test.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/runner.ts +6 -0
- package/detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs +5 -0
- package/detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs +5 -1
- package/integrity.json +41 -7
- package/lib/journey-chain.mjs +677 -0
- package/package.json +3 -2
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// afokapu/atdd#1248 route space + #1251 runner call model). Each check under ../checks/*.mjs imports
|
|
4
4
|
// this module and scans a consumer tree for ONE tester.bun.interlocking-* rule.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// Node builtins only, except lib/journey-chain.mjs, which proves the journey-mediated chain on the
|
|
7
|
+
// TypeScript compiler AST. The interlocking route space is stack-neutral planner
|
|
7
8
|
// data (snake_case, plan/_trains/_interlockings/**); the e2e tests are Bun/TS under e2e/**.
|
|
8
9
|
|
|
9
10
|
import { excludedPath } from "../../../lib/scan.mjs";
|
|
11
|
+
import { entryDelegatedAction, provenJourneyAction } from "../../../lib/journey-chain.mjs";
|
|
10
12
|
import { existsSync, readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
|
|
11
13
|
import { dirname, join, resolve, sep } from "node:path";
|
|
12
14
|
|
|
@@ -338,41 +340,239 @@ function productionReceivers(text) {
|
|
|
338
340
|
// [{ resolution, execution }] means an actual resolution value feeds actual train execution.
|
|
339
341
|
// `execution` is null for an unassigned execute result; route/runner proof may assert resolution,
|
|
340
342
|
// while trace and sequence proof require the named execution result.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
+
function resolvedLocalNamedImport(text, file, croot, symbol) {
|
|
344
|
+
if (!file || !croot) return null;
|
|
343
345
|
const re = /^\s*import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/gm;
|
|
344
346
|
for (const match of text.matchAll(re)) {
|
|
345
347
|
if (!match[2].startsWith(".")) continue;
|
|
346
348
|
const names = match[1].split(",").map(name => name.trim().split(/\s+as\s+/)[0]);
|
|
349
|
+
if (!names.includes(symbol)) continue;
|
|
347
350
|
const target = resolve(dirname(file), match[2]);
|
|
348
351
|
const candidates = [target, `${target}.ts`, `${target}.tsx`, `${target}.js`, `${target}.mjs`, join(target, "index.ts")];
|
|
349
352
|
const source = candidates.find(candidate => existsSync(candidate) && candidate.startsWith(resolve(croot) + sep) && !/\.(test|spec)\.[cm]?[jt]sx?$/.test(candidate));
|
|
350
|
-
if (
|
|
351
|
-
|
|
353
|
+
if (source) return source;
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function hasResolvedProductionRunnerImports(text, file, croot) {
|
|
359
|
+
return Boolean(
|
|
360
|
+
resolvedLocalNamedImport(text, file, croot, PROD_INTERLOCKING) &&
|
|
361
|
+
resolvedLocalNamedImport(text, file, croot, PROD_TRAIN),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Index just past a string or comment starting at `i`, or `i` itself when `i` starts code.
|
|
366
|
+
function skipLiteral(text, i) {
|
|
367
|
+
const c = text[i], next = text[i + 1];
|
|
368
|
+
if (c === "/" && next === "/") { const end = text.indexOf("\n", i + 2); return end < 0 ? text.length : end; }
|
|
369
|
+
if (c === "/" && next === "*") { const end = text.indexOf("*/", i + 2); return end < 0 ? text.length : end + 2; }
|
|
370
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
371
|
+
for (let j = i + 1; j < text.length; j++) {
|
|
372
|
+
if (text[j] === "\\") { j++; continue; }
|
|
373
|
+
if (text[j] === c) return j + 1;
|
|
374
|
+
}
|
|
375
|
+
return text.length;
|
|
376
|
+
}
|
|
377
|
+
return i;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Index of the first bracket that closes a scope opened before `text` begins.
|
|
381
|
+
function enclosingEnd(text) {
|
|
382
|
+
let depth = 0;
|
|
383
|
+
for (let i = 0; i < text.length; i++) {
|
|
384
|
+
const skip = skipLiteral(text, i);
|
|
385
|
+
if (skip !== i) { i = skip - 1; continue; }
|
|
386
|
+
if ("([{".includes(text[i])) depth++;
|
|
387
|
+
else if (")]}".includes(text[i]) && --depth < 0) return i;
|
|
388
|
+
}
|
|
389
|
+
return text.length;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A `return` statement ends at `;`, at the scope's closing bracket, or at a newline that cannot continue it.
|
|
393
|
+
function returnStatementEnd(text, start) {
|
|
394
|
+
let depth = 0;
|
|
395
|
+
for (let i = start + "return".length; i < text.length; i++) {
|
|
396
|
+
const skip = skipLiteral(text, i);
|
|
397
|
+
if (skip !== i) { i = skip - 1; continue; }
|
|
398
|
+
const c = text[i];
|
|
399
|
+
if ("([{".includes(c)) depth++;
|
|
400
|
+
else if (")]}".includes(c)) { if (--depth < 0) return i; }
|
|
401
|
+
else if (depth === 0 && c === ";") return i;
|
|
402
|
+
else if (depth === 0 && c === "\n") {
|
|
403
|
+
const head = text.slice(start + "return".length, i).trim();
|
|
404
|
+
const next = text.slice(i + 1).trimStart()[0] ?? "";
|
|
405
|
+
if (!head || (!/[=+\-*/%&|^?:,.!<>]$/.test(head) && !/^[.?:+\-*/%&|^,=<>)\]]/.test(next))) return i;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return text.length;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// True when a statement starting at `index` is the body of a braceless `if (…)`, `for (…)`, `while (…)`, or `else`.
|
|
412
|
+
function conditionalStatement(text, index) {
|
|
413
|
+
const before = maskComments(text.slice(0, index)).trimEnd();
|
|
414
|
+
if (/(?:^|[^\w$.])else$/.test(before)) return true;
|
|
415
|
+
if (!before.endsWith(")")) return false;
|
|
416
|
+
let depth = 0;
|
|
417
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
418
|
+
if (before[i] === ")") depth++;
|
|
419
|
+
else if (before[i] === "(" && --depth === 0) return /(?:^|[^\w$.])(?:if|for|while)\s*$/.test(before.slice(0, i));
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// [[start, end]] of the first `return` that always runs when control reaches it at the top level of `text`
|
|
425
|
+
// (or [] when none does); anything after it is unreachable. Nested blocks, callbacks, and braceless
|
|
426
|
+
// conditional bodies are excluded; `try`/`finally` bodies are not.
|
|
427
|
+
function topLevelReturns(text) {
|
|
428
|
+
const ranges = [], stack = [];
|
|
429
|
+
for (let i = 0; i < text.length; i++) {
|
|
430
|
+
const skip = skipLiteral(text, i);
|
|
431
|
+
if (skip !== i) { i = skip - 1; continue; }
|
|
432
|
+
const c = text[i];
|
|
433
|
+
if ("([{".includes(c)) stack.push(c === "{" && /(?:^|[^\w$.])(?:try|finally)\s*$/.test(text.slice(Math.max(0, i - 16), i)) ? "always" : c);
|
|
434
|
+
else if (")]}".includes(c)) { if (!stack.length) break; stack.pop(); }
|
|
435
|
+
else if (stack.every((entry) => entry === "always") && text.startsWith("return", i) &&
|
|
436
|
+
!/[\w$.]/.test(text[i - 1] ?? "") && !/[\w$]/.test(text[i + 6] ?? "") && !conditionalStatement(text, i)) {
|
|
437
|
+
ranges.push([i, returnStatementEnd(text, i)]);
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return ranges;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Operators that make an operand conditional, discarded, or replaced by another value: comma, ternary,
|
|
445
|
+
// short-circuit logic, arrows, unary/binary arithmetic and comparison, and `void`/`typeof`/`delete`/
|
|
446
|
+
// `instanceof`/`in`. A non-null `!` may follow a value, so only a prefix `!` counts.
|
|
447
|
+
const PREFIX_REPLACING = /^(?:\?(?![.?])|\?\?|&&|\|\||=>|[,:!~+\-*/%<>=&|^])|^(?:void|typeof|delete|instanceof|in)\b/;
|
|
448
|
+
const SUFFIX_REPLACING = /^(?:\?(?![.?])|\?\?|&&|\|\||[,:~+\-*/%<>=&|^])|^(?:instanceof|in)\b/;
|
|
449
|
+
const replacing = (pattern, text, i) => !/[\w$]/.test(text[i - 1] ?? "") || !/^[a-z]/.test(text[i]) ? pattern.exec(text.slice(i, i + 10)) : null;
|
|
450
|
+
|
|
451
|
+
function returnsValueAt(text, start, end, index) {
|
|
452
|
+
const stack = [{ kind: "group", operator: false }];
|
|
453
|
+
for (let i = start + "return".length; i < index; i++) {
|
|
454
|
+
const skip = skipLiteral(text, i);
|
|
455
|
+
if (skip !== i) { i = skip - 1; continue; }
|
|
456
|
+
const c = text[i];
|
|
457
|
+
if (c === "(") {
|
|
458
|
+
const prev = text.slice(start, i);
|
|
459
|
+
const group = /(?:^|[^\w$])(?:return|await|typeof|void|yield|in|of|case)\s*$/.test(prev) || !/[\w$)\]]\s*$/.test(prev);
|
|
460
|
+
stack.push({ kind: group ? "group" : "call", operator: false });
|
|
461
|
+
} else if ("[{".includes(c)) stack.push({ kind: c, operator: false });
|
|
462
|
+
else if (")]}".includes(c)) stack.pop();
|
|
463
|
+
else {
|
|
464
|
+
const op = replacing(PREFIX_REPLACING, text, i);
|
|
465
|
+
if (op) { stack.at(-1).operator = true; i += op[0].length - 1; }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
for (let level = stack.length - 1; level >= 0 && stack[level].kind === "group"; level--) if (stack[level].operator) return false;
|
|
469
|
+
let depth = 0;
|
|
470
|
+
for (let i = index; i < end; i++) {
|
|
471
|
+
const skip = skipLiteral(text, i);
|
|
472
|
+
if (skip !== i) { i = skip - 1; continue; }
|
|
473
|
+
const c = text[i];
|
|
474
|
+
if ("([{".includes(c)) depth++;
|
|
475
|
+
else if (")]}".includes(c)) {
|
|
476
|
+
if (depth > 0) depth--;
|
|
477
|
+
else { stack.pop(); if (!stack.length || stack.at(-1).kind !== "group") return true; }
|
|
478
|
+
} else if (depth === 0 && stack.at(-1).kind === "group" && replacing(SUFFIX_REPLACING, text, i)) return false;
|
|
479
|
+
}
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// True when a match of `pattern` ending by `limit` is returned at the top level of `text`, inline or
|
|
484
|
+
// through a variable assigned from it.
|
|
485
|
+
function returnsMatch(text, pattern, limit = text.length) {
|
|
486
|
+
const ranges = topLevelReturns(text);
|
|
487
|
+
const returned = (index) => ranges.some(([start, end]) => index > start && index < end && returnsValueAt(text, start, end, index));
|
|
488
|
+
for (const m of text.matchAll(new RegExp(pattern, "g"))) {
|
|
489
|
+
if ((m.index ?? 0) + m[0].length <= limit && returned(m.index ?? 0)) return true;
|
|
352
490
|
}
|
|
353
|
-
|
|
491
|
+
const assigned = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?${pattern}`, "g");
|
|
492
|
+
for (const m of text.matchAll(assigned)) {
|
|
493
|
+
const after = (m.index ?? 0) + m[0].length;
|
|
494
|
+
if (after > limit) continue;
|
|
495
|
+
for (const ref of text.matchAll(new RegExp(`(?<![\\w$.])${escaped(m[1])}(?![\\w$])`, "g"))) {
|
|
496
|
+
if ((ref.index ?? 0) >= after && returned(ref.index ?? 0)) return true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Index of the first use of `name` that could rebind or mutate it. Allowed: member-chain reads (not
|
|
503
|
+
// assigned or called), object keys, `void`/`console.*` arguments, the execute argument matched by
|
|
504
|
+
// `executeArgument`, and object/array values inside a returned expression. An object or array holding the
|
|
505
|
+
// resolution anywhere else is an alias whose later member writes are invisible here, so it ends the binding.
|
|
506
|
+
function firstRebinding(text, name, executeArgument, returns) {
|
|
507
|
+
for (const m of text.matchAll(new RegExp(`(?<![\\w$.])${escaped(name)}(?![\\w$])`, "g"))) {
|
|
508
|
+
const index = m.index ?? 0;
|
|
509
|
+
const before = text.slice(0, index), rest = text.slice(index + m[0].length);
|
|
510
|
+
const chain = /^(?:\s*\??\.\s*[A-Za-z_$][\w$]*|\s*\[[^\]]*\])+/.exec(rest);
|
|
511
|
+
if (chain) {
|
|
512
|
+
if (/^\s*(?:=(?![=>])|\+\+|--|(?:\*\*|[-+*/%&|^]|<<|>>>?|\?\?|&&|\|\|)=|\()/.test(rest.slice(chain[0].length))) return index;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (new RegExp(`(?:${executeArgument})$`).test(before)) continue;
|
|
516
|
+
if (/[{,]\s*$/.test(before) && /^\s*:/.test(rest)) continue; // an object key, not a reference
|
|
517
|
+
if (/\bvoid\s+$/.test(before)) continue;
|
|
518
|
+
if (/\bconsole\s*\.\s*(?:log|info|warn|error|debug|trace)\s*\(\s*$/.test(before) && /^\s*\)/.test(rest)) continue;
|
|
519
|
+
if (/(?:[{,]\s*(?:[A-Za-z_$][\w$]*\s*:\s*)?|\[\s*)$/.test(before) && /^\s*[,}\]]/.test(rest) &&
|
|
520
|
+
returns.some(([start, end]) => index > start && index < end)) continue;
|
|
521
|
+
return index;
|
|
522
|
+
}
|
|
523
|
+
return text.length;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// `after` starts inside the `resolveTrain(` call that produced `resolution`. The binding holds only inside
|
|
527
|
+
// the block that declared the resolution, and only until the resolution is passed, aliased, assigned, or
|
|
528
|
+
// mutated anywhere other than the execution itself.
|
|
529
|
+
function resolutionBinding(after, resolution, executeArgument) {
|
|
530
|
+
const statement = after.slice(enclosingEnd(after) + 1);
|
|
531
|
+
const scope = statement.slice(0, enclosingEnd(statement));
|
|
532
|
+
return { scope, bound: scope.slice(0, firstRebinding(scope, resolution, executeArgument, topLevelReturns(scope))) };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function trainExecutionForms(resolution) {
|
|
536
|
+
const r = escaped(resolution);
|
|
537
|
+
const legacy = `new\\s+${PROD_TRAIN}\\s*\\(\\s*${r}\\.(?:trainId|selectedTrainId)\\s*\\)\\s*\\.execute\\s*\\(`;
|
|
538
|
+
// The declaration-driven runner form constructs from THIS resolution's trainPath (optionally followed
|
|
539
|
+
// by handler/context args) and executes that same resolution object.
|
|
540
|
+
const constructed = `new\\s+${PROD_TRAIN}\\s*\\(\\s*${r}\\.trainPath\\s*(?:,[^()]*)?\\)\\s*\\.execute\\s*\\(\\s*`;
|
|
541
|
+
return { legacy, constructed, declaration: `${constructed}${r}\\s*(?:,|\\))` };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function resolutionTrainExecution(after, resolution) {
|
|
545
|
+
const { legacy, constructed, declaration } = trainExecutionForms(resolution);
|
|
546
|
+
const { scope, bound } = resolutionBinding(after, resolution, constructed);
|
|
547
|
+
const assignment = `\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?`;
|
|
548
|
+
const assigned = new RegExp(`${assignment}${legacy}`).exec(after) ?? new RegExp(`${assignment}${declaration}`).exec(bound);
|
|
549
|
+
const returns = `\\breturn\\s+(?:await\\s+)?`;
|
|
550
|
+
return {
|
|
551
|
+
execution: assigned?.[1] ?? null,
|
|
552
|
+
found: Boolean(assigned || new RegExp(legacy).test(after) || new RegExp(declaration).test(bound)),
|
|
553
|
+
returned: new RegExp(`${returns}${legacy}`).test(after) || returnsMatch(scope, declaration, bound.length) ||
|
|
554
|
+
Boolean(assigned && new RegExp(`${returns}${escaped(assigned[1])}\\s*;?`).test(after)),
|
|
555
|
+
};
|
|
354
556
|
}
|
|
355
557
|
|
|
356
558
|
export function productionExecutionProofs(text, file, croot) {
|
|
357
559
|
if (file && croot && !hasResolvedProductionRunnerImports(text, file, croot)) return [];
|
|
358
560
|
const receivers = productionReceivers(text);
|
|
359
561
|
const proofs = [];
|
|
360
|
-
const resolutions = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*([
|
|
562
|
+
const resolutions = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*([^=;{}\\n]{0,240}?)\\.resolveTrain\\s*\\(`, "g");
|
|
361
563
|
for (const m of text.matchAll(resolutions)) {
|
|
362
564
|
const [whole, resolution, receiver] = m;
|
|
363
565
|
const direct = new RegExp(`new\\s+${PROD_INTERLOCKING}\\b`).test(receiver);
|
|
364
566
|
const named = [...receivers].some((name) => receiver.trim().endsWith(name));
|
|
365
567
|
if (!direct && !named) continue;
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
const anonymous = new RegExp(`new\\s+${PROD_TRAIN}\\s*\\(\\s*${escaped(resolution)}\\.(?:trainId|selectedTrainId)\\s*\\)\\s*\\.execute\\s*\\(`).test(after);
|
|
369
|
-
if (execute || anonymous) proofs.push({ resolution, execution: execute?.[1] ?? null });
|
|
568
|
+
const proof = resolutionTrainExecution(text.slice((m.index ?? 0) + whole.length), resolution);
|
|
569
|
+
if (proof.found) proofs.push({ resolution, execution: proof.execution });
|
|
370
570
|
}
|
|
371
571
|
return proofs;
|
|
372
572
|
}
|
|
373
573
|
|
|
374
574
|
export function assertsExpression(text, expression, expected = null) {
|
|
375
|
-
const pat = new RegExp(`\\bexpect\\s*\\(\\s*(?:await\\s+)?${expression}\\s*\\)\\s*\\.(?:to(?:Be|BeDefined|Equal|StrictEqual|Contain|ContainEqual|Match|BeTruthy)|not\\.to(?:Be|BeDefined|Equal|StrictEqual|Contain|ContainEqual|Match))\\b([\\s\\S]{0,220})`);
|
|
575
|
+
const pat = new RegExp(`\\bexpect\\s*\\(\\s*(?:await\\s+)?${expression}\\s*\\)\\s*\\.(?:to(?:Be|BeDefined|Equal|StrictEqual|Contain|ContainEqual|MatchObject|Match|BeTruthy)|not\\.to(?:Be|BeDefined|Equal|StrictEqual|Contain|ContainEqual|MatchObject|Match))\\b([\\s\\S]{0,220})`);
|
|
376
576
|
const m = pat.exec(text);
|
|
377
577
|
return Boolean(m && (!expected || tokenCovered(expected, m[0])));
|
|
378
578
|
}
|
|
@@ -451,33 +651,55 @@ function exportedActionBody(text, name) {
|
|
|
451
651
|
return null;
|
|
452
652
|
}
|
|
453
653
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
654
|
+
// As on main, the exported action itself must resolve on a recognized InterlockingRunner and
|
|
655
|
+
// return that resolution's execution; a discarded or module-local execution is not evidence.
|
|
656
|
+
function directProductionModuleExecution(body, moduleText = body) {
|
|
657
|
+
if (!new RegExp(`new\\s+${PROD_INTERLOCKING}\\b`).test(moduleText) || !new RegExp(`new\\s+${PROD_TRAIN}\\b`).test(moduleText)) return false;
|
|
658
|
+
const receivers = productionReceivers(moduleText);
|
|
659
|
+
const resolutions = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*([^=;{}\\n]{0,240}?)\\.resolveTrain\\s*\\(`, "g");
|
|
660
|
+
return [...body.matchAll(resolutions)].some(match => {
|
|
661
|
+
const recognized = new RegExp(`new\\s+${PROD_INTERLOCKING}\\b`).test(match[2]) ||
|
|
662
|
+
[...receivers].some((name) => match[2].trim().endsWith(name));
|
|
663
|
+
return recognized && resolutionTrainExecution(body.slice((match.index ?? 0) + match[0].length), match[1]).returned;
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// HTTP/module entrypoints do not expose a StationMaster instance to the test. Their equivalent
|
|
668
|
+
// witness is an asserted result from the same exported action the test invokes. That action must
|
|
669
|
+
// either directly execute the selected train or return a JourneyRunner execution whose local
|
|
670
|
+
// InterlockingRunner then executes the same resolution's declaration path.
|
|
671
|
+
function stationModuleCall(text, action) {
|
|
672
|
+
return new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?(?:${ident}\\.)?(dispatch|handleAction|executeAction)\\s*\\(\\s*["']${escaped(action)}["']`).exec(text);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function stationModuleExecutionProof(text, stationText, action, stationFile, croot) {
|
|
676
|
+
if (!stationText || !stationFile || !croot) return false;
|
|
677
|
+
const call = stationModuleCall(text, action);
|
|
678
|
+
if (call && assertsExpression(text, `${escaped(call[1])}(?:\\.[A-Za-z_$][\\w$]*)?`)) {
|
|
679
|
+
// The journey-mediated chain is proven on the TypeScript AST (lib/journey-chain.mjs).
|
|
680
|
+
if (provenJourneyAction(stationText, stationFile, croot, call[2])) return true;
|
|
681
|
+
const body = exportedActionBody(stationText, call[2]);
|
|
682
|
+
if (body && directProductionModuleExecution(body, stationText)) return true;
|
|
469
683
|
}
|
|
470
|
-
return
|
|
684
|
+
return stationEntryExecutionProof(text, stationText, action, stationFile, croot);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// An HTTP-shaped entry (e.g. `stationMaster(command)`) whose asserted result depends on its awaited
|
|
688
|
+
// call of a proven journey action with this literal action name.
|
|
689
|
+
function stationEntryExecutionProof(text, stationText, action, stationFile, croot) {
|
|
690
|
+
const calls = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?(${ident})\\s*\\(`, "g");
|
|
691
|
+
return [...text.matchAll(calls)].some(([, result, entry]) => {
|
|
692
|
+
const delegated = entryDelegatedAction(stationText, stationFile, entry);
|
|
693
|
+
return Boolean(delegated && delegated.action === action &&
|
|
694
|
+
assertsExpression(text, `${escaped(result)}(?:\\.[A-Za-z_$][\\w$]*)?`) &&
|
|
695
|
+
provenJourneyAction(stationText, stationFile, croot, delegated.callee));
|
|
696
|
+
});
|
|
471
697
|
}
|
|
472
698
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (!stationText) return false;
|
|
478
|
-
const call = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*await\\s+(?:${ident}\\.)?(dispatch|handleAction|executeAction)\\s*\\(\\s*["']${escaped(action)}["']`).exec(text);
|
|
479
|
-
return Boolean(call && assertsExpression(text, `${escaped(call[1])}(?:\\.[A-Za-z_$][\\w$]*)?`) &&
|
|
480
|
-
exportedActionReturnsProductionExecution(stationText, call[2]));
|
|
699
|
+
export function routeHasStationModuleProof(route, text, action, stationText, stationFile, croot) {
|
|
700
|
+
const call = stationModuleCall(text, action);
|
|
701
|
+
return Boolean(call && stationModuleExecutionProof(text, stationText, action, stationFile, croot) &&
|
|
702
|
+
assertsExpression(text, `${escaped(call[1])}\\.selectedTrainId`, route.trainId));
|
|
481
703
|
}
|
|
482
704
|
|
|
483
705
|
// Bun's native `expect` vocabulary provides a compact mutation witness: exact ordered equality
|
|
@@ -39,7 +39,9 @@ for (const scanRoot of roots) {
|
|
|
39
39
|
// A test that imports the Station Master drives the runners it composes: the call model's entry point
|
|
40
40
|
// (FWS #sEW3F9b49iA7). It counts only when the Station Master module itself references both production runners;
|
|
41
41
|
// substitutes are still caught by FORBIDDEN_PATTERNS above.
|
|
42
|
-
const station = ["server.ts", join("src", "server.ts")]
|
|
42
|
+
const station = ["server.ts", join("src", "server.ts")]
|
|
43
|
+
.map((name) => ({ file: join(croot, name), text: readText(join(croot, name)) }))
|
|
44
|
+
.find((entry) => entry.text);
|
|
43
45
|
const drivesStationMaster = (text) => Boolean(station) && /\bfrom\s+["'][^"']*\bserver(?:\.ts)?["']|import\(\s*["'][^"']*\bserver(?:\.ts)?["']\s*\)/.test(text);
|
|
44
46
|
const records = interlockingFiles(croot)
|
|
45
47
|
.map((f) => parseInterlocking(readText(f)))
|
|
@@ -72,7 +74,9 @@ for (const scanRoot of roots) {
|
|
|
72
74
|
|
|
73
75
|
const semantic = hasProductionExecutionProof(text, file, croot) ||
|
|
74
76
|
records.some((rec) => rec.actions.some((action) => stationMasterExecutionProof(text, action))) ||
|
|
75
|
-
(drivesStationMaster(text) && records.some((rec) => rec.actions.some((action) =>
|
|
77
|
+
(drivesStationMaster(text) && records.some((rec) => rec.actions.some((action) =>
|
|
78
|
+
stationModuleExecutionProof(text, station.text, action, station.file, croot),
|
|
79
|
+
)));
|
|
76
80
|
if (!semantic) {
|
|
77
81
|
violations.push(
|
|
78
82
|
mk(
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// exercises it — referenced by its routeId or resolved trainId in an `e2e/**/*.ts` test. An admissible
|
|
7
7
|
// route with no covering e2e test is a silent-green route-control branch. Bun mirror of core
|
|
8
8
|
// tester.interlocking.route-coverage.
|
|
9
|
+
import { join } from "node:path";
|
|
9
10
|
import {
|
|
10
11
|
parseJsonEnv,
|
|
11
12
|
readText,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
e2eFiles,
|
|
15
16
|
parseInterlocking,
|
|
16
17
|
routeHasProductionProof,
|
|
18
|
+
routeHasStationModuleProof,
|
|
17
19
|
maskComments,
|
|
18
20
|
rel,
|
|
19
21
|
mk,
|
|
@@ -31,10 +33,17 @@ for (const scanRoot of roots) {
|
|
|
31
33
|
.map((f) => ({ file: f, rec: parseInterlocking(readText(f)) }))
|
|
32
34
|
.filter((x) => x.rec);
|
|
33
35
|
const e2eTexts = e2eFiles(croot).map((file) => ({ file, text: maskComments(readText(file)) }));
|
|
36
|
+
const station = ["server.ts", "src/server.ts"]
|
|
37
|
+
.map((name) => ({ file: join(croot, name), text: readText(join(croot, name)) }))
|
|
38
|
+
.find((entry) => entry.text);
|
|
34
39
|
|
|
35
40
|
for (const { file, rec } of records) {
|
|
36
41
|
for (const route of rec.routes) {
|
|
37
|
-
|
|
42
|
+
const covered = e2eTexts.some(({ file: testFile, text }) =>
|
|
43
|
+
routeHasProductionProof(route, text, rec.interlockingId, testFile, croot) ||
|
|
44
|
+
(station && rec.actions.some((action) => routeHasStationModuleProof(route, text, action, station.text, station.file, croot))),
|
|
45
|
+
);
|
|
46
|
+
if (covered) continue;
|
|
38
47
|
if (unbuiltWagons(croot, route.trainId).length) continue; // pending: a wagon on its train has no source yet
|
|
39
48
|
const cat =
|
|
40
49
|
route.category !== null
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// (exposed:false) interlockings have no Station Master action and are OUT OF SCOPE. Bun mirror of
|
|
8
8
|
// core tester.interlocking.smoke-coverage-for-station-master. The enforced minimum smoke signature is
|
|
9
9
|
// an `e2e/**/*.ts` file referencing the action name AND a Station Master reference AND both runners.
|
|
10
|
+
import { join } from "node:path";
|
|
10
11
|
import {
|
|
11
12
|
parseJsonEnv,
|
|
12
13
|
readText,
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
mk,
|
|
27
28
|
writeReport,
|
|
28
29
|
} from "../_shared/interlocking.mjs";
|
|
29
|
-
import { join } from "node:path";
|
|
30
30
|
|
|
31
31
|
const RULE = "tester.bun.interlocking-smoke-coverage-for-station-master";
|
|
32
32
|
const roots = parseJsonEnv("ATDD_SCAN_ROOTS", []);
|
|
@@ -36,11 +36,12 @@ const violations = [];
|
|
|
36
36
|
// it under the smoke rules (no substituted collaborators, observable outcome). A local E2E that names the action beside
|
|
37
37
|
// the runners is not a smoke and no longer clears the rule (FWS #TzSCGS5ajYhP).
|
|
38
38
|
const SMOKE_PHASE = /^\s*\/\/\s*Phase:\s*SMOKE\b/m;
|
|
39
|
-
function actionSmokeCovered(action, e2eFiles,
|
|
39
|
+
function actionSmokeCovered(action, e2eFiles, station, croot) {
|
|
40
40
|
return e2eFiles.some(
|
|
41
|
-
({ raw, text: t }) =>
|
|
42
|
-
SMOKE_PHASE.test(raw) && tokenCovered(action, t) && STATION_MASTER.test(t) &&
|
|
43
|
-
(stationMasterExecutionProof(t, action) ||
|
|
41
|
+
({ raw, text: t, file }) =>
|
|
42
|
+
SMOKE_PHASE.test(raw) && tokenCovered(action, t) && STATION_MASTER.test(t) &&
|
|
43
|
+
(stationMasterExecutionProof(t, action) ||
|
|
44
|
+
(station && stationModuleExecutionProof(t, station.text, action, station.file, croot))),
|
|
44
45
|
);
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -49,15 +50,15 @@ for (const scanRoot of roots) {
|
|
|
49
50
|
const records = interlockingFiles(croot)
|
|
50
51
|
.map((f) => ({ file: f, rec: parseInterlocking(readText(f)) }))
|
|
51
52
|
.filter((x) => x.rec);
|
|
52
|
-
const e2eTexts = e2eFiles(croot).map((
|
|
53
|
-
const
|
|
54
|
-
.map((name) => readText(join(croot, name)))
|
|
55
|
-
.find((
|
|
53
|
+
const e2eTexts = e2eFiles(croot).map((file) => { const raw = readText(file); return { file, raw, text: maskComments(raw) }; });
|
|
54
|
+
const station = ["server.ts", "src/server.ts"]
|
|
55
|
+
.map((name) => ({ file: join(croot, name), text: readText(join(croot, name)) }))
|
|
56
|
+
.find((entry) => entry.text);
|
|
56
57
|
|
|
57
58
|
for (const { file, rec } of records) {
|
|
58
59
|
if (!rec.exposed) continue;
|
|
59
60
|
for (const action of rec.actions) {
|
|
60
|
-
if (actionSmokeCovered(action, e2eTexts,
|
|
61
|
+
if (actionSmokeCovered(action, e2eTexts, station, croot)) continue;
|
|
61
62
|
const [line, src] = lineOf(rec.rawText, new RegExp("^\\s*-\\s*['\"]?" + action + "['\"]?\\s*$"));
|
|
62
63
|
violations.push(
|
|
63
64
|
mk(
|
|
@@ -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,10 @@
|
|
|
1
|
+
import { JourneyRunner } from "./src/trains/journey";
|
|
2
|
+
|
|
3
|
+
export const JOURNEY_MAP = {
|
|
4
|
+
resolve_match: { journeyId: "journey:match", path: "plan/_journeys/match.yaml" },
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function dispatch(action: keyof typeof JOURNEY_MAP, inputs: object, context: { handlers: object; seed: object }) {
|
|
8
|
+
const mapping = JOURNEY_MAP[action];
|
|
9
|
+
return new JourneyRunner(mapping.path).execute(action, inputs, context);
|
|
10
|
+
}
|
|
@@ -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,14 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { InterlockingRunner } from "../../../src/trains/interlocking.ts";
|
|
3
|
+
import { TrainRunner } from "../../../src/trains/runner.ts";
|
|
4
|
+
import { testTrainContext } from "../../support/production-train.ts";
|
|
5
|
+
|
|
6
|
+
test("create_scenario selects the declared compile train of interlocking:scenario-compilation", async () => {
|
|
7
|
+
const runner = new InterlockingRunner("plan/_trains/_interlockings/scenario-compilation.yaml");
|
|
8
|
+
const resolution = await runner.resolveTrain("create_scenario", { scenario: { declared: true } });
|
|
9
|
+
const context = await testTrainContext(resolution.trainPath);
|
|
10
|
+
const executed = await new TrainRunner(resolution.trainPath, context.handlers).execute(resolution, context.seed);
|
|
11
|
+
expect(resolution.routeId).toBe("compile");
|
|
12
|
+
expect(resolution.trainId).toBe("train:scenario:compile");
|
|
13
|
+
expect(executed.selectedTrainId).toBe("train:scenario:compile");
|
|
14
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { InterlockingRunner } from "../../../src/trains/interlocking.ts";
|
|
3
|
+
import { TrainRunner } from "../../../src/trains/runner.ts";
|
|
4
|
+
import { testTrainContext } from "../../support/production-train.ts";
|
|
5
|
+
|
|
6
|
+
test("replay_session selects the declared replay train of interlocking:session-replay", async () => {
|
|
7
|
+
const runner = new InterlockingRunner("plan/_trains/_interlockings/session-replay.yaml");
|
|
8
|
+
const resolution = await runner.resolveTrain("replay_session", { replay: { declared: true } });
|
|
9
|
+
const context = await testTrainContext(resolution.trainPath);
|
|
10
|
+
const executed = await new TrainRunner(resolution.trainPath, context.handlers).execute(resolution, context.seed);
|
|
11
|
+
expect(resolution.routeId).toBe("replay");
|
|
12
|
+
expect(resolution.trainId).toBe("train:session:replay");
|
|
13
|
+
expect(executed.selectedTrainId).toBe("train:session:replay");
|
|
14
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { InterlockingRunner } from "../../../src/trains/interlocking.ts";
|
|
3
|
+
import { TrainRunner } from "../../../src/trains/runner.ts";
|
|
4
|
+
import { testTrainContext } from "../../support/production-train.ts";
|
|
5
|
+
|
|
6
|
+
test("create_world selects the declared refuse train of interlocking:source-corpus-admission", async () => {
|
|
7
|
+
const runner = new InterlockingRunner("plan/_trains/_interlockings/source-corpus-admission.yaml");
|
|
8
|
+
const resolution = await runner.resolveTrain("create_world", { source: { declared: true } });
|
|
9
|
+
const context = await testTrainContext(resolution.trainPath);
|
|
10
|
+
const executed = await new TrainRunner(resolution.trainPath, context.handlers).execute(resolution, context.seed);
|
|
11
|
+
expect(resolution.routeId).toBe("refuse");
|
|
12
|
+
expect(resolution.trainId).toBe("train:source:refuse");
|
|
13
|
+
expect(executed.selectedTrainId).toBe("train:source:refuse");
|
|
14
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Phase: SMOKE
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
executeAction,
|
|
5
|
+
JOURNEY_MAP,
|
|
6
|
+
stationMaster,
|
|
7
|
+
} from "../../src/server.ts";
|
|
8
|
+
import { InterlockingRunner } from "../../src/trains/interlocking.ts";
|
|
9
|
+
import { TrainRunner } from "../../src/trains/runner.ts";
|
|
10
|
+
import { testTrainContext } from "../support/production-train.ts";
|
|
11
|
+
|
|
12
|
+
const proveProductionRoute = async (path: string, action: string, inputs: Record<string, unknown>) => {
|
|
13
|
+
const runner = new InterlockingRunner(path);
|
|
14
|
+
const resolution = await runner.resolveTrain(action, inputs);
|
|
15
|
+
const context = await testTrainContext(resolution.trainPath);
|
|
16
|
+
const executed = await new TrainRunner(resolution.trainPath, context.handlers).execute(resolution, context.seed);
|
|
17
|
+
expect(resolution.trainId).toBeDefined();
|
|
18
|
+
expect(executed).toBeDefined();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
test("Station Master returns an incomplete Scenario continuation", async () => {
|
|
22
|
+
await proveProductionRoute("plan/_trains/_interlockings/scenario-compilation.yaml", "create_scenario", { scenario: { valid: true } });
|
|
23
|
+
const result = await executeAction("create_scenario", { scenario: { valid: true } });
|
|
24
|
+
expect(result).toMatchObject({ disposition: "INCOMPLETE_CONTINUATION", status: 202 });
|
|
25
|
+
expect(JOURNEY_MAP.create_scenario.interlockingId).toBe("interlocking:scenario-compilation");
|
|
26
|
+
expect(InterlockingRunner).toBeDefined();
|
|
27
|
+
expect(TrainRunner).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("Station Master returns an incomplete replay continuation", async () => {
|
|
31
|
+
await proveProductionRoute("plan/_trains/_interlockings/session-replay.yaml", "replay_session", { replay: { requested: true } });
|
|
32
|
+
const result = await executeAction("replay_session", { replay: { requested: true } });
|
|
33
|
+
expect(result).toMatchObject({ disposition: "INCOMPLETE_CONTINUATION", status: 202 });
|
|
34
|
+
expect(JOURNEY_MAP.replay_session.interlockingId).toBe("interlocking:session-replay");
|
|
35
|
+
expect(stationMaster).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("Station Master executes the bounded source refusal terminal", async () => {
|
|
39
|
+
await proveProductionRoute("plan/_trains/_interlockings/source-corpus-admission.yaml", "create_world", { source: { required_supported: false } });
|
|
40
|
+
const response = await stationMaster({ submission: { id: "smoke" }, supported: false });
|
|
41
|
+
expect(response.status).toBe(422);
|
|
42
|
+
expect(response.headers.get("x-journey-disposition")).toBe("TERMINAL");
|
|
43
|
+
expect(await response.text()).toContain("UNSUPPORTED_SOURCE_FORMAT");
|
|
44
|
+
});
|
|
@@ -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
|
+
}
|