@afokapu/atdd-bun 0.10.97 → 0.10.99
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.
|
@@ -12,7 +12,7 @@ if (!report || !Array.isArray(roots) || !roots.length) process.exit(2);
|
|
|
12
12
|
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"]);
|
|
13
13
|
const testName = /(?:^test_.*|.*(?:\.test|\.spec|\.e2e|_test))\.(?:[cm]?[jt]sx?)$/;
|
|
14
14
|
const violations = [];
|
|
15
|
-
const plans = { acc: new Set(), wmbt: new Set(), train: new Set() };
|
|
15
|
+
const plans = { acc: new Set(), wmbt: new Set(), train: new Set(), journey: new Set() };
|
|
16
16
|
const tests = new Map();
|
|
17
17
|
const declaredAt = new Map();
|
|
18
18
|
const sources = [];
|
|
@@ -36,7 +36,8 @@ function add(rule_id, file, line, evidence, source_line = "") {
|
|
|
36
36
|
function lineOf(content, token) { return lineAt(content, content.indexOf(token)); }
|
|
37
37
|
function lineAt(content, index) { return content.slice(0, index).split("\n").length; }
|
|
38
38
|
// The identity each binding header must carry.
|
|
39
|
-
const PREFIX = { Acceptance: "acc:", WMBT: "wmbt:", Train: "train:" };
|
|
39
|
+
const PREFIX = { Acceptance: "acc:", WMBT: "wmbt:", Train: "train:", Journey: "journey:" };
|
|
40
|
+
const JOURNEY_ID = /^journey:[a-z][a-z0-9-]*$/;
|
|
40
41
|
|
|
41
42
|
for (const root of roots) {
|
|
42
43
|
// `plan_root: .` (or `./`) must still match root-relative paths, which never start with `./`.
|
|
@@ -44,12 +45,16 @@ for (const root of roots) {
|
|
|
44
45
|
walk(root, (path) => {
|
|
45
46
|
const name = path.split("/").pop() || "";
|
|
46
47
|
const content = text(path);
|
|
47
|
-
|
|
48
|
+
const planPath = relative(root, path).replaceAll("\\", "/");
|
|
49
|
+
if (/\.ya?ml$/i.test(name) && planPath.startsWith(planPrefix)) {
|
|
48
50
|
for (const [kind, re] of Object.entries({
|
|
49
51
|
acc: /\bacc:[A-Za-z0-9_.:-]+/g,
|
|
50
52
|
wmbt: /\bwmbt:[A-Za-z0-9_.:-]+/g,
|
|
51
53
|
train: /\btrain:[A-Za-z0-9_.:-]+/g,
|
|
52
54
|
})) for (const match of content.matchAll(re)) plans[kind].add(match[0]);
|
|
55
|
+
if (planPath.startsWith(`${planPrefix}_journeys/`)) {
|
|
56
|
+
for (const match of content.matchAll(/["']?\bjourney_id["']?[ \t]*:[ \t]*["']?(journey:[A-Za-z0-9_.:-]+)/g)) plans.journey.add(match[1]);
|
|
57
|
+
}
|
|
53
58
|
// Where each acceptance is DECLARED (its `urn:` line), so a missing test is reported on the artifact to fix.
|
|
54
59
|
for (const m of content.matchAll(/["']?\burn["']?[ \t]*:[ \t]*["']?(acc:[A-Za-z0-9_.:-]+)/g)) if (!declaredAt.has(m[1])) declaredAt.set(m[1], { path, line: lineAt(content, m.index) });
|
|
55
60
|
return;
|
|
@@ -60,14 +65,14 @@ for (const root of roots) {
|
|
|
60
65
|
const urn = head.match(/^\s*\/\/\s*URN:\s*(test:[^\s]+)/m);
|
|
61
66
|
if (!urn) return;
|
|
62
67
|
// EVERY binding header is judged, not only the first: a valid Acceptance: must not carry an unresolved Train:.
|
|
63
|
-
const headers = [...head.matchAll(/^[ \t]*\/\/[ \t]*(Acceptance|WMBT|Train):[ \t]*(\S*)/gm)].map((m) => ({ kind: m[1], id: m[2], raw: m[0].trim(), line: lineAt(head, m.index) }));
|
|
64
|
-
const bindings = headers.filter((h) => h.id.startsWith(PREFIX[h.kind]));
|
|
68
|
+
const headers = [...head.matchAll(/^[ \t]*\/\/[ \t]*(Acceptance|WMBT|Train|Journey):[ \t]*(\S*)/gm)].map((m) => ({ kind: m[1], id: m[2], raw: m[0].trim(), line: lineAt(head, m.index) }));
|
|
69
|
+
const bindings = headers.filter((h) => h.kind === "Journey" ? JOURNEY_ID.test(h.id) : h.id.startsWith(PREFIX[h.kind]));
|
|
65
70
|
// A test identity may be reused by equivalent smoke harnesses. Preserve every
|
|
66
71
|
// independently declared binding: replacing the earlier record made a valid
|
|
67
72
|
// acceptance disappear solely because a later file had the same test URN.
|
|
68
73
|
const prior = tests.get(urn[1]);
|
|
69
74
|
tests.set(urn[1], { path, bindings: [...(prior?.bindings ?? []), ...bindings] });
|
|
70
|
-
if (!headers.length) add("traceability.test.binding-resolves", path, lineOf(head, urn[0]), "test has a URN but no Acceptance:, WMBT:, or
|
|
75
|
+
if (!headers.length) add("traceability.test.binding-resolves", path, lineOf(head, urn[0]), "test has a URN but no Acceptance:, WMBT:, Train:, or Journey: binding", urn[0]);
|
|
71
76
|
for (const h of headers) if (!bindings.includes(h)) add("traceability.test.binding-resolves", path, h.line, `${h.kind}: ${h.id || "<empty>"} is not a ${PREFIX[h.kind]} identity`, h.raw);
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// data (snake_case, plan/_trains/_interlockings/**); the e2e tests are Bun/TS under e2e/**.
|
|
9
9
|
|
|
10
10
|
import { excludedPath } from "../../../lib/scan.mjs";
|
|
11
|
-
import { entryDelegatedAction, provenJourneyAction } from "../../../lib/journey-chain.mjs";
|
|
11
|
+
import { directInterlockingExecutionProofs, entryDelegatedAction, provenJourneyAction } from "../../../lib/journey-chain.mjs";
|
|
12
12
|
import { existsSync, readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { dirname, join, resolve, sep } from "node:path";
|
|
14
14
|
|
|
@@ -556,9 +556,10 @@ function resolutionTrainExecution(after, resolution) {
|
|
|
556
556
|
}
|
|
557
557
|
|
|
558
558
|
export function productionExecutionProofs(text, file, croot) {
|
|
559
|
-
|
|
559
|
+
const directProofs = file && croot ? directInterlockingExecutionProofs(text, file, croot) : [];
|
|
560
|
+
if (file && croot && !hasResolvedProductionRunnerImports(text, file, croot)) return directProofs;
|
|
560
561
|
const receivers = productionReceivers(text);
|
|
561
|
-
const proofs = [];
|
|
562
|
+
const proofs = [...directProofs];
|
|
562
563
|
const resolutions = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*([^=;{}\\n]{0,240}?)\\.resolveTrain\\s*\\(`, "g");
|
|
563
564
|
for (const m of text.matchAll(resolutions)) {
|
|
564
565
|
const [whole, resolution, receiver] = m;
|
|
@@ -577,8 +578,16 @@ export function assertsExpression(text, expression, expected = null) {
|
|
|
577
578
|
return Boolean(m && (!expected || tokenCovered(expected, m[0])));
|
|
578
579
|
}
|
|
579
580
|
|
|
581
|
+
function directResolutionAssertion(proof, fields, expected = null) {
|
|
582
|
+
return fields.some((field) => (proof.direct?.resolution[field] ?? [])
|
|
583
|
+
.some((assertion) => !expected || tokenCovered(expected, assertion)));
|
|
584
|
+
}
|
|
585
|
+
|
|
580
586
|
export function hasProductionExecutionProof(text, file, croot) {
|
|
581
|
-
return productionExecutionProofs(text, file, croot).some((
|
|
587
|
+
return productionExecutionProofs(text, file, croot).some((proof) => {
|
|
588
|
+
const { resolution, execution, direct } = proof;
|
|
589
|
+
if (direct) return directResolutionAssertion(proof, ["routeId", "trainId", "selectedTrainId"]) ||
|
|
590
|
+
direct.execution || direct.traceFields.length > 0;
|
|
582
591
|
if (assertsExpression(text, `${escaped(resolution)}\\.(?:routeId|trainId|selectedTrainId)`) ||
|
|
583
592
|
(execution && assertsExpression(text, escaped(execution)))) return true;
|
|
584
593
|
if (!execution) return false;
|
|
@@ -589,16 +598,24 @@ export function hasProductionExecutionProof(text, file, croot) {
|
|
|
589
598
|
|
|
590
599
|
export function routeHasProductionProof(route, text, interlockingId = null, file, croot) {
|
|
591
600
|
const slug = interlockingId?.replace(/^interlocking:/, "");
|
|
592
|
-
return productionExecutionProofs(text, file, croot).some((
|
|
593
|
-
const
|
|
594
|
-
const
|
|
601
|
+
return productionExecutionProofs(text, file, croot).some((proof) => {
|
|
602
|
+
const { resolution, execution, direct } = proof;
|
|
603
|
+
const assertedRoute = direct
|
|
604
|
+
? directResolutionAssertion(proof, ["routeId"], route.routeId)
|
|
605
|
+
: assertsExpression(text, `${escaped(resolution)}\\.routeId`, route.routeId);
|
|
606
|
+
const assertedTrain = direct
|
|
607
|
+
? directResolutionAssertion(proof, ["trainId", "selectedTrainId"], route.trainId)
|
|
608
|
+
: assertsExpression(text, `${escaped(resolution)}\\.(?:trainId|selectedTrainId)`, route.trainId);
|
|
595
609
|
const scoped = !interlockingId || tokenCovered(interlockingId, text) || tokenCovered(slug, text);
|
|
596
|
-
|
|
610
|
+
const assertedExecution = direct ? direct.execution : execution && assertsExpression(text, escaped(execution));
|
|
611
|
+
return (assertedRoute && scoped) || assertedTrain || Boolean(scoped && assertedExecution &&
|
|
612
|
+
(tokenCovered(route.routeId, text) || tokenCovered(route.trainId, text)));
|
|
597
613
|
});
|
|
598
614
|
}
|
|
599
615
|
|
|
600
616
|
export function traceHasProductionProvenance(text, file, croot) {
|
|
601
|
-
return productionExecutionProofs(text, file, croot).some(({ execution }) => {
|
|
617
|
+
return productionExecutionProofs(text, file, croot).some(({ execution, direct }) => {
|
|
618
|
+
if (direct) return direct.traceBound && REQUIRED_TRACE_FIELDS.every(([field]) => direct.traceFields.includes(field));
|
|
602
619
|
if (!execution) return false;
|
|
603
620
|
const trace = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?${escaped(execution)}\\.trace\\b`).exec(text)?.[1];
|
|
604
621
|
return Boolean(trace);
|
|
@@ -706,7 +723,10 @@ export function routeHasStationModuleProof(route, text, action, stationText, sta
|
|
|
706
723
|
// kills reorder/removal mutants, objectContaining records a handoff edge, and at(-1) observes the
|
|
707
724
|
// final wagon. All three must be about the TrainRunner result, never a hand-built literal.
|
|
708
725
|
export function hasSequenceMutationProof(text, trainId, file, croot) {
|
|
709
|
-
return productionExecutionProofs(text, file, croot).some((
|
|
726
|
+
return productionExecutionProofs(text, file, croot).some((proof) => {
|
|
727
|
+
const { resolution, execution, direct } = proof;
|
|
728
|
+
if (direct) return directResolutionAssertion(proof, ["trainId", "selectedTrainId"], trainId) &&
|
|
729
|
+
direct.sequence.ordered && direct.sequence.handoff && direct.sequence.final;
|
|
710
730
|
if (!execution || !assertsExpression(text, `${escaped(resolution)}\\.(?:trainId|selectedTrainId)`, trainId)) return false;
|
|
711
731
|
const result = escaped(execution);
|
|
712
732
|
const ordered = new RegExp(`expect\\s*\\(\\s*${result}\\.(?:steps|sequence|wagons)\\s*\\)\\s*\\.to(?:Equal|StrictEqual)\\s*\\(\\s*\\[`, "s").test(text);
|
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.99",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
"detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/order.ts": "6fdaad52671ef25b47d93f9b4e97e12c62eb78f5b4cde727435c3ac135a1c0e5",
|
|
192
192
|
"detectors/atdd_topology/fixtures/dirty/tests/wagons/orders/features/place-order/unit/order.test.ts": "f117e6c8b034700e1f00889bea48cb1528f0003d699cfd3e279cd9164b264a38",
|
|
193
193
|
"detectors/atdd_traceability_closure/atdd.implementation.yaml": "82e56cc85539476e42fc8d58e718bc998f1eff6c325a0cd240c0284625834d68",
|
|
194
|
-
"detectors/atdd_traceability_closure/detect.mjs": "
|
|
194
|
+
"detectors/atdd_traceability_closure/detect.mjs": "4fe2f6c73fc5535255f15676fb89f0a887598e3bf80c82f048c8e69432c38342",
|
|
195
195
|
"detectors/atdd_traceability_closure/fixtures/clean/plan/orders/E001.yaml": "0ddad928dde10c2ab690558259a6975125af540a1f30eca9aa0c615bf0ad69fb",
|
|
196
196
|
"detectors/atdd_traceability_closure/fixtures/clean/src/order.ts": "efe6db7e3f723c7b6096abdf58dec9c88b27ec8978d434a10135b3e77bfce321",
|
|
197
197
|
"detectors/atdd_traceability_closure/fixtures/clean/test/order.test.ts": "bee451198d472749616f85c1f1e97c0a06ad28255b9a6a2cc8701bd2e8d56562",
|
|
@@ -385,7 +385,7 @@
|
|
|
385
385
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/server.ts": "d5bc34c5b1b2b0a511e0c4604ce6f9c77491dca9aee994be720540f3052babc1",
|
|
386
386
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/interlocking.ts": "9bcfdb4ecff6efd1f658b9fd587342615179924e25468c75daa07c2626d4d8ab",
|
|
387
387
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/runner.ts": "0e4b885e50f12cc2a8c8f6740bf83e7b2e7137a28e1e143586e9a8550ea5bd45",
|
|
388
|
-
"detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "
|
|
388
|
+
"detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "e4de9503c31ed631e2e6a0bd5b21e78ee8ca1f1186fa93ff65106ff47695013e",
|
|
389
389
|
"detectors/bun_interlocking_coverage/atdd.implementation.yaml": "f160517a8138ba3ca05e3d130e6fbfb796e643376f25743fdef4fc210196525b",
|
|
390
390
|
"detectors/bun_interlocking_coverage/checks/_map.json": "0a218ea319cc6258af7bd721c2154cb9561fe67e80d80ef32d08fc77bc9403c3",
|
|
391
391
|
"detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs": "97224d46f0c4c3259f15e41f90bb7f9f5a4e651d4facd41f1e037cac919be500",
|
|
@@ -1034,7 +1034,7 @@
|
|
|
1034
1034
|
"lib/frontend.mjs": "d6d9b29c3d7f2d162cda03bb34b20a3224a82887b6be3db7cab375510913c9a3",
|
|
1035
1035
|
"lib/imports.mjs": "3db4979269488fa26f6e7aefded6ece9c646be6587a10e249821a5a208109962",
|
|
1036
1036
|
"lib/interlocking.mjs": "d0803d080ac7d7477c51be70112df1e85213d339fd3c6b71f75f26145fa8accd",
|
|
1037
|
-
"lib/journey-chain.mjs": "
|
|
1037
|
+
"lib/journey-chain.mjs": "34b1efa073b17940fa398af2970c85bb2fac35e4b2608ce635cfb6c39e3adc53",
|
|
1038
1038
|
"lib/scan.mjs": "b6973e411ecd14c548a055c871e01e948f002b772042136be8401f38d998e687",
|
|
1039
1039
|
"planner-nodes/ENFORCEMENT_SCOPE.yaml": "d8ec7039c219857f3d77af5d4d84612b533b69c1a5fcef935fbf46470adafed4",
|
|
1040
1040
|
"planner-nodes/nodes/planner.acceptance.abstract-fields-required.convention.yaml": "bd565693f0ae861f0561ad2f7a91a8c55f9a209c3acd59c5750cfcffd96a85d8",
|
package/lib/journey-chain.mjs
CHANGED
|
@@ -134,7 +134,7 @@ const accessName = (node) =>
|
|
|
134
134
|
|
|
135
135
|
// Why the module can replace a runner's behaviour outside its declared method (see the override
|
|
136
136
|
// contract above), or null. Deliberately module-wide: it fails closed.
|
|
137
|
-
function tampered(ctx) {
|
|
137
|
+
function tampered(ctx, allowedRunnerWrite = () => false) {
|
|
138
138
|
let found = null;
|
|
139
139
|
const runnerClass = (node) => (ts.isClassDeclaration(node) || ts.isClassExpression(node)) && RUNNERS.has(node.name?.text);
|
|
140
140
|
const insideRunnerClass = (node) => {
|
|
@@ -207,7 +207,8 @@ function tampered(ctx) {
|
|
|
207
207
|
node.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
|
|
208
208
|
const target = unwrap(node.left);
|
|
209
209
|
if ((ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) &&
|
|
210
|
-
(reachesRunner(target.expression) || (MEMBERS.has(accessName(target)) && prototypeish(target.expression)))
|
|
210
|
+
(reachesRunner(target.expression) || (MEMBERS.has(accessName(target)) && prototypeish(target.expression))) &&
|
|
211
|
+
!allowedRunnerWrite(node, target)) found = "runner write";
|
|
211
212
|
}
|
|
212
213
|
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
213
214
|
const api = node.expression.name.text;
|
|
@@ -612,6 +613,289 @@ function journeyExecutes(croot) {
|
|
|
612
613
|
};
|
|
613
614
|
}
|
|
614
615
|
|
|
616
|
+
function importedValue(ctx, identifier, croot) {
|
|
617
|
+
if (!identifier || !ts.isIdentifier(identifier)) return null;
|
|
618
|
+
const declaration = declarationOf(ctx, identifier);
|
|
619
|
+
if (!declaration || !ts.isImportSpecifier(declaration) || declaration.isTypeOnly) return null;
|
|
620
|
+
const clause = declaration.parent.parent;
|
|
621
|
+
const statement = clause.parent;
|
|
622
|
+
if (clause.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier)) return null;
|
|
623
|
+
const file = productionModule(statement.moduleSpecifier.text, ctx.file, croot);
|
|
624
|
+
return file ? { file, name: (declaration.propertyName ?? declaration.name).text } : null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function exportedConst(ctx, name) {
|
|
628
|
+
let found = null;
|
|
629
|
+
let directlyExported = false;
|
|
630
|
+
let listed = false;
|
|
631
|
+
for (const statement of ctx.source.statements) {
|
|
632
|
+
if (ts.isVariableStatement(statement) && statement.declarationList.flags & ts.NodeFlags.Const) {
|
|
633
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
634
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue;
|
|
635
|
+
if (found) return null;
|
|
636
|
+
found = declaration;
|
|
637
|
+
directlyExported = Boolean(ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (ts.isExportDeclaration(statement) && !statement.moduleSpecifier && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
641
|
+
listed ||= statement.exportClause.elements.some((element) =>
|
|
642
|
+
(element.propertyName ?? element.name).text === name && element.name.text === name);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return found && (directlyExported || listed) ? found : null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function handlerConfiguration(ctx, expression, seen = new Set()) {
|
|
649
|
+
const value = unwrap(expression);
|
|
650
|
+
if (!value) return false;
|
|
651
|
+
if (ts.isObjectLiteralExpression(value)) return value.properties.length > 0;
|
|
652
|
+
if (!ts.isIdentifier(value)) return false;
|
|
653
|
+
const initializer = constInitializer(ctx, value);
|
|
654
|
+
if (!initializer || seen.has(initializer)) return false;
|
|
655
|
+
seen.add(initializer);
|
|
656
|
+
return handlerConfiguration(ctx, initializer, seen);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function injectedTrainField(ctx, cls) {
|
|
660
|
+
const constructors = cls.members.filter(ts.isConstructorDeclaration);
|
|
661
|
+
if (constructors.length !== 1 || !constructors[0].body || constructors[0].parameters.length !== 1) return null;
|
|
662
|
+
const constructor = constructors[0];
|
|
663
|
+
const parameter = constructor.parameters[0].name;
|
|
664
|
+
if (!ts.isObjectBindingPattern(parameter)) return null;
|
|
665
|
+
const binding = parameter.elements.find((element) =>
|
|
666
|
+
!element.dotDotDotToken && ts.isIdentifier(element.name) &&
|
|
667
|
+
(element.propertyName ?? element.name).getText() === "trainRunner");
|
|
668
|
+
if (!binding || !ts.isIdentifier(binding.name)) return null;
|
|
669
|
+
const parameterSymbol = symbolOf(ctx, binding.name);
|
|
670
|
+
const assignments = [];
|
|
671
|
+
walk(constructor.body, (node) => {
|
|
672
|
+
if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return;
|
|
673
|
+
const target = unwrap(node.left);
|
|
674
|
+
const value = unwrap(node.right);
|
|
675
|
+
if (ts.isPropertyAccessExpression(target) && target.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
676
|
+
ts.isIdentifier(value) && symbolOf(ctx, value) === parameterSymbol) assignments.push({ node, field: target.name.text });
|
|
677
|
+
}, false);
|
|
678
|
+
return assignments.length === 1 ? assignments[0] : null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function objectProperty(ctx, expression, name, seen = new Set()) {
|
|
682
|
+
const value = unwrap(expression);
|
|
683
|
+
if (!value) return null;
|
|
684
|
+
if (ts.isIdentifier(value)) {
|
|
685
|
+
const initializer = constInitializer(ctx, value);
|
|
686
|
+
if (!initializer || seen.has(initializer)) return null;
|
|
687
|
+
seen.add(initializer);
|
|
688
|
+
return objectProperty(ctx, initializer, name, seen);
|
|
689
|
+
}
|
|
690
|
+
if (!ts.isObjectLiteralExpression(value)) return null;
|
|
691
|
+
const properties = value.properties.filter((property) => memberName(property) === name);
|
|
692
|
+
if (properties.length !== 1) return null;
|
|
693
|
+
const property = properties[0];
|
|
694
|
+
if (ts.isPropertyAssignment(property)) return property.initializer;
|
|
695
|
+
if (ts.isShorthandPropertyAssignment(property)) return property.name;
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function directInterlockingMethod(ctx, cls, field) {
|
|
700
|
+
if (!soleMethod(ctx, cls, "resolveTrain")) return null;
|
|
701
|
+
const method = soleMethod(ctx, cls, "execute");
|
|
702
|
+
const returns = method && liveReturns(method);
|
|
703
|
+
if (!method || !returns?.length) return null;
|
|
704
|
+
const declarations = [];
|
|
705
|
+
walk(method.body, (node) => {
|
|
706
|
+
if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer) return;
|
|
707
|
+
const call = unwrap(node.initializer);
|
|
708
|
+
if (ts.isCallExpression(call) && ts.isPropertyAccessExpression(call.expression) &&
|
|
709
|
+
call.expression.expression.kind === ts.SyntaxKind.ThisKeyword && call.expression.name.text === "resolveTrain") declarations.push(node);
|
|
710
|
+
}, false);
|
|
711
|
+
for (const declaration of declarations) {
|
|
712
|
+
const symbol = symbolOf(ctx, declaration.name);
|
|
713
|
+
if (!symbol) continue;
|
|
714
|
+
const isResolution = (node) => Boolean(node) && ts.isIdentifier(unwrap(node)) && symbolOf(ctx, unwrap(node)) === symbol;
|
|
715
|
+
const isExecution = (node) => {
|
|
716
|
+
const call = unwrap(node);
|
|
717
|
+
return Boolean(call && ts.isCallExpression(call) && ts.isPropertyAccessExpression(call.expression) &&
|
|
718
|
+
call.expression.name.text === "execute" && ts.isPropertyAccessExpression(call.expression.expression) &&
|
|
719
|
+
call.expression.expression.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
720
|
+
call.expression.expression.name.text === field && isResolution(call.arguments[0]));
|
|
721
|
+
};
|
|
722
|
+
const returned = returns.every((expression) => {
|
|
723
|
+
const resolution = expression && objectProperty(ctx, expression, "resolution");
|
|
724
|
+
const trainResult = expression && objectProperty(ctx, expression, "trainResult");
|
|
725
|
+
return resolution && trainResult && carries(ctx, resolution, isResolution) && carries(ctx, trainResult, isExecution);
|
|
726
|
+
});
|
|
727
|
+
if (returned && resolutionUnchanged(ctx, method, declaration, symbol, returns, isExecution)) return method;
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function compositionProof(serverFile, exportName, croot) {
|
|
733
|
+
const server = analyze(serverFile);
|
|
734
|
+
const interlocking = exportedConst(server, exportName);
|
|
735
|
+
const construction = interlocking?.initializer && unwrap(interlocking.initializer);
|
|
736
|
+
if (!construction || !ts.isNewExpression(construction)) return null;
|
|
737
|
+
const interlockingFile = importedRunner(server, construction.expression, "InterlockingRunner", croot);
|
|
738
|
+
const argument = construction.arguments?.[0];
|
|
739
|
+
const trainReference = argument && objectProperty(server, argument, "trainRunner");
|
|
740
|
+
if (!interlockingFile || !trainReference || !ts.isIdentifier(unwrap(trainReference))) return null;
|
|
741
|
+
const trainDeclaration = declarationOf(server, unwrap(trainReference));
|
|
742
|
+
if (!trainDeclaration || !ts.isVariableDeclaration(trainDeclaration) ||
|
|
743
|
+
!ts.isVariableDeclarationList(trainDeclaration.parent) || !(trainDeclaration.parent.flags & ts.NodeFlags.Const)) return null;
|
|
744
|
+
const trainConstruction = trainDeclaration.initializer && unwrap(trainDeclaration.initializer);
|
|
745
|
+
if (!trainConstruction || !ts.isNewExpression(trainConstruction) ||
|
|
746
|
+
!handlerConfiguration(server, trainConstruction.arguments?.[0])) return null;
|
|
747
|
+
const trainFile = importedRunner(server, trainConstruction.expression, "TrainRunner", croot);
|
|
748
|
+
if (!trainFile) return null;
|
|
749
|
+
|
|
750
|
+
const interlockingCtx = analyze(interlockingFile);
|
|
751
|
+
const interlockingClass = exportedClass(interlockingCtx, "InterlockingRunner");
|
|
752
|
+
const injection = interlockingClass && injectedTrainField(interlockingCtx, interlockingClass);
|
|
753
|
+
if (!interlockingClass || !injection || constructorReturnsValue(interlockingClass) ||
|
|
754
|
+
tampered(interlockingCtx, (node) => node === injection.node) ||
|
|
755
|
+
!directInterlockingMethod(interlockingCtx, interlockingClass, injection.field)) return null;
|
|
756
|
+
|
|
757
|
+
const trainCtx = analyze(trainFile);
|
|
758
|
+
const trainClass = exportedClass(trainCtx, "TrainRunner");
|
|
759
|
+
if (!trainClass || constructorReturnsValue(trainClass) || tampered(trainCtx) || !soleMethod(trainCtx, trainClass, "execute")) return null;
|
|
760
|
+
return { interlockingFile, interlockingCtx, injection, trainFile };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function awaitedCall(expression) {
|
|
764
|
+
let value = expression;
|
|
765
|
+
while (value && (ts.isParenthesizedExpression(value) || ts.isAsExpression(value) || ts.isNonNullExpression(value) ||
|
|
766
|
+
ts.isSatisfiesExpression(value) || ts.isTypeAssertionExpression(value))) value = value.expression;
|
|
767
|
+
return value && ts.isAwaitExpression(value) ? unwrap(value.expression) : null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function expectIdentifier(ctx, node) {
|
|
771
|
+
if (!ts.isIdentifier(node) || node.text !== "expect") return false;
|
|
772
|
+
const declaration = declarationOf(ctx, node);
|
|
773
|
+
if (!declaration) return true;
|
|
774
|
+
const statement = ts.isImportSpecifier(declaration) ? declaration.parent.parent.parent : null;
|
|
775
|
+
return Boolean(statement && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "bun:test" &&
|
|
776
|
+
(declaration.propertyName ?? declaration.name).text === "expect");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function expectAssertion(ctx, node) {
|
|
780
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return null;
|
|
781
|
+
let access = node.expression;
|
|
782
|
+
const matcher = access.name.text;
|
|
783
|
+
let receiver = access.expression, negated = false;
|
|
784
|
+
if (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "not") {
|
|
785
|
+
negated = true;
|
|
786
|
+
receiver = receiver.expression;
|
|
787
|
+
}
|
|
788
|
+
if (!MATCHERS.has(matcher) || (negated && !NEGATED_MATCHERS.has(matcher)) ||
|
|
789
|
+
!ts.isCallExpression(receiver) || !expectIdentifier(ctx, receiver.expression) || !receiver.arguments[0]) return null;
|
|
790
|
+
return { actual: receiver.arguments[0], matcher, negated, expected: node.arguments[0] ?? null, call: node };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function pathFromSymbol(ctx, expression, symbol) {
|
|
794
|
+
const node = unwrap(expression);
|
|
795
|
+
if (!node) return null;
|
|
796
|
+
if (ts.isIdentifier(node)) return symbolOf(ctx, node) === symbol ? [] : null;
|
|
797
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
798
|
+
const path = pathFromSymbol(ctx, node.expression, symbol);
|
|
799
|
+
return path && [...path, node.name.text];
|
|
800
|
+
}
|
|
801
|
+
if (ts.isElementAccessExpression(node) && node.argumentExpression && ts.isStringLiteralLike(node.argumentExpression)) {
|
|
802
|
+
const path = pathFromSymbol(ctx, node.expression, symbol);
|
|
803
|
+
return path && [...path, node.argumentExpression.text];
|
|
804
|
+
}
|
|
805
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "at" &&
|
|
806
|
+
node.arguments.length === 1 && node.arguments[0].getText() === "-1") {
|
|
807
|
+
const path = pathFromSymbol(ctx, node.expression.expression, symbol);
|
|
808
|
+
return path && [...path, "at(-1)"];
|
|
809
|
+
}
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function directAssertionFacts(ctx, resolutionSymbol, executionSymbol) {
|
|
814
|
+
const traceSymbols = new Set();
|
|
815
|
+
walk(ctx.source, (node) => {
|
|
816
|
+
if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer ||
|
|
817
|
+
!(ts.isVariableDeclarationList(node.parent) && node.parent.flags & ts.NodeFlags.Const)) return;
|
|
818
|
+
const path = pathFromSymbol(ctx, node.initializer, executionSymbol);
|
|
819
|
+
if (path?.length === 1 && path[0] === "trace") traceSymbols.add(symbolOf(ctx, node.name));
|
|
820
|
+
});
|
|
821
|
+
const facts = {
|
|
822
|
+
resolution: { routeId: [], trainId: [], selectedTrainId: [] },
|
|
823
|
+
execution: false,
|
|
824
|
+
traceBound: traceSymbols.size > 0,
|
|
825
|
+
traceFields: [],
|
|
826
|
+
sequence: { ordered: false, handoff: false, final: false },
|
|
827
|
+
};
|
|
828
|
+
walk(ctx.source, (node) => {
|
|
829
|
+
const assertion = expectAssertion(ctx, node);
|
|
830
|
+
if (!assertion || dead(node, ctx.source) || insideCatch(node, ctx.source)) return;
|
|
831
|
+
const resolutionPath = pathFromSymbol(ctx, assertion.actual, resolutionSymbol);
|
|
832
|
+
if (resolutionPath?.length === 1 && Object.hasOwn(facts.resolution, resolutionPath[0])) {
|
|
833
|
+
facts.resolution[resolutionPath[0]].push(assertion.call.getText(ctx.source));
|
|
834
|
+
}
|
|
835
|
+
const executionPath = pathFromSymbol(ctx, assertion.actual, executionSymbol);
|
|
836
|
+
if (executionPath?.length === 0) facts.execution = true;
|
|
837
|
+
if (!assertion.negated && executionPath?.length === 1 && ["steps", "sequence", "wagons"].includes(executionPath[0])) {
|
|
838
|
+
facts.sequence.ordered ||= ["toEqual", "toStrictEqual"].includes(assertion.matcher) &&
|
|
839
|
+
Boolean(assertion.expected && ts.isArrayLiteralExpression(unwrap(assertion.expected)));
|
|
840
|
+
facts.sequence.handoff ||= assertion.matcher === "toContainEqual" &&
|
|
841
|
+
/\bexpect\.objectContaining\s*\(\s*\{[\s\S]{0,240}?\b(?:from|to)\s*:/.test(assertion.expected?.getText(ctx.source) ?? "");
|
|
842
|
+
}
|
|
843
|
+
if (!assertion.negated && executionPath?.length === 2 && executionPath[1] === "at(-1)" &&
|
|
844
|
+
["steps", "sequence", "wagons"].includes(executionPath[0]) &&
|
|
845
|
+
["toBe", "toEqual", "toStrictEqual", "toMatch"].includes(assertion.matcher)) facts.sequence.final = true;
|
|
846
|
+
for (const traceSymbol of traceSymbols) {
|
|
847
|
+
const tracePath = pathFromSymbol(ctx, assertion.actual, traceSymbol);
|
|
848
|
+
if (tracePath?.length === 1 && !facts.traceFields.includes(tracePath[0])) facts.traceFields.push(tracePath[0]);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
return facts;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Direct production proof for a test that awaits an imported, server-composed InterlockingRunner,
|
|
855
|
+
// destructures its real resolution/trainResult pair, and leaves the whole local production chain
|
|
856
|
+
// untampered. Assertion facts retain checker-symbol identity so a shadowed same-name literal cannot
|
|
857
|
+
// satisfy any downstream route, runner, trace or sequence proof.
|
|
858
|
+
export function directInterlockingExecutionProofs(testText, testFile, croot) {
|
|
859
|
+
if (!testText || !testFile || !croot ||
|
|
860
|
+
!/\bconst\s*\{(?=[^}\n]*\bresolution\b)(?=[^}\n]*\btrainResult\b)[^}\n]*\}[^=;\n]*=\s*await\b/.test(testText)) return [];
|
|
861
|
+
const ctx = analyze(testFile, testText);
|
|
862
|
+
const proofs = [];
|
|
863
|
+
walk(ctx.source, (node) => {
|
|
864
|
+
if (!ts.isVariableDeclaration(node) || !ts.isObjectBindingPattern(node.name) || !node.initializer ||
|
|
865
|
+
!(ts.isVariableDeclarationList(node.parent) && node.parent.flags & ts.NodeFlags.Const) ||
|
|
866
|
+
dead(node, ctx.source) || insideCatch(node, ctx.source)) return;
|
|
867
|
+
const call = awaitedCall(node.initializer);
|
|
868
|
+
if (!call || !ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression) ||
|
|
869
|
+
call.expression.name.text !== "execute" || !ts.isIdentifier(call.expression.expression)) return;
|
|
870
|
+
const imported = importedValue(ctx, call.expression.expression, croot);
|
|
871
|
+
if (!imported) return;
|
|
872
|
+
const composition = compositionProof(imported.file, imported.name, croot);
|
|
873
|
+
if (!composition) return;
|
|
874
|
+
const binding = (property) => {
|
|
875
|
+
const element = node.name.elements.find((candidate) => !candidate.dotDotDotToken &&
|
|
876
|
+
(candidate.propertyName ?? candidate.name).getText() === property);
|
|
877
|
+
return element && ts.isIdentifier(element.name) ? element.name : null;
|
|
878
|
+
};
|
|
879
|
+
const resolutionBinding = binding("resolution"), executionBinding = binding("trainResult");
|
|
880
|
+
const resolutionSymbol = resolutionBinding && symbolOf(ctx, resolutionBinding);
|
|
881
|
+
const executionSymbol = executionBinding && symbolOf(ctx, executionBinding);
|
|
882
|
+
if (!resolutionBinding || !executionBinding || !resolutionSymbol || !executionSymbol) return;
|
|
883
|
+
const closure = localImportClosure(testFile, croot);
|
|
884
|
+
const clean = [...closure].every((file) => {
|
|
885
|
+
const module = analyze(file);
|
|
886
|
+
return file === composition.interlockingFile
|
|
887
|
+
? !tampered(module, (assignment) => assignment === composition.injection.node)
|
|
888
|
+
: !tampered(module);
|
|
889
|
+
});
|
|
890
|
+
if (clean) proofs.push({
|
|
891
|
+
resolution: resolutionBinding.text,
|
|
892
|
+
execution: executionBinding.text,
|
|
893
|
+
direct: directAssertionFacts(ctx, resolutionSymbol, executionSymbol),
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
return proofs;
|
|
897
|
+
}
|
|
898
|
+
|
|
615
899
|
// Exported functions of the module by name: declarations and const-bound arrow/function expressions.
|
|
616
900
|
function exportedFunctions(ctx) {
|
|
617
901
|
const out = new Map();
|