@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.
@@ -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
- if (file && croot && !hasResolvedProductionRunnerImports(text, file, croot)) return [];
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(({ resolution, execution }) => {
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(({ resolution, execution }) => {
593
- const assertedRoute = assertsExpression(text, `${escaped(resolution)}\\.routeId`, route.routeId);
594
- const assertedTrain = assertsExpression(text, `${escaped(resolution)}\\.(?:trainId|selectedTrainId)`, route.trainId);
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
- return (assertedRoute && scoped) || assertedTrain || Boolean(scoped && execution && assertsExpression(text, escaped(execution)) && (tokenCovered(route.routeId, text) || tokenCovered(route.trainId, text)));
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(({ resolution, execution }) => {
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.98",
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": "8a90490daaaa6eecf4c971fb569da38156b45969d48755dbcf355175dde0b079",
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": "bdafc9cc3da4ade644aebacc6e1015e29285b764465588d764b7c581bade9025",
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": "b96b5fee3a03c2f4c317d6725d6d21ed3133db18d0cdcdda72183cf05c7b6639",
1264
+ "src/hooks.ts": "8194c2dec14afc307b5ce9e90f64d8659210c579d8f427492823293f302e2876",
1265
1265
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1266
1266
  "src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
1267
1267
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
@@ -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)))) found = "runner write";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.98",
3
+ "version": "0.10.100",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
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
- const scratch = await mkdtemp(join(tmpdir(), "atdd-ratchet-commit-gate-"));
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) return bad(`could not materialize ratchet activation base: ${added.err || added.out}`);
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) return bad(`could not checkout ratchet activation base: ${checkedOut.err || checkedOut.out}`);
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
- const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch, finding.file) }));
85
- // Scan and cache the unpatched HEAD before materializing the staged candidate; both keys remain exact Git trees.
86
- const cacheDirectory = await findingsCacheDirectory(root), toolkit = await toolkitIdentity().catch(() => null);
87
- const findingsAt = async (treeId: string) => cachedFindings(cacheDirectory, treeId, toolkit, selectedProfiles, async () => normalize(await enforce({ root: scratch, profiles: selectedProfiles as any })));
88
- const baseFindings = selectedProfiles.length ? await findingsAt(baseTree.out) : [];
89
- const patch = await git(root, ["diff", "--cached", "--binary"], undefined, true);
90
- if (patch.code) return bad(`could not read staged ratchet activation candidate: ${patch.err || patch.out}`);
91
- const applied = await git(scratch, ["apply", "--whitespace=nowarn"], patch.out);
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. */