@gakim-digital/dexter-bridge 0.5.21 → 0.11.1

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.
@@ -0,0 +1,962 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export const BEHAVIOR_ACTIVATIONS = [
4
+ 'tap',
5
+ 'hover',
6
+ 'submit',
7
+ 'appear',
8
+ 'key',
9
+ ];
10
+
11
+ export const BEHAVIOR_PRESENTATIONS = [
12
+ 'none',
13
+ 'inline',
14
+ 'floating',
15
+ 'modal',
16
+ 'navigation',
17
+ ];
18
+
19
+ export const BEHAVIOR_LAYOUT_EFFECTS = [
20
+ 'none',
21
+ 'reflow',
22
+ 'overlay',
23
+ 'navigation',
24
+ ];
25
+
26
+ export const BEHAVIOR_MECHANISMS = [
27
+ 'native_event',
28
+ 'native_overlay',
29
+ 'native_component_variants',
30
+ 'existing_component_control',
31
+ 'code_component',
32
+ ];
33
+
34
+ const MECHANISM_CAPABILITIES = {
35
+ native_event: {
36
+ activations: new Set(['tap', 'hover', 'appear', 'key']),
37
+ presentations: new Set(['none', 'navigation']),
38
+ layoutEffects: new Set(['none', 'navigation']),
39
+ stateChange: false,
40
+ reversible: false,
41
+ repeatable: true,
42
+ },
43
+ native_overlay: {
44
+ activations: new Set(['tap', 'hover', 'appear', 'key']),
45
+ presentations: new Set(['floating', 'modal']),
46
+ layoutEffects: new Set(['overlay']),
47
+ stateChange: true,
48
+ reversible: true,
49
+ repeatable: true,
50
+ },
51
+ native_component_variants: {
52
+ activations: new Set(['tap', 'hover', 'appear', 'key']),
53
+ presentations: new Set(['none', 'inline']),
54
+ layoutEffects: new Set(['none', 'reflow']),
55
+ stateChange: true,
56
+ reversible: true,
57
+ repeatable: true,
58
+ },
59
+ existing_component_control: {
60
+ activations: new Set(BEHAVIOR_ACTIVATIONS),
61
+ presentations: new Set(BEHAVIOR_PRESENTATIONS),
62
+ layoutEffects: new Set(BEHAVIOR_LAYOUT_EFFECTS),
63
+ stateChange: true,
64
+ reversible: true,
65
+ repeatable: true,
66
+ },
67
+ code_component: {
68
+ activations: new Set(BEHAVIOR_ACTIVATIONS),
69
+ presentations: new Set(BEHAVIOR_PRESENTATIONS),
70
+ layoutEffects: new Set(BEHAVIOR_LAYOUT_EFFECTS),
71
+ stateChange: true,
72
+ reversible: true,
73
+ repeatable: true,
74
+ },
75
+ };
76
+
77
+ const FRAME_EVENT_NODE_TYPES = new Set([
78
+ 'FrameNode',
79
+ 'RichTextNode',
80
+ 'IconNode',
81
+ 'ComponentInstanceNode',
82
+ ]);
83
+
84
+ function isRecord(value) {
85
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
86
+ }
87
+
88
+ function text(value, maximum = 500) {
89
+ return typeof value === 'string'
90
+ ? value.trim().slice(0, maximum)
91
+ : '';
92
+ }
93
+
94
+ function uniqueStrings(values, maximum = 50) {
95
+ return Array.from(new Set(
96
+ (Array.isArray(values) ? values : [])
97
+ .map((value) => text(value, 500))
98
+ .filter(Boolean),
99
+ )).slice(0, maximum);
100
+ }
101
+
102
+ function integer(value, fallback = 1, minimum = 1, maximum = 50) {
103
+ const parsed = Number(value);
104
+ return Number.isFinite(parsed)
105
+ ? Math.min(maximum, Math.max(minimum, Math.floor(parsed)))
106
+ : fallback;
107
+ }
108
+
109
+ function contractError(code, message) {
110
+ return Object.assign(new Error(message), { code, statusCode: 400 });
111
+ }
112
+
113
+ export function behaviorSignature(behavior) {
114
+ return crypto
115
+ .createHash('sha256')
116
+ .update(JSON.stringify({
117
+ activation: behavior.activation,
118
+ presentation: behavior.presentation,
119
+ stateChange: behavior.stateChange,
120
+ layoutEffect: behavior.layoutEffect,
121
+ reversible: behavior.reversible,
122
+ repeatable: behavior.repeatable,
123
+ expectedTargets: behavior.expectedTargets,
124
+ initialState: behavior.initialState,
125
+ activatedState: behavior.activatedState,
126
+ }))
127
+ .digest('hex');
128
+ }
129
+
130
+ function normalizeBehavior(rawBehavior, index) {
131
+ if (!isRecord(rawBehavior)) {
132
+ throw contractError(
133
+ 'FRAMER_BEHAVIOR_CONTRACT_INVALID',
134
+ `Behavior ${index + 1} must be an object.`,
135
+ );
136
+ }
137
+ const id = text(rawBehavior.id, 120);
138
+ const description = text(rawBehavior.description, 500);
139
+ const triggerDescription = text(rawBehavior.triggerDescription, 300);
140
+ const activation = text(rawBehavior.activation, 40);
141
+ const presentation = text(rawBehavior.presentation, 40);
142
+ const layoutEffect = text(rawBehavior.layoutEffect, 40);
143
+ const initialState = text(rawBehavior.initialState, 300);
144
+ const activatedState = text(rawBehavior.activatedState, 300);
145
+ if (
146
+ !id
147
+ || description.length < 5
148
+ || triggerDescription.length < 3
149
+ || !BEHAVIOR_ACTIVATIONS.includes(activation)
150
+ || !BEHAVIOR_PRESENTATIONS.includes(presentation)
151
+ || !BEHAVIOR_LAYOUT_EFFECTS.includes(layoutEffect)
152
+ || !initialState
153
+ || !activatedState
154
+ ) {
155
+ throw contractError(
156
+ 'FRAMER_BEHAVIOR_CONTRACT_INVALID',
157
+ `Behavior ${index + 1} must declare its identity, trigger, activation, presentation, initial state, activated state, and layout effect.`,
158
+ );
159
+ }
160
+ const behavior = {
161
+ id,
162
+ description,
163
+ triggerDescription,
164
+ activation,
165
+ presentation,
166
+ stateChange: rawBehavior.stateChange === true,
167
+ layoutEffect,
168
+ reversible: rawBehavior.reversible === true,
169
+ repeatable: rawBehavior.repeatable === true,
170
+ expectedTargets: integer(rawBehavior.expectedTargets),
171
+ expectedContent: uniqueStrings(rawBehavior.expectedContent, 20),
172
+ initialState,
173
+ activatedState,
174
+ };
175
+ return {
176
+ ...behavior,
177
+ signature: behaviorSignature(behavior),
178
+ };
179
+ }
180
+
181
+ function normalizeMechanism(rawMechanism, index) {
182
+ if (!isRecord(rawMechanism)) {
183
+ throw contractError(
184
+ 'FRAMER_BEHAVIOR_MECHANISM_INVALID',
185
+ `Mechanism ${index + 1} must be an object.`,
186
+ );
187
+ }
188
+ const behaviorId = text(rawMechanism.behaviorId, 120);
189
+ const mechanism = text(rawMechanism.mechanism, 80);
190
+ const rationale = text(rawMechanism.rationale, 800);
191
+ if (
192
+ !behaviorId
193
+ || !BEHAVIOR_MECHANISMS.includes(mechanism)
194
+ || rationale.length < 10
195
+ ) {
196
+ throw contractError(
197
+ 'FRAMER_BEHAVIOR_MECHANISM_INVALID',
198
+ `Mechanism ${index + 1} must identify a behavior, a supported mechanism, and a concrete rationale.`,
199
+ );
200
+ }
201
+ return {
202
+ behaviorId,
203
+ mechanism,
204
+ rationale,
205
+ representativeFirst: rawMechanism.representativeFirst !== false,
206
+ };
207
+ }
208
+
209
+ export function normalizeRejectedMechanisms(values) {
210
+ return (Array.isArray(values) ? values : [])
211
+ .slice(0, 30)
212
+ .flatMap((raw, index) => {
213
+ if (!isRecord(raw)) return [];
214
+ const behaviorId = text(raw.behaviorId, 120);
215
+ const behaviorSignatureValue = text(raw.behaviorSignature, 128);
216
+ const mechanism = text(raw.mechanism, 80);
217
+ const reason = text(raw.reason, 800);
218
+ if (
219
+ (!behaviorId && !behaviorSignatureValue)
220
+ || !BEHAVIOR_MECHANISMS.includes(mechanism)
221
+ || reason.length < 5
222
+ ) {
223
+ throw contractError(
224
+ 'FRAMER_REJECTED_MECHANISM_INVALID',
225
+ `Rejected mechanism ${index + 1} must identify a behavior or signature, a supported mechanism, and a reason.`,
226
+ );
227
+ }
228
+ return [{
229
+ ...(behaviorId ? { behaviorId } : {}),
230
+ ...(behaviorSignatureValue
231
+ ? { behaviorSignature: behaviorSignatureValue }
232
+ : {}),
233
+ mechanism,
234
+ reason,
235
+ source:
236
+ raw.source === 'user_reported_failure'
237
+ || raw.source === 'verification_failure'
238
+ ? raw.source
239
+ : 'verification_failure',
240
+ }];
241
+ });
242
+ }
243
+
244
+ export function mechanismCompatibility(behavior, mechanism) {
245
+ const capabilities = MECHANISM_CAPABILITIES[mechanism];
246
+ if (!capabilities) {
247
+ return {
248
+ compatible: false,
249
+ failures: [`Unknown behavior mechanism ${mechanism}.`],
250
+ };
251
+ }
252
+ const failures = [];
253
+ if (!capabilities.activations.has(behavior.activation)) {
254
+ failures.push(`${mechanism} does not support ${behavior.activation} activation`);
255
+ }
256
+ if (!capabilities.presentations.has(behavior.presentation)) {
257
+ failures.push(`${mechanism} does not support ${behavior.presentation} presentation`);
258
+ }
259
+ if (!capabilities.layoutEffects.has(behavior.layoutEffect)) {
260
+ failures.push(`${mechanism} does not support ${behavior.layoutEffect} layout behavior`);
261
+ }
262
+ if (behavior.stateChange && !capabilities.stateChange) {
263
+ failures.push(`${mechanism} cannot provide state changes`);
264
+ }
265
+ if (behavior.reversible && !capabilities.reversible) {
266
+ failures.push(`${mechanism} cannot provide reversible behavior`);
267
+ }
268
+ if (behavior.repeatable && !capabilities.repeatable) {
269
+ failures.push(`${mechanism} cannot be repeated across targets`);
270
+ }
271
+ return {
272
+ compatible: failures.length === 0,
273
+ failures,
274
+ };
275
+ }
276
+
277
+ export function normalizeBehaviorPlan(rawArguments, inheritedRejected = []) {
278
+ const behaviors = (Array.isArray(rawArguments.behaviors)
279
+ ? rawArguments.behaviors
280
+ : []).slice(0, 20).map(normalizeBehavior);
281
+ const mechanisms = (Array.isArray(rawArguments.mechanisms)
282
+ ? rawArguments.mechanisms
283
+ : []).slice(0, 20).map(normalizeMechanism);
284
+ const rejectedMechanisms = [
285
+ ...normalizeRejectedMechanisms(inheritedRejected),
286
+ ...normalizeRejectedMechanisms(rawArguments.rejectedMechanisms),
287
+ ];
288
+ const behaviorIds = new Set();
289
+ for (const behavior of behaviors) {
290
+ if (behaviorIds.has(behavior.id)) {
291
+ throw contractError(
292
+ 'FRAMER_BEHAVIOR_CONTRACT_INVALID',
293
+ `Behavior IDs must be unique; "${behavior.id}" is duplicated.`,
294
+ );
295
+ }
296
+ behaviorIds.add(behavior.id);
297
+ }
298
+ const mechanismByBehavior = new Map();
299
+ for (const decision of mechanisms) {
300
+ if (!behaviorIds.has(decision.behaviorId)) {
301
+ throw contractError(
302
+ 'FRAMER_BEHAVIOR_MECHANISM_INVALID',
303
+ `Mechanism references unknown behavior "${decision.behaviorId}".`,
304
+ );
305
+ }
306
+ if (mechanismByBehavior.has(decision.behaviorId)) {
307
+ throw contractError(
308
+ 'FRAMER_BEHAVIOR_MECHANISM_INVALID',
309
+ `Behavior "${decision.behaviorId}" has more than one selected mechanism.`,
310
+ );
311
+ }
312
+ mechanismByBehavior.set(decision.behaviorId, decision);
313
+ }
314
+ const compatibility = behaviors.map((behavior) => {
315
+ const decision = mechanismByBehavior.get(behavior.id);
316
+ if (!decision) {
317
+ throw contractError(
318
+ 'FRAMER_BEHAVIOR_MECHANISM_REQUIRED',
319
+ `Behavior "${behavior.id}" requires one explicit implementation mechanism.`,
320
+ );
321
+ }
322
+ const rejected = rejectedMechanisms.find((item) =>
323
+ item.mechanism === decision.mechanism
324
+ && (
325
+ item.behaviorId === behavior.id
326
+ || item.behaviorSignature === behavior.signature
327
+ ));
328
+ if (rejected) {
329
+ throw contractError(
330
+ 'FRAMER_BEHAVIOR_MECHANISM_REJECTED',
331
+ `${decision.mechanism} was already rejected for behavior "${behavior.id}": ${rejected.reason}`,
332
+ );
333
+ }
334
+ const result = mechanismCompatibility(behavior, decision.mechanism);
335
+ if (!result.compatible) {
336
+ throw contractError(
337
+ 'FRAMER_BEHAVIOR_MECHANISM_INCOMPATIBLE',
338
+ `The selected mechanism for behavior "${behavior.id}" is incompatible: ${result.failures.join('; ')}.`,
339
+ );
340
+ }
341
+ return {
342
+ behaviorId: behavior.id,
343
+ behaviorSignature: behavior.signature,
344
+ mechanism: decision.mechanism,
345
+ compatible: true,
346
+ requiredCapabilities: behaviorCapabilities(behavior),
347
+ };
348
+ });
349
+ return {
350
+ behaviors,
351
+ mechanisms,
352
+ rejectedMechanisms,
353
+ compatibility,
354
+ };
355
+ }
356
+
357
+ export function behaviorCapabilities(behavior) {
358
+ return [
359
+ `activation:${behavior.activation}`,
360
+ `presentation:${behavior.presentation}`,
361
+ `layout:${behavior.layoutEffect}`,
362
+ ...(behavior.stateChange ? ['state-change'] : []),
363
+ ...(behavior.reversible ? ['reversible'] : []),
364
+ ...(behavior.repeatable ? ['repeatable'] : []),
365
+ ];
366
+ }
367
+
368
+ export function mergeBehaviorPlans(current, next) {
369
+ if (!current) return next;
370
+ const byId = new Map(current.behaviors.map((item) => [item.id, item]));
371
+ next.behaviors.forEach((item) => byId.set(item.id, item));
372
+ const mechanisms = new Map(
373
+ current.mechanisms.map((item) => [item.behaviorId, item]),
374
+ );
375
+ next.mechanisms.forEach((item) => mechanisms.set(item.behaviorId, item));
376
+ const merged = {
377
+ behaviors: [...byId.values()],
378
+ mechanisms: [...mechanisms.values()],
379
+ rejectedMechanisms: [
380
+ ...current.rejectedMechanisms,
381
+ ...next.rejectedMechanisms,
382
+ ],
383
+ };
384
+ return normalizeBehaviorPlan(merged);
385
+ }
386
+
387
+ function normalizedEvidenceText(value) {
388
+ try {
389
+ return JSON.stringify(value).toLowerCase();
390
+ } catch {
391
+ return '';
392
+ }
393
+ }
394
+
395
+ function eventEvidenceValues(value, event) {
396
+ const matches = [];
397
+ const expected = event.toLowerCase();
398
+ const visit = (current) => {
399
+ if (Array.isArray(current)) {
400
+ current.forEach(visit);
401
+ return;
402
+ }
403
+ if (!isRecord(current)) return;
404
+ for (const [key, nested] of Object.entries(current)) {
405
+ const normalizedKey = key.toLowerCase();
406
+ if (
407
+ normalizedKey === expected
408
+ || normalizedKey.startsWith(`${expected}.`)
409
+ ) {
410
+ matches.push(nested);
411
+ }
412
+ visit(nested);
413
+ }
414
+ };
415
+ visit(value);
416
+ return matches;
417
+ }
418
+
419
+ function snapshotFor(snapshots, id) {
420
+ if (!id) return null;
421
+ if (snapshots instanceof Map) return snapshots.get(id) || null;
422
+ return snapshots?.[id] || null;
423
+ }
424
+
425
+ function nodeFromSnapshot(snapshot) {
426
+ return isRecord(snapshot?.node) ? snapshot.node : snapshot;
427
+ }
428
+
429
+ function nodeType(snapshot) {
430
+ const node = nodeFromSnapshot(snapshot);
431
+ return text(node?.type || node?.nodeType, 80);
432
+ }
433
+
434
+ function parentId(snapshot) {
435
+ const node = nodeFromSnapshot(snapshot);
436
+ return text(
437
+ snapshot?.parentId
438
+ || node?.parentId
439
+ || node?.parent?.id,
440
+ 200,
441
+ );
442
+ }
443
+
444
+ function ancestorIds(snapshot) {
445
+ const node = nodeFromSnapshot(snapshot);
446
+ return uniqueStrings([
447
+ ...(Array.isArray(snapshot?.ancestorIds) ? snapshot.ancestorIds : []),
448
+ ...(Array.isArray(node?.ancestorIds) ? node.ancestorIds : []),
449
+ ...(Array.isArray(node?.ancestors)
450
+ ? node.ancestors.map((ancestor) =>
451
+ typeof ancestor === 'string' ? ancestor : ancestor?.id)
452
+ : []),
453
+ ], 100);
454
+ }
455
+
456
+ function belongsTo(snapshot, expectedAncestorId) {
457
+ if (!snapshot || !expectedAncestorId) return false;
458
+ return (
459
+ parentId(snapshot) === expectedAncestorId
460
+ || ancestorIds(snapshot).includes(expectedAncestorId)
461
+ );
462
+ }
463
+
464
+ function referencesComponent(snapshot, componentNodeId) {
465
+ const node = nodeFromSnapshot(snapshot);
466
+ if (!node || !componentNodeId) return false;
467
+ const component = isRecord(node.component) ? node.component : {};
468
+ return [
469
+ node.componentId,
470
+ node.componentIdentifier,
471
+ component.id,
472
+ component.nodeId,
473
+ component.componentId,
474
+ component.componentIdentifier,
475
+ ].some((value) => text(value, 200) === componentNodeId);
476
+ }
477
+
478
+ function nodeVisible(snapshot) {
479
+ const node = nodeFromSnapshot(snapshot);
480
+ if (!node) return null;
481
+ if (node.visible === false || node.hidden === true) return false;
482
+ if (node.visible === true || node.hidden === false) return true;
483
+ return true;
484
+ }
485
+
486
+ function eventText(snapshot, event) {
487
+ const node = nodeFromSnapshot(snapshot);
488
+ if (!node) return '';
489
+ const values = eventEvidenceValues(node, event);
490
+ return values.length > 0 ? normalizedEvidenceText(values) : '';
491
+ }
492
+
493
+ function expectedEvents(activation) {
494
+ if (activation === 'tap') return ['onTap', 'onTapStart'];
495
+ if (activation === 'hover') return ['onMouseEnter', 'onMouseLeave'];
496
+ if (activation === 'appear') return ['onAppear'];
497
+ if (activation === 'key') return ['onKeyDown'];
498
+ return [];
499
+ }
500
+
501
+ function normalizeEvidence(raw, behaviorById) {
502
+ if (!isRecord(raw)) {
503
+ throw contractError(
504
+ 'FRAMER_BEHAVIOR_EVIDENCE_INVALID',
505
+ 'Interaction evidence must be an object.',
506
+ );
507
+ }
508
+ const behaviorId = text(raw.behaviorId, 120);
509
+ const behavior = behaviorById.get(behaviorId);
510
+ if (!behavior) {
511
+ throw contractError(
512
+ 'FRAMER_BEHAVIOR_EVIDENCE_INVALID',
513
+ `Interaction evidence references unknown behavior "${behaviorId}".`,
514
+ );
515
+ }
516
+ return {
517
+ behaviorId,
518
+ description: text(raw.description, 300),
519
+ triggerNodeId: text(raw.triggerNodeId, 200),
520
+ event: text(raw.event, 80),
521
+ targetNodeId: text(raw.targetNodeId, 200),
522
+ componentNodeId: text(raw.componentNodeId, 200),
523
+ instanceNodeIds: uniqueStrings(raw.instanceNodeIds, 50),
524
+ openVariantNodeId: text(raw.openVariantNodeId, 200),
525
+ closedVariantNodeId: text(raw.closedVariantNodeId, 200),
526
+ openAnswerNodeId: text(raw.openAnswerNodeId, 200),
527
+ closedAnswerNodeId: text(raw.closedAnswerNodeId, 200),
528
+ };
529
+ }
530
+
531
+ export function normalizeBehaviorEvidence(rawChecks, plan) {
532
+ const behaviorById = new Map(
533
+ (plan?.behaviors || []).map((behavior) => [behavior.id, behavior]),
534
+ );
535
+ const checks = (Array.isArray(rawChecks) ? rawChecks : []).slice(0, 50);
536
+ if (!checks.length) {
537
+ throw contractError(
538
+ 'FRAMER_BEHAVIOR_EVIDENCE_REQUIRED',
539
+ 'Declare evidence for at least one planned behavior.',
540
+ );
541
+ }
542
+ return checks.map((raw) => normalizeEvidence(raw, behaviorById));
543
+ }
544
+
545
+ export function evidenceNodeIds(checks) {
546
+ return uniqueStrings(checks.flatMap((check) => [
547
+ check.triggerNodeId,
548
+ check.targetNodeId,
549
+ check.componentNodeId,
550
+ check.openVariantNodeId,
551
+ check.closedVariantNodeId,
552
+ check.openAnswerNodeId,
553
+ check.closedAnswerNodeId,
554
+ ...check.instanceNodeIds,
555
+ ]), 200);
556
+ }
557
+
558
+ function verifyTrigger(check, behavior, trigger, failures) {
559
+ if (!check.triggerNodeId || !trigger) {
560
+ failures.push('trigger node was not found');
561
+ return '';
562
+ }
563
+ const allowedEvents = expectedEvents(behavior.activation);
564
+ if (
565
+ allowedEvents.length > 0
566
+ && !allowedEvents.includes(check.event)
567
+ ) {
568
+ failures.push(
569
+ `${check.event || 'the supplied event'} does not match ${behavior.activation} activation`,
570
+ );
571
+ }
572
+ if (!check.event) {
573
+ failures.push('the trigger event was not supplied');
574
+ return '';
575
+ }
576
+ if (!FRAME_EVENT_NODE_TYPES.has(nodeType(trigger))) {
577
+ failures.push(`${nodeType(trigger) || 'the trigger node'} does not support verified frame events`);
578
+ }
579
+ const evidence = eventText(trigger, check.event);
580
+ if (!evidence) failures.push(`${check.event} is not configured on the trigger`);
581
+ return evidence;
582
+ }
583
+
584
+ function verifyExpectedContent(behavior, snapshots, nodeIds, failures) {
585
+ const combined = normalizedEvidenceText(
586
+ nodeIds.map((id) => nodeFromSnapshot(snapshotFor(snapshots, id))),
587
+ );
588
+ for (const expected of behavior.expectedContent) {
589
+ if (!combined.includes(expected.toLowerCase())) {
590
+ failures.push(`expected content was not found: ${expected}`);
591
+ }
592
+ }
593
+ }
594
+
595
+ function verifyOverlay(check, behavior, snapshots, failures) {
596
+ const trigger = snapshotFor(snapshots, check.triggerNodeId);
597
+ const target = snapshotFor(snapshots, check.targetNodeId);
598
+ const triggerEvent = verifyTrigger(check, behavior, trigger, failures);
599
+ if (!check.targetNodeId || !target) {
600
+ failures.push('overlay target node was not found');
601
+ return;
602
+ }
603
+ if (!triggerEvent.includes('show_overlay')) {
604
+ failures.push(`${check.event} does not contain SHOW_OVERLAY`);
605
+ }
606
+ if (!triggerEvent.includes(check.targetNodeId.toLowerCase())) {
607
+ failures.push(`${check.event} does not reference target ${check.targetNodeId}`);
608
+ }
609
+ const expectedType =
610
+ behavior.presentation === 'modal'
611
+ ? 'FixedOverlayNode'
612
+ : 'RelativeOverlayNode';
613
+ if (nodeType(target) !== expectedType) {
614
+ failures.push(`the behavior requires a ${expectedType}`);
615
+ }
616
+ if (parentId(target) !== check.triggerNodeId) {
617
+ failures.push('the overlay is not parented to its actual trigger');
618
+ }
619
+ verifyExpectedContent(behavior, snapshots, [check.targetNodeId], failures);
620
+ }
621
+
622
+ function verifyComponentInstances(
623
+ check,
624
+ behavior,
625
+ snapshots,
626
+ failures,
627
+ context,
628
+ ) {
629
+ const component = snapshotFor(snapshots, check.componentNodeId);
630
+ if (!component || nodeType(component) !== 'ComponentNode') {
631
+ failures.push('component definition was not found');
632
+ }
633
+ const instances = check.instanceNodeIds.map((id) => ({
634
+ id,
635
+ snapshot: snapshotFor(snapshots, id),
636
+ }));
637
+ for (const instance of instances) {
638
+ if (!instance.snapshot || nodeType(instance.snapshot) !== 'ComponentInstanceNode') {
639
+ failures.push(`component instance ${instance.id} was not found`);
640
+ } else if (!referencesComponent(instance.snapshot, check.componentNodeId)) {
641
+ failures.push(`component instance ${instance.id} does not reference ${check.componentNodeId}`);
642
+ }
643
+ const authorizedContainerIds = uniqueStrings(
644
+ context?.authorizedInstanceContainerIds,
645
+ 100,
646
+ );
647
+ if (instance.snapshot && authorizedContainerIds.length > 0) {
648
+ const ancestry = new Set([
649
+ instance.id,
650
+ parentId(instance.snapshot),
651
+ ...(Array.isArray(instance.snapshot.ancestorIds)
652
+ ? instance.snapshot.ancestorIds
653
+ : []),
654
+ ].filter(Boolean));
655
+ if (!authorizedContainerIds.some((id) => ancestry.has(id))) {
656
+ failures.push(
657
+ `component instance ${instance.id} is outside the authorized write scope`,
658
+ );
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ function verifyComponentVariants(
665
+ check,
666
+ behavior,
667
+ snapshots,
668
+ failures,
669
+ context,
670
+ ) {
671
+ const trigger = snapshotFor(snapshots, check.triggerNodeId);
672
+ const component = snapshotFor(snapshots, check.componentNodeId);
673
+ const openVariant = snapshotFor(snapshots, check.openVariantNodeId);
674
+ const closedVariant = snapshotFor(snapshots, check.closedVariantNodeId);
675
+ const openAnswer = snapshotFor(snapshots, check.openAnswerNodeId);
676
+ const closedAnswer = snapshotFor(snapshots, check.closedAnswerNodeId);
677
+ const triggerEvent = verifyTrigger(check, behavior, trigger, failures);
678
+ verifyComponentInstances(check, behavior, snapshots, failures, context);
679
+ if (component && trigger && !belongsTo(trigger, check.componentNodeId)) {
680
+ failures.push('the trigger is not inside the component definition');
681
+ }
682
+ if (
683
+ !openVariant
684
+ || !closedVariant
685
+ || check.openVariantNodeId === check.closedVariantNodeId
686
+ ) {
687
+ failures.push('distinct open and closed variants were not found');
688
+ } else {
689
+ if (!belongsTo(openVariant, check.componentNodeId)) {
690
+ failures.push('the open variant does not belong to the component');
691
+ }
692
+ if (!belongsTo(closedVariant, check.componentNodeId)) {
693
+ failures.push('the closed variant does not belong to the component');
694
+ }
695
+ }
696
+ if (!triggerEvent.includes('set_variant')) {
697
+ failures.push(`${check.event} does not contain SET_VARIANT`);
698
+ }
699
+ if (
700
+ triggerEvent
701
+ && !triggerEvent.includes('cycle')
702
+ && !triggerEvent.includes(check.openVariantNodeId.toLowerCase())
703
+ && !triggerEvent.includes(check.closedVariantNodeId.toLowerCase())
704
+ ) {
705
+ failures.push('SET_VARIANT does not reference cycle or a declared variant');
706
+ }
707
+ if (!openAnswer || !closedAnswer) {
708
+ failures.push('open and closed answer-state nodes were not found');
709
+ } else {
710
+ if (!belongsTo(openAnswer, check.openVariantNodeId)) {
711
+ failures.push('the visible answer does not belong to the open variant');
712
+ }
713
+ if (!belongsTo(closedAnswer, check.closedVariantNodeId)) {
714
+ failures.push('the hidden answer does not belong to the closed variant');
715
+ }
716
+ if (nodeVisible(openAnswer) === false) {
717
+ failures.push('the answer is hidden in the open variant');
718
+ }
719
+ if (nodeVisible(closedAnswer) !== false) {
720
+ failures.push('the answer is not hidden in the closed variant');
721
+ }
722
+ }
723
+ verifyExpectedContent(
724
+ behavior,
725
+ snapshots,
726
+ [check.openAnswerNodeId, check.closedAnswerNodeId],
727
+ failures,
728
+ );
729
+ }
730
+
731
+ function verifyExistingComponent(
732
+ check,
733
+ behavior,
734
+ snapshots,
735
+ failures,
736
+ context,
737
+ ) {
738
+ const trigger = snapshotFor(snapshots, check.triggerNodeId);
739
+ const triggerEvent = verifyTrigger(check, behavior, trigger, failures);
740
+ verifyComponentInstances(check, behavior, snapshots, failures, context);
741
+ if (trigger && !belongsTo(trigger, check.componentNodeId)) {
742
+ failures.push('the trigger is not inside the existing component definition');
743
+ }
744
+ const permittedActions =
745
+ behavior.presentation === 'floating' || behavior.presentation === 'modal'
746
+ ? ['show_overlay', 'trigger_event']
747
+ : behavior.stateChange
748
+ ? ['set_variant', 'trigger_event']
749
+ : [];
750
+ if (
751
+ permittedActions.length > 0
752
+ && !permittedActions.some((action) => triggerEvent.includes(action))
753
+ ) {
754
+ failures.push('the component trigger does not expose an action capable of the declared behavior');
755
+ }
756
+ verifyExpectedContent(
757
+ behavior,
758
+ snapshots,
759
+ [check.componentNodeId, ...check.instanceNodeIds],
760
+ failures,
761
+ );
762
+ }
763
+
764
+ function hasCodeComponentEvidence(snapshot) {
765
+ const node = nodeFromSnapshot(snapshot);
766
+ if (!node) return false;
767
+ const serialized = normalizedEvidenceText(node);
768
+ return (
769
+ serialized.includes('codecomponent')
770
+ || serialized.includes('code_file')
771
+ || serialized.includes('codefile')
772
+ || serialized.includes('componentidentifier')
773
+ );
774
+ }
775
+
776
+ function verifyCodeComponent(check, behavior, snapshots, failures, context) {
777
+ verifyComponentInstances(check, behavior, snapshots, failures, context);
778
+ const nodes = check.instanceNodeIds.map((id) => snapshotFor(snapshots, id));
779
+ if (
780
+ !nodes.some(hasCodeComponentEvidence)
781
+ && Number(context?.lowLevelWriteCalls || 0) < 1
782
+ ) {
783
+ failures.push('no code-component source or installed code-component evidence was found');
784
+ }
785
+ verifyExpectedContent(
786
+ behavior,
787
+ snapshots,
788
+ [check.componentNodeId, ...check.instanceNodeIds],
789
+ failures,
790
+ );
791
+ }
792
+
793
+ function verifyNativeEvent(check, behavior, snapshots, failures) {
794
+ verifyTrigger(
795
+ check,
796
+ behavior,
797
+ snapshotFor(snapshots, check.triggerNodeId),
798
+ failures,
799
+ );
800
+ }
801
+
802
+ export function verifyBehaviorEvidence({
803
+ plan,
804
+ checks,
805
+ snapshots,
806
+ context = {},
807
+ }) {
808
+ const behaviorById = new Map(
809
+ (plan?.behaviors || []).map((behavior) => [behavior.id, behavior]),
810
+ );
811
+ const mechanismByBehavior = new Map(
812
+ (plan?.mechanisms || []).map((decision) => [
813
+ decision.behaviorId,
814
+ decision,
815
+ ]),
816
+ );
817
+ const verifiedChecks = checks.map((check) => {
818
+ const behavior = behaviorById.get(check.behaviorId);
819
+ const decision = mechanismByBehavior.get(check.behaviorId);
820
+ const failures = [];
821
+ if (!behavior || !decision) {
822
+ failures.push('the planned behavior or mechanism was not found');
823
+ } else if (decision.mechanism === 'native_overlay') {
824
+ verifyOverlay(check, behavior, snapshots, failures);
825
+ } else if (decision.mechanism === 'native_component_variants') {
826
+ verifyComponentVariants(check, behavior, snapshots, failures, context);
827
+ } else if (decision.mechanism === 'existing_component_control') {
828
+ verifyExistingComponent(check, behavior, snapshots, failures, context);
829
+ } else if (decision.mechanism === 'code_component') {
830
+ verifyCodeComponent(check, behavior, snapshots, failures, context);
831
+ } else {
832
+ verifyNativeEvent(check, behavior, snapshots, failures);
833
+ }
834
+ return {
835
+ ...check,
836
+ mechanism: decision?.mechanism,
837
+ passed: failures.length === 0,
838
+ failures,
839
+ };
840
+ });
841
+
842
+ const behaviorResults = (plan?.behaviors || []).map((behavior) => {
843
+ const decision = mechanismByBehavior.get(behavior.id);
844
+ const matching = verifiedChecks.filter((check) =>
845
+ check.behaviorId === behavior.id);
846
+ const suppliedTargets = new Set(
847
+ matching.flatMap((check) =>
848
+ decision?.mechanism === 'native_component_variants'
849
+ || decision?.mechanism === 'existing_component_control'
850
+ || decision?.mechanism === 'code_component'
851
+ ? check.instanceNodeIds
852
+ : [check.triggerNodeId]),
853
+ );
854
+ const failures = matching.flatMap((check) => check.failures);
855
+ if (matching.length === 0) failures.push('no evidence was supplied');
856
+ if (suppliedTargets.size < behavior.expectedTargets) {
857
+ failures.push(
858
+ `verified ${suppliedTargets.size} of ${behavior.expectedTargets} expected targets`,
859
+ );
860
+ }
861
+ return {
862
+ behaviorId: behavior.id,
863
+ behaviorSignature: behavior.signature,
864
+ mechanism: decision?.mechanism,
865
+ expectedTargets: behavior.expectedTargets,
866
+ verifiedTargets: suppliedTargets.size,
867
+ passed: failures.length === 0,
868
+ failures: uniqueStrings(failures, 50),
869
+ };
870
+ });
871
+ const representativeBehaviorIds = behaviorResults
872
+ .filter((result) =>
873
+ verifiedChecks.some((check) =>
874
+ check.behaviorId === result.behaviorId && check.passed))
875
+ .map((result) => result.behaviorId);
876
+ const completed =
877
+ behaviorResults.length > 0
878
+ && behaviorResults.every((result) => result.passed);
879
+ return {
880
+ completed,
881
+ verificationLevel: completed
882
+ ? 'structurally_verified'
883
+ : 'unverified',
884
+ checks: verifiedChecks,
885
+ behaviors: behaviorResults,
886
+ representativeBehaviorIds,
887
+ };
888
+ }
889
+
890
+ export function summarizeFramerChanges(changes) {
891
+ const commands = String(changes || '')
892
+ .split(';')
893
+ .map((command) => command.trim())
894
+ .filter(Boolean)
895
+ .slice(0, 200)
896
+ .map((command) => {
897
+ const created = command.match(/^\+([A-Za-z0-9_]+)\s+([^\s]+)/);
898
+ if (created) {
899
+ return {
900
+ operation: 'create',
901
+ nodeType: created[1],
902
+ nodeId: created[2],
903
+ parentId: command.match(/\bparent="([^"]+)"/)?.[1],
904
+ };
905
+ }
906
+ const set = command.match(/^SET\s+([^\s]+)/i);
907
+ if (set) {
908
+ return {
909
+ operation: 'set',
910
+ nodeId: set[1],
911
+ eventActions: Array.from(
912
+ command.matchAll(
913
+ /\b(on[A-Za-z]+)\.\d+\.action="([A-Z_]+)"/g,
914
+ ),
915
+ ).map((match) => ({ event: match[1], action: match[2] })),
916
+ };
917
+ }
918
+ const variant = command.match(/^CREATE_VARIANT\s+([^\s]+)\s+from="([^"]+)"/i);
919
+ if (variant) {
920
+ return {
921
+ operation: 'create_variant',
922
+ nodeId: variant[1],
923
+ sourceNodeId: variant[2],
924
+ };
925
+ }
926
+ const operation = text(command.split(/\s+/)[0], 40).toLowerCase();
927
+ return { operation: operation || 'unknown' };
928
+ });
929
+ return {
930
+ changeHash: crypto
931
+ .createHash('sha256')
932
+ .update(String(changes || ''))
933
+ .digest('hex'),
934
+ commandCount: commands.length,
935
+ commands,
936
+ };
937
+ }
938
+
939
+ export function canonicalIdMappings(value) {
940
+ const mappings = {};
941
+ const visit = (current) => {
942
+ if (Array.isArray(current)) {
943
+ current.forEach(visit);
944
+ return;
945
+ }
946
+ if (!isRecord(current)) return;
947
+ if (isRecord(current.renamedIds)) {
948
+ for (const [temporaryId, canonicalId] of Object.entries(
949
+ current.renamedIds,
950
+ )) {
951
+ const safeTemporary = text(temporaryId, 200);
952
+ const safeCanonical = text(canonicalId, 200);
953
+ if (safeTemporary && safeCanonical) {
954
+ mappings[safeTemporary] = safeCanonical;
955
+ }
956
+ }
957
+ }
958
+ Object.values(current).forEach(visit);
959
+ };
960
+ visit(value);
961
+ return mappings;
962
+ }