@saptools/service-flow 0.1.65 → 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 +12 -0
- package/README.md +67 -11
- package/TECHNICAL-NOTE.md +41 -1
- package/dist/{chunk-OONNRIDL.js → chunk-TOVX4WYH.js} +3764 -643
- package/dist/chunk-TOVX4WYH.js.map +1 -0
- package/dist/cli.js +158 -295
- 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 +7 -4
- 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-OONNRIDL.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, {
|
|
@@ -261,7 +261,7 @@ export async function parseExecutableSymbols(
|
|
|
261
261
|
const eventSubscriptionOffsets = new Set(
|
|
262
262
|
classifyOutboundCallsInSource(source, sourceFile)
|
|
263
263
|
.filter((call) => call.fact.callType === 'async_subscribe')
|
|
264
|
-
.map((call) => call.node.getStart(source)),
|
|
264
|
+
.map((call) => `${call.node.getStart(source)}:${call.node.getEnd()}`),
|
|
265
265
|
);
|
|
266
266
|
const exportNames = exportDeclarations(source);
|
|
267
267
|
const objectExports = new Set<string>();
|
|
@@ -383,7 +383,7 @@ export async function parseExecutableSymbols(
|
|
|
383
383
|
const name = `event:${eventName}:${startLine}`;
|
|
384
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 } });
|
|
385
385
|
}
|
|
386
|
-
if (eventSubscriptionOffsets.has(node.getStart(source))) {
|
|
386
|
+
if (eventSubscriptionOffsets.has(`${node.getStart(source)}:${node.getEnd()}`)) {
|
|
387
387
|
const startLine = lineOf(source, node.getStart(source));
|
|
388
388
|
const handlerArgument = node.arguments[1];
|
|
389
389
|
const target = handlerArgument
|
|
@@ -399,6 +399,9 @@ export async function parseExecutableSymbols(
|
|
|
399
399
|
importSource: target.importSource,
|
|
400
400
|
sourceFile,
|
|
401
401
|
sourceLine: startLine,
|
|
402
|
+
callSiteStartOffset: node.getStart(source),
|
|
403
|
+
callSiteEndOffset: node.getEnd(),
|
|
404
|
+
callRole: 'event_subscribe_handler',
|
|
402
405
|
evidence: {
|
|
403
406
|
relation: target.relation,
|
|
404
407
|
caller: anchor.qualifiedName,
|
|
@@ -406,7 +409,7 @@ export async function parseExecutableSymbols(
|
|
|
406
409
|
...(target.wrapperFunction
|
|
407
410
|
? { wrapperFunction: target.wrapperFunction }
|
|
408
411
|
: {}),
|
|
409
|
-
|
|
412
|
+
factOrigin: 'event_subscribe_handler_reference',
|
|
410
413
|
},
|
|
411
414
|
});
|
|
412
415
|
}
|
|
@@ -464,7 +467,7 @@ export async function parseExecutableSymbols(
|
|
|
464
467
|
const ignored = loggerLike || terminalMember || ignoredFrameworkCall(callee);
|
|
465
468
|
const resolvedTarget = provenThisMethod ? thisTarget : targetName;
|
|
466
469
|
const keep = Boolean(resolvedTarget) && !ignored && (provenLocal || provenThisMethod || provenRelativeImport || provenClassInstance || provenPackageImport);
|
|
467
|
-
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 } });
|
|
468
471
|
}
|
|
469
472
|
}
|
|
470
473
|
ts.forEachChild(node, visitCalls);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { Db } from '../db/connection.js';
|
|
3
|
+
import type { ContextBinding } from './008-contextual-runtime-state.js';
|
|
4
|
+
|
|
5
|
+
export interface TraversalScopeIdentity {
|
|
6
|
+
workspaceId?: number;
|
|
7
|
+
repoId?: number;
|
|
8
|
+
files?: ReadonlySet<string>;
|
|
9
|
+
symbolIds?: ReadonlySet<number>;
|
|
10
|
+
context?: ReadonlyMap<string, ContextBinding>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TraversalScopeState {
|
|
14
|
+
structuralKey: string;
|
|
15
|
+
evaluationKey: string;
|
|
16
|
+
ancestry: ReadonlySet<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type TraversalScheduleKind = 'scheduled' | 'converged' | 'cycle';
|
|
20
|
+
|
|
21
|
+
export interface TraversalScheduleDecision {
|
|
22
|
+
kind: TraversalScheduleKind;
|
|
23
|
+
state: TraversalScopeState;
|
|
24
|
+
alreadyExpanded: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function compareBinary(left: string, right: string): number {
|
|
28
|
+
const leftPoints = [...left];
|
|
29
|
+
const rightPoints = [...right];
|
|
30
|
+
const length = Math.min(leftPoints.length, rightPoints.length);
|
|
31
|
+
for (let index = 0; index < length; index += 1) {
|
|
32
|
+
const leftPoint = leftPoints[index]?.codePointAt(0) ?? 0;
|
|
33
|
+
const rightPoint = rightPoints[index]?.codePointAt(0) ?? 0;
|
|
34
|
+
if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1;
|
|
35
|
+
}
|
|
36
|
+
return leftPoints.length - rightPoints.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveTraversalWorkspaceId(
|
|
40
|
+
db: Db,
|
|
41
|
+
requestedWorkspaceId: number | undefined,
|
|
42
|
+
repoId: number | undefined,
|
|
43
|
+
): number | undefined {
|
|
44
|
+
if (requestedWorkspaceId !== undefined) return requestedWorkspaceId;
|
|
45
|
+
if (repoId !== undefined) {
|
|
46
|
+
const row = db.prepare(
|
|
47
|
+
'SELECT workspace_id workspaceId FROM repositories WHERE id=?',
|
|
48
|
+
).get(repoId) as { workspaceId?: number } | undefined;
|
|
49
|
+
return typeof row?.workspaceId === 'number' ? row.workspaceId : undefined;
|
|
50
|
+
}
|
|
51
|
+
const rows = db.prepare(`SELECT DISTINCT w.id workspaceId FROM workspaces w
|
|
52
|
+
JOIN repositories r ON r.workspace_id=w.id ORDER BY w.id LIMIT 2`).all() as
|
|
53
|
+
Array<{ workspaceId?: number }>;
|
|
54
|
+
return rows.length === 1 && typeof rows[0]?.workspaceId === 'number'
|
|
55
|
+
? rows[0].workspaceId : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function structuralScopeKey(
|
|
59
|
+
workspaceId: number | undefined,
|
|
60
|
+
repoId: number | undefined,
|
|
61
|
+
files: ReadonlySet<string> | undefined,
|
|
62
|
+
symbolIds: ReadonlySet<number> | undefined,
|
|
63
|
+
): string {
|
|
64
|
+
return JSON.stringify([
|
|
65
|
+
workspaceId ?? null,
|
|
66
|
+
repoId ?? null,
|
|
67
|
+
files ? [...files].sort(compareBinary) : null,
|
|
68
|
+
symbolIds ? [...symbolIds].sort((left, right) => left - right) : null,
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function canonicalContextFingerprint(
|
|
73
|
+
context: ReadonlyMap<string, ContextBinding> | undefined,
|
|
74
|
+
): string {
|
|
75
|
+
const entries = [...(context ?? new Map<string, ContextBinding>()).entries()]
|
|
76
|
+
.map(([name, binding]) => `${JSON.stringify(name)}:${canonicalValue(binding)}`)
|
|
77
|
+
.sort(compareBinary);
|
|
78
|
+
return createHash('sha256').update(`[${entries.join(',')}]`).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function evaluationScopeKey(
|
|
82
|
+
structuralKey: string,
|
|
83
|
+
context: ReadonlyMap<string, ContextBinding> | undefined,
|
|
84
|
+
): string {
|
|
85
|
+
return JSON.stringify([structuralKey, canonicalContextFingerprint(context)]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class TraversalScopeScheduler {
|
|
89
|
+
readonly #scheduled = new Set<string>();
|
|
90
|
+
readonly #expanded = new Set<string>();
|
|
91
|
+
readonly #structuralByEvaluation = new Map<string, string>();
|
|
92
|
+
readonly #childrenByEvaluation = new Map<string, Set<string>>();
|
|
93
|
+
|
|
94
|
+
schedule(
|
|
95
|
+
identity: TraversalScopeIdentity,
|
|
96
|
+
parent?: TraversalScopeState,
|
|
97
|
+
): TraversalScheduleDecision {
|
|
98
|
+
const state = scopeState(identity, parent);
|
|
99
|
+
this.#rememberState(state);
|
|
100
|
+
if (parent) this.#rememberState(parent);
|
|
101
|
+
const cycle = parent
|
|
102
|
+
? parent.ancestry.has(state.structuralKey)
|
|
103
|
+
|| this.#reachesStructural(state.evaluationKey, parent.structuralKey)
|
|
104
|
+
: false;
|
|
105
|
+
if (parent) this.#recordEdge(parent.evaluationKey, state.evaluationKey);
|
|
106
|
+
if (cycle)
|
|
107
|
+
return { kind: 'cycle', state, alreadyExpanded: this.#expanded.has(state.evaluationKey) };
|
|
108
|
+
if (this.#scheduled.has(state.evaluationKey))
|
|
109
|
+
return { kind: 'converged', state, alreadyExpanded: this.#expanded.has(state.evaluationKey) };
|
|
110
|
+
this.#scheduled.add(state.evaluationKey);
|
|
111
|
+
return { kind: 'scheduled', state, alreadyExpanded: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
markExpanded(state: TraversalScopeState): boolean {
|
|
115
|
+
if (this.#expanded.has(state.evaluationKey)) return false;
|
|
116
|
+
this.#expanded.add(state.evaluationKey);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#rememberState(state: TraversalScopeState): void {
|
|
121
|
+
this.#structuralByEvaluation.set(state.evaluationKey, state.structuralKey);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#recordEdge(parent: string, child: string): void {
|
|
125
|
+
const children = this.#childrenByEvaluation.get(parent) ?? new Set<string>();
|
|
126
|
+
children.add(child);
|
|
127
|
+
this.#childrenByEvaluation.set(parent, children);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#reachesStructural(start: string, targetStructuralKey: string): boolean {
|
|
131
|
+
const pending = [start];
|
|
132
|
+
const seen = new Set<string>();
|
|
133
|
+
let cursor = 0;
|
|
134
|
+
while (cursor < pending.length) {
|
|
135
|
+
const current = pending[cursor];
|
|
136
|
+
cursor += 1;
|
|
137
|
+
if (seen.has(current)) continue;
|
|
138
|
+
seen.add(current);
|
|
139
|
+
if (this.#structuralByEvaluation.get(current) === targetStructuralKey)
|
|
140
|
+
return true;
|
|
141
|
+
pending.push(...(this.#childrenByEvaluation.get(current) ?? []));
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function scopeState(
|
|
148
|
+
identity: TraversalScopeIdentity,
|
|
149
|
+
parent: TraversalScopeState | undefined,
|
|
150
|
+
): TraversalScopeState {
|
|
151
|
+
const structuralKey = structuralScopeKey(
|
|
152
|
+
identity.workspaceId, identity.repoId, identity.files, identity.symbolIds,
|
|
153
|
+
);
|
|
154
|
+
const ancestry = new Set(parent?.ancestry ?? []);
|
|
155
|
+
ancestry.add(structuralKey);
|
|
156
|
+
return {
|
|
157
|
+
structuralKey,
|
|
158
|
+
evaluationKey: evaluationScopeKey(structuralKey, identity.context),
|
|
159
|
+
ancestry,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function canonicalValue(value: unknown): string {
|
|
164
|
+
if (value === null) return 'null';
|
|
165
|
+
if (value === undefined) return 'undefined';
|
|
166
|
+
if (typeof value === 'string') return `string:${JSON.stringify(value)}`;
|
|
167
|
+
if (typeof value === 'boolean') return `boolean:${String(value)}`;
|
|
168
|
+
if (typeof value === 'number') return canonicalNumber(value);
|
|
169
|
+
if (typeof value === 'bigint') return `bigint:${String(value)}`;
|
|
170
|
+
if (Array.isArray(value))
|
|
171
|
+
return `array:[${value.map(canonicalValue).join(',')}]`;
|
|
172
|
+
if (!isRecord(value)) return `unsupported:${typeof value}`;
|
|
173
|
+
const entries = Object.entries(value).sort(([left], [right]) => compareBinary(left, right));
|
|
174
|
+
return `object:{${entries.map(([key, child]) =>
|
|
175
|
+
`${JSON.stringify(key)}:${canonicalValue(child)}`).join(',')}}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function canonicalNumber(value: number): string {
|
|
179
|
+
if (Number.isNaN(value)) return 'number:NaN';
|
|
180
|
+
if (value === Number.POSITIVE_INFINITY) return 'number:Infinity';
|
|
181
|
+
if (value === Number.NEGATIVE_INFINITY) return 'number:-Infinity';
|
|
182
|
+
if (Object.is(value, -0)) return 'number:-0';
|
|
183
|
+
return `number:${String(value)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
187
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
188
|
+
}
|