@saptools/service-flow 0.1.37 → 0.1.39

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 (49) hide show
  1. package/dist/{chunk-WE3A6TOJ.js → chunk-SAZ5OK7R.js} +50 -9
  2. package/dist/chunk-SAZ5OK7R.js.map +1 -0
  3. package/dist/cli.js +4 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +3 -2
  7. package/src/cli.ts +865 -0
  8. package/src/config/defaults.ts +14 -0
  9. package/src/config/workspace-config.ts +61 -0
  10. package/src/db/connection.ts +131 -0
  11. package/src/db/migrations.ts +81 -0
  12. package/src/db/repositories.ts +353 -0
  13. package/src/db/schema.ts +24 -0
  14. package/src/discovery/classify-repository.ts +50 -0
  15. package/src/discovery/discover-repositories.ts +58 -0
  16. package/src/index.ts +13 -0
  17. package/src/indexer/incremental-index.ts +25 -0
  18. package/src/indexer/repository-indexer.ts +137 -0
  19. package/src/indexer/workspace-indexer.ts +29 -0
  20. package/src/linker/cross-repo-linker.ts +406 -0
  21. package/src/linker/dynamic-edge-resolver.ts +45 -0
  22. package/src/linker/external-http-target.ts +38 -0
  23. package/src/linker/helper-package-linker.ts +57 -0
  24. package/src/linker/odata-path-normalizer.ts +236 -0
  25. package/src/linker/operation-decorator-normalizer.ts +47 -0
  26. package/src/linker/remote-query-target.ts +39 -0
  27. package/src/linker/service-resolver.ts +223 -0
  28. package/src/output/json-output.ts +7 -0
  29. package/src/output/mermaid-output.ts +16 -0
  30. package/src/output/table-output.ts +21 -0
  31. package/src/parsers/cds-parser.ts +147 -0
  32. package/src/parsers/decorator-parser.ts +76 -0
  33. package/src/parsers/generated-constants-parser.ts +23 -0
  34. package/src/parsers/handler-registration-parser.ts +177 -0
  35. package/src/parsers/outbound-call-parser.ts +441 -0
  36. package/src/parsers/package-json-parser.ts +63 -0
  37. package/src/parsers/service-binding-parser.ts +826 -0
  38. package/src/parsers/symbol-parser.ts +328 -0
  39. package/src/parsers/ts-project.ts +13 -0
  40. package/src/trace/selectors.ts +20 -0
  41. package/src/trace/trace-engine.ts +647 -0
  42. package/src/trace/traversal.ts +4 -0
  43. package/src/types.ts +186 -0
  44. package/src/utils/diagnostics.ts +10 -0
  45. package/src/utils/hashing.ts +10 -0
  46. package/src/utils/path-utils.ts +13 -0
  47. package/src/utils/redaction.ts +20 -0
  48. package/src/version.ts +4 -0
  49. package/dist/chunk-WE3A6TOJ.js.map +0 -1
@@ -0,0 +1,647 @@
1
+ import type { Db } from '../db/connection.js';
2
+ import { extractPlaceholders, substituteVariables, type RuntimeSubstitution } from '../linker/dynamic-edge-resolver.js';
3
+ import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
4
+ import { resolveOperation, type OperationTarget } from '../linker/service-resolver.js';
5
+ import type { TraceEdge, TraceResult, TraceStart } from '../types.js';
6
+
7
+ interface RepoRef {
8
+ id: number;
9
+ name: string;
10
+ }
11
+ interface StartScope {
12
+ repo?: RepoRef;
13
+ sourceFiles?: Set<string>;
14
+ symbolIds?: Set<number>;
15
+ selectorMatched: boolean;
16
+ startOperationId?: string;
17
+ startDiagnostics?: Array<Record<string, unknown>>;
18
+ }
19
+ interface CallRow extends Record<string, unknown> {
20
+ id: number;
21
+ repo_id: number;
22
+ repoName: string;
23
+ source_file: string;
24
+ source_line: number;
25
+ call_type: string;
26
+ confidence: number;
27
+ source_symbol_id?: number;
28
+ }
29
+ interface GraphRow extends Record<string, unknown> {
30
+ id: number;
31
+ edge_type: string;
32
+ from_id: string;
33
+ to_kind: string;
34
+ to_id: string;
35
+ confidence: number;
36
+ evidence_json: string;
37
+ unresolved_reason?: string;
38
+ status?: string;
39
+ }
40
+ interface ContextBinding {
41
+ bindingId: number;
42
+ alias?: string;
43
+ aliasExpr?: string;
44
+ destinationExpr?: string;
45
+ servicePathExpr?: string;
46
+ requireServicePath?: string;
47
+ requireDestination?: string;
48
+ effectiveServicePath?: string;
49
+ effectiveDestination?: string;
50
+ sourceFile?: string;
51
+ sourceLine?: number;
52
+ source: string;
53
+ callerArgument?: string;
54
+ callerProperty?: string;
55
+ calleeParameter?: string;
56
+ calleeObjectProperty?: string;
57
+ calleeLocalDestructuredIdentifier?: string;
58
+ parameterPropertyAliasKind?: unknown;
59
+ parameterPropertyAliasLine?: unknown;
60
+ calleeReceiver: string;
61
+ }
62
+ interface Candidate {
63
+ servicePath?: string;
64
+ operationPath?: string;
65
+ repoName?: string;
66
+ operationName?: string;
67
+ score?: number;
68
+ }
69
+
70
+ function normalizeOperation(value: string | undefined): string | undefined {
71
+ if (!value) return undefined;
72
+ return value.startsWith('/') ? value.slice(1) : value;
73
+ }
74
+ function positiveDepth(value: number): number {
75
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 25;
76
+ }
77
+
78
+ function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart): { files?: Set<string>; symbols?: Set<number>; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
79
+ const requested = normalizeOperation(start.operationPath ?? start.operation);
80
+ if (!requested) return undefined;
81
+ const rows = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_path operationPath,s.service_path servicePath,r.id repoId,r.name repoName
82
+ FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
83
+ WHERE (? IS NULL OR r.id=?) AND (? IS NULL OR s.service_path=?) AND (o.operation_name=? OR o.operation_path=? OR o.operation_path=?)
84
+ ORDER BY r.name,s.service_path,o.operation_name,o.id`).all(repoId, repoId, start.servicePath, start.servicePath, requested, requested, requested.startsWith('/') ? requested : `/${requested}`) as Array<Record<string, unknown>>;
85
+ if (rows.length === 0) return undefined;
86
+ const repoCount = new Set(rows.map((row) => String(row.repoName))).size;
87
+ const serviceCount = new Set(rows.map((row) => `${String(row.repoName)}:${String(row.servicePath)}`)).size;
88
+ if (!repoId && repoCount > 1) return { diagnostics: [{ severity: 'warning', code: 'trace_start_ambiguous', message: 'Operation trace start matched multiple repositories; add --repo to disambiguate', normalizedSelectorValue: requested, resolutionStage: 'operation', resolutionStatus: 'ambiguous_operation', candidates: rows }] };
89
+ if (!start.servicePath && serviceCount > 1) return { diagnostics: [{ severity: 'warning', code: 'trace_start_ambiguous', message: 'Operation trace start matched multiple services; add --service to disambiguate', normalizedSelectorValue: requested, resolutionStage: 'operation', resolutionStatus: 'ambiguous_operation', candidates: rows }] };
90
+ if (rows.length !== 1) return { diagnostics: [{ severity: 'warning', code: 'trace_start_ambiguous', message: 'Operation trace start matched multiple indexed operations', normalizedSelectorValue: requested, resolutionStage: 'operation', resolutionStatus: 'ambiguous_operation', candidates: rows }] };
91
+ const operationId = String(rows[0]?.operationId);
92
+ const impl = implementationScope(db, operationId);
93
+ if (impl.edge?.status === 'resolved' && impl.files.size > 0) return { files: impl.files, symbols: impl.symbolId ? new Set([impl.symbolId]) : undefined, operationId, diagnostics: [] };
94
+ if (impl.edge) return { operationId, diagnostics: [{ severity: 'warning', code: impl.edge.status === 'ambiguous' ? 'trace_start_ambiguous' : 'trace_start_implementation_unresolved', message: `Indexed operation matched but implementation edge is ${String(impl.edge.status ?? 'unresolved')}`, resolutionStage: 'implementation', resolutionStatus: impl.edge.status === 'ambiguous' ? 'ambiguous_implementation' : 'rejected_implementation', implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, candidates: parseEvidence(impl.edge.evidence_json).candidates }] };
95
+ return { operationId, diagnostics: [{ severity: 'warning', code: 'trace_start_implementation_unresolved', message: 'Indexed operation matched but no implementation candidate exists', resolutionStage: 'implementation', resolutionStatus: 'operation_without_implementation' }] };
96
+ }
97
+
98
+ function sourceFilesForStart(
99
+ db: Db,
100
+ repoId: number | undefined,
101
+ start: TraceStart,
102
+ ): { files?: Set<string>; symbols?: Set<number> } | undefined {
103
+ const handler = start.handler;
104
+ const operation = normalizeOperation(start.operation ?? start.operationPath);
105
+ if (!handler && !operation) return undefined;
106
+ const rows = db
107
+ .prepare(
108
+ `SELECT DISTINCT hc.source_file sourceFile,s.id symbolId
109
+ FROM handler_classes hc LEFT JOIN handler_methods hm ON hm.handler_class_id=hc.id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name
110
+ WHERE (? IS NULL OR hc.repo_id=?) AND (? IS NULL OR hc.class_name=? OR hm.method_name=?)
111
+ AND (? IS NULL OR hm.decorator_value=? OR hm.method_name=?)
112
+ AND (? IS NULL OR EXISTS (SELECT 1 FROM cds_services s JOIN cds_operations o ON o.service_id=s.id WHERE s.repo_id=hc.repo_id AND s.service_path=? AND (? IS NULL OR o.operation_path=? OR o.operation_name=? OR hm.decorator_value=? OR hm.method_name=?)))`,
113
+ )
114
+ .all(
115
+ repoId,
116
+ repoId,
117
+ handler,
118
+ handler,
119
+ handler,
120
+ operation,
121
+ operation,
122
+ operation,
123
+ start.servicePath,
124
+ start.servicePath,
125
+ operation,
126
+ operation,
127
+ operation,
128
+ operation,
129
+ operation,
130
+ ) as Array<{ sourceFile?: string; symbolId?: number }>;
131
+ if (rows.length > 0) return { files: new Set(rows.map((row) => row.sourceFile).filter(Boolean) as string[]), symbols: new Set(rows.map((row) => Number(row.symbolId)).filter(Boolean)) };
132
+ if (start.servicePath && operation) {
133
+ const implRows = db.prepare(`SELECT DISTINCT hc.source_file sourceFile,sym.id symbolId
134
+ FROM cds_services s JOIN cds_operations o ON o.service_id=s.id
135
+ JOIN graph_edges e ON e.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND e.status='resolved' AND e.from_kind='operation' AND e.from_id=CAST(o.id AS TEXT)
136
+ JOIN handler_methods hm ON hm.id=CAST(e.to_id AS INTEGER)
137
+ JOIN handler_classes hc ON hc.id=hm.handler_class_id
138
+ LEFT JOIN symbols sym ON sym.repo_id=hc.repo_id AND sym.source_file=hc.source_file AND sym.name=hm.method_name
139
+ WHERE (? IS NULL OR s.repo_id=?) AND s.service_path=? AND (o.operation_path=? OR o.operation_name=?)`).all(repoId, repoId, start.servicePath, operation, operation) as Array<{ sourceFile?: string; symbolId?: number }>;
140
+ if (implRows.length > 0) return { files: new Set(implRows.map((row) => row.sourceFile).filter(Boolean) as string[]), symbols: new Set(implRows.map((row) => Number(row.symbolId)).filter(Boolean)) };
141
+ }
142
+ return undefined;
143
+ }
144
+ function startScope(db: Db, start: TraceStart): StartScope {
145
+ const repo = start.repo
146
+ ? (db
147
+ .prepare(
148
+ 'SELECT id,name FROM repositories WHERE name=? OR package_name=?',
149
+ )
150
+ .get(start.repo, start.repo) as RepoRef | undefined)
151
+ : undefined;
152
+ if (start.repo && !repo) return { repo, selectorMatched: false };
153
+ const operationScope = operationStartScope(db, repo?.id, start);
154
+ const terminalOperationScope = operationScope && !operationScope.files && (operationScope.diagnostics ?? []).some((d) => d.resolutionStage === 'operation' || d.resolutionStage === 'implementation');
155
+ const sourceScope = operationScope?.files || terminalOperationScope ? operationScope : sourceFilesForStart(db, repo?.id, start);
156
+ const sourceFiles = sourceScope?.files;
157
+ const hasSelector = Boolean(
158
+ start.handler ?? start.operation ?? start.operationPath ?? start.servicePath,
159
+ );
160
+ if (start.servicePath && !start.operation && !start.operationPath && !start.handler)
161
+ return { repo, selectorMatched: false };
162
+ return {
163
+ repo,
164
+ sourceFiles,
165
+ symbolIds: sourceScope?.symbols,
166
+ selectorMatched: !terminalOperationScope && (!hasSelector || sourceFiles !== undefined),
167
+ startOperationId: operationScope?.operationId,
168
+ startDiagnostics: operationScope?.diagnostics,
169
+ };
170
+ }
171
+ function handlerFilesForOperation(db: Db, operationId: string): Set<string> {
172
+ const op = db
173
+ .prepare(
174
+ `SELECT o.operation_name operationName,o.operation_path operationPath,s.repo_id repoId
175
+ FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?`,
176
+ )
177
+ .get(operationId) as
178
+ | { operationName?: string; operationPath?: string; repoId?: number }
179
+ | undefined;
180
+ if (!op) return new Set();
181
+ const operation = normalizeOperation(op.operationPath ?? op.operationName);
182
+ const rows = db
183
+ .prepare(
184
+ `SELECT DISTINCT hc.source_file sourceFile,sym.id symbolId FROM handler_classes hc
185
+ JOIN handler_methods hm ON hm.handler_class_id=hc.id
186
+ LEFT JOIN symbols sym ON sym.repo_id=hc.repo_id AND sym.source_file=hc.source_file AND sym.name=hm.method_name
187
+ WHERE hc.repo_id=? AND (hm.decorator_value=? OR hm.method_name=? OR hm.decorator_value=?)`,
188
+ )
189
+ .all(op.repoId, operation, operation, op.operationName) as Array<{
190
+ sourceFile?: string;
191
+ }>;
192
+ return new Set(rows.map((row) => row.sourceFile).filter(Boolean) as string[]);
193
+ }
194
+
195
+ function implementationEdge(db: Db, operationId: string): GraphRow | undefined {
196
+ return db.prepare("SELECT * FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND from_kind='operation' AND from_id=? ORDER BY CASE status WHEN 'resolved' THEN 0 WHEN 'ambiguous' THEN 1 ELSE 2 END,id LIMIT 1").get(operationId) as GraphRow | undefined;
197
+ }
198
+ function handlerMethodNode(db: Db, methodId: string): Record<string, unknown> | undefined {
199
+ const row = db.prepare(`SELECT hm.id methodId,hm.method_name methodName,hm.decorator_value decoratorValue,hm.source_line sourceLine,hc.class_name className,hc.source_file sourceFile,r.name repoName,r.id repoId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id JOIN repositories r ON r.id=hc.repo_id WHERE hm.id=?`).get(methodId) as Record<string, unknown> | undefined;
200
+ if (!row) return undefined;
201
+ return { id: `handler_method:${methodId}`, kind: 'handler_method', label: `${String(row.repoName)}:${String(row.className)}.${String(row.methodName)}`, ...row };
202
+ }
203
+ function implementationScope(db: Db, operationId: string): { repoId?: number; files: Set<string>; symbolId?: number; edge?: GraphRow } {
204
+ const edge = implementationEdge(db, operationId);
205
+ if (!edge || edge.status !== 'resolved') return { files: new Set(), edge };
206
+ const row = db.prepare('SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name WHERE hm.id=?').get(edge.to_id) as { repoId?: number; sourceFile?: string; symbolId?: number } | undefined;
207
+ return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), symbolId: row?.symbolId, edge };
208
+ }
209
+ function contextImplementationMethodId(edge: GraphRow | undefined, callerRepoId: number | undefined, remoteEvidence: Record<string, unknown> = {}): { methodId?: string; evidence: Record<string, unknown> } {
210
+ if (!edge || edge.status !== 'ambiguous' || callerRepoId === undefined) return { evidence: { status: 'not_applicable' } };
211
+ const evidence = JSON.parse(String(edge.evidence_json || '{}')) as { candidates?: Array<{ accepted?: boolean; methodId?: number; handlerPackage?: { id?: number; name?: string }; applicationPackage?: { id?: number; name?: string }; reasons?: string[]; score?: number }> };
212
+ const scores = (evidence.candidates ?? []).filter((item) => item.accepted).map((item) => {
213
+ const reasons: string[] = [];
214
+ let score = Number(item.score ?? 0);
215
+ if (Number(item.handlerPackage?.id) === callerRepoId) { score += 10; reasons.push('handler_package_matches_caller_repository'); }
216
+ if (Number(item.applicationPackage?.id) === callerRepoId) { score += 10; reasons.push('registration_package_matches_caller_repository'); }
217
+ if (typeof remoteEvidence.effectiveServicePath === 'string' || typeof remoteEvidence.effectiveDestination === 'string' || typeof remoteEvidence.effectiveAlias === 'string') { score += 1; reasons.push('remote_call_context_available'); }
218
+ return { methodId: item.methodId, score, reasons, handlerPackage: item.handlerPackage, applicationPackage: item.applicationPackage };
219
+ }).sort((a, b) => b.score - a.score);
220
+ if (scores.length === 0) return { evidence: { status: 'not_applicable', candidateScores: [] } };
221
+ const [first, second] = scores;
222
+ if (first && first.methodId !== undefined && first.score > 0 && (!second || first.score > second.score)) return { methodId: String(first.methodId), evidence: { status: 'selected', selectedMethodId: first.methodId, candidateScores: scores } };
223
+ return { evidence: { status: 'tied', tieReason: scores.length > 1 ? 'duplicate_helper_implementation_candidates' : 'no_unique_materially_stronger_candidate', candidateScores: scores } };
224
+ }
225
+ function handlerScope(db: Db, methodId: string): { repoId?: number; files: Set<string>; symbolId?: number } | undefined {
226
+ const row = db.prepare('SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name WHERE hm.id=?').get(methodId) as { repoId?: number; sourceFile?: string; symbolId?: number } | undefined;
227
+ if (!row) return undefined;
228
+ return { repoId: row.repoId, files: new Set(row.sourceFile ? [row.sourceFile] : []), symbolId: row.symbolId };
229
+ }
230
+
231
+
232
+ function traceEdgeType(call: CallRow, row: GraphRow): string {
233
+ if (row.to_kind === 'operation' && row.edge_type === 'REMOTE_CALL_RESOLVES_TO_OPERATION') return 'remote_action';
234
+ if (row.to_kind === 'operation' && row.edge_type === 'LOCAL_CALL_RESOLVES_TO_OPERATION') return 'local_service_call';
235
+ return String(call.call_type);
236
+ }
237
+
238
+ function includeCall(
239
+ type: string,
240
+ options: {
241
+ includeExternal?: boolean;
242
+ includeDb?: boolean;
243
+ includeAsync?: boolean;
244
+ },
245
+ ): boolean {
246
+ if (!options.includeDb && type === 'local_db_query') return false;
247
+ if (!options.includeExternal && type === 'external_http') return false;
248
+ if (!options.includeAsync && type.startsWith('async_')) return false;
249
+ return true;
250
+ }
251
+ function graphForCalls(db: Db, callIds: number[]): Map<number, GraphRow[]> {
252
+ const map = new Map<number, GraphRow[]>();
253
+ if (callIds.length === 0) return map;
254
+ const rows = db
255
+ .prepare(
256
+ `SELECT * FROM graph_edges WHERE from_kind='call' AND from_id IN (${callIds.map(() => '?').join(',')}) ORDER BY id`,
257
+ )
258
+ .all(...callIds.map((id) => String(id))) as GraphRow[];
259
+ for (const row of rows) {
260
+ const id = Number(row.from_id);
261
+ map.set(id, [...(map.get(id) ?? []), row]);
262
+ }
263
+ return map;
264
+ }
265
+ function hasRuntimeVariable(value: unknown, vars: Record<string, string>): boolean {
266
+ return typeof value === 'string' && extractPlaceholders(value).some((key) => Object.hasOwn(vars, key));
267
+ }
268
+
269
+ function isRemoteRuntimeCandidate(row: GraphRow, evidence: Record<string, unknown>, vars: Record<string, string> | undefined): boolean {
270
+ if (!vars || Object.keys(vars).length === 0) return false;
271
+ if (!['dynamic', 'ambiguous', 'unresolved'].includes(String(row.status ?? ''))) return false;
272
+ if (!['DYNAMIC_EDGE_CANDIDATE', 'UNRESOLVED_EDGE', 'REMOTE_CALL_RESOLVES_TO_OPERATION'].includes(row.edge_type)) return false;
273
+ if (row.status === 'resolved') return false;
274
+ return ['servicePath', 'operationPath', 'serviceAliasExpr', 'serviceAlias', 'destination'].some((key) => hasRuntimeVariable(evidence[key], vars));
275
+ }
276
+
277
+ function evidenceWithRuntimeVariables(
278
+ evidence: Record<string, unknown>,
279
+ vars: Record<string, string> | undefined,
280
+ ): Record<string, unknown> {
281
+ if (!vars || Object.keys(vars).length === 0) return evidence;
282
+ const substitutions: Record<string, RuntimeSubstitution> = {};
283
+ for (const key of ['servicePath', 'operationPath', 'serviceAliasExpr', 'serviceAlias', 'destination']) {
284
+ const substitution = substituteVariables(typeof evidence[key] === 'string' ? String(evidence[key]) : undefined, vars);
285
+ if (substitution.placeholders.length > 0) substitutions[key] = substitution;
286
+ }
287
+ const next: Record<string, unknown> = { ...evidence, runtimeVariablesApplied: true, runtimeSubstitutions: substitutions };
288
+ for (const [key, value] of Object.entries(substitutions)) {
289
+ if (value.effective) next[key] = value.effective;
290
+ }
291
+ const missing = Object.values(substitutions).flatMap((value) => value.missing);
292
+ if (missing.length > 0) next.missingRuntimeVariables = [...new Set(missing)];
293
+ return next;
294
+ }
295
+
296
+
297
+ function symbolNode(db: Db, symbolId: number): Record<string, unknown> | undefined {
298
+ const row = db.prepare(`SELECT s.id symbolId,s.name symbolName,s.qualified_name qualifiedName,s.source_file sourceFile,s.start_line startLine,s.end_line endLine,r.name repoName,r.id repoId FROM symbols s JOIN repositories r ON r.id=s.repo_id WHERE s.id=?`).get(symbolId) as Record<string, unknown> | undefined;
299
+ if (!row) return undefined;
300
+ const fileName = String(row.sourceFile ?? '').split('/').at(-1) ?? String(row.sourceFile ?? '');
301
+ return { id: `symbol:${symbolId}`, kind: 'symbol', label: `${fileName}:${String(row.qualifiedName ?? row.symbolName)}`, ...row };
302
+ }
303
+
304
+ function operationNode(db: Db, operationId: string): Record<string, unknown> | undefined {
305
+ const row = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_type operationType,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.id serviceId,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,r.id repoId,r.name repoName FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operationId) as Record<string, unknown> | undefined;
306
+ if (!row) return undefined;
307
+ return { id: `operation:${operationId}`, kind: 'operation', label: `${String(row.repoName)}:${String(row.servicePath)}${String(row.operationPath)}`, ...row };
308
+ }
309
+ function workspaceIdForCall(db: Db, callId: string): number | undefined {
310
+ return (db.prepare('SELECT r.workspace_id workspaceId FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE c.id=?').get(callId) as { workspaceId?: number } | undefined)?.workspaceId;
311
+ }
312
+ function runtimeResolution(db: Db, row: GraphRow, evidence: Record<string, unknown>, vars: Record<string, string> | undefined): { row: GraphRow; evidence: Record<string, unknown>; target?: OperationTarget; unresolvedReason?: string } {
313
+ if (!isRemoteRuntimeCandidate(row, evidence, vars))
314
+ return { row, evidence, unresolvedReason: row.unresolved_reason };
315
+ const nextEvidence = evidenceWithRuntimeVariables(evidence, vars);
316
+ const servicePath = typeof nextEvidence.servicePath === 'string' ? nextEvidence.servicePath : undefined;
317
+ const operationPath = typeof nextEvidence.normalizedOperationPath === 'string' ? nextEvidence.normalizedOperationPath : typeof nextEvidence.operationPath === 'string' ? nextEvidence.operationPath : undefined;
318
+ const alias = typeof nextEvidence.serviceAliasExpr === 'string' ? nextEvidence.serviceAliasExpr : typeof nextEvidence.serviceAlias === 'string' ? nextEvidence.serviceAlias : undefined;
319
+ const destination = typeof nextEvidence.destination === 'string' ? nextEvidence.destination : undefined;
320
+ const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceIdForCall(db, row.from_id));
321
+ nextEvidence.runtimeResolutionStatus = resolution.status;
322
+ nextEvidence.runtimeResolutionReasons = resolution.reasons;
323
+ if (resolution.target) {
324
+ nextEvidence.runtimeResolvedCandidate = resolution.target;
325
+ return { row: { ...row, to_kind: 'operation', to_id: String(resolution.target.operationId), unresolved_reason: undefined, confidence: Math.max(0, Math.min(1, resolution.target.score)) }, evidence: nextEvidence, target: resolution.target };
326
+ }
327
+ const unresolvedReason = resolution.status === 'dynamic' ? `Dynamic target is missing runtime variables: ${resolution.reasons.join(', ')}` : resolution.status === 'ambiguous' ? 'Ambiguous runtime operation candidates' : 'No runtime operation candidate matched substituted service and operation path';
328
+ return { row, evidence: nextEvidence, unresolvedReason };
329
+ }
330
+ function parseEvidence(value: unknown): Record<string, unknown> {
331
+ try {
332
+ const parsed = JSON.parse(String(value || '{}')) as unknown;
333
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
334
+ } catch {
335
+ return {};
336
+ }
337
+ }
338
+ function receiverFromEvidence(value: unknown): string | undefined {
339
+ const evidence = parseEvidence(value);
340
+ return typeof evidence.receiver === 'string' ? evidence.receiver : undefined;
341
+ }
342
+ function hasDynamicPlaceholder(value: string | undefined): boolean {
343
+ return extractPlaceholders(value).length > 0;
344
+ }
345
+ function enrichBinding(row: ContextBinding): ContextBinding {
346
+ const effectiveServicePath = row.servicePathExpr && !hasDynamicPlaceholder(row.servicePathExpr) ? row.servicePathExpr : !row.servicePathExpr ? row.requireServicePath : undefined;
347
+ const effectiveDestination = row.destinationExpr && !hasDynamicPlaceholder(row.destinationExpr) ? row.destinationExpr : !row.destinationExpr ? row.requireDestination : undefined;
348
+ return { ...row, effectiveServicePath, effectiveDestination };
349
+ }
350
+ function knownBindingsForCalls(db: Db, calls: CallRow[]): Map<string, ContextBinding> {
351
+ const map = new Map<string, ContextBinding>();
352
+ for (const call of calls) {
353
+ const receiver = receiverFromEvidence(call.evidence_json);
354
+ const bindingId = Number(call.service_binding_id ?? 0);
355
+ if (!receiver || !bindingId) continue;
356
+ const row = db.prepare(`SELECT b.id,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.source_file sourceFile,b.source_line sourceLine,req.service_path requireServicePath,req.destination requireDestination
357
+ FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
358
+ WHERE b.id=?`).get(bindingId) as ContextBinding | undefined;
359
+ if (row) map.set(receiver, enrichBinding({ ...row, bindingId, source: 'local_service_binding', calleeReceiver: receiver }));
360
+ }
361
+ return map;
362
+ }
363
+ function knownBindingsForScope(db: Db, repoId: number | undefined, symbolIds: Set<number> | undefined, files: Set<string> | undefined): Map<string, ContextBinding> {
364
+ const map = new Map<string, ContextBinding>();
365
+ if (repoId === undefined) return map;
366
+ type BindingRow = Omit<ContextBinding, 'bindingId' | 'source' | 'calleeReceiver'> & { id?: number; variableName?: string };
367
+ const rows = db.prepare(`SELECT b.id,b.variable_name variableName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.source_file sourceFile,b.source_line sourceLine,req.service_path requireServicePath,req.destination requireDestination
368
+ FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
369
+ WHERE b.repo_id=?`).all(repoId) as BindingRow[];
370
+ for (const row of rows) {
371
+ if (!row.variableName) continue;
372
+ if (files && !files.has(String(row.sourceFile))) continue;
373
+ if (symbolIds && symbolIds.size > 0) {
374
+ const owner = db.prepare('SELECT id FROM symbols WHERE id IN (' + [...symbolIds].map(() => '?').join(',') + ') AND source_file=? AND start_line<=? AND end_line>=? LIMIT 1').get(...symbolIds, row.sourceFile, row.sourceLine, row.sourceLine) as { id?: number } | undefined;
375
+ if (!owner) continue;
376
+ }
377
+ map.set(row.variableName, enrichBinding({ ...row, bindingId: Number(row.id), source: 'local_service_binding', calleeReceiver: row.variableName }));
378
+ }
379
+ return map;
380
+ }
381
+ function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, callerBindings: Map<string, ContextBinding>): Map<string, ContextBinding> {
382
+ const next = new Map<string, ContextBinding>();
383
+ if (callerBindings.size === 0) return next;
384
+ const callEvidence = parseEvidence(symbolCall.evidence_json);
385
+ const callee = db.prepare('SELECT evidence_json evidenceJson FROM symbols WHERE id=?').get(symbolCall.callee_symbol_id) as { evidenceJson?: string } | undefined;
386
+ const calleeEvidence = parseEvidence(callee?.evidenceJson);
387
+ const params = Array.isArray(calleeEvidence.parameters) ? calleeEvidence.parameters.filter((item): item is string => typeof item === 'string') : [];
388
+ const parameterBindings = Array.isArray(calleeEvidence.parameterBindings) ? calleeEvidence.parameterBindings.filter((item): item is Record<string, unknown> => Boolean(item && typeof item === 'object' && !Array.isArray(item))) : [];
389
+ const parameterPropertyAliases = Array.isArray(calleeEvidence.parameterPropertyAliases) ? calleeEvidence.parameterPropertyAliases.filter((item): item is Record<string, unknown> => Boolean(item && typeof item === 'object' && !Array.isArray(item))) : [];
390
+ const args = Array.isArray(callEvidence.callArguments) ? callEvidence.callArguments as Array<Record<string, unknown>> : [];
391
+ args.forEach((arg, index) => {
392
+ const paramBinding = parameterBindings.find((binding) => binding.index === index);
393
+ const param = paramBinding?.kind === 'identifier' && typeof paramBinding.name === 'string' ? paramBinding.name : params[index];
394
+ if (arg.kind === 'identifier' && typeof arg.name === 'string') {
395
+ const binding = callerBindings.get(arg.name);
396
+ if (binding && param) next.set(param, { ...binding, source: 'local_symbol_argument', callerArgument: arg.name, calleeParameter: param, calleeReceiver: param });
397
+ }
398
+ if (arg.kind === 'object_literal' && Array.isArray(arg.properties)) {
399
+ for (const prop of arg.properties as Array<Record<string, unknown>>) {
400
+ if (typeof prop.property !== 'string' || typeof prop.argument !== 'string') continue;
401
+ const binding = callerBindings.get(prop.argument);
402
+ if (!binding) continue;
403
+ const destructured = paramBinding?.kind === 'object_pattern' && Array.isArray(paramBinding.properties)
404
+ ? (paramBinding.properties as Array<Record<string, unknown>>).find((item) => item.property === prop.property && typeof item.local === 'string')
405
+ : undefined;
406
+ if (destructured && typeof destructured.local === 'string') next.set(destructured.local, { ...binding, source: 'local_symbol_destructured_object_argument', callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: String(index), calleeReceiver: destructured.local });
407
+ else if (param) {
408
+ next.set(`${param}.${prop.property}`, { ...binding, source: 'local_symbol_object_argument', callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: param, calleeReceiver: `${param}.${prop.property}` });
409
+ for (const alias of parameterPropertyAliases) {
410
+ if (alias.parameter === param && alias.property === prop.property && typeof alias.local === 'string') next.set(alias.local, { ...binding, source: 'local_symbol_object_parameter_destructure', callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: param, calleeObjectProperty: `${param}.${prop.property}`, calleeReceiver: alias.local, calleeLocalDestructuredIdentifier: alias.local, parameterPropertyAliasKind: alias.kind, parameterPropertyAliasLine: alias.line });
411
+ }
412
+ }
413
+ }
414
+ }
415
+ });
416
+ return next;
417
+ }
418
+ function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBinding | undefined, workspaceId: number | undefined, persistedRows: GraphRow[] = []): { row?: GraphRow; evidence?: Record<string, unknown>; unresolvedReason?: string } {
419
+ if (!binding || String(call.call_type) !== 'remote_action' || call.operation_path_expr === undefined || call.operation_path_expr === null) return {};
420
+ const normalized = normalizeODataOperationInvocationPath(String(call.operation_path_expr));
421
+ const op = normalized?.normalizedOperationPath ?? (String(call.operation_path_expr).startsWith('/') ? String(call.operation_path_expr) : `/${String(call.operation_path_expr)}`);
422
+ const servicePath = binding.effectiveServicePath ?? binding.servicePathExpr ?? binding.requireServicePath;
423
+ const destination = binding.effectiveDestination ?? binding.destinationExpr ?? binding.requireDestination;
424
+ const resolution = resolveOperation(db, { servicePath, operationPath: op, alias: binding.aliasExpr ?? binding.alias, destination, hasExplicitOverride: true, isDynamic: false }, workspaceId);
425
+ const evidence: Record<string, unknown> = { contextualServiceBindingAttempted: true, contextualBinding: { source: binding.source, callerArgument: binding.callerArgument, callerProperty: binding.callerProperty, calleeParameter: binding.calleeParameter, calleeReceiver: binding.calleeReceiver, bindingSourceFile: binding.sourceFile, bindingSourceLine: binding.sourceLine, alias: binding.alias, aliasExpr: binding.aliasExpr, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination }, operationPath: op, rawOperationPath: normalized?.rawOperationPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, servicePath, serviceAlias: binding.alias, serviceAliasExpr: binding.aliasExpr, destination, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination, contextualResolutionStatus: resolution.status, contextualCandidateCount: resolution.candidates.length, candidates: resolution.candidates, contextualResolutionReasons: resolution.reasons, resolutionReasons: resolution.reasons };
426
+ if (!resolution.target) return { evidence, unresolvedReason: resolution.status === 'ambiguous' ? 'Ambiguous contextual operation candidates' : resolution.status === 'dynamic' ? `Dynamic contextual target is missing runtime variables: ${resolution.reasons.join(', ')}` : 'No contextual operation candidate matched' };
427
+ const resolvedEvidence = { ...evidence, contextualServiceBindingSelected: true, targetRepo: resolution.target.repoName, targetServicePath: resolution.target.servicePath, targetOperationPath: resolution.target.operationPath, targetOperation: resolution.target.operationName };
428
+ const persistedResolved = persistedRows.find((item) => item.status === 'resolved');
429
+ if (persistedResolved) return { row: undefined, evidence: { ...resolvedEvidence, contextualPreservedPersistedResolvedEdge: true }, unresolvedReason: undefined };
430
+ return { row: { id: -Number(call.id), edge_type: 'REMOTE_CALL_RESOLVES_TO_OPERATION', from_id: String(call.id), to_kind: 'operation', to_id: String(resolution.target.operationId), confidence: resolution.target.score, evidence_json: JSON.stringify(resolvedEvidence), status: 'resolved' }, evidence: resolvedEvidence, unresolvedReason: undefined };
431
+ }
432
+ function edgeTarget(row: GraphRow, evidence: Record<string, unknown>): string {
433
+ const runtimeCandidate = evidence.runtimeResolvedCandidate as
434
+ | Candidate
435
+ | undefined;
436
+ if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath)
437
+ return `${runtimeCandidate.servicePath}${runtimeCandidate.operationPath}`;
438
+ const targetServicePath = typeof evidence.targetServicePath === 'string' ? evidence.targetServicePath : undefined;
439
+ const targetOperationPath = typeof evidence.targetOperationPath === 'string' ? evidence.targetOperationPath : undefined;
440
+ if (targetServicePath && targetOperationPath) return `${targetServicePath}${targetOperationPath}`;
441
+ const servicePath =
442
+ typeof evidence.servicePath === 'string' ? evidence.servicePath : undefined;
443
+ const operationPath =
444
+ typeof evidence.operationPath === 'string'
445
+ ? evidence.operationPath
446
+ : undefined;
447
+ const targetOperation =
448
+ typeof evidence.targetOperation === 'string'
449
+ ? evidence.targetOperation
450
+ : undefined;
451
+ const targetRepo =
452
+ typeof evidence.targetRepo === 'string' ? evidence.targetRepo : '';
453
+ if (row.edge_type === 'HANDLER_RUNS_DB_QUERY') return `Entity: ${row.to_id || 'unknown'}`;
454
+ if (row.edge_type === 'HANDLER_RUNS_REMOTE_QUERY') return typeof evidence.remoteQueryTarget === 'string' ? evidence.remoteQueryTarget : `Remote query: ${row.to_id || 'unknown'}`;
455
+ if (row.edge_type === 'HANDLER_CALLS_EXTERNAL_HTTP') {
456
+ const target = evidence.externalTarget as Record<string, unknown> | undefined;
457
+ return typeof target?.label === 'string' ? target.label : `External endpoint: ${row.to_id || 'unknown'}`;
458
+ }
459
+ return servicePath && operationPath
460
+ ? `${servicePath}${operationPath}`
461
+ : targetOperation
462
+ ? `${targetRepo}:${targetOperation}`
463
+ : row.to_id;
464
+ }
465
+ export function trace(
466
+ db: Db,
467
+ start: TraceStart,
468
+ options: {
469
+ depth: number;
470
+ vars?: Record<string, string>;
471
+ includeExternal?: boolean;
472
+ includeDb?: boolean;
473
+ includeAsync?: boolean;
474
+ },
475
+ ): TraceResult {
476
+ const scope = startScope(db, start);
477
+ const diagnostics = db
478
+ .prepare(
479
+ 'SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics WHERE (? IS NULL OR repo_id=?)',
480
+ )
481
+ .all(scope.repo?.id, scope.repo?.id) as Array<Record<string, unknown>>;
482
+ const stale = db.prepare('SELECT name,graph_stale_reason reason FROM repositories WHERE graph_stale_reason IS NOT NULL AND (? IS NULL OR id=?)').all(scope.repo?.id, scope.repo?.id) as Array<{ name?: string; reason?: string }>;
483
+ for (const row of stale)
484
+ diagnostics.unshift({ severity: 'warning', code: 'graph_stale', message: `Graph is stale for ${row.name ?? 'repository'}: ${row.reason ?? 'facts_changed'}. Run service-flow link.` });
485
+ for (const diagnostic of scope.startDiagnostics ?? []) diagnostics.unshift(diagnostic);
486
+ if (!scope.selectorMatched && !(scope.startDiagnostics?.length))
487
+ diagnostics.unshift({
488
+ severity: 'warning',
489
+ code: 'trace_start_not_found',
490
+ message: start.servicePath && !start.operation && !start.operationPath && !start.handler ? 'Service-only trace requires --operation or --path and will not broaden to the whole workspace' : 'No handler source matched the requested trace start selector',
491
+ });
492
+ const maxDepth = positiveDepth(options.depth);
493
+ const edges: TraceEdge[] = [];
494
+ const nodes = new Map<string, Record<string, unknown>>();
495
+ const seenEdges = new Set<number>();
496
+ const queue: Array<{ repoId?: number; files?: Set<string>; symbolIds?: Set<number>; depth: number; context?: Map<string, ContextBinding> }> =
497
+ scope.selectorMatched
498
+ ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: new Map() }]
499
+ : [];
500
+ if (scope.startOperationId && scope.selectorMatched) {
501
+ const op = operationNode(db, scope.startOperationId);
502
+ const impl = implementationScope(db, scope.startOperationId);
503
+ if (op) nodes.set(String(op.id), op);
504
+ if (impl.edge && impl.edge.status === 'resolved') {
505
+ const implEvidence = { ...parseEvidence(impl.edge.evidence_json), startResolution: { strategy: 'indexed_operation_graph', matchedOperationId: scope.startOperationId, implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, selectedHandlerMethodId: impl.edge.status === 'resolved' ? impl.edge.to_id : undefined } };
506
+ const handlerNode = impl.edge.status === 'resolved' ? handlerMethodNode(db, impl.edge.to_id) : undefined;
507
+ if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
508
+ seenEdges.add(Number(impl.edge.id));
509
+ edges.push({ step: 1, type: 'operation_implemented_by_handler', from: op?.label ? String(op.label) : `operation:${scope.startOperationId}`, to: handlerNode?.label ? String(handlerNode.label) : `${impl.edge.to_kind}:${impl.edge.to_id}`, evidence: implEvidence, confidence: Number(impl.edge.confidence ?? 0), unresolvedReason: impl.edge.status === 'resolved' ? undefined : String(impl.edge.unresolved_reason ?? impl.edge.status) });
510
+ }
511
+ }
512
+ const seenScopes = new Set<string>();
513
+ while (queue.length > 0) {
514
+ const current = queue.shift();
515
+ if (!current || current.depth > maxDepth) continue;
516
+ const contextKey = [...(current.context ?? new Map<string, ContextBinding>()).keys()].sort().join(',');
517
+ const key = `${current.repoId ?? '*'}:${[...(current.symbolIds ?? new Set(['*']))].sort().join(',')}:${[...(current.files ?? new Set(['*']))].sort().join(',')}:${contextKey}`;
518
+ if (seenScopes.has(key)) continue;
519
+ seenScopes.add(key);
520
+ const calls = db
521
+ .prepare(
522
+ `SELECT c.*,r.name repoName FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE (? IS NULL OR c.repo_id=?) ORDER BY c.source_file,c.source_line`,
523
+ )
524
+ .all(current.repoId, current.repoId) as CallRow[];
525
+ const filtered = calls.filter(
526
+ (c) =>
527
+ (!current.symbolIds || current.symbolIds.has(Number(c.source_symbol_id))) && (!current.files || current.files.has(String(c.source_file))) &&
528
+ includeCall(String(c.call_type), options),
529
+ );
530
+ const callerBindings = new Map<string, ContextBinding>([...(current.context ?? new Map<string, ContextBinding>()), ...knownBindingsForScope(db, current.repoId, current.symbolIds, current.files), ...knownBindingsForCalls(db, filtered)]);
531
+
532
+ if (current.symbolIds && current.symbolIds.size > 0 && current.depth < maxDepth) {
533
+ const symbolRows = db.prepare(`SELECT sc.*,s.repo_id calleeRepoId,s.source_file calleeFile FROM symbol_calls sc LEFT JOIN symbols s ON s.id=sc.callee_symbol_id WHERE sc.caller_symbol_id IN (${[...current.symbolIds].map(() => '?').join(',')}) ORDER BY sc.source_file,sc.source_line`).all(...current.symbolIds) as Array<Record<string, unknown>>;
534
+ for (const symbolCall of symbolRows) {
535
+ if (!symbolCall.callee_symbol_id) continue;
536
+ const nextSymbols = new Set([Number(symbolCall.callee_symbol_id)]);
537
+ const nextFiles = new Set([String(symbolCall.calleeFile)]);
538
+ const nextRepoId = Number(symbolCall.calleeRepoId);
539
+ const nextKey = `${nextRepoId}:${[...nextSymbols].join(',')}:${[...nextFiles].join(',')}`;
540
+ const calleeNode = symbolNode(db, Number(symbolCall.callee_symbol_id));
541
+ if (calleeNode) nodes.set(String(calleeNode.id), calleeNode);
542
+ const evidence = { ...(JSON.parse(String(symbolCall.evidence_json || '{}')) as Record<string, unknown>), sourceFile: symbolCall.source_file, sourceLine: symbolCall.source_line, calleeSymbolId: symbolCall.callee_symbol_id, calleeSymbolName: calleeNode?.symbolName, calleeSymbolFile: calleeNode?.sourceFile, resolutionStatus: symbolCall.status };
543
+ edges.push({ step: current.depth, type: 'local_symbol_call', from: String(symbolCall.callee_expression), to: calleeNode?.label ? String(calleeNode.label) : `symbol:${String(symbolCall.callee_symbol_id)}`, evidence, confidence: Number(symbolCall.confidence ?? 0.8), unresolvedReason: String(symbolCall.status) === 'resolved' ? undefined : symbolCall.unresolved_reason ? String(symbolCall.unresolved_reason) : undefined });
544
+ if (seenScopes.has(nextKey)) edges.push({ step: current.depth, type: 'cycle', from: String(symbolCall.callee_expression), to: nextKey, evidence: { cycle: true, symbolCallId: symbolCall.id }, confidence: 1, unresolvedReason: 'Cycle detected; downstream symbol already visited' });
545
+ else queue.push({ repoId: nextRepoId, files: nextFiles, symbolIds: nextSymbols, depth: current.depth + 1, context: contextForSymbolCall(db, symbolCall, callerBindings) });
546
+ }
547
+ }
548
+ const graph = graphForCalls(
549
+ db,
550
+ filtered.map((c) => Number(c.id)),
551
+ );
552
+ for (const call of filtered) {
553
+ const callNode = `call:${call.id}`;
554
+ nodes.set(callNode, {
555
+ id: callNode,
556
+ kind: 'outbound_call',
557
+ repo: call.repoName,
558
+ file: call.source_file,
559
+ line: call.source_line,
560
+ callType: call.call_type,
561
+ });
562
+ const persistedRowsForCall = graph.get(Number(call.id)) ?? [];
563
+ const contextual = contextualRuntimeResolution(db, call, callerBindings.get(receiverFromEvidence(call.evidence_json) ?? ''), workspaceIdForCall(db, String(call.id)), persistedRowsForCall);
564
+ const graphRows = contextual.row ? [contextual.row] : persistedRowsForCall;
565
+ for (const row of graphRows) {
566
+ if (seenEdges.has(Number(row.id))) continue;
567
+ seenEdges.add(Number(row.id));
568
+ const persistedEvidence = JSON.parse(
569
+ String(row.evidence_json || '{}'),
570
+ ) as Record<string, unknown>;
571
+ const rawEvidence = { ...persistedEvidence, ...(contextual.evidence ?? {}), graphEdgeId: row.id, persistedGraphEdgeId: row.id > 0 ? row.id : undefined, outboundCallId: call.id, callSite: { sourceFile: call.source_file, sourceLine: call.source_line }, sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, linker: { status: row.status, confidence: row.confidence, reason: row.unresolved_reason, edgeType: row.edge_type }, persistedTarget: { kind: row.to_kind, id: row.to_id }, contextualResolutionParticipated: Boolean(contextual.evidence?.contextualServiceBindingAttempted) };
572
+ const effective = runtimeResolution(db, row, rawEvidence, options.vars);
573
+ const evidence = effective.evidence;
574
+ const effectiveRow = effective.row;
575
+ const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
576
+ const opNode = effectiveRow.to_kind === 'operation' ? operationNode(db, effectiveRow.to_id) : undefined;
577
+ nodes.set(targetNode, opNode ?? {
578
+ id: targetNode,
579
+ kind: effectiveRow.to_kind,
580
+ label: effectiveRow.to_kind === 'db_entity' ? `Entity: ${effectiveRow.to_id || 'unknown'}` : effectiveRow.to_id,
581
+ });
582
+ const to = edgeTarget(effectiveRow, evidence);
583
+ edges.push({
584
+ step: current.depth,
585
+ type: traceEdgeType(call, effectiveRow),
586
+ from: `${call.repoName}:${call.source_file}:${call.source_line}`,
587
+ to,
588
+ evidence,
589
+ confidence: Number(effectiveRow.confidence ?? call.confidence),
590
+ unresolvedReason: effective.unresolvedReason,
591
+ });
592
+ if (effectiveRow.to_kind === 'operation') {
593
+ const implementation = implementationScope(db, effectiveRow.to_id);
594
+ const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId, evidence);
595
+ const contextMethodId = contextSelection.methodId;
596
+ const contextNode = contextMethodId ? handlerMethodNode(db, contextMethodId) : undefined;
597
+ if (implementation.edge) {
598
+ const implEvidence = JSON.parse(String(implementation.edge.evidence_json || '{}')) as Record<string, unknown>;
599
+ const handlerNode = implementation.edge.status === 'resolved' ? handlerMethodNode(db, implementation.edge.to_id) : contextNode;
600
+ const implTo = handlerNode?.label ? String(handlerNode.label) : `${implementation.edge.to_kind}:${implementation.edge.to_id}`;
601
+ if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
602
+ edges.push({
603
+ step: current.depth,
604
+ type: 'operation_implemented_by_handler',
605
+ from: to,
606
+ to: implTo,
607
+ evidence: contextMethodId ? { ...implEvidence, contextualImplementationSelected: true, contextualImplementation: contextSelection.evidence } : { ...implEvidence, contextualImplementation: contextSelection.evidence },
608
+ confidence: Number(implementation.edge.confidence ?? 0),
609
+ unresolvedReason: implementation.edge.status === 'resolved' || contextMethodId ? undefined : String(implementation.edge.unresolved_reason ?? implementation.edge.status),
610
+ });
611
+ }
612
+ if (current.depth >= maxDepth) continue;
613
+ const contextScope = contextMethodId ? handlerScope(db, contextMethodId) : undefined;
614
+ const files = contextScope?.files ?? (implementation.files.size > 0 ? implementation.files : handlerFilesForOperation(db, effectiveRow.to_id));
615
+ const symbolIds = contextScope?.symbolId ? new Set([contextScope.symbolId]) : implementation.symbolId ? new Set([implementation.symbolId]) : undefined;
616
+ if ((implementation.edge?.status === 'resolved' || contextScope) && files.size > 0) {
617
+ const targetRepoId = contextScope?.repoId ?? implementation.repoId ?? (db
618
+ .prepare(
619
+ 'SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?',
620
+ )
621
+ .get(effectiveRow.to_id)?.repoId as number | undefined);
622
+ const nextKey = `${targetRepoId ?? '*'}:${[...(symbolIds ?? new Set(['*']))].sort().join(',')}:${[...files].sort().join(',')}`;
623
+ if (seenScopes.has(nextKey))
624
+ edges.push({
625
+ step: current.depth,
626
+ type: 'cycle',
627
+ from: to,
628
+ to: nextKey,
629
+ evidence: { ...evidence, cycle: true },
630
+ confidence: 1,
631
+ unresolvedReason:
632
+ 'Cycle detected; downstream scope already visited',
633
+ });
634
+ else
635
+ queue.push({
636
+ repoId: targetRepoId,
637
+ files,
638
+ symbolIds,
639
+ depth: current.depth + 1,
640
+ });
641
+ }
642
+ }
643
+ }
644
+ }
645
+ }
646
+ return { start, nodes: [...nodes.values()], edges, diagnostics };
647
+ }