@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.
- package/CHANGELOG.md +18 -0
- package/README.md +67 -11
- package/TECHNICAL-NOTE.md +41 -1
- package/dist/{chunk-TBH33OYC.js → chunk-TOVX4WYH.js} +3765 -643
- package/dist/chunk-TOVX4WYH.js.map +1 -0
- package/dist/cli.js +231 -292
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +196 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/002-doctor-lifecycle.ts +66 -0
- package/src/cli/doctor.ts +14 -27
- package/src/cli.ts +130 -102
- package/src/db/000-call-fact-repository.ts +537 -0
- package/src/db/001-fact-lifecycle.ts +111 -0
- package/src/db/migrations.ts +16 -1
- package/src/db/repositories.ts +1 -315
- package/src/db/schema.ts +4 -2
- package/src/index.ts +22 -0
- package/src/linker/004-event-subscription-handler-linker.ts +300 -0
- package/src/linker/cross-repo-linker.ts +23 -2
- package/src/output/001-compact-json-output.ts +6 -0
- package/src/parsers/imported-wrapper-parser.ts +4 -0
- package/src/parsers/outbound-call-parser.ts +11 -1
- package/src/parsers/symbol-parser.ts +110 -2
- package/src/trace/010-traversal-scope.ts +188 -0
- package/src/trace/011-event-subscriber-traversal.ts +366 -0
- package/src/trace/012-trace-graph-lookups.ts +74 -0
- package/src/trace/013-trace-root-scopes.ts +279 -0
- package/src/trace/014-compact-contract.ts +347 -0
- package/src/trace/015-trace-edge-recorder.ts +336 -0
- package/src/trace/016-compact-projector.ts +697 -0
- package/src/trace/017-trace-context.ts +378 -0
- package/src/trace/018-compact-trace.ts +87 -0
- package/src/trace/019-trace-edge-semantics.ts +328 -0
- package/src/trace/020-compact-field-projection.ts +387 -0
- package/src/trace/dynamic-branches.ts +35 -13
- package/src/trace/trace-engine.ts +271 -245
- package/src/types.ts +10 -0
- package/src/version.ts +1 -1
- 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
|
-
|
|
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;
|
|
@@ -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: {
|
|
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 {
|
|
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);
|