@gakim-digital/dexter-bridge 0.11.4 → 0.11.6

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.
@@ -24,6 +24,7 @@ export const BEHAVIOR_LAYOUT_EFFECTS = [
24
24
  ];
25
25
 
26
26
  export const BEHAVIOR_MECHANISMS = [
27
+ 'native_link',
27
28
  'native_event',
28
29
  'native_effect',
29
30
  'native_overlay',
@@ -33,6 +34,14 @@ export const BEHAVIOR_MECHANISMS = [
33
34
  ];
34
35
 
35
36
  const MECHANISM_CAPABILITIES = {
37
+ native_link: {
38
+ activations: new Set(['tap', 'key']),
39
+ presentations: new Set(['navigation']),
40
+ layoutEffects: new Set(['navigation']),
41
+ stateChange: false,
42
+ reversible: false,
43
+ repeatable: true,
44
+ },
36
45
  native_event: {
37
46
  activations: new Set(['tap', 'hover', 'appear', 'key']),
38
47
  presentations: new Set(['none', 'navigation']),
@@ -147,6 +156,7 @@ export function behaviorSignature(behavior) {
147
156
  expectedTargets: behavior.expectedTargets,
148
157
  initialState: behavior.initialState,
149
158
  activatedState: behavior.activatedState,
159
+ expectedDestinations: behavior.expectedDestinations,
150
160
  }))
151
161
  .digest('hex');
152
162
  }
@@ -193,9 +203,20 @@ function normalizeBehavior(rawBehavior, index) {
193
203
  repeatable: rawBehavior.repeatable === true,
194
204
  expectedTargets: integer(rawBehavior.expectedTargets),
195
205
  expectedContent: uniqueStrings(rawBehavior.expectedContent, 20),
206
+ expectedDestinations: uniqueStrings(rawBehavior.expectedDestinations, 20),
196
207
  initialState,
197
208
  activatedState,
198
209
  };
210
+ if (
211
+ (behavior.presentation === 'navigation'
212
+ || behavior.layoutEffect === 'navigation')
213
+ && behavior.expectedDestinations.length === 0
214
+ ) {
215
+ throw contractError(
216
+ 'FRAMER_BEHAVIOR_CONTRACT_INVALID',
217
+ `Behavior ${index + 1} must declare expectedDestinations for navigation.`,
218
+ );
219
+ }
199
220
  return {
200
221
  ...behavior,
201
222
  signature: behaviorSignature(behavior),
@@ -227,6 +248,11 @@ function normalizeMechanism(rawMechanism, index) {
227
248
  mechanism,
228
249
  rationale,
229
250
  representativeFirst: rawMechanism.representativeFirst !== false,
251
+ targetNodeIds: uniqueStrings(rawMechanism.targetNodeIds, 50),
252
+ representativeTargetNodeId: text(
253
+ rawMechanism.representativeTargetNodeId,
254
+ 200,
255
+ ),
230
256
  };
231
257
  }
232
258
 
@@ -333,6 +359,21 @@ export function normalizeBehaviorPlan(rawArguments, inheritedRejected = []) {
333
359
  `Behavior "${decision.behaviorId}" has more than one selected mechanism.`,
334
360
  );
335
361
  }
362
+ const behavior = behaviors.find((item) => item.id === decision.behaviorId);
363
+ if (
364
+ behavior?.expectedTargets > 1
365
+ && decision.representativeFirst
366
+ && (
367
+ decision.targetNodeIds.length !== behavior.expectedTargets
368
+ || !decision.representativeTargetNodeId
369
+ || !decision.targetNodeIds.includes(decision.representativeTargetNodeId)
370
+ )
371
+ ) {
372
+ throw contractError(
373
+ 'FRAMER_BEHAVIOR_REPRESENTATIVE_TARGETS_REQUIRED',
374
+ `Repeated behavior "${decision.behaviorId}" must declare all targetNodeIds and one representativeTargetNodeId before mutation.`,
375
+ );
376
+ }
336
377
  mechanismByBehavior.set(decision.behaviorId, decision);
337
378
  }
338
379
  const compatibility = behaviors.map((behavior) => {
@@ -520,9 +561,19 @@ function referencesComponent(snapshot, componentNodeId) {
520
561
  function nodeVisible(snapshot) {
521
562
  const node = nodeFromSnapshot(snapshot);
522
563
  if (!node) return null;
523
- if (node.visible === false || node.hidden === true) return false;
524
- if (node.visible === true || node.hidden === false) return true;
525
- return true;
564
+ const visible = Object.prototype.hasOwnProperty.call(node, 'visible')
565
+ ? node.visible
566
+ : isRecord(node.attributes)
567
+ ? node.attributes.visible
568
+ : undefined;
569
+ const hidden = Object.prototype.hasOwnProperty.call(node, 'hidden')
570
+ ? node.hidden
571
+ : isRecord(node.attributes)
572
+ ? node.attributes.hidden
573
+ : undefined;
574
+ if (visible === false || hidden === true) return false;
575
+ if (visible === true || hidden === false) return true;
576
+ return null;
526
577
  }
527
578
 
528
579
  function eventText(snapshot, event) {
@@ -570,11 +621,24 @@ function normalizeEvidence(raw, behaviorById) {
570
621
  `Interaction evidence references unknown behavior "${behaviorId}".`,
571
622
  );
572
623
  }
624
+ const expectedDestination = text(raw.expectedDestination, 500);
625
+ if (
626
+ expectedDestination
627
+ && !behavior.expectedDestinations.some((planned) =>
628
+ destinationCandidates(planned).some((candidate) =>
629
+ destinationCandidates(expectedDestination).includes(candidate)))
630
+ ) {
631
+ throw contractError(
632
+ 'FRAMER_BEHAVIOR_EVIDENCE_INVALID',
633
+ `Interaction evidence for "${behaviorId}" must use a destination declared in the behavior plan.`,
634
+ );
635
+ }
573
636
  return {
574
637
  behaviorId,
575
638
  description: text(raw.description, 300),
576
639
  triggerNodeId: text(raw.triggerNodeId, 200),
577
640
  event: text(raw.event, 80),
641
+ expectedDestination,
578
642
  targetNodeId: text(raw.targetNodeId, 200),
579
643
  componentNodeId: text(raw.componentNodeId, 200),
580
644
  instanceNodeIds: uniqueStrings(raw.instanceNodeIds, 50),
@@ -582,6 +646,19 @@ function normalizeEvidence(raw, behaviorById) {
582
646
  closedVariantNodeId: text(raw.closedVariantNodeId, 200),
583
647
  openAnswerNodeId: text(raw.openAnswerNodeId, 200),
584
648
  closedAnswerNodeId: text(raw.closedAnswerNodeId, 200),
649
+ semanticAssessment: isRecord(raw.semanticAssessment)
650
+ && ['verified', 'contradicted', 'unknown']
651
+ .includes(raw.semanticAssessment.verdict)
652
+ && text(raw.semanticAssessment.rationale, 1200).length >= 10
653
+ ? {
654
+ verdict: raw.semanticAssessment.verdict,
655
+ rationale: text(raw.semanticAssessment.rationale, 1200),
656
+ evidenceNodeIds: uniqueStrings(
657
+ raw.semanticAssessment.evidenceNodeIds,
658
+ 50,
659
+ ),
660
+ }
661
+ : null,
585
662
  };
586
663
  }
587
664
 
@@ -612,94 +689,154 @@ export function evidenceNodeIds(checks) {
612
689
  ]), 200);
613
690
  }
614
691
 
615
- function verifyTrigger(check, behavior, trigger, failures) {
692
+ function evidenceState() {
693
+ return {
694
+ contradictions: [],
695
+ unknowns: [],
696
+ observations: [],
697
+ };
698
+ }
699
+
700
+ function contradict(state, message) {
701
+ state.contradictions.push(message);
702
+ }
703
+
704
+ function unknown(state, message) {
705
+ state.unknowns.push(message);
706
+ }
707
+
708
+ function observe(state, kind, detail = {}) {
709
+ state.observations.push({ kind, ...detail });
710
+ }
711
+
712
+ function verifyTrigger(check, behavior, trigger, state) {
616
713
  if (!check.triggerNodeId || !trigger) {
617
- failures.push('trigger node was not found');
714
+ unknown(state, 'trigger node was not found');
618
715
  return '';
619
716
  }
717
+ observe(state, 'node_found', {
718
+ nodeId: check.triggerNodeId,
719
+ nodeType: nodeType(trigger),
720
+ });
620
721
  const allowedEvents = expectedEvents(behavior.activation);
621
722
  if (
622
723
  allowedEvents.length > 0
623
724
  && !allowedEvents.includes(check.event)
624
725
  ) {
625
- failures.push(
726
+ contradict(
727
+ state,
626
728
  `${check.event || 'the supplied event'} does not match ${behavior.activation} activation`,
627
729
  );
628
730
  }
629
731
  if (!check.event) {
630
- failures.push('the trigger event was not supplied');
732
+ unknown(state, 'the trigger event was not supplied');
631
733
  return '';
632
734
  }
633
735
  if (!FRAME_EVENT_NODE_TYPES.has(nodeType(trigger))) {
634
- failures.push(`${nodeType(trigger) || 'the trigger node'} does not support verified frame events`);
736
+ contradict(
737
+ state,
738
+ `${nodeType(trigger) || 'the trigger node'} does not support verified frame events`,
739
+ );
635
740
  }
636
741
  const evidence = eventText(trigger, check.event);
637
- if (!evidence) failures.push(`${check.event} is not configured on the trigger`);
742
+ if (!evidence) {
743
+ unknown(state, `${check.event} was not visible in the serialized trigger`);
744
+ } else {
745
+ observe(state, 'event_binding', {
746
+ nodeId: check.triggerNodeId,
747
+ event: check.event,
748
+ value: evidence.slice(0, 1000),
749
+ });
750
+ }
638
751
  return evidence;
639
752
  }
640
753
 
641
- function verifyExpectedContent(behavior, snapshots, nodeIds, failures) {
754
+ function verifyExpectedContent(behavior, snapshots, nodeIds, state) {
642
755
  const combined = normalizedEvidenceText(
643
756
  nodeIds.map((id) => nodeFromSnapshot(snapshotFor(snapshots, id))),
644
757
  );
645
758
  for (const expected of behavior.expectedContent) {
646
759
  if (!combined.includes(expected.toLowerCase())) {
647
- failures.push(`expected content was not found: ${expected}`);
760
+ unknown(state, `expected content was not visible in the evidence: ${expected}`);
761
+ } else {
762
+ observe(state, 'expected_content', { value: expected });
648
763
  }
649
764
  }
650
765
  }
651
766
 
652
- function verifyOverlay(check, behavior, snapshots, failures) {
767
+ function verifyOverlay(check, behavior, snapshots, state) {
653
768
  const trigger = snapshotFor(snapshots, check.triggerNodeId);
654
769
  const target = snapshotFor(snapshots, check.targetNodeId);
655
- const triggerEvent = verifyTrigger(check, behavior, trigger, failures);
770
+ const triggerEvent = verifyTrigger(check, behavior, trigger, state);
656
771
  if (!check.targetNodeId || !target) {
657
- failures.push('overlay target node was not found');
772
+ unknown(state, 'overlay target node was not found');
658
773
  return;
659
774
  }
660
- if (!triggerEvent.includes('show_overlay')) {
661
- failures.push(`${check.event} does not contain SHOW_OVERLAY`);
775
+ observe(state, 'node_found', {
776
+ nodeId: check.targetNodeId,
777
+ nodeType: nodeType(target),
778
+ });
779
+ if (triggerEvent && !triggerEvent.includes('show_overlay')) {
780
+ contradict(state, `${check.event} does not contain SHOW_OVERLAY`);
662
781
  }
663
- if (!triggerEvent.includes(check.targetNodeId.toLowerCase())) {
664
- failures.push(`${check.event} does not reference target ${check.targetNodeId}`);
782
+ if (
783
+ triggerEvent
784
+ && !triggerEvent.includes(check.targetNodeId.toLowerCase())
785
+ ) {
786
+ contradict(state, `${check.event} does not reference target ${check.targetNodeId}`);
665
787
  }
666
788
  const expectedType =
667
789
  behavior.presentation === 'modal'
668
790
  ? 'FixedOverlayNode'
669
791
  : 'RelativeOverlayNode';
670
792
  if (nodeType(target) !== expectedType) {
671
- failures.push(`the behavior requires a ${expectedType}`);
793
+ contradict(state, `the behavior requires a ${expectedType}`);
672
794
  }
673
- if (parentId(target) !== check.triggerNodeId) {
674
- failures.push('the overlay is not parented to its actual trigger');
795
+ const actualParentId = parentId(target);
796
+ if (!actualParentId) {
797
+ unknown(state, 'the overlay parent was not visible in the evidence');
798
+ } else if (actualParentId !== check.triggerNodeId) {
799
+ contradict(state, 'the overlay is not parented to its actual trigger');
675
800
  }
676
- verifyExpectedContent(behavior, snapshots, [check.targetNodeId], failures);
801
+ verifyExpectedContent(behavior, snapshots, [check.targetNodeId], state);
677
802
  }
678
803
 
679
804
  function verifyComponentInstances(
680
805
  check,
681
806
  behavior,
682
807
  snapshots,
683
- failures,
808
+ state,
684
809
  context,
685
810
  { requireDefinition = true } = {},
686
811
  ) {
687
812
  const component = snapshotFor(snapshots, check.componentNodeId);
688
- if (
813
+ if (requireDefinition && !component) {
814
+ unknown(state, 'component definition was not found');
815
+ } else if (
689
816
  requireDefinition
690
- && (!component || nodeType(component) !== 'ComponentNode')
817
+ && nodeType(component) !== 'ComponentNode'
691
818
  ) {
692
- failures.push('component definition was not found');
819
+ contradict(state, 'the declared component definition is not a ComponentNode');
693
820
  }
694
821
  const instances = check.instanceNodeIds.map((id) => ({
695
822
  id,
696
823
  snapshot: snapshotFor(snapshots, id),
697
824
  }));
698
825
  for (const instance of instances) {
699
- if (!instance.snapshot || nodeType(instance.snapshot) !== 'ComponentInstanceNode') {
700
- failures.push(`component instance ${instance.id} was not found`);
826
+ if (!instance.snapshot) {
827
+ unknown(state, `component instance ${instance.id} was not found`);
828
+ } else if (nodeType(instance.snapshot) !== 'ComponentInstanceNode') {
829
+ contradict(state, `${instance.id} is not a ComponentInstanceNode`);
701
830
  } else if (!referencesComponent(instance.snapshot, check.componentNodeId)) {
702
- failures.push(`component instance ${instance.id} does not reference ${check.componentNodeId}`);
831
+ contradict(
832
+ state,
833
+ `component instance ${instance.id} does not reference ${check.componentNodeId}`,
834
+ );
835
+ } else {
836
+ observe(state, 'component_instance', {
837
+ nodeId: instance.id,
838
+ componentNodeId: check.componentNodeId,
839
+ });
703
840
  }
704
841
  const authorizedContainerIds = uniqueStrings(
705
842
  context?.authorizedInstanceContainerIds,
@@ -714,7 +851,8 @@ function verifyComponentInstances(
714
851
  : []),
715
852
  ].filter(Boolean));
716
853
  if (!authorizedContainerIds.some((id) => ancestry.has(id))) {
717
- failures.push(
854
+ contradict(
855
+ state,
718
856
  `component instance ${instance.id} is outside the authorized write scope`,
719
857
  );
720
858
  }
@@ -726,7 +864,7 @@ function verifyComponentVariants(
726
864
  check,
727
865
  behavior,
728
866
  snapshots,
729
- failures,
867
+ state,
730
868
  context,
731
869
  ) {
732
870
  const trigger = snapshotFor(snapshots, check.triggerNodeId);
@@ -735,27 +873,28 @@ function verifyComponentVariants(
735
873
  const closedVariant = snapshotFor(snapshots, check.closedVariantNodeId);
736
874
  const openAnswer = snapshotFor(snapshots, check.openAnswerNodeId);
737
875
  const closedAnswer = snapshotFor(snapshots, check.closedAnswerNodeId);
738
- const triggerEvent = verifyTrigger(check, behavior, trigger, failures);
739
- verifyComponentInstances(check, behavior, snapshots, failures, context);
876
+ const triggerEvent = verifyTrigger(check, behavior, trigger, state);
877
+ verifyComponentInstances(check, behavior, snapshots, state, context);
740
878
  if (component && trigger && !belongsTo(trigger, check.componentNodeId)) {
741
- failures.push('the trigger is not inside the component definition');
879
+ contradict(state, 'the trigger is not inside the component definition');
742
880
  }
743
881
  if (
744
882
  !openVariant
745
883
  || !closedVariant
746
- || check.openVariantNodeId === check.closedVariantNodeId
747
884
  ) {
748
- failures.push('distinct open and closed variants were not found');
885
+ unknown(state, 'open and closed variants were not both found');
886
+ } else if (check.openVariantNodeId === check.closedVariantNodeId) {
887
+ contradict(state, 'open and closed variants must be distinct');
749
888
  } else {
750
889
  if (!belongsTo(openVariant, check.componentNodeId)) {
751
- failures.push('the open variant does not belong to the component');
890
+ contradict(state, 'the open variant does not belong to the component');
752
891
  }
753
892
  if (!belongsTo(closedVariant, check.componentNodeId)) {
754
- failures.push('the closed variant does not belong to the component');
893
+ contradict(state, 'the closed variant does not belong to the component');
755
894
  }
756
895
  }
757
- if (!triggerEvent.includes('set_variant')) {
758
- failures.push(`${check.event} does not contain SET_VARIANT`);
896
+ if (triggerEvent && !triggerEvent.includes('set_variant')) {
897
+ contradict(state, `${check.event} does not contain SET_VARIANT`);
759
898
  }
760
899
  if (
761
900
  triggerEvent
@@ -763,44 +902,167 @@ function verifyComponentVariants(
763
902
  && !triggerEvent.includes(check.openVariantNodeId.toLowerCase())
764
903
  && !triggerEvent.includes(check.closedVariantNodeId.toLowerCase())
765
904
  ) {
766
- failures.push('SET_VARIANT does not reference cycle or a declared variant');
905
+ contradict(state, 'SET_VARIANT does not reference cycle or a declared variant');
767
906
  }
768
907
  if (!openAnswer || !closedAnswer) {
769
- failures.push('open and closed answer-state nodes were not found');
908
+ unknown(state, 'open and closed answer-state nodes were not found');
770
909
  } else {
771
910
  if (!belongsTo(openAnswer, check.openVariantNodeId)) {
772
- failures.push('the visible answer does not belong to the open variant');
911
+ contradict(state, 'the visible answer does not belong to the open variant');
773
912
  }
774
913
  if (!belongsTo(closedAnswer, check.closedVariantNodeId)) {
775
- failures.push('the hidden answer does not belong to the closed variant');
914
+ contradict(state, 'the hidden answer does not belong to the closed variant');
776
915
  }
777
- if (nodeVisible(openAnswer) === false) {
778
- failures.push('the answer is hidden in the open variant');
916
+ const openVisibility = nodeVisible(openAnswer);
917
+ const closedVisibility = nodeVisible(closedAnswer);
918
+ observe(state, 'visibility', {
919
+ nodeId: check.openAnswerNodeId,
920
+ value:
921
+ openVisibility === null
922
+ ? 'unknown'
923
+ : openVisibility
924
+ ? 'visible'
925
+ : 'hidden',
926
+ });
927
+ observe(state, 'visibility', {
928
+ nodeId: check.closedAnswerNodeId,
929
+ value:
930
+ closedVisibility === null
931
+ ? 'unknown'
932
+ : closedVisibility
933
+ ? 'visible'
934
+ : 'hidden',
935
+ });
936
+ if (openVisibility === false) {
937
+ contradict(state, 'the answer is hidden in the open variant');
938
+ } else if (openVisibility === null) {
939
+ unknown(state, 'the open answer visibility was not exposed');
779
940
  }
780
- if (nodeVisible(closedAnswer) !== false) {
781
- failures.push('the answer is not hidden in the closed variant');
941
+ if (closedVisibility === true) {
942
+ contradict(state, 'the answer is not hidden in the closed variant');
943
+ } else if (closedVisibility === null) {
944
+ unknown(state, 'the closed answer visibility was not exposed');
782
945
  }
783
946
  }
784
- verifyExpectedContent(
785
- behavior,
786
- snapshots,
787
- [check.openAnswerNodeId, check.closedAnswerNodeId],
788
- failures,
789
- );
947
+ }
948
+
949
+ function destinationCandidates(value) {
950
+ const candidate = text(value, 2000);
951
+ if (!candidate) return [];
952
+ const normalized = candidate.toLowerCase().replace(/\/+$/, '') || '/';
953
+ const candidates = new Set([normalized]);
954
+ try {
955
+ const parsed = new URL(candidate, 'https://dexter.local');
956
+ candidates.add(
957
+ `${parsed.pathname}${parsed.search}${parsed.hash}`
958
+ .toLowerCase()
959
+ .replace(/\/+$/, '') || '/',
960
+ );
961
+ } catch {
962
+ // Non-URL control values are ignored unless the raw value matches.
963
+ }
964
+ return [...candidates];
965
+ }
966
+
967
+ function linkFacts(value) {
968
+ const facts = [];
969
+ const visit = (current, path = []) => {
970
+ if (Array.isArray(current)) {
971
+ current.forEach((nested, index) => visit(nested, [...path, String(index)]));
972
+ return;
973
+ }
974
+ if (!isRecord(current)) return;
975
+ for (const [key, nested] of Object.entries(current)) {
976
+ const nextPath = [...path, key];
977
+ if (
978
+ typeof nested === 'string'
979
+ && /(?:href|url|link|destination|route|path)$/i.test(key)
980
+ ) {
981
+ facts.push({
982
+ path: nextPath.join('.'),
983
+ value: nested,
984
+ candidates: destinationCandidates(nested),
985
+ });
986
+ }
987
+ visit(nested, nextPath);
988
+ }
989
+ };
990
+ visit(value);
991
+ return facts.filter((fact) => fact.candidates.length > 0);
992
+ }
993
+
994
+ function verifyNativeLink(check, behavior, snapshots, state, context) {
995
+ const targetIds = check.instanceNodeIds.length > 0
996
+ ? check.instanceNodeIds
997
+ : [check.triggerNodeId].filter(Boolean);
998
+ const expectedValues = check.expectedDestination
999
+ ? [check.expectedDestination]
1000
+ : behavior.expectedDestinations;
1001
+ const expected = new Set(expectedValues.flatMap(destinationCandidates));
1002
+ if (targetIds.length === 0) {
1003
+ unknown(state, 'no navigation target node was supplied');
1004
+ return;
1005
+ }
1006
+ for (const nodeId of targetIds) {
1007
+ const snapshot = snapshotFor(snapshots, nodeId);
1008
+ if (!snapshot) {
1009
+ unknown(state, `navigation node ${nodeId} was not found`);
1010
+ continue;
1011
+ }
1012
+ const facts = linkFacts([
1013
+ nodeFromSnapshot(snapshot),
1014
+ snapshot?.controls,
1015
+ ]);
1016
+ if (facts.length === 0) {
1017
+ const controlFacts = linkFacts(context?.componentControls);
1018
+ if (controlFacts.length > 0) {
1019
+ observe(state, 'component_link_control_available', {
1020
+ nodeId,
1021
+ sources: controlFacts.map((fact) => fact.path).slice(0, 20),
1022
+ });
1023
+ }
1024
+ unknown(
1025
+ state,
1026
+ `no native link or component-control destination was visible for ${nodeId}`,
1027
+ );
1028
+ continue;
1029
+ }
1030
+ const matchingFact = facts.find((fact) =>
1031
+ fact.candidates.some((candidate) => expected.has(candidate)));
1032
+ if (matchingFact) {
1033
+ observe(state, 'navigation_destination', {
1034
+ nodeId,
1035
+ source: matchingFact.path,
1036
+ value: matchingFact.value,
1037
+ });
1038
+ continue;
1039
+ }
1040
+ contradict(
1041
+ state,
1042
+ `${nodeId} points to ${facts.map((fact) => fact.value).join(', ')} instead of ${expectedValues.join(' or ')}`,
1043
+ );
1044
+ }
790
1045
  }
791
1046
 
792
1047
  function verifyExistingComponent(
793
1048
  check,
794
1049
  behavior,
795
1050
  snapshots,
796
- failures,
1051
+ state,
797
1052
  context,
798
1053
  ) {
799
1054
  const trigger = snapshotFor(snapshots, check.triggerNodeId);
800
- const triggerEvent = verifyTrigger(check, behavior, trigger, failures);
801
- verifyComponentInstances(check, behavior, snapshots, failures, context);
1055
+ verifyComponentInstances(check, behavior, snapshots, state, context);
1056
+ if (
1057
+ behavior.presentation === 'navigation'
1058
+ || behavior.layoutEffect === 'navigation'
1059
+ ) {
1060
+ verifyNativeLink(check, behavior, snapshots, state, context);
1061
+ return;
1062
+ }
1063
+ const triggerEvent = verifyTrigger(check, behavior, trigger, state);
802
1064
  if (trigger && !belongsTo(trigger, check.componentNodeId)) {
803
- failures.push('the trigger is not inside the existing component definition');
1065
+ contradict(state, 'the trigger is not inside the existing component definition');
804
1066
  }
805
1067
  const permittedActions =
806
1068
  behavior.presentation === 'floating' || behavior.presentation === 'modal'
@@ -810,15 +1072,19 @@ function verifyExistingComponent(
810
1072
  : [];
811
1073
  if (
812
1074
  permittedActions.length > 0
1075
+ && triggerEvent
813
1076
  && !permittedActions.some((action) => triggerEvent.includes(action))
814
1077
  ) {
815
- failures.push('the component trigger does not expose an action capable of the declared behavior');
1078
+ contradict(
1079
+ state,
1080
+ 'the component trigger does not expose an action capable of the declared behavior',
1081
+ );
816
1082
  }
817
1083
  verifyExpectedContent(
818
1084
  behavior,
819
1085
  snapshots,
820
1086
  [check.componentNodeId, ...check.instanceNodeIds],
821
- failures,
1087
+ state,
822
1088
  );
823
1089
  }
824
1090
 
@@ -834,7 +1100,7 @@ function hasCodeComponentEvidence(snapshot) {
834
1100
  );
835
1101
  }
836
1102
 
837
- function verifyCodeComponent(check, behavior, snapshots, failures, context) {
1103
+ function verifyCodeComponent(check, behavior, snapshots, state, context) {
838
1104
  // Code files are not native canvas ComponentNodes. Their durable structural
839
1105
  // proof is an installed ComponentInstanceNode whose component reference
840
1106
  // resolves to the authored code-file ID.
@@ -842,7 +1108,7 @@ function verifyCodeComponent(check, behavior, snapshots, failures, context) {
842
1108
  check,
843
1109
  behavior,
844
1110
  snapshots,
845
- failures,
1111
+ state,
846
1112
  context,
847
1113
  { requireDefinition: false },
848
1114
  );
@@ -851,7 +1117,10 @@ function verifyCodeComponent(check, behavior, snapshots, failures, context) {
851
1117
  !nodes.some(hasCodeComponentEvidence)
852
1118
  && Number(context?.lowLevelWriteCalls || 0) < 1
853
1119
  ) {
854
- failures.push('no code-component source or installed code-component evidence was found');
1120
+ unknown(
1121
+ state,
1122
+ 'no code-component source or installed code-component evidence was found',
1123
+ );
855
1124
  }
856
1125
  // Content is covered by the mandatory structural and visual checks. Behavior
857
1126
  // plans may describe content semantically rather than quote literal canvas
@@ -859,49 +1128,61 @@ function verifyCodeComponent(check, behavior, snapshots, failures, context) {
859
1128
  // false failures for otherwise valid code components.
860
1129
  }
861
1130
 
862
- function verifyNativeEvent(check, behavior, snapshots, failures) {
1131
+ function verifyNativeEvent(check, behavior, snapshots, state) {
863
1132
  verifyTrigger(
864
1133
  check,
865
1134
  behavior,
866
1135
  snapshotFor(snapshots, check.triggerNodeId),
867
- failures,
1136
+ state,
868
1137
  );
869
1138
  }
870
1139
 
871
- function verifyNativeEffect(check, behavior, snapshots, failures) {
1140
+ function verifyNativeEffect(check, behavior, snapshots, state) {
872
1141
  const trigger = snapshotFor(snapshots, check.triggerNodeId);
873
1142
  if (!check.triggerNodeId || !trigger) {
874
- failures.push('effect target node was not found');
1143
+ unknown(state, 'effect target node was not found');
875
1144
  return null;
876
1145
  }
1146
+ observe(state, 'node_found', {
1147
+ nodeId: check.triggerNodeId,
1148
+ nodeType: nodeType(trigger),
1149
+ });
877
1150
  if (!FRAME_EFFECT_NODE_TYPES.has(nodeType(trigger))) {
878
- failures.push(
1151
+ contradict(
1152
+ state,
879
1153
  `${nodeType(trigger) || 'the target node'} does not support verified Framer effects`,
880
1154
  );
881
1155
  }
882
1156
  const effectName = NATIVE_EFFECT_BY_ACTIVATION[behavior.activation];
883
1157
  if (!effectName) {
884
- failures.push(
1158
+ contradict(
1159
+ state,
885
1160
  `native_effect does not support ${behavior.activation} activation`,
886
1161
  );
887
1162
  return null;
888
1163
  }
889
1164
  if (check.event !== effectName) {
890
- failures.push(
1165
+ contradict(
1166
+ state,
891
1167
  `${check.event || 'the supplied effect'} does not match ${effectName}`,
892
1168
  );
893
1169
  }
894
1170
  const effect = directNodeProperty(trigger, effectName);
895
1171
  if (!isRecord(effect) || Object.keys(effect).length === 0) {
896
- failures.push(`${effectName} is not configured on the target`);
1172
+ unknown(state, `${effectName} is not visible on the serialized target`);
897
1173
  return null;
898
1174
  }
1175
+ observe(state, 'native_effect', {
1176
+ nodeId: check.triggerNodeId,
1177
+ effectName,
1178
+ value: effect,
1179
+ });
899
1180
  if (
900
1181
  effectName === 'appearEffect'
901
1182
  && !['onInView', 'onMount', 'onScrollTarget', 'onScrollDirection']
902
1183
  .includes(effect.trigger)
903
1184
  ) {
904
- failures.push('appearEffect does not declare a supported trigger');
1185
+ contradict(state, 'appearEffect does not declare a supported trigger');
905
1186
  }
906
1187
  return {
907
1188
  effectName,
@@ -909,6 +1190,66 @@ function verifyNativeEffect(check, behavior, snapshots, failures) {
909
1190
  };
910
1191
  }
911
1192
 
1193
+ function verdictFor(state) {
1194
+ if (state.contradictions.length > 0) return 'contradicted';
1195
+ if (state.unknowns.length > 0) return 'unknown';
1196
+ return 'verified';
1197
+ }
1198
+
1199
+ function applySemanticAssessment(
1200
+ check,
1201
+ result,
1202
+ mode,
1203
+ liveEvidenceNodeIds = new Set(),
1204
+ ) {
1205
+ const assessment = check.semanticAssessment;
1206
+ const base = {
1207
+ ...result,
1208
+ deterministicVerdict: result.verdict,
1209
+ semanticAssessment: assessment
1210
+ ? {
1211
+ ...assessment,
1212
+ mode,
1213
+ accepted: false,
1214
+ }
1215
+ : null,
1216
+ };
1217
+ if (
1218
+ mode !== 'enforce'
1219
+ || result.verdict !== 'unknown'
1220
+ || !assessment
1221
+ || assessment.verdict === 'unknown'
1222
+ ) return base;
1223
+ if (
1224
+ assessment.evidenceNodeIds.length === 0
1225
+ || !assessment.evidenceNodeIds.every((nodeId) =>
1226
+ liveEvidenceNodeIds.has(nodeId))
1227
+ ) {
1228
+ return {
1229
+ ...base,
1230
+ unknowns: uniqueStrings([
1231
+ ...base.unknowns,
1232
+ 'semantic verification was not accepted because its evidence IDs were not backed by live observations',
1233
+ ]),
1234
+ };
1235
+ }
1236
+ return {
1237
+ ...base,
1238
+ verdict: assessment.verdict,
1239
+ contradictions:
1240
+ assessment.verdict === 'contradicted'
1241
+ ? uniqueStrings([
1242
+ ...base.contradictions,
1243
+ `Semantic assessment contradicted the behavior: ${assessment.rationale}`,
1244
+ ], 30)
1245
+ : base.contradictions,
1246
+ semanticAssessment: {
1247
+ ...base.semanticAssessment,
1248
+ accepted: true,
1249
+ },
1250
+ };
1251
+ }
1252
+
912
1253
  export function verifyBehaviorEvidence({
913
1254
  plan,
914
1255
  checks,
@@ -924,82 +1265,154 @@ export function verifyBehaviorEvidence({
924
1265
  decision,
925
1266
  ]),
926
1267
  );
1268
+ const liveEvidenceNodeIds = new Set(
1269
+ snapshots instanceof Map
1270
+ ? [...snapshots.entries()]
1271
+ .filter(([, snapshot]) => Boolean(snapshot))
1272
+ .map(([nodeId]) => nodeId)
1273
+ : Object.entries(snapshots || {})
1274
+ .filter(([, snapshot]) => Boolean(snapshot))
1275
+ .map(([nodeId]) => nodeId),
1276
+ );
927
1277
  const verifiedChecks = checks.map((check) => {
928
1278
  const behavior = behaviorById.get(check.behaviorId);
929
1279
  const decision = mechanismByBehavior.get(check.behaviorId);
930
- const failures = [];
1280
+ const state = evidenceState();
931
1281
  let nativeEffectEvidence = null;
932
1282
  if (!behavior || !decision) {
933
- failures.push('the planned behavior or mechanism was not found');
1283
+ contradict(state, 'the planned behavior or mechanism was not found');
1284
+ } else if (decision.mechanism === 'native_link') {
1285
+ verifyNativeLink(check, behavior, snapshots, state, context);
934
1286
  } else if (decision.mechanism === 'native_overlay') {
935
- verifyOverlay(check, behavior, snapshots, failures);
1287
+ verifyOverlay(check, behavior, snapshots, state);
936
1288
  } else if (decision.mechanism === 'native_component_variants') {
937
- verifyComponentVariants(check, behavior, snapshots, failures, context);
1289
+ verifyComponentVariants(check, behavior, snapshots, state, context);
938
1290
  } else if (decision.mechanism === 'existing_component_control') {
939
- verifyExistingComponent(check, behavior, snapshots, failures, context);
1291
+ verifyExistingComponent(check, behavior, snapshots, state, context);
940
1292
  } else if (decision.mechanism === 'code_component') {
941
- verifyCodeComponent(check, behavior, snapshots, failures, context);
1293
+ verifyCodeComponent(check, behavior, snapshots, state, context);
942
1294
  } else if (decision.mechanism === 'native_effect') {
943
1295
  nativeEffectEvidence = verifyNativeEffect(
944
1296
  check,
945
1297
  behavior,
946
1298
  snapshots,
947
- failures,
1299
+ state,
948
1300
  );
949
1301
  } else {
950
- verifyNativeEvent(check, behavior, snapshots, failures);
1302
+ verifyNativeEvent(check, behavior, snapshots, state);
951
1303
  }
952
- return {
1304
+ const result = {
953
1305
  ...check,
954
1306
  mechanism: decision?.mechanism,
955
1307
  ...(nativeEffectEvidence || {}),
956
- passed: failures.length === 0,
957
- failures,
1308
+ verdict: verdictFor(state),
1309
+ contradictions: uniqueStrings(state.contradictions, 30),
1310
+ unknowns: uniqueStrings(state.unknowns, 30),
1311
+ observations: state.observations.slice(0, 50),
1312
+ repairAllowed: state.contradictions.length > 0,
1313
+ nextSafeActions:
1314
+ state.contradictions.length > 0
1315
+ ? ['Repair only the observed contradictory properties, then verify once.']
1316
+ : state.unknowns.length > 0
1317
+ ? ['Collect a different live evidence source or provide a grounded semantic assessment; do not mutate based only on unknown evidence.']
1318
+ : [],
958
1319
  };
1320
+ return applySemanticAssessment(
1321
+ check,
1322
+ result,
1323
+ ['off', 'shadow', 'enforce'].includes(context?.semanticVerifierMode)
1324
+ ? context.semanticVerifierMode
1325
+ : 'shadow',
1326
+ liveEvidenceNodeIds,
1327
+ );
959
1328
  });
960
1329
 
961
1330
  const behaviorResults = (plan?.behaviors || []).map((behavior) => {
962
1331
  const decision = mechanismByBehavior.get(behavior.id);
963
1332
  const matching = verifiedChecks.filter((check) =>
964
1333
  check.behaviorId === behavior.id);
965
- const suppliedTargets = new Set(
966
- matching.flatMap((check) =>
967
- decision?.mechanism === 'native_component_variants'
968
- || decision?.mechanism === 'existing_component_control'
969
- || decision?.mechanism === 'code_component'
970
- ? check.instanceNodeIds
971
- : [check.triggerNodeId]),
1334
+ const targetIdsFor = (check) =>
1335
+ decision?.mechanism === 'native_component_variants'
1336
+ || decision?.mechanism === 'existing_component_control'
1337
+ || decision?.mechanism === 'code_component'
1338
+ || (
1339
+ decision?.mechanism === 'native_link'
1340
+ && check.instanceNodeIds.length > 0
1341
+ )
1342
+ ? check.instanceNodeIds
1343
+ : [check.triggerNodeId].filter(Boolean);
1344
+ const observedTargets = new Set(matching.flatMap(targetIdsFor));
1345
+ const verifiedTargets = new Set(
1346
+ matching
1347
+ .filter((check) => check.verdict === 'verified')
1348
+ .flatMap(targetIdsFor),
1349
+ );
1350
+ const contradictedTargets = new Set(
1351
+ matching
1352
+ .filter((check) => check.verdict === 'contradicted')
1353
+ .flatMap(targetIdsFor),
972
1354
  );
973
- const failures = matching.flatMap((check) => check.failures);
974
- if (matching.length === 0) failures.push('no evidence was supplied');
975
- if (suppliedTargets.size < behavior.expectedTargets) {
976
- failures.push(
977
- `verified ${suppliedTargets.size} of ${behavior.expectedTargets} expected targets`,
1355
+ const contradictions = matching
1356
+ .filter((check) => check.verdict === 'contradicted')
1357
+ .flatMap((check) => check.contradictions);
1358
+ const unknowns = matching
1359
+ .filter((check) => check.verdict === 'unknown')
1360
+ .flatMap((check) => check.unknowns);
1361
+ if (matching.length === 0) unknowns.push('no evidence was supplied');
1362
+ if (verifiedTargets.size < behavior.expectedTargets) {
1363
+ unknowns.push(
1364
+ `verified ${verifiedTargets.size} of ${behavior.expectedTargets} expected targets`,
978
1365
  );
979
1366
  }
1367
+ const verdict =
1368
+ matching.some((check) => check.verdict === 'contradicted')
1369
+ ? 'contradicted'
1370
+ : matching.some((check) => check.verdict === 'unknown')
1371
+ || unknowns.length > 0
1372
+ ? 'unknown'
1373
+ : 'verified';
980
1374
  return {
981
1375
  behaviorId: behavior.id,
982
1376
  behaviorSignature: behavior.signature,
983
1377
  mechanism: decision?.mechanism,
984
1378
  expectedTargets: behavior.expectedTargets,
985
- verifiedTargets: suppliedTargets.size,
986
- passed: failures.length === 0,
987
- failures: uniqueStrings(failures, 50),
1379
+ observedTargets: observedTargets.size,
1380
+ verifiedTargets: verifiedTargets.size,
1381
+ contradictedTargets: contradictedTargets.size,
1382
+ unknownTargets: Math.max(
1383
+ 0,
1384
+ behavior.expectedTargets
1385
+ - verifiedTargets.size
1386
+ - contradictedTargets.size,
1387
+ ),
1388
+ verdict,
1389
+ contradictions: uniqueStrings(contradictions, 50),
1390
+ unknowns: uniqueStrings(unknowns, 50),
988
1391
  };
989
1392
  });
990
1393
  const representativeBehaviorIds = behaviorResults
991
1394
  .filter((result) =>
992
1395
  verifiedChecks.some((check) =>
993
- check.behaviorId === result.behaviorId && check.passed))
1396
+ check.behaviorId === result.behaviorId
1397
+ && check.verdict === 'verified'))
994
1398
  .map((result) => result.behaviorId);
995
1399
  const completed =
996
1400
  behaviorResults.length > 0
997
- && behaviorResults.every((result) => result.passed);
1401
+ && behaviorResults.every((result) => result.verdict === 'verified');
1402
+ const hasContradictions = behaviorResults.some((result) =>
1403
+ result.verdict === 'contradicted');
1404
+ const usedSemanticAssessment = verifiedChecks.some((check) =>
1405
+ check.semanticAssessment?.accepted === true);
998
1406
  return {
999
1407
  completed,
1000
- verificationLevel: completed
1001
- ? 'structurally_verified'
1002
- : 'unverified',
1408
+ verificationLevel:
1409
+ completed && usedSemanticAssessment
1410
+ ? 'semantically_verified'
1411
+ : completed
1412
+ ? 'structurally_verified'
1413
+ : hasContradictions
1414
+ ? 'contradicted'
1415
+ : 'unknown',
1003
1416
  checks: verifiedChecks,
1004
1417
  behaviors: behaviorResults,
1005
1418
  representativeBehaviorIds,