@saptools/service-flow 0.1.64 → 0.1.66

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 (42) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +67 -11
  3. package/TECHNICAL-NOTE.md +41 -1
  4. package/dist/{chunk-TBH33OYC.js → chunk-TOVX4WYH.js} +3765 -643
  5. package/dist/chunk-TOVX4WYH.js.map +1 -0
  6. package/dist/cli.js +231 -292
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +196 -1
  9. package/dist/index.js +7 -3
  10. package/dist/index.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/cli/002-doctor-lifecycle.ts +66 -0
  13. package/src/cli/doctor.ts +14 -27
  14. package/src/cli.ts +130 -102
  15. package/src/db/000-call-fact-repository.ts +537 -0
  16. package/src/db/001-fact-lifecycle.ts +111 -0
  17. package/src/db/migrations.ts +16 -1
  18. package/src/db/repositories.ts +1 -315
  19. package/src/db/schema.ts +4 -2
  20. package/src/index.ts +22 -0
  21. package/src/linker/004-event-subscription-handler-linker.ts +300 -0
  22. package/src/linker/cross-repo-linker.ts +23 -2
  23. package/src/output/001-compact-json-output.ts +6 -0
  24. package/src/parsers/imported-wrapper-parser.ts +4 -0
  25. package/src/parsers/outbound-call-parser.ts +11 -1
  26. package/src/parsers/symbol-parser.ts +110 -2
  27. package/src/trace/010-traversal-scope.ts +188 -0
  28. package/src/trace/011-event-subscriber-traversal.ts +366 -0
  29. package/src/trace/012-trace-graph-lookups.ts +74 -0
  30. package/src/trace/013-trace-root-scopes.ts +279 -0
  31. package/src/trace/014-compact-contract.ts +347 -0
  32. package/src/trace/015-trace-edge-recorder.ts +336 -0
  33. package/src/trace/016-compact-projector.ts +697 -0
  34. package/src/trace/017-trace-context.ts +378 -0
  35. package/src/trace/018-compact-trace.ts +87 -0
  36. package/src/trace/019-trace-edge-semantics.ts +328 -0
  37. package/src/trace/020-compact-field-projection.ts +387 -0
  38. package/src/trace/dynamic-branches.ts +35 -13
  39. package/src/trace/trace-engine.ts +271 -245
  40. package/src/types.ts +10 -0
  41. package/src/version.ts +1 -1
  42. package/dist/chunk-TBH33OYC.js.map +0 -1
@@ -0,0 +1,300 @@
1
+ import type { Db } from '../db/connection.js';
2
+
3
+ export interface SubscriptionHandlerLinkSummary {
4
+ edgeCount: number;
5
+ resolvedCount: number;
6
+ ambiguousCount: number;
7
+ unresolvedCount: number;
8
+ missingAssociationCount: number;
9
+ }
10
+
11
+ interface SubscriptionRow {
12
+ id: number;
13
+ workspaceId: number;
14
+ repoId: number;
15
+ repoName: string;
16
+ sourceSymbolId?: number | null;
17
+ eventName: string;
18
+ sourceFile: string;
19
+ sourceLine: number;
20
+ startOffset?: number | null;
21
+ endOffset?: number | null;
22
+ confidence: number;
23
+ }
24
+
25
+ interface HandlerCallRow {
26
+ id: number;
27
+ callerSymbolId: number;
28
+ calleeSymbolId?: number | null;
29
+ status: string;
30
+ unresolvedReason?: string | null;
31
+ confidence: number;
32
+ sourceLine: number;
33
+ factOrigin?: string | null;
34
+ wrapperFunction?: string | null;
35
+ strategy?: string | null;
36
+ candidateCount?: number | null;
37
+ targetSourceFile?: string | null;
38
+ targetSourceLine?: number | null;
39
+ targetWorkspaceId?: number | null;
40
+ }
41
+
42
+ interface HandlerAssociation {
43
+ status: 'resolved' | 'ambiguous' | 'unresolved';
44
+ toKind: 'symbol' | 'symbol_reference' | 'subscription_handler';
45
+ toId: string;
46
+ reasonCode?: string;
47
+ call?: HandlerCallRow;
48
+ matchCount: number;
49
+ missing: boolean;
50
+ factOrigin?: string;
51
+ symbolCallResolutionStatus?: string;
52
+ }
53
+
54
+ const symbolCallReasonLimit = 512;
55
+
56
+ function subscriptionRows(db: Db, workspaceId: number): SubscriptionRow[] {
57
+ return db.prepare(`SELECT c.id,r.workspace_id workspaceId,c.repo_id repoId,r.name repoName,
58
+ c.source_symbol_id sourceSymbolId,c.event_name_expr eventName,
59
+ c.source_file sourceFile,c.source_line sourceLine,
60
+ c.call_site_start_offset startOffset,c.call_site_end_offset endOffset,
61
+ c.confidence
62
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
63
+ WHERE r.workspace_id=? AND c.call_type='async_subscribe'
64
+ ORDER BY r.name COLLATE BINARY,r.id,c.source_file COLLATE BINARY,
65
+ c.call_site_start_offset,c.call_site_end_offset,c.id`).all(
66
+ workspaceId,
67
+ ) as unknown as SubscriptionRow[];
68
+ }
69
+
70
+ function roleSiteRows(db: Db, subscription: SubscriptionRow): HandlerCallRow[] {
71
+ if (typeof subscription.startOffset !== 'number'
72
+ || typeof subscription.endOffset !== 'number') return [];
73
+ return db.prepare(`SELECT sc.id,sc.caller_symbol_id callerSymbolId,
74
+ sc.callee_symbol_id calleeSymbolId,sc.status,
75
+ sc.unresolved_reason unresolvedReason,sc.confidence,sc.source_line sourceLine,
76
+ json_extract(sc.evidence_json,'$.factOrigin') factOrigin,
77
+ json_extract(sc.evidence_json,'$.wrapperFunction') wrapperFunction,
78
+ json_extract(sc.evidence_json,'$.candidateStrategy') strategy,
79
+ json_extract(sc.evidence_json,'$.candidateCount') candidateCount,
80
+ target.source_file targetSourceFile,target.start_line targetSourceLine,
81
+ target_repo.workspace_id targetWorkspaceId
82
+ FROM symbol_calls sc LEFT JOIN symbols target ON target.id=sc.callee_symbol_id
83
+ LEFT JOIN repositories target_repo ON target_repo.id=target.repo_id
84
+ WHERE sc.repo_id=? AND sc.source_file=?
85
+ AND sc.call_site_start_offset=? AND sc.call_site_end_offset=?
86
+ AND sc.call_role='event_subscribe_handler'
87
+ ORDER BY sc.id`).all(
88
+ subscription.repoId,
89
+ subscription.sourceFile,
90
+ subscription.startOffset,
91
+ subscription.endOffset,
92
+ ) as unknown as HandlerCallRow[];
93
+ }
94
+
95
+ function invalidSpan(subscription: SubscriptionRow): boolean {
96
+ return typeof subscription.startOffset !== 'number'
97
+ || typeof subscription.endOffset !== 'number'
98
+ || subscription.startOffset < 0
99
+ || subscription.endOffset <= subscription.startOffset;
100
+ }
101
+
102
+ function associationFor(
103
+ subscription: SubscriptionRow,
104
+ matches: HandlerCallRow[],
105
+ ): HandlerAssociation {
106
+ if (invalidSpan(subscription)) return missingAssociation(
107
+ subscription.id, matches.length, 'subscription_call_span_missing',
108
+ );
109
+ if (matches.length === 0) return missingAssociation(
110
+ subscription.id, 0, 'subscription_handler_role_site_missing',
111
+ );
112
+ if (matches.length > 1) return ambiguousRoleSiteAssociation(
113
+ subscription.id, matches,
114
+ );
115
+ const call = matches[0];
116
+ return call
117
+ ? singleCallAssociation(subscription, call)
118
+ : missingAssociation(
119
+ subscription.id, 0, 'subscription_handler_role_site_missing',
120
+ );
121
+ }
122
+
123
+ function ambiguousRoleSiteAssociation(
124
+ subscriptionId: number,
125
+ matches: HandlerCallRow[],
126
+ ): HandlerAssociation {
127
+ return {
128
+ status: 'ambiguous', toKind: 'subscription_handler',
129
+ toId: String(subscriptionId), reasonCode: 'multiple_handler_role_site_matches',
130
+ matchCount: matches.length, missing: false,
131
+ factOrigin: agreedOrMixed(matches.map((match) => match.factOrigin)),
132
+ symbolCallResolutionStatus: agreedOrMixed(
133
+ matches.map((match) => match.status),
134
+ ),
135
+ };
136
+ }
137
+
138
+ function singleCallAssociation(
139
+ subscription: SubscriptionRow,
140
+ call: HandlerCallRow,
141
+ ): HandlerAssociation {
142
+ const mismatch = associationMismatch(subscription, call);
143
+ if (mismatch) return missingAssociation(subscription.id, 1, mismatch, call);
144
+ return handlerReferenceAssociation(call);
145
+ }
146
+
147
+ function associationMismatch(
148
+ subscription: SubscriptionRow,
149
+ call: HandlerCallRow,
150
+ ): string | undefined {
151
+ if (subscription.sourceSymbolId != null
152
+ && call.callerSymbolId !== subscription.sourceSymbolId)
153
+ return 'subscription_handler_caller_mismatch';
154
+ if (call.sourceLine !== subscription.sourceLine)
155
+ return 'subscription_handler_source_line_mismatch';
156
+ if (call.targetWorkspaceId != null
157
+ && call.targetWorkspaceId !== subscription.workspaceId)
158
+ return 'subscription_handler_target_workspace_mismatch';
159
+ return undefined;
160
+ }
161
+
162
+ function handlerReferenceAssociation(call: HandlerCallRow): HandlerAssociation {
163
+ if (call.status === 'resolved' && typeof call.calleeSymbolId === 'number')
164
+ return { status: 'resolved', toKind: 'symbol', toId: String(call.calleeSymbolId), call, matchCount: 1, missing: false };
165
+ if (call.status === 'ambiguous') return {
166
+ status: 'ambiguous', toKind: 'symbol_reference', toId: String(call.id),
167
+ reasonCode: 'subscription_handler_reference_ambiguous', call,
168
+ matchCount: 1, missing: false,
169
+ };
170
+ return {
171
+ status: 'unresolved', toKind: 'symbol_reference', toId: String(call.id),
172
+ reasonCode: call.status === 'resolved'
173
+ ? 'resolved_handler_symbol_missing'
174
+ : 'subscription_handler_reference_unresolved',
175
+ call, matchCount: 1, missing: false,
176
+ };
177
+ }
178
+
179
+ function agreedOrMixed(
180
+ values: Array<string | null | undefined>,
181
+ ): string | undefined {
182
+ const distinct = new Set(values.map((value) => value ?? 'missing'));
183
+ if (distinct.size > 1) return 'mixed';
184
+ const value = distinct.values().next().value;
185
+ return value === 'missing' ? undefined : value;
186
+ }
187
+
188
+ function missingAssociation(
189
+ subscriptionId: number,
190
+ matchCount: number,
191
+ reasonCode: string,
192
+ call?: HandlerCallRow,
193
+ ): HandlerAssociation {
194
+ return {
195
+ status: 'unresolved', toKind: 'subscription_handler',
196
+ toId: String(subscriptionId), reasonCode, call, matchCount, missing: true,
197
+ };
198
+ }
199
+
200
+ function evidenceFor(
201
+ subscription: SubscriptionRow,
202
+ association: HandlerAssociation,
203
+ ): Record<string, unknown> {
204
+ const call: Partial<HandlerCallRow> = association.call ?? {};
205
+ const symbolCallReason = boundedSymbolCallReason(call.unresolvedReason);
206
+ return {
207
+ eventName: subscription.eventName,
208
+ associationBasis: 'exact_subscription_call_span',
209
+ dispatchScope: 'workspace_event_name_only',
210
+ subscribeCallId: subscription.id,
211
+ symbolCallId: call.id,
212
+ roleSiteMatchCount: association.matchCount,
213
+ callRole: association.matchCount > 0 ? 'event_subscribe_handler' : undefined,
214
+ factOrigin: association.factOrigin ?? call.factOrigin,
215
+ repositoryId: subscription.repoId,
216
+ repositoryName: subscription.repoName,
217
+ sourceFile: subscription.sourceFile,
218
+ sourceLine: subscription.sourceLine,
219
+ callSiteStartOffset: subscription.startOffset,
220
+ callSiteEndOffset: subscription.endOffset,
221
+ handlerSymbolId: call.calleeSymbolId,
222
+ handlerSourceFile: call.targetSourceFile,
223
+ handlerSourceLine: call.targetSourceLine,
224
+ wrapperFunction: call.wrapperFunction,
225
+ associationStatus: association.status,
226
+ symbolCallResolutionStatus:
227
+ association.symbolCallResolutionStatus ?? call.status,
228
+ resolutionStatus: association.status,
229
+ resolutionStrategy: call.strategy,
230
+ candidateCount: call.candidateCount,
231
+ reasonCode: association.reasonCode,
232
+ ...symbolCallReason,
233
+ };
234
+ }
235
+
236
+ function boundedSymbolCallReason(
237
+ reason: string | null | undefined,
238
+ ): Record<string, unknown> {
239
+ if (!reason) return {};
240
+ const value = reason.slice(0, symbolCallReasonLimit);
241
+ return {
242
+ symbolCallUnresolvedReason: value,
243
+ omittedSymbolCallUnresolvedReasonCharacterCount:
244
+ Math.max(0, reason.length - value.length),
245
+ };
246
+ }
247
+
248
+ function insertAssociationEdge(
249
+ db: Db,
250
+ workspaceId: number,
251
+ generation: number,
252
+ subscription: SubscriptionRow,
253
+ association: HandlerAssociation,
254
+ ): void {
255
+ db.prepare(`INSERT INTO graph_edges(
256
+ workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
257
+ confidence,evidence_json,is_dynamic,unresolved_reason,generation
258
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(
259
+ workspaceId,
260
+ 'EVENT_SUBSCRIPTION_HANDLED_BY',
261
+ association.status,
262
+ 'event',
263
+ subscription.eventName,
264
+ association.toKind,
265
+ association.toId,
266
+ association.call?.confidence ?? subscription.confidence,
267
+ JSON.stringify(evidenceFor(subscription, association)),
268
+ 0,
269
+ association.reasonCode ?? association.call?.unresolvedReason ?? null,
270
+ generation,
271
+ );
272
+ }
273
+
274
+ export function linkEventSubscriptionHandlers(
275
+ db: Db,
276
+ workspaceId: number,
277
+ generation: number,
278
+ ): SubscriptionHandlerLinkSummary {
279
+ const summary: SubscriptionHandlerLinkSummary = {
280
+ edgeCount: 0,
281
+ resolvedCount: 0,
282
+ ambiguousCount: 0,
283
+ unresolvedCount: 0,
284
+ missingAssociationCount: 0,
285
+ };
286
+ for (const subscription of subscriptionRows(db, workspaceId)) {
287
+ const association = associationFor(
288
+ subscription, roleSiteRows(db, subscription),
289
+ );
290
+ insertAssociationEdge(
291
+ db, workspaceId, generation, subscription, association,
292
+ );
293
+ summary.edgeCount += 1;
294
+ summary.resolvedCount += association.status === 'resolved' ? 1 : 0;
295
+ summary.ambiguousCount += association.status === 'ambiguous' ? 1 : 0;
296
+ summary.unresolvedCount += association.status === 'unresolved' ? 1 : 0;
297
+ summary.missingAssociationCount += association.missing ? 1 : 0;
298
+ }
299
+ return summary;
300
+ }
@@ -13,6 +13,8 @@ import {
13
13
  objectValue,
14
14
  } from './002-call-evidence.js';
15
15
  import { linkPackageImportSymbolCalls } from './003-package-import-symbol-resolver.js';
16
+ import { linkEventSubscriptionHandlers } from './004-event-subscription-handler-linker.js';
17
+ import { assertWorkspaceLinkable } from '../db/001-fact-lifecycle.js';
16
18
  export interface LinkWorkspaceResult {
17
19
  edgeCount: number;
18
20
  unresolvedCount: number;
@@ -27,24 +29,43 @@ export interface LinkWorkspaceResult {
27
29
  implementationResolvedCount: number;
28
30
  implementationAmbiguousCount: number;
29
31
  implementationUnresolvedCount: number;
32
+ subscriptionHandlerResolvedCount: number;
33
+ subscriptionHandlerAmbiguousCount: number;
34
+ subscriptionHandlerUnresolvedCount: number;
35
+ subscriptionHandlerMissingAssociationCount: number;
30
36
  }
31
37
  export function linkWorkspace(db: Db, workspaceId: number, vars: Record<string, string> = {}): LinkWorkspaceResult {
32
38
  return db.transaction(() => {
39
+ assertWorkspaceLinkable(db, workspaceId);
33
40
  const generation = nextGraphGeneration(db, workspaceId);
34
41
  db.prepare('DELETE FROM graph_edges WHERE workspace_id=?').run(workspaceId);
35
42
  const deps = linkHelperPackages(db, workspaceId, generation);
36
43
  linkPackageImportSymbolCalls(db, workspaceId);
44
+ const subscriptions = linkEventSubscriptionHandlers(
45
+ db, workspaceId, generation,
46
+ );
37
47
  const impl = linkCanonicalImplementations(db, workspaceId, generation);
38
48
  const callSummary = linkCalls(db, workspaceId, vars, generation);
39
49
  db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
40
- return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount };
50
+ return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount + subscriptions.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount, subscriptionHandlerResolvedCount: subscriptions.resolvedCount, subscriptionHandlerAmbiguousCount: subscriptions.ambiguousCount, subscriptionHandlerUnresolvedCount: subscriptions.unresolvedCount, subscriptionHandlerMissingAssociationCount: subscriptions.missingAssociationCount };
41
51
  });
42
52
  }
43
53
  function nextGraphGeneration(db: Db, workspaceId: number): number {
44
54
  const row = db.prepare('SELECT COALESCE(MAX(graph_generation),0) generation FROM repositories WHERE workspace_id=?').get(workspaceId) as { generation?: number } | undefined;
45
55
  return Number(row?.generation ?? 0) + 1;
46
56
  }
47
- function linkCalls(db: Db, workspaceId: number, vars: Record<string, string>, generation: number): Omit<LinkWorkspaceResult, 'dependencyResolvedCount' | 'dependencyAmbiguousCount' | 'implementationResolvedCount' | 'implementationAmbiguousCount' | 'implementationUnresolvedCount'> {
57
+ type CallLinkSummary = Omit<LinkWorkspaceResult,
58
+ | 'dependencyResolvedCount'
59
+ | 'dependencyAmbiguousCount'
60
+ | 'implementationResolvedCount'
61
+ | 'implementationAmbiguousCount'
62
+ | 'implementationUnresolvedCount'
63
+ | 'subscriptionHandlerResolvedCount'
64
+ | 'subscriptionHandlerAmbiguousCount'
65
+ | 'subscriptionHandlerUnresolvedCount'
66
+ | 'subscriptionHandlerMissingAssociationCount'>;
67
+
68
+ function linkCalls(db: Db, workspaceId: number, vars: Record<string, string>, generation: number): CallLinkSummary {
48
69
  let edgeCount = 0;
49
70
  let unresolvedCount = 0;
50
71
  let resolvedCount = 0;
@@ -0,0 +1,6 @@
1
+ import type { CompactGraphV1 } from '../trace/014-compact-contract.js';
2
+ import { redactValue } from '../utils/redaction.js';
3
+
4
+ export function renderCompactJson(value: CompactGraphV1): string {
5
+ return `${JSON.stringify(redactValue(value))}\n`;
6
+ }
@@ -168,10 +168,14 @@ function wrapperCallFact(
168
168
  payloadSummary: call.getText(source),
169
169
  sourceFile: normalizePath(filePath),
170
170
  sourceLine: lineOf(source, call),
171
+ callSiteStartOffset: call.getStart(source),
172
+ callSiteEndOffset: call.getEnd(),
171
173
  confidence: operationPathExpr ? 0.85 : 0.5,
172
174
  unresolvedReason,
173
175
  evidence: {
174
176
  parser: 'typescript_ast',
177
+ startOffset: call.getStart(source),
178
+ endOffset: call.getEnd(),
175
179
  classifier: importedWrapperClassifier(pathAnalysis.status),
176
180
  receiver: client.text,
177
181
  wrapperFunction: spec.chain[0],
@@ -348,7 +348,15 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
348
348
  const wrapperSpecs = collectWrapperSpecs(source);
349
349
  const wrapperInternalRanges = [...wrapperSpecs.values()].map((spec) => ({ start: spec.internalStart, end: spec.internalEnd }));
350
350
  const add = (node: ts.CallExpression, fact: Omit<OutboundCallFact, 'sourceFile' | 'sourceLine' | 'confidence'> & { confidence?: number }, extra?: Record<string, unknown>): void => {
351
- calls.push({ node, fact: { ...fact, sourceFile, sourceLine: lineOf(source.text, node.getStart(source)), confidence: fact.confidence ?? 0.8, evidence: parserEvidence(source, node, extra) } });
351
+ calls.push({ node, fact: {
352
+ ...fact,
353
+ sourceFile,
354
+ sourceLine: lineOf(source.text, node.getStart(source)),
355
+ callSiteStartOffset: node.getStart(source),
356
+ callSiteEndOffset: node.getEnd(),
357
+ confidence: fact.confidence ?? 0.8,
358
+ evidence: parserEvidence(source, node, extra),
359
+ } });
352
360
  };
353
361
  const visit = (node: ts.Node): void => {
354
362
  if (ts.isCallExpression(node)) {
@@ -505,6 +513,8 @@ function parseLocalServiceCalls(
505
513
  aliasChain: parsed.chain,
506
514
  sourceFile: normalizePath(filePath),
507
515
  sourceLine: lineOf(text, node.getStart(source)),
516
+ callSiteStartOffset: node.getStart(source),
517
+ callSiteEndOffset: node.getEnd(),
508
518
  confidence: 0.9,
509
519
  unresolvedReason: ['send', 'emit', 'publish', 'on'].includes(parsed.operation) ? 'transport_client_method' : undefined,
510
520
  evidence: parserEvidence(source, node, {
@@ -2,7 +2,10 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import ts from 'typescript';
4
4
  import type { ExecutableSymbolFact, SymbolCallFact } from '../types.js';
5
- import { containsSupportedOutboundCall } from './outbound-call-parser.js';
5
+ import {
6
+ classifyOutboundCallsInSource,
7
+ containsSupportedOutboundCall,
8
+ } from './outbound-call-parser.js';
6
9
  import type { RepositorySourceContext } from './ts-project.js';
7
10
  import { normalizePath } from '../utils/path-utils.js';
8
11
 
@@ -38,6 +41,76 @@ function exportDeclarations(source: ts.SourceFile): Map<string, string> {
38
41
  function isRelativeImport(value: string | undefined): boolean {
39
42
  return Boolean(value?.startsWith('.'));
40
43
  }
44
+ type HandlerReferenceRelation =
45
+ | 'relative_import'
46
+ | 'package_import'
47
+ | 'indexed_local_symbol'
48
+ | 'relative_import_namespace_member';
49
+ interface HandlerReferenceTarget {
50
+ calleeExpression: string;
51
+ calleeLocalName: string;
52
+ importSource?: string;
53
+ relation: HandlerReferenceRelation;
54
+ wrapperFunction?: string;
55
+ }
56
+ function directHandlerReferenceTarget(
57
+ expression: ts.Expression,
58
+ source: ts.SourceFile,
59
+ imports: Map<string, string>,
60
+ namespaceImports: Set<string>,
61
+ ): HandlerReferenceTarget | undefined {
62
+ if (ts.isIdentifier(expression)) {
63
+ const importSource = imports.get(expression.text);
64
+ return {
65
+ calleeExpression: expression.text,
66
+ calleeLocalName: expression.text,
67
+ importSource,
68
+ relation: importSource
69
+ ? isRelativeImport(importSource) ? 'relative_import' : 'package_import'
70
+ : 'indexed_local_symbol',
71
+ };
72
+ }
73
+ if (!ts.isPropertyAccessExpression(expression) || expression.questionDotToken
74
+ || !ts.isIdentifier(expression.expression) || !ts.isIdentifier(expression.name))
75
+ return undefined;
76
+ const objectName = expression.expression.text;
77
+ const memberName = expression.name.text;
78
+ const importSource = imports.get(objectName);
79
+ if (namespaceImports.has(objectName)) return {
80
+ calleeExpression: expression.getText(source),
81
+ calleeLocalName: memberName,
82
+ importSource,
83
+ relation: 'relative_import_namespace_member',
84
+ };
85
+ const qualifiedName = `${objectName}.${memberName}`;
86
+ return {
87
+ calleeExpression: qualifiedName,
88
+ calleeLocalName: qualifiedName,
89
+ importSource,
90
+ relation: importSource
91
+ ? isRelativeImport(importSource) ? 'relative_import' : 'package_import'
92
+ : 'indexed_local_symbol',
93
+ };
94
+ }
95
+ function handlerReferenceTarget(
96
+ expression: ts.Expression,
97
+ source: ts.SourceFile,
98
+ imports: Map<string, string>,
99
+ namespaceImports: Set<string>,
100
+ ): HandlerReferenceTarget | undefined {
101
+ const direct = directHandlerReferenceTarget(
102
+ expression, source, imports, namespaceImports,
103
+ );
104
+ if (direct) return direct;
105
+ if (!ts.isCallExpression(expression) || expression.questionDotToken
106
+ || expression.arguments.length !== 1) return undefined;
107
+ const inner = directHandlerReferenceTarget(
108
+ expression.arguments[0], source, imports, namespaceImports,
109
+ );
110
+ return inner
111
+ ? { ...inner, wrapperFunction: expression.expression.getText(source) }
112
+ : undefined;
113
+ }
41
114
  function isObjectFunction(node: ts.Node): boolean {
42
115
  return ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node);
43
116
  }
@@ -185,6 +258,11 @@ export async function parseExecutableSymbols(
185
258
  const calls: SymbolCallFact[] = [];
186
259
  const imports = new Map<string, string>();
187
260
  const namespaceImports = new Set<string>();
261
+ const eventSubscriptionOffsets = new Set(
262
+ classifyOutboundCallsInSource(source, sourceFile)
263
+ .filter((call) => call.fact.callType === 'async_subscribe')
264
+ .map((call) => `${call.node.getStart(source)}:${call.node.getEnd()}`),
265
+ );
188
266
  const exportNames = exportDeclarations(source);
189
267
  const objectExports = new Set<string>();
190
268
  const exportedClasses = new Set<string>();
@@ -305,6 +383,36 @@ export async function parseExecutableSymbols(
305
383
  const name = `event:${eventName}:${startLine}`;
306
384
  symbols.push({ kind: 'event_registration', localName: name, qualifiedName: `module:${sourceFile}#${name}`, sourceFile, startLine, endLine: lineOf(source, node.getEnd()), startOffset: node.getStart(source), endOffset: node.getEnd(), exported: false, importExportEvidence: { source: 'synthetic_event_registration', eventName: eventArg.text, registrationLine: startLine, receiver } });
307
385
  }
386
+ if (eventSubscriptionOffsets.has(`${node.getStart(source)}:${node.getEnd()}`)) {
387
+ const startLine = lineOf(source, node.getStart(source));
388
+ const handlerArgument = node.arguments[1];
389
+ const target = handlerArgument
390
+ ? handlerReferenceTarget(
391
+ handlerArgument, source, imports, namespaceImports,
392
+ )
393
+ : undefined;
394
+ const anchor = nearest(symbols, startLine);
395
+ if (target && anchor) calls.push({
396
+ callerQualifiedName: anchor.qualifiedName,
397
+ calleeExpression: target.calleeExpression,
398
+ calleeLocalName: target.calleeLocalName,
399
+ importSource: target.importSource,
400
+ sourceFile,
401
+ sourceLine: startLine,
402
+ callSiteStartOffset: node.getStart(source),
403
+ callSiteEndOffset: node.getEnd(),
404
+ callRole: 'event_subscribe_handler',
405
+ evidence: {
406
+ relation: target.relation,
407
+ caller: anchor.qualifiedName,
408
+ targetName: target.calleeLocalName,
409
+ ...(target.wrapperFunction
410
+ ? { wrapperFunction: target.wrapperFunction }
411
+ : {}),
412
+ factOrigin: 'event_subscribe_handler_reference',
413
+ },
414
+ });
415
+ }
308
416
  }
309
417
  ts.forEachChild(node, visitEventRegistrationSymbols);
310
418
  };
@@ -359,7 +467,7 @@ export async function parseExecutableSymbols(
359
467
  const ignored = loggerLike || terminalMember || ignoredFrameworkCall(callee);
360
468
  const resolvedTarget = provenThisMethod ? thisTarget : targetName;
361
469
  const keep = Boolean(resolvedTarget) && !ignored && (provenLocal || provenThisMethod || provenRelativeImport || provenClassInstance || provenPackageImport);
362
- if (keep) calls.push({ callerQualifiedName: caller.qualifiedName, calleeExpression: callee.expression, calleeLocalName: resolvedTarget, receiverLocalName: callee.member ? (callee.local ?? callee.receiver) : undefined, importSource, sourceFile, sourceLine: line, evidence: { relation: instance ? 'class_instance_method' : proxy ? 'relative_import_proxy_member' : packageImport ? 'package_import' : namespaceMember ? 'relative_import_namespace_member' : importSource ? 'relative_import' : provenThisMethod ? 'indexed_this_method' : 'indexed_local_symbol', caller: caller.qualifiedName, targetName: resolvedTarget, instanceVariable: instance ? (instance.propertyName ?? callee.local) : undefined, className: instance?.className, methodName: instance ? callee.member : undefined, classImportSource: instance?.importSource, callArguments: argumentEvidence(node.arguments, source), proxyVariableName: proxy?.variableName, factory: proxy?.factory, factoryExpression: proxy?.factory, factoryImportSource: proxy?.importSource, candidateStrategy: instance ? (instance.importSource ? 'relative_import_class_instance_method' : 'same_file_class_instance_method') : proxy ? 'proxy_member_exact_export_or_unique_member' : undefined } });
470
+ if (keep) calls.push({ callerQualifiedName: caller.qualifiedName, calleeExpression: callee.expression, calleeLocalName: resolvedTarget, receiverLocalName: callee.member ? (callee.local ?? callee.receiver) : undefined, importSource, sourceFile, sourceLine: line, callSiteStartOffset: node.getStart(source), callSiteEndOffset: node.getEnd(), callRole: 'ordinary_call', evidence: { relation: instance ? 'class_instance_method' : proxy ? 'relative_import_proxy_member' : packageImport ? 'package_import' : namespaceMember ? 'relative_import_namespace_member' : importSource ? 'relative_import' : provenThisMethod ? 'indexed_this_method' : 'indexed_local_symbol', caller: caller.qualifiedName, targetName: resolvedTarget, instanceVariable: instance ? (instance.propertyName ?? callee.local) : undefined, className: instance?.className, methodName: instance ? callee.member : undefined, classImportSource: instance?.importSource, callArguments: argumentEvidence(node.arguments, source), proxyVariableName: proxy?.variableName, factory: proxy?.factory, factoryExpression: proxy?.factory, factoryImportSource: proxy?.importSource, candidateStrategy: instance ? (instance.importSource ? 'relative_import_class_instance_method' : 'same_file_class_instance_method') : proxy ? 'proxy_member_exact_export_or_unique_member' : undefined } });
363
471
  }
364
472
  }
365
473
  ts.forEachChild(node, visitCalls);