@saptools/service-flow 0.1.70 → 0.1.73

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +23 -6
  3. package/TECHNICAL-NOTE.md +24 -2
  4. package/dist/{chunk-GSLFY6J2.js → chunk-32WOTGTS.js} +12061 -9288
  5. package/dist/chunk-32WOTGTS.js.map +1 -0
  6. package/dist/cli.js +854 -161
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +54 -14
  9. package/dist/index.js +2 -23
  10. package/dist/index.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/cli/doctor-event-quality.ts +446 -0
  13. package/src/cli/doctor.ts +4 -6
  14. package/src/cli.ts +35 -13
  15. package/src/config/workspace-config.ts +15 -0
  16. package/src/db/call-fact-repository.ts +6 -3
  17. package/src/db/current-fact-semantics.ts +5 -29
  18. package/src/db/event-fact-semantics.ts +546 -0
  19. package/src/db/event-site-semantics.ts +62 -0
  20. package/src/db/event-surface-invalidation.ts +38 -0
  21. package/src/db/fact-json-inventory.ts +16 -0
  22. package/src/db/fact-lifecycle.ts +70 -12
  23. package/src/db/migrations.ts +15 -1
  24. package/src/db/package-target-invalidation.ts +80 -0
  25. package/src/db/repositories.ts +28 -2
  26. package/src/db/schema-structure.ts +41 -1
  27. package/src/db/schema.ts +6 -2
  28. package/src/indexer/repository-indexer.ts +93 -10
  29. package/src/indexer/workspace-indexer.ts +13 -3
  30. package/src/linker/cross-repo-linker.ts +27 -3
  31. package/src/linker/event-environment-link.ts +211 -0
  32. package/src/linker/event-shape-candidate-linker.ts +204 -0
  33. package/src/linker/event-subscription-handler-linker.ts +220 -25
  34. package/src/linker/event-template-link.ts +40 -6
  35. package/src/linker/package-event-constant-resolver.ts +302 -0
  36. package/src/output/repository-inspection.ts +11 -0
  37. package/src/output/table-output.ts +13 -1
  38. package/src/parsers/decorator-parser.ts +9 -53
  39. package/src/parsers/environment-declarations.ts +404 -0
  40. package/src/parsers/event-call-analysis.ts +370 -0
  41. package/src/parsers/event-environment-reference.ts +242 -0
  42. package/src/parsers/event-loop-registration.ts +132 -0
  43. package/src/parsers/event-name-import-resolution.ts +243 -0
  44. package/src/parsers/event-receiver-analysis.ts +394 -0
  45. package/src/parsers/event-subscription-facts.ts +4 -0
  46. package/src/parsers/generated-constants-parser.ts +80 -14
  47. package/src/parsers/outbound-call-classifier.ts +27 -124
  48. package/src/parsers/outbound-call-parser.ts +13 -1
  49. package/src/parsers/outbound-expression-analysis.ts +2 -2
  50. package/src/parsers/stable-local-value.ts +42 -10
  51. package/src/parsers/string-constant-lookups.ts +408 -0
  52. package/src/trace/edge-target.ts +65 -0
  53. package/src/trace/event-runtime-resolution.ts +24 -3
  54. package/src/trace/event-shape-candidate-trace.ts +172 -0
  55. package/src/trace/event-subscriber-traversal.ts +90 -8
  56. package/src/trace/evidence.ts +7 -28
  57. package/src/trace/trace-scope-execution.ts +22 -30
  58. package/src/types.ts +19 -1
  59. package/src/utils/event-skeleton.ts +207 -0
  60. package/src/version.ts +1 -1
  61. package/dist/chunk-GSLFY6J2.js.map +0 -1
@@ -0,0 +1,204 @@
1
+ import type { Db } from '../db/connection.js';
2
+ import { parseEventSkeletonFact } from '../utils/event-skeleton.js';
3
+ import { boundCandidateLikeEvidence } from '../utils/bounded-projection.js';
4
+
5
+ export interface EventShapeCandidateLinkSummary {
6
+ edgeCount: number;
7
+ omittedCount: number;
8
+ }
9
+
10
+ const EVENT_SHAPE_LINK_CAP_PER_EMIT = 100;
11
+
12
+ interface EventShapeRow extends Record<string, unknown> {
13
+ id: number;
14
+ repoId: number;
15
+ repoName: string;
16
+ signature: string;
17
+ skeletonJson: string;
18
+ evidenceJson: string;
19
+ }
20
+
21
+ interface SubscriberAssociation extends Record<string, unknown> {
22
+ graphEdgeId: number;
23
+ subscribeCallId: number;
24
+ targetKind: string;
25
+ targetId: string;
26
+ status: string;
27
+ evidenceJson: string;
28
+ targetLabel?: string | null;
29
+ }
30
+
31
+ function eventRows(
32
+ db: Db,
33
+ workspaceId: number,
34
+ callType: 'async_emit' | 'async_subscribe',
35
+ ): EventShapeRow[] {
36
+ return db.prepare(`SELECT c.id,c.repo_id repoId,r.name repoName,
37
+ c.event_skeleton_signature signature,
38
+ c.event_skeleton_json skeletonJson,c.evidence_json evidenceJson
39
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
40
+ WHERE r.workspace_id=? AND c.call_type=?
41
+ AND c.event_skeleton_signature IS NOT NULL
42
+ AND c.event_skeleton_json IS NOT NULL
43
+ ORDER BY c.event_skeleton_signature COLLATE BINARY,
44
+ r.name COLLATE BINARY,r.id,c.source_file COLLATE BINARY,
45
+ c.call_site_start_offset,c.call_site_end_offset,c.id`).all(
46
+ workspaceId, callType,
47
+ ) as unknown as EventShapeRow[];
48
+ }
49
+
50
+ function associations(
51
+ db: Db,
52
+ workspaceId: number,
53
+ generation: number,
54
+ ): SubscriberAssociation[] {
55
+ return db.prepare(`SELECT edge.id graphEdgeId,
56
+ CAST(json_extract(edge.evidence_json,'$.subscribeCallId') AS INTEGER)
57
+ subscribeCallId,
58
+ edge.to_kind targetKind,edge.to_id targetId,edge.status,
59
+ edge.evidence_json evidenceJson,
60
+ target.source_file || ':' || target.qualified_name targetLabel
61
+ FROM graph_edges edge
62
+ LEFT JOIN symbols target ON edge.to_kind='symbol'
63
+ AND target.id=CAST(edge.to_id AS INTEGER)
64
+ WHERE edge.workspace_id=? AND edge.generation=?
65
+ AND edge.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
66
+ AND edge.to_kind='symbol'
67
+ ORDER BY subscribeCallId,edge.id`).all(
68
+ workspaceId, generation,
69
+ ) as unknown as SubscriberAssociation[];
70
+ }
71
+
72
+ function parsedEvidence(value: string): Record<string, unknown> {
73
+ try {
74
+ const parsed: unknown = JSON.parse(value);
75
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
76
+ ? parsed as Record<string, unknown> : {};
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ function candidateEligible(
83
+ emit: EventShapeRow,
84
+ subscribe: EventShapeRow,
85
+ ): boolean {
86
+ if (emit.signature !== subscribe.signature) return false;
87
+ const left = parseEventSkeletonFact(emit.skeletonJson);
88
+ const right = parseEventSkeletonFact(subscribe.skeletonJson);
89
+ return Boolean(left?.candidateEligible && right?.candidateEligible
90
+ && left.holeCount === right.holeCount
91
+ && JSON.stringify(left.literalSpans) === JSON.stringify(right.literalSpans));
92
+ }
93
+
94
+ function candidateEvidence(
95
+ emit: EventShapeRow,
96
+ subscribe: EventShapeRow,
97
+ association: SubscriberAssociation,
98
+ total: number,
99
+ shown: number,
100
+ ): Record<string, unknown> {
101
+ const evidence = parsedEvidence(association.evidenceJson);
102
+ const parser = parsedEvidence(emit.evidenceJson);
103
+ return {
104
+ publishCallId: emit.id,
105
+ subscribeCallId: subscribe.id,
106
+ eventSkeletonSignature: emit.signature,
107
+ dispatchScope: 'workspace_event_name_only',
108
+ dispatchCertainty: 'skeleton_equivalent',
109
+ subscriptionRepositoryId: subscribe.repoId,
110
+ subscriptionRepositoryName: subscribe.repoName,
111
+ subscriptionConsumerRepositoryId:
112
+ evidence.subscriptionConsumerRepositoryId,
113
+ subscriptionConsumerRepositoryName:
114
+ evidence.subscriptionConsumerRepositoryName,
115
+ handlerSymbolId: Number(association.targetId),
116
+ eventShapeCandidateTargetLabel: association.targetLabel,
117
+ associationGraphEdgeId: association.graphEdgeId,
118
+ outboundEvidence: boundCandidateLikeEvidence(parser),
119
+ eventShapeLinkCandidateCount: total,
120
+ shownEventShapeLinkCandidateCount: shown,
121
+ omittedEventShapeLinkCandidateCount: Math.max(0, total - shown),
122
+ };
123
+ }
124
+
125
+ function insertCandidate(
126
+ db: Db,
127
+ workspaceId: number,
128
+ generation: number,
129
+ emit: EventShapeRow,
130
+ subscribe: EventShapeRow,
131
+ association: SubscriberAssociation,
132
+ total: number,
133
+ shown: number,
134
+ ): void {
135
+ db.prepare(`INSERT INTO graph_edges(
136
+ workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
137
+ confidence,evidence_json,is_dynamic,unresolved_reason,generation
138
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(
139
+ workspaceId,
140
+ 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER',
141
+ 'dynamic',
142
+ 'call',
143
+ String(emit.id),
144
+ association.targetKind,
145
+ association.targetId,
146
+ 0.3,
147
+ JSON.stringify(candidateEvidence(
148
+ emit, subscribe, association, total, shown,
149
+ )),
150
+ 1,
151
+ 'event_skeleton_equivalent_non_authoritative',
152
+ generation,
153
+ );
154
+ }
155
+
156
+ interface ShapeCandidate {
157
+ subscription: EventShapeRow;
158
+ association: SubscriberAssociation;
159
+ }
160
+
161
+ function candidatesForEmit(
162
+ emit: EventShapeRow,
163
+ subscriptions: readonly EventShapeRow[],
164
+ bySubscription: ReadonlyMap<number, SubscriberAssociation[]>,
165
+ ): ShapeCandidate[] {
166
+ return subscriptions.flatMap((subscription) =>
167
+ !candidateEligible(emit, subscription) ? []
168
+ : (bySubscription.get(subscription.id) ?? []).map((association) => ({
169
+ subscription,
170
+ association,
171
+ })));
172
+ }
173
+
174
+ export function linkEventShapeCandidates(
175
+ db: Db,
176
+ workspaceId: number,
177
+ generation: number,
178
+ ): EventShapeCandidateLinkSummary {
179
+ const emits = eventRows(db, workspaceId, 'async_emit');
180
+ const subscriptions = eventRows(db, workspaceId, 'async_subscribe');
181
+ const bySubscription = new Map<number, SubscriberAssociation[]>();
182
+ for (const association of associations(db, workspaceId, generation))
183
+ bySubscription.set(association.subscribeCallId, [
184
+ ...(bySubscription.get(association.subscribeCallId) ?? []),
185
+ association,
186
+ ]);
187
+ let edgeCount = 0;
188
+ let omittedCount = 0;
189
+ for (const emit of emits) {
190
+ const candidates = candidatesForEmit(
191
+ emit, subscriptions, bySubscription,
192
+ );
193
+ const shown = candidates.slice(0, EVENT_SHAPE_LINK_CAP_PER_EMIT);
194
+ for (const candidate of shown) {
195
+ insertCandidate(
196
+ db, workspaceId, generation, emit, candidate.subscription,
197
+ candidate.association, candidates.length, shown.length,
198
+ );
199
+ edgeCount += 1;
200
+ }
201
+ omittedCount += Math.max(0, candidates.length - shown.length);
202
+ }
203
+ return { edgeCount, omittedCount };
204
+ }
@@ -3,6 +3,11 @@ import {
3
3
  linkEventTemplate,
4
4
  type LinkedEventTemplate,
5
5
  } from './event-template-link.js';
6
+ import {
7
+ subscriptionEnvironmentTargets,
8
+ type SubscriptionEnvironmentTarget,
9
+ } from './event-environment-link.js';
10
+ import { parseEventSkeletonFact } from '../utils/event-skeleton.js';
6
11
 
7
12
  export interface SubscriptionHandlerLinkSummary {
8
13
  edgeCount: number;
@@ -25,6 +30,10 @@ interface SubscriptionRow {
25
30
  endOffset?: number | null;
26
31
  confidence: number;
27
32
  unresolvedReason?: string | null;
33
+ packageName?: string | null;
34
+ environmentJson?: string | null;
35
+ eventSkeletonJson?: string | null;
36
+ evidenceJson?: string | null;
28
37
  }
29
38
 
30
39
  interface HandlerCallRow {
@@ -63,7 +72,11 @@ function subscriptionRows(db: Db, workspaceId: number): SubscriptionRow[] {
63
72
  c.source_symbol_id sourceSymbolId,c.event_name_expr eventName,
64
73
  c.source_file sourceFile,c.source_line sourceLine,
65
74
  c.call_site_start_offset startOffset,c.call_site_end_offset endOffset,
66
- c.confidence,c.unresolved_reason unresolvedReason
75
+ c.confidence,c.unresolved_reason unresolvedReason,
76
+ c.event_skeleton_json eventSkeletonJson,
77
+ c.evidence_json evidenceJson,
78
+ r.package_name packageName,
79
+ r.environment_declarations_json environmentJson
67
80
  FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
68
81
  WHERE r.workspace_id=? AND c.call_type='async_subscribe'
69
82
  AND json_extract(c.evidence_json,'$.handlerReferenceStatus')
@@ -208,11 +221,36 @@ function evidenceFor(
208
221
  subscription: SubscriptionRow,
209
222
  association: HandlerAssociation,
210
223
  event: LinkedEventTemplate,
224
+ environment?: SubscriptionEnvironmentTarget,
225
+ loopValue?: string,
211
226
  ): Record<string, unknown> {
212
227
  const call: Partial<HandlerCallRow> = association.call ?? {};
213
228
  const symbolCallReason = boundedSymbolCallReason(call.unresolvedReason);
229
+ const environmentAmbiguous = environment?.resolution.status === 'ambiguous'
230
+ || Number(environment?.collisionCount ?? 1) > 1;
231
+ const resolutionStatus = event.isDynamic
232
+ ? 'unresolved'
233
+ : environmentAmbiguous ? 'ambiguous' : association.status;
234
+ return {
235
+ ...eventDispatchEvidence(subscription, event, environment, loopValue),
236
+ ...handlerAssociationEvidence(
237
+ subscription, association, call, resolutionStatus,
238
+ environmentAmbiguous, environment,
239
+ ),
240
+ ...symbolCallReason,
241
+ };
242
+ }
243
+
244
+ function eventDispatchEvidence(
245
+ subscription: SubscriptionRow,
246
+ event: LinkedEventTemplate,
247
+ environment?: SubscriptionEnvironmentTarget,
248
+ loopValue?: string,
249
+ ): Record<string, unknown> {
214
250
  return {
215
251
  eventName: subscription.eventName,
252
+ materializedLoopEventName: loopValue,
253
+ ...(eventSkeletonEvidence(subscription.eventSkeletonJson)),
216
254
  ...(event.substitution.placeholders.length > 0 ? {
217
255
  effectiveEventName: event.targetId,
218
256
  eventTemplateResolution: event.substitution,
@@ -220,12 +258,37 @@ function evidenceFor(
220
258
  associationBasis: 'exact_subscription_call_span',
221
259
  dispatchScope: 'workspace_event_name_only',
222
260
  subscribeCallId: subscription.id,
261
+ repositoryId: subscription.repoId,
262
+ repositoryName: subscription.repoName,
263
+ subscriptionConsumerRepositoryId: environment?.consumerRepoId,
264
+ subscriptionConsumerRepositoryName: environment?.consumerRepoName,
265
+ dispatchCertainty: environment?.resolution.status === 'resolved'
266
+ ? 'environment_declaration_exact' : 'static_name_only',
267
+ ...(environment?.resolution.status === 'not_applicable' ? {} : {
268
+ eventEnvironmentResolution: {
269
+ status: environment?.resolution.status,
270
+ reason: environment?.resolution.reason,
271
+ provenance: environment?.resolution.provenance,
272
+ collisionCount: environment?.collisionCount,
273
+ },
274
+ }),
275
+ };
276
+ }
277
+
278
+ function handlerAssociationEvidence(
279
+ subscription: SubscriptionRow,
280
+ association: HandlerAssociation,
281
+ call: Partial<HandlerCallRow>,
282
+ resolutionStatus: string,
283
+ environmentAmbiguous: boolean,
284
+ environment?: SubscriptionEnvironmentTarget,
285
+ ): Record<string, unknown> {
286
+ return {
223
287
  symbolCallId: call.id,
224
288
  roleSiteMatchCount: association.matchCount,
225
- callRole: association.matchCount > 0 ? 'event_subscribe_handler' : undefined,
289
+ callRole: association.matchCount > 0
290
+ ? 'event_subscribe_handler' : undefined,
226
291
  factOrigin: association.factOrigin ?? call.factOrigin,
227
- repositoryId: subscription.repoId,
228
- repositoryName: subscription.repoName,
229
292
  sourceFile: subscription.sourceFile,
230
293
  sourceLine: subscription.sourceLine,
231
294
  callSiteStartOffset: subscription.startOffset,
@@ -237,14 +300,22 @@ function evidenceFor(
237
300
  associationStatus: association.status,
238
301
  symbolCallResolutionStatus:
239
302
  association.symbolCallResolutionStatus ?? call.status,
240
- resolutionStatus: association.status,
303
+ resolutionStatus,
241
304
  resolutionStrategy: call.strategy,
242
305
  candidateCount: call.candidateCount,
243
- reasonCode: association.reasonCode,
244
- ...symbolCallReason,
306
+ reasonCode: environmentAmbiguous
307
+ ? environment?.resolution.reason ?? 'event_environment_value_collision'
308
+ : association.reasonCode,
245
309
  };
246
310
  }
247
311
 
312
+ function eventSkeletonEvidence(
313
+ value: string | null | undefined,
314
+ ): Record<string, unknown> {
315
+ const skeleton = parseEventSkeletonFact(value);
316
+ return skeleton ? { eventSkeleton: skeleton } : {};
317
+ }
318
+
248
319
  function boundedSymbolCallReason(
249
320
  reason: string | null | undefined,
250
321
  ): Record<string, unknown> {
@@ -264,11 +335,14 @@ function insertAssociationEdge(
264
335
  subscription: SubscriptionRow,
265
336
  association: HandlerAssociation,
266
337
  event: LinkedEventTemplate,
338
+ environment?: SubscriptionEnvironmentTarget,
339
+ loopValue?: string,
267
340
  ): void {
268
- const status = event.isDynamic ? 'unresolved' : association.status;
269
- const reason = event.isDynamic
270
- ? 'event_template_variables_missing'
271
- : association.reasonCode ?? association.call?.unresolvedReason ?? null;
341
+ const ambiguous = environmentTargetAmbiguous(environment);
342
+ const status = associationEdgeStatus(event, association, ambiguous);
343
+ const reason = associationEdgeReason(
344
+ event, association, environment, ambiguous,
345
+ );
272
346
  db.prepare(`INSERT INTO graph_edges(
273
347
  workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
274
348
  confidence,evidence_json,is_dynamic,unresolved_reason,generation
@@ -281,13 +355,134 @@ function insertAssociationEdge(
281
355
  association.toKind,
282
356
  association.toId,
283
357
  association.call?.confidence ?? subscription.confidence,
284
- JSON.stringify(evidenceFor(subscription, association, event)),
358
+ JSON.stringify(evidenceFor(
359
+ subscription, association, event, environment, loopValue,
360
+ )),
285
361
  event.isDynamic ? 1 : 0,
286
362
  reason,
287
363
  generation,
288
364
  );
289
365
  }
290
366
 
367
+ function environmentTargetAmbiguous(
368
+ environment?: SubscriptionEnvironmentTarget,
369
+ ): boolean {
370
+ return environment?.resolution.status === 'ambiguous'
371
+ || Number(environment?.collisionCount ?? 1) > 1;
372
+ }
373
+
374
+ function associationEdgeStatus(
375
+ event: LinkedEventTemplate,
376
+ association: HandlerAssociation,
377
+ environmentAmbiguous: boolean,
378
+ ): 'resolved' | 'ambiguous' | 'unresolved' {
379
+ if (event.isDynamic) return 'unresolved';
380
+ return environmentAmbiguous ? 'ambiguous' : association.status;
381
+ }
382
+
383
+ function associationEdgeReason(
384
+ event: LinkedEventTemplate,
385
+ association: HandlerAssociation,
386
+ environment: SubscriptionEnvironmentTarget | undefined,
387
+ environmentAmbiguous: boolean,
388
+ ): string | null {
389
+ if (event.isDynamic)
390
+ return event.substitution.missing.length > 0
391
+ ? 'event_template_variables_missing'
392
+ : event.unresolvedReason ?? 'event_name_unsupported_constant_expression';
393
+ if (environmentAmbiguous)
394
+ return environment?.resolution.reason
395
+ ?? 'event_environment_value_collision';
396
+ return association.reasonCode ?? association.call?.unresolvedReason ?? null;
397
+ }
398
+
399
+ function parsedEvidenceRecord(
400
+ value: string,
401
+ ): Record<string, unknown> | undefined {
402
+ try {
403
+ const parsed: unknown = JSON.parse(value);
404
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
405
+ ? parsed as Record<string, unknown> : undefined;
406
+ } catch {
407
+ return undefined;
408
+ }
409
+ }
410
+
411
+ function enumeratedLoopValues(
412
+ evidenceJson: string | null | undefined,
413
+ ): string[] | undefined {
414
+ if (!evidenceJson) return undefined;
415
+ const evidence = parsedEvidenceRecord(evidenceJson);
416
+ if (!evidence
417
+ || evidence.subscriptionLoopRegistrationStatus !== 'enumerated')
418
+ return undefined;
419
+ const values = evidence.subscriptionLoopValues;
420
+ if (!Array.isArray(values)
421
+ || !values.every((item): item is string => typeof item === 'string'))
422
+ return undefined;
423
+ return evidence.subscriptionLoopRegistrationCount === values.length
424
+ && evidence.omittedSubscriptionLoopValueCount === 0 ? values : undefined;
425
+ }
426
+
427
+ function linkedSubscriptionEvents(
428
+ db: Db,
429
+ workspaceId: number,
430
+ subscription: SubscriptionRow,
431
+ variables: Record<string, string>,
432
+ ): Array<{
433
+ event: LinkedEventTemplate;
434
+ environment: SubscriptionEnvironmentTarget;
435
+ loopValue?: string;
436
+ }> {
437
+ const skeleton = parseEventSkeletonFact(subscription.eventSkeletonJson);
438
+ const environments = subscriptionEnvironmentTargets(
439
+ db,
440
+ workspaceId,
441
+ subscription.packageName ?? undefined,
442
+ subscription.eventSkeletonJson,
443
+ subscription.environmentJson,
444
+ variables,
445
+ );
446
+ const loopValues = enumeratedLoopValues(subscription.evidenceJson);
447
+ const templates = loopValues ?? [subscription.eventName];
448
+ return environments.flatMap((environment) =>
449
+ templates.map((template) => ({
450
+ environment,
451
+ loopValue: loopValues ? template : undefined,
452
+ event: linkEventTemplate(
453
+ template,
454
+ environment.resolution.variables,
455
+ loopValues ? undefined : subscription.unresolvedReason ?? undefined,
456
+ loopValues ? undefined : skeleton,
457
+ ),
458
+ })));
459
+ }
460
+
461
+ function targetStatus(
462
+ association: HandlerAssociation,
463
+ event: LinkedEventTemplate,
464
+ environment: SubscriptionEnvironmentTarget,
465
+ ): 'resolved' | 'ambiguous' | 'unresolved' {
466
+ if (event.isDynamic || association.status === 'unresolved')
467
+ return 'unresolved';
468
+ if (association.status === 'ambiguous'
469
+ || environment.resolution.status === 'ambiguous'
470
+ || environment.collisionCount > 1) return 'ambiguous';
471
+ return 'resolved';
472
+ }
473
+
474
+ function incrementSummary(
475
+ summary: SubscriptionHandlerLinkSummary,
476
+ status: 'resolved' | 'ambiguous' | 'unresolved',
477
+ missing: boolean,
478
+ ): void {
479
+ summary.edgeCount += 1;
480
+ summary.resolvedCount += status === 'resolved' ? 1 : 0;
481
+ summary.ambiguousCount += status === 'ambiguous' ? 1 : 0;
482
+ summary.unresolvedCount += status === 'unresolved' ? 1 : 0;
483
+ summary.missingAssociationCount += missing ? 1 : 0;
484
+ }
485
+
291
486
  export function linkEventSubscriptionHandlers(
292
487
  db: Db,
293
488
  workspaceId: number,
@@ -302,22 +497,22 @@ export function linkEventSubscriptionHandlers(
302
497
  missingAssociationCount: 0,
303
498
  };
304
499
  for (const subscription of subscriptionRows(db, workspaceId)) {
305
- const event = linkEventTemplate(
306
- subscription.eventName, variables,
307
- subscription.unresolvedReason ?? undefined,
308
- );
309
500
  const association = associationFor(
310
501
  subscription, roleSiteRows(db, subscription),
311
502
  );
312
- insertAssociationEdge(
313
- db, workspaceId, generation, subscription, association, event,
314
- );
315
- const status = event.isDynamic ? 'unresolved' : association.status;
316
- summary.edgeCount += 1;
317
- summary.resolvedCount += status === 'resolved' ? 1 : 0;
318
- summary.ambiguousCount += status === 'ambiguous' ? 1 : 0;
319
- summary.unresolvedCount += status === 'unresolved' ? 1 : 0;
320
- summary.missingAssociationCount += association.missing ? 1 : 0;
503
+ for (const target of linkedSubscriptionEvents(
504
+ db, workspaceId, subscription, variables,
505
+ )) {
506
+ insertAssociationEdge(
507
+ db, workspaceId, generation, subscription, association,
508
+ target.event, target.environment, target.loopValue,
509
+ );
510
+ incrementSummary(
511
+ summary,
512
+ targetStatus(association, target.event, target.environment),
513
+ association.missing,
514
+ );
515
+ }
321
516
  }
322
517
  return summary;
323
518
  }
@@ -3,6 +3,14 @@ import {
3
3
  substituteVariables,
4
4
  type RuntimeSubstitution,
5
5
  } from './dynamic-edge-resolver.js';
6
+ import {
7
+ resolveEventEnvironment,
8
+ } from './event-environment-link.js';
9
+ import {
10
+ eventTemplateVariables,
11
+ parseEventSkeletonFact,
12
+ type EventSkeletonFact,
13
+ } from '../utils/event-skeleton.js';
6
14
 
7
15
  export interface LinkedEventTemplate {
8
16
  targetId: string;
@@ -17,8 +25,11 @@ export function linkEventTemplate(
17
25
  template: string,
18
26
  variables: Record<string, string>,
19
27
  parserReason?: string,
28
+ skeleton?: EventSkeletonFact,
20
29
  ): LinkedEventTemplate {
21
- const substitution = substituteVariables(template, variables);
30
+ const substitution = substituteVariables(
31
+ template, eventTemplateVariables(skeleton, variables),
32
+ );
22
33
  const missing = substitution.missing.length > 0;
23
34
  const unsupportedDynamic = substitution.placeholders.length === 0
24
35
  && parserReason !== undefined;
@@ -47,18 +58,41 @@ export function insertEventCallEdge(
47
58
  evidence: Record<string, unknown>,
48
59
  ): { status: string; callType: string } {
49
60
  const callType = String(call.call_type);
61
+ const skeleton = parseEventSkeletonFact(call.event_skeleton_json);
62
+ const environment = resolveEventEnvironment(
63
+ call.event_skeleton_json,
64
+ call.environmentDeclarationsJson,
65
+ variables,
66
+ );
50
67
  const event = linkEventTemplate(
51
- String(call.event_name_expr ?? ''), variables,
68
+ String(call.event_name_expr ?? ''), environment.variables,
52
69
  typeof call.unresolved_reason === 'string'
53
70
  ? call.unresolved_reason : undefined,
71
+ skeleton,
54
72
  );
55
73
  const edgeType = event.isDynamic
56
74
  ? 'DYNAMIC_EDGE_CANDIDATE'
57
75
  : callType === 'async_emit'
58
76
  ? 'HANDLER_EMITS_EVENT' : 'EVENT_CONSUMED_BY_HANDLER';
59
- const eventEvidence = event.substitution.placeholders.length > 0
60
- ? { ...evidence, eventTemplateResolution: event.substitution }
61
- : evidence;
77
+ const eventEvidence = {
78
+ ...evidence,
79
+ dispatchScope: 'workspace_event_name_only',
80
+ dispatchCertainty: environment.status === 'resolved'
81
+ ? 'environment_declaration_exact'
82
+ : event.isDynamic ? 'runtime_variables_required' : 'static_name_only',
83
+ ...(skeleton ? { eventSkeleton: skeleton } : {}),
84
+ ...(event.substitution.placeholders.length > 0
85
+ ? { eventTemplateResolution: event.substitution } : {}),
86
+ ...(environment.status === 'not_applicable' ? {} : {
87
+ eventEnvironmentResolution: {
88
+ status: environment.status,
89
+ reason: environment.reason,
90
+ provenance: environment.provenance,
91
+ },
92
+ }),
93
+ };
94
+ const unresolvedReason = environment.reason
95
+ ?? event.unresolvedReason;
62
96
  db.prepare(`INSERT INTO graph_edges(
63
97
  workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
64
98
  confidence,evidence_json,is_dynamic,unresolved_reason,generation
@@ -66,7 +100,7 @@ export function insertEventCallEdge(
66
100
  workspaceId, edgeType, event.status, 'call', String(call.id),
67
101
  event.targetKind, event.targetId, Number(call.confidence ?? 0.2),
68
102
  JSON.stringify(eventEvidence), event.isDynamic ? 1 : 0,
69
- event.unresolvedReason ?? null, generation,
103
+ unresolvedReason ?? null, generation,
70
104
  );
71
105
  return { status: event.status, callType };
72
106
  }