@saptools/service-flow 0.1.70 → 0.1.72

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 (55) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -5
  3. package/TECHNICAL-NOTE.md +13 -0
  4. package/dist/{chunk-GSLFY6J2.js → chunk-Z6D433R5.js} +8696 -6476
  5. package/dist/chunk-Z6D433R5.js.map +1 -0
  6. package/dist/cli.js +685 -142
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +53 -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 +371 -0
  13. package/src/cli/doctor.ts +4 -6
  14. package/src/cli.ts +1 -1
  15. package/src/db/call-fact-repository.ts +6 -3
  16. package/src/db/current-fact-semantics.ts +5 -5
  17. package/src/db/event-fact-semantics.ts +347 -0
  18. package/src/db/event-surface-invalidation.ts +38 -0
  19. package/src/db/fact-json-inventory.ts +16 -0
  20. package/src/db/migrations.ts +12 -1
  21. package/src/db/package-target-invalidation.ts +79 -0
  22. package/src/db/repositories.ts +28 -2
  23. package/src/db/schema-structure.ts +41 -1
  24. package/src/db/schema.ts +6 -2
  25. package/src/indexer/repository-indexer.ts +45 -6
  26. package/src/linker/cross-repo-linker.ts +25 -3
  27. package/src/linker/event-environment-link.ts +211 -0
  28. package/src/linker/event-shape-candidate-linker.ts +161 -0
  29. package/src/linker/event-subscription-handler-linker.ts +123 -19
  30. package/src/linker/event-template-link.ts +40 -6
  31. package/src/linker/package-event-constant-resolver.ts +298 -0
  32. package/src/output/table-output.ts +13 -1
  33. package/src/parsers/decorator-parser.ts +9 -53
  34. package/src/parsers/environment-declarations.ts +327 -0
  35. package/src/parsers/event-call-analysis.ts +242 -0
  36. package/src/parsers/event-environment-reference.ts +231 -0
  37. package/src/parsers/event-loop-registration.ts +132 -0
  38. package/src/parsers/event-name-import-resolution.ts +243 -0
  39. package/src/parsers/event-receiver-analysis.ts +404 -0
  40. package/src/parsers/event-subscription-facts.ts +4 -0
  41. package/src/parsers/generated-constants-parser.ts +80 -14
  42. package/src/parsers/outbound-call-classifier.ts +27 -124
  43. package/src/parsers/outbound-call-parser.ts +13 -1
  44. package/src/parsers/outbound-expression-analysis.ts +2 -2
  45. package/src/parsers/stable-local-value.ts +30 -9
  46. package/src/parsers/string-constant-lookups.ts +358 -0
  47. package/src/trace/event-runtime-resolution.ts +24 -3
  48. package/src/trace/event-shape-candidate-trace.ts +172 -0
  49. package/src/trace/event-subscriber-traversal.ts +19 -7
  50. package/src/trace/evidence.ts +7 -0
  51. package/src/trace/trace-scope-execution.ts +21 -29
  52. package/src/types.ts +17 -1
  53. package/src/utils/event-skeleton.ts +207 -0
  54. package/src/version.ts +1 -1
  55. package/dist/chunk-GSLFY6J2.js.map +0 -1
@@ -0,0 +1,161 @@
1
+ import type { Db } from '../db/connection.js';
2
+ import { parseEventSkeletonFact } from '../utils/event-skeleton.js';
3
+
4
+ export interface EventShapeCandidateLinkSummary {
5
+ edgeCount: number;
6
+ }
7
+
8
+ interface EventShapeRow extends Record<string, unknown> {
9
+ id: number;
10
+ repoId: number;
11
+ repoName: string;
12
+ signature: string;
13
+ skeletonJson: string;
14
+ }
15
+
16
+ interface SubscriberAssociation extends Record<string, unknown> {
17
+ graphEdgeId: number;
18
+ subscribeCallId: number;
19
+ targetKind: string;
20
+ targetId: string;
21
+ status: string;
22
+ evidenceJson: string;
23
+ }
24
+
25
+ function eventRows(
26
+ db: Db,
27
+ workspaceId: number,
28
+ callType: 'async_emit' | 'async_subscribe',
29
+ ): EventShapeRow[] {
30
+ return db.prepare(`SELECT c.id,c.repo_id repoId,r.name repoName,
31
+ c.event_skeleton_signature signature,
32
+ c.event_skeleton_json skeletonJson
33
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
34
+ WHERE r.workspace_id=? AND c.call_type=?
35
+ AND c.event_skeleton_signature IS NOT NULL
36
+ AND c.event_skeleton_json IS NOT NULL
37
+ ORDER BY c.event_skeleton_signature COLLATE BINARY,
38
+ r.name COLLATE BINARY,r.id,c.source_file COLLATE BINARY,
39
+ c.call_site_start_offset,c.call_site_end_offset,c.id`).all(
40
+ workspaceId, callType,
41
+ ) as unknown as EventShapeRow[];
42
+ }
43
+
44
+ function associations(
45
+ db: Db,
46
+ workspaceId: number,
47
+ generation: number,
48
+ ): SubscriberAssociation[] {
49
+ return db.prepare(`SELECT id graphEdgeId,
50
+ CAST(json_extract(evidence_json,'$.subscribeCallId') AS INTEGER)
51
+ subscribeCallId,
52
+ to_kind targetKind,to_id targetId,status,evidence_json evidenceJson
53
+ FROM graph_edges
54
+ WHERE workspace_id=? AND generation=?
55
+ AND edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
56
+ AND to_kind='symbol'
57
+ ORDER BY subscribeCallId,id`).all(
58
+ workspaceId, generation,
59
+ ) as unknown as SubscriberAssociation[];
60
+ }
61
+
62
+ function parsedEvidence(value: string): Record<string, unknown> {
63
+ try {
64
+ const parsed: unknown = JSON.parse(value);
65
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
66
+ ? parsed as Record<string, unknown> : {};
67
+ } catch {
68
+ return {};
69
+ }
70
+ }
71
+
72
+ function candidateEligible(
73
+ emit: EventShapeRow,
74
+ subscribe: EventShapeRow,
75
+ ): boolean {
76
+ if (emit.signature !== subscribe.signature) return false;
77
+ const left = parseEventSkeletonFact(emit.skeletonJson);
78
+ const right = parseEventSkeletonFact(subscribe.skeletonJson);
79
+ return Boolean(left?.candidateEligible && right?.candidateEligible
80
+ && left.holeCount === right.holeCount
81
+ && JSON.stringify(left.literalSpans) === JSON.stringify(right.literalSpans));
82
+ }
83
+
84
+ function candidateEvidence(
85
+ emit: EventShapeRow,
86
+ subscribe: EventShapeRow,
87
+ association: SubscriberAssociation,
88
+ ): Record<string, unknown> {
89
+ const evidence = parsedEvidence(association.evidenceJson);
90
+ return {
91
+ publishCallId: emit.id,
92
+ subscribeCallId: subscribe.id,
93
+ eventSkeletonSignature: emit.signature,
94
+ dispatchScope: 'workspace_event_name_only',
95
+ dispatchCertainty: 'skeleton_equivalent',
96
+ subscriptionRepositoryId: subscribe.repoId,
97
+ subscriptionRepositoryName: subscribe.repoName,
98
+ subscriptionConsumerRepositoryId:
99
+ evidence.subscriptionConsumerRepositoryId,
100
+ subscriptionConsumerRepositoryName:
101
+ evidence.subscriptionConsumerRepositoryName,
102
+ handlerSymbolId: Number(association.targetId),
103
+ associationGraphEdgeId: association.graphEdgeId,
104
+ };
105
+ }
106
+
107
+ function insertCandidate(
108
+ db: Db,
109
+ workspaceId: number,
110
+ generation: number,
111
+ emit: EventShapeRow,
112
+ subscribe: EventShapeRow,
113
+ association: SubscriberAssociation,
114
+ ): void {
115
+ db.prepare(`INSERT INTO graph_edges(
116
+ workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
117
+ confidence,evidence_json,is_dynamic,unresolved_reason,generation
118
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(
119
+ workspaceId,
120
+ 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER',
121
+ 'dynamic',
122
+ 'call',
123
+ String(emit.id),
124
+ association.targetKind,
125
+ association.targetId,
126
+ 0.3,
127
+ JSON.stringify(candidateEvidence(
128
+ emit, subscribe, association,
129
+ )),
130
+ 1,
131
+ 'event_skeleton_equivalent_non_authoritative',
132
+ generation,
133
+ );
134
+ }
135
+
136
+ export function linkEventShapeCandidates(
137
+ db: Db,
138
+ workspaceId: number,
139
+ generation: number,
140
+ ): EventShapeCandidateLinkSummary {
141
+ const emits = eventRows(db, workspaceId, 'async_emit');
142
+ const subscriptions = eventRows(db, workspaceId, 'async_subscribe');
143
+ const bySubscription = new Map<number, SubscriberAssociation[]>();
144
+ for (const association of associations(db, workspaceId, generation))
145
+ bySubscription.set(association.subscribeCallId, [
146
+ ...(bySubscription.get(association.subscribeCallId) ?? []),
147
+ association,
148
+ ]);
149
+ let edgeCount = 0;
150
+ for (const emit of emits)
151
+ for (const subscription of subscriptions) {
152
+ if (!candidateEligible(emit, subscription)) continue;
153
+ for (const association of bySubscription.get(subscription.id) ?? []) {
154
+ insertCandidate(
155
+ db, workspaceId, generation, emit, subscription, association,
156
+ );
157
+ edgeCount += 1;
158
+ }
159
+ }
160
+ return { edgeCount };
161
+ }
@@ -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,9 @@ 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;
28
36
  }
29
37
 
30
38
  interface HandlerCallRow {
@@ -63,7 +71,10 @@ function subscriptionRows(db: Db, workspaceId: number): SubscriptionRow[] {
63
71
  c.source_symbol_id sourceSymbolId,c.event_name_expr eventName,
64
72
  c.source_file sourceFile,c.source_line sourceLine,
65
73
  c.call_site_start_offset startOffset,c.call_site_end_offset endOffset,
66
- c.confidence,c.unresolved_reason unresolvedReason
74
+ c.confidence,c.unresolved_reason unresolvedReason,
75
+ c.event_skeleton_json eventSkeletonJson,
76
+ r.package_name packageName,
77
+ r.environment_declarations_json environmentJson
67
78
  FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
68
79
  WHERE r.workspace_id=? AND c.call_type='async_subscribe'
69
80
  AND json_extract(c.evidence_json,'$.handlerReferenceStatus')
@@ -208,11 +219,18 @@ function evidenceFor(
208
219
  subscription: SubscriptionRow,
209
220
  association: HandlerAssociation,
210
221
  event: LinkedEventTemplate,
222
+ environment?: SubscriptionEnvironmentTarget,
211
223
  ): Record<string, unknown> {
212
224
  const call: Partial<HandlerCallRow> = association.call ?? {};
213
225
  const symbolCallReason = boundedSymbolCallReason(call.unresolvedReason);
226
+ const environmentAmbiguous = environment?.resolution.status === 'ambiguous'
227
+ || Number(environment?.collisionCount ?? 1) > 1;
228
+ const resolutionStatus = event.isDynamic
229
+ ? 'unresolved'
230
+ : environmentAmbiguous ? 'ambiguous' : association.status;
214
231
  return {
215
232
  eventName: subscription.eventName,
233
+ ...(eventSkeletonEvidence(subscription.eventSkeletonJson)),
216
234
  ...(event.substitution.placeholders.length > 0 ? {
217
235
  effectiveEventName: event.targetId,
218
236
  eventTemplateResolution: event.substitution,
@@ -226,6 +244,18 @@ function evidenceFor(
226
244
  factOrigin: association.factOrigin ?? call.factOrigin,
227
245
  repositoryId: subscription.repoId,
228
246
  repositoryName: subscription.repoName,
247
+ subscriptionConsumerRepositoryId: environment?.consumerRepoId,
248
+ subscriptionConsumerRepositoryName: environment?.consumerRepoName,
249
+ dispatchCertainty: environment?.resolution.status === 'resolved'
250
+ ? 'environment_declaration_exact' : 'static_name_only',
251
+ ...(environment?.resolution.status === 'not_applicable' ? {} : {
252
+ eventEnvironmentResolution: {
253
+ status: environment?.resolution.status,
254
+ reason: environment?.resolution.reason,
255
+ provenance: environment?.resolution.provenance,
256
+ collisionCount: environment?.collisionCount,
257
+ },
258
+ }),
229
259
  sourceFile: subscription.sourceFile,
230
260
  sourceLine: subscription.sourceLine,
231
261
  callSiteStartOffset: subscription.startOffset,
@@ -237,14 +267,23 @@ function evidenceFor(
237
267
  associationStatus: association.status,
238
268
  symbolCallResolutionStatus:
239
269
  association.symbolCallResolutionStatus ?? call.status,
240
- resolutionStatus: association.status,
270
+ resolutionStatus,
241
271
  resolutionStrategy: call.strategy,
242
272
  candidateCount: call.candidateCount,
243
- reasonCode: association.reasonCode,
273
+ reasonCode: environmentAmbiguous
274
+ ? environment?.resolution.reason ?? 'event_environment_value_collision'
275
+ : association.reasonCode,
244
276
  ...symbolCallReason,
245
277
  };
246
278
  }
247
279
 
280
+ function eventSkeletonEvidence(
281
+ value: string | null | undefined,
282
+ ): Record<string, unknown> {
283
+ const skeleton = parseEventSkeletonFact(value);
284
+ return skeleton ? { eventSkeleton: skeleton } : {};
285
+ }
286
+
248
287
  function boundedSymbolCallReason(
249
288
  reason: string | null | undefined,
250
289
  ): Record<string, unknown> {
@@ -264,10 +303,20 @@ function insertAssociationEdge(
264
303
  subscription: SubscriptionRow,
265
304
  association: HandlerAssociation,
266
305
  event: LinkedEventTemplate,
306
+ environment?: SubscriptionEnvironmentTarget,
267
307
  ): void {
268
- const status = event.isDynamic ? 'unresolved' : association.status;
308
+ const environmentAmbiguous = environment?.resolution.status === 'ambiguous'
309
+ || Number(environment?.collisionCount ?? 1) > 1;
310
+ const status = event.isDynamic
311
+ ? 'unresolved'
312
+ : environmentAmbiguous ? 'ambiguous' : association.status;
269
313
  const reason = event.isDynamic
270
- ? 'event_template_variables_missing'
314
+ ? event.substitution.missing.length > 0
315
+ ? 'event_template_variables_missing'
316
+ : event.unresolvedReason ?? 'event_name_unsupported_constant_expression'
317
+ : environmentAmbiguous
318
+ ? environment?.resolution.reason
319
+ ?? 'event_environment_value_collision'
271
320
  : association.reasonCode ?? association.call?.unresolvedReason ?? null;
272
321
  db.prepare(`INSERT INTO graph_edges(
273
322
  workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
@@ -281,13 +330,68 @@ function insertAssociationEdge(
281
330
  association.toKind,
282
331
  association.toId,
283
332
  association.call?.confidence ?? subscription.confidence,
284
- JSON.stringify(evidenceFor(subscription, association, event)),
333
+ JSON.stringify(evidenceFor(
334
+ subscription, association, event, environment,
335
+ )),
285
336
  event.isDynamic ? 1 : 0,
286
337
  reason,
287
338
  generation,
288
339
  );
289
340
  }
290
341
 
342
+ function linkedSubscriptionEvents(
343
+ db: Db,
344
+ workspaceId: number,
345
+ subscription: SubscriptionRow,
346
+ variables: Record<string, string>,
347
+ ): Array<{
348
+ event: LinkedEventTemplate;
349
+ environment: SubscriptionEnvironmentTarget;
350
+ }> {
351
+ const skeleton = parseEventSkeletonFact(subscription.eventSkeletonJson);
352
+ return subscriptionEnvironmentTargets(
353
+ db,
354
+ workspaceId,
355
+ subscription.packageName ?? undefined,
356
+ subscription.eventSkeletonJson,
357
+ subscription.environmentJson,
358
+ variables,
359
+ ).map((environment) => ({
360
+ environment,
361
+ event: linkEventTemplate(
362
+ subscription.eventName,
363
+ environment.resolution.variables,
364
+ subscription.unresolvedReason ?? undefined,
365
+ skeleton,
366
+ ),
367
+ }));
368
+ }
369
+
370
+ function targetStatus(
371
+ association: HandlerAssociation,
372
+ event: LinkedEventTemplate,
373
+ environment: SubscriptionEnvironmentTarget,
374
+ ): 'resolved' | 'ambiguous' | 'unresolved' {
375
+ if (event.isDynamic || association.status === 'unresolved')
376
+ return 'unresolved';
377
+ if (association.status === 'ambiguous'
378
+ || environment.resolution.status === 'ambiguous'
379
+ || environment.collisionCount > 1) return 'ambiguous';
380
+ return 'resolved';
381
+ }
382
+
383
+ function incrementSummary(
384
+ summary: SubscriptionHandlerLinkSummary,
385
+ status: 'resolved' | 'ambiguous' | 'unresolved',
386
+ missing: boolean,
387
+ ): void {
388
+ summary.edgeCount += 1;
389
+ summary.resolvedCount += status === 'resolved' ? 1 : 0;
390
+ summary.ambiguousCount += status === 'ambiguous' ? 1 : 0;
391
+ summary.unresolvedCount += status === 'unresolved' ? 1 : 0;
392
+ summary.missingAssociationCount += missing ? 1 : 0;
393
+ }
394
+
291
395
  export function linkEventSubscriptionHandlers(
292
396
  db: Db,
293
397
  workspaceId: number,
@@ -302,22 +406,22 @@ export function linkEventSubscriptionHandlers(
302
406
  missingAssociationCount: 0,
303
407
  };
304
408
  for (const subscription of subscriptionRows(db, workspaceId)) {
305
- const event = linkEventTemplate(
306
- subscription.eventName, variables,
307
- subscription.unresolvedReason ?? undefined,
308
- );
309
409
  const association = associationFor(
310
410
  subscription, roleSiteRows(db, subscription),
311
411
  );
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;
412
+ for (const target of linkedSubscriptionEvents(
413
+ db, workspaceId, subscription, variables,
414
+ )) {
415
+ insertAssociationEdge(
416
+ db, workspaceId, generation, subscription, association,
417
+ target.event, target.environment,
418
+ );
419
+ incrementSummary(
420
+ summary,
421
+ targetStatus(association, target.event, target.environment),
422
+ association.missing,
423
+ );
424
+ }
321
425
  }
322
426
  return summary;
323
427
  }
@@ -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
  }