@afokapu/atdd-bun 0.10.98 → 0.10.100
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 +30 -10
- package/integrity.json +4 -4
- package/lib/journey-chain.mjs +286 -2
- package/package.json +1 -1
- package/src/hooks.ts +20 -15
|
@@ -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.100",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
@@ -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",
|
|
@@ -1261,7 +1261,7 @@
|
|
|
1261
1261
|
"src/cli.ts": "d93c3aebb78e752fd96347a5b177c0ed0697010c6e2533e116de93e772cd65a8",
|
|
1262
1262
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1263
1263
|
"src/enforce.ts": "e0c10519aeecd7080f2900ea230fad2f09482af47c1cee885d5de71573e29732",
|
|
1264
|
-
"src/hooks.ts": "
|
|
1264
|
+
"src/hooks.ts": "8194c2dec14afc307b5ce9e90f64d8659210c579d8f427492823293f302e2876",
|
|
1265
1265
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1266
1266
|
"src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
|
|
1267
1267
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
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();
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -61,44 +61,49 @@ async function ratchetCommitGate(root: string, staged: string[], registryChanged
|
|
|
61
61
|
try { candidateConfig = Bun.YAML.parse(stagedConfig.out) as Record<string, unknown>; policy = parseRatchetPolicy(candidateConfig); }
|
|
62
62
|
catch (error) { return bad(`ratchet activation commit gate configuration is invalid: ${String(error)}`); }
|
|
63
63
|
if (policy?.mode !== "reject-new" || !Array.isArray(candidateConfig.profiles) || !candidateConfig.profiles.every(profile => typeof profile === "string")) return null;
|
|
64
|
-
if ((await git(root, ["diff", "--quiet"])).code !== 0 || (await git(root, ["ls-files", "--others", "--exclude-standard"])).out) return bad("ratchet activation commit gate requires no unstaged or untracked candidate state");
|
|
65
64
|
const head = await git(root, ["rev-parse", "--verify", "HEAD^{commit}"]);
|
|
66
65
|
if (head.code || !/^[0-9a-f]{40}$/i.test(head.out)) return bad("ratchet activation commit gate requires an exact committed HEAD base");
|
|
67
|
-
|
|
66
|
+
let scratch: string | undefined;
|
|
68
67
|
try {
|
|
68
|
+
scratch = await mkdtemp(join(tmpdir(), "atdd-ratchet-commit-gate-"));
|
|
69
69
|
const added = await git(root, ["worktree", "add", "--detach", "--no-checkout", scratch, head.out]);
|
|
70
|
-
if (added.code)
|
|
70
|
+
if (added.code) throw new Error(`could not materialize ratchet activation base: ${added.err || added.out}`);
|
|
71
71
|
const checkedOut = await git(scratch, ["checkout", "--detach", head.out]);
|
|
72
|
-
if (checkedOut.code)
|
|
72
|
+
if (checkedOut.code) throw new Error(`could not checkout ratchet activation base: ${checkedOut.err || checkedOut.out}`);
|
|
73
73
|
const baseConfig = await configAt(scratch), candidateProfiles = stable(candidateConfig.profiles.map(normalizeProfileName));
|
|
74
74
|
const baseProfiles = stable(await enabledProfiles(scratch));
|
|
75
75
|
const policyMatches = stable(policy.profiles.map(normalizeProfileName)).join(",") === candidateProfiles.join(",");
|
|
76
76
|
const activation = isStrictProfileExpansion(baseProfiles, candidateProfiles) && activationContextDigest(baseConfig) === activationContextDigest(candidateConfig);
|
|
77
77
|
const unchanged = baseProfiles.join(",") === candidateProfiles.join(",") && contextDigest(baseConfig, baseProfiles) === contextDigest(candidateConfig, candidateProfiles);
|
|
78
78
|
if (candidateProfiles.some(profile => !concreteProfiles.includes(profile as typeof concreteProfiles[number])) || !policyMatches || (!activation && !unchanged)) return bad("ratchet reject-new requires either unchanged explicit profiles/context or an explicit monotonic profile expansion with identical non-profile context");
|
|
79
|
-
const selectedProfiles = activation || staged.some(needsFullCommitRatchetProfiles) ? candidateProfiles : (await affectedProfiles(root, staged, registryChanged)).filter(profile => candidateProfiles.includes(profile));
|
|
80
79
|
const tree = await git(root, ["write-tree"]);
|
|
81
80
|
if (tree.code || !/^[0-9a-f]{40}$/i.test(tree.out)) return bad(`could not identify staged ratchet activation candidate: ${tree.err || tree.out}`);
|
|
82
81
|
const baseTree = await git(root, ["rev-parse", "--verify", `${head.out}^{tree}`]);
|
|
83
82
|
if (baseTree.code || !/^[0-9a-f]{40}$/i.test(baseTree.out)) return bad(`could not identify ratchet activation base tree: ${baseTree.err || baseTree.out}`);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (applied.code) return bad(`could not materialize staged ratchet activation candidate: ${applied.err || applied.out}`);
|
|
83
|
+
// Scan only exact Git trees in private scratch: live unstaged/untracked bytes never enter, and cache keys stay tree IDs.
|
|
84
|
+
const materialize = async (treeId: string, label: string) => {
|
|
85
|
+
const reset = await git(scratch!, ["read-tree", "--reset", "-u", treeId]);
|
|
86
|
+
if (reset.code) throw new Error(`could not materialize ${label}: ${reset.err || reset.out}`);
|
|
87
|
+
const exact = await git(scratch!, ["write-tree"]);
|
|
88
|
+
if (exact.code || exact.out !== treeId) throw new Error(`${label} did not materialize exactly: ${exact.err || exact.out}`);
|
|
89
|
+
};
|
|
90
|
+
await materialize(tree.out, "staged ratchet activation candidate");
|
|
93
91
|
const materialized = await configAt(scratch), materializedProfiles = stable(await enabledProfiles(scratch));
|
|
94
92
|
const materializedContext = activation ? activationContextDigest(materialized) === activationContextDigest(candidateConfig) : materializedProfiles.join(",") === candidateProfiles.join(",") && contextDigest(materialized, materializedProfiles) === contextDigest(candidateConfig, candidateProfiles);
|
|
95
93
|
if (!materializedContext) return bad("staged ratchet candidate did not materialize exactly");
|
|
94
|
+
const selectedProfiles = activation || staged.some(needsFullCommitRatchetProfiles) ? candidateProfiles : (await affectedProfiles(scratch, staged, registryChanged)).filter(profile => candidateProfiles.includes(profile));
|
|
95
|
+
const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch!, finding.file) }));
|
|
96
|
+
const cacheDirectory = await findingsCacheDirectory(root), toolkit = await toolkitIdentity().catch(() => null);
|
|
97
|
+
const findingsAt = async (treeId: string) => cachedFindings(cacheDirectory, treeId, toolkit, selectedProfiles, async () => normalize(await enforce({ root: scratch, profiles: selectedProfiles as any })));
|
|
98
|
+
await materialize(baseTree.out, "ratchet activation base tree");
|
|
99
|
+
const baseFindings = selectedProfiles.length ? await findingsAt(baseTree.out) : [];
|
|
100
|
+
await materialize(tree.out, "staged ratchet activation candidate");
|
|
96
101
|
const candidateFindings = selectedProfiles.length ? await findingsAt(tree.out) : [];
|
|
97
102
|
const delta = compareFindings(baseFindings, candidateFindings);
|
|
98
103
|
const report = JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base: head.out, candidate: tree.out, profiles: selectedProfiles, context: contextDigest(candidateConfig, candidateProfiles), ...delta });
|
|
99
104
|
return { ok: delta.new.length === 0, message: report };
|
|
100
105
|
} catch (error) { return bad(`ratchet activation commit gate failed closed: ${String(error)}`); }
|
|
101
|
-
finally { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); }
|
|
106
|
+
finally { if (scratch) { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); } }
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/** A pushed reject-new candidate is judged from its immutable object IDs, never the hook's checkout or index. */
|