@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +67 -11
  3. package/TECHNICAL-NOTE.md +41 -1
  4. package/dist/{chunk-OONNRIDL.js → chunk-TOVX4WYH.js} +3764 -643
  5. package/dist/chunk-TOVX4WYH.js.map +1 -0
  6. package/dist/cli.js +158 -295
  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 +7 -4
  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-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
- 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, {
@@ -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
- candidateStrategy: 'event_subscribe_handler_reference',
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
+ }