@saptools/service-flow 0.1.43 → 0.1.44

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.
@@ -1,8 +1,9 @@
1
1
  import type { Db } from '../db/connection.js';
2
- import { extractPlaceholders, substituteVariables, type RuntimeSubstitution } from '../linker/dynamic-edge-resolver.js';
2
+ import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
3
3
  import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
4
- import { resolveOperation, type OperationTarget } from '../linker/service-resolver.js';
4
+ import { resolveOperation } from '../linker/service-resolver.js';
5
5
  import type { TraceEdge, TraceResult, TraceStart } from '../types.js';
6
+ import { baseTraceEvidence, edgeTarget, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
6
7
 
7
8
  interface RepoRef {
8
9
  id: number;
@@ -59,14 +60,10 @@ interface ContextBinding {
59
60
  parameterPropertyAliasLine?: unknown;
60
61
  calleeReceiver: string;
61
62
  }
62
- interface Candidate {
63
- servicePath?: string;
64
- operationPath?: string;
65
- repoName?: string;
66
- operationName?: string;
67
- score?: number;
63
+ interface ImplementationSelection {
64
+ methodId?: string;
65
+ evidence: Record<string, unknown>;
68
66
  }
69
-
70
67
  function normalizeOperation(value: string | undefined): string | undefined {
71
68
  if (!value) return undefined;
72
69
  return value.startsWith('/') ? value.slice(1) : value;
@@ -75,7 +72,7 @@ function positiveDepth(value: number): number {
75
72
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 25;
76
73
  }
77
74
 
78
- function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart): { files?: Set<string>; symbols?: Set<number>; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
75
+ function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart, implementationRepo?: string): { files?: Set<string>; symbols?: Set<number>; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
79
76
  const requested = normalizeOperation(start.operationPath ?? start.operation);
80
77
  if (!requested) return undefined;
81
78
  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
@@ -91,7 +88,15 @@ function operationStartScope(db: Db, repoId: number | undefined, start: TraceSta
91
88
  const operationId = String(rows[0]?.operationId);
92
89
  const impl = implementationScope(db, operationId);
93
90
  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 }] };
91
+ const hinted = implementationMethodIdFromHint(impl.edge, implementationRepo);
92
+ if (hinted.methodId) {
93
+ const hintedScope = handlerScope(db, hinted.methodId);
94
+ if (hintedScope?.files.size) return { files: hintedScope.files, symbols: hintedScope.symbolId ? new Set([hintedScope.symbolId]) : undefined, operationId, diagnostics: [] };
95
+ }
96
+ if (impl.edge) {
97
+ const evidence = parseEvidence(impl.edge.evidence_json);
98
+ 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, implementationAmbiguityReasons: evidence.ambiguityReasons, candidates: evidence.candidates }] };
99
+ }
95
100
  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
101
  }
97
102
 
@@ -141,7 +146,7 @@ function sourceFilesForStart(
141
146
  }
142
147
  return undefined;
143
148
  }
144
- function startScope(db: Db, start: TraceStart): StartScope {
149
+ function startScope(db: Db, start: TraceStart, implementationRepo?: string): StartScope {
145
150
  const repo = start.repo
146
151
  ? (db
147
152
  .prepare(
@@ -150,7 +155,7 @@ function startScope(db: Db, start: TraceStart): StartScope {
150
155
  .get(start.repo, start.repo) as RepoRef | undefined)
151
156
  : undefined;
152
157
  if (start.repo && !repo) return { repo, selectorMatched: false };
153
- const operationScope = operationStartScope(db, repo?.id, start);
158
+ const operationScope = operationStartScope(db, repo?.id, start, implementationRepo);
154
159
  const terminalOperationScope = operationScope && !operationScope.files && (operationScope.diagnostics ?? []).some((d) => d.resolutionStage === 'operation' || d.resolutionStage === 'implementation');
155
160
  const sourceScope = operationScope?.files || terminalOperationScope ? operationScope : sourceFilesForStart(db, repo?.id, start);
156
161
  const sourceFiles = sourceScope?.files;
@@ -206,7 +211,31 @@ function implementationScope(db: Db, operationId: string): { repoId?: number; fi
206
211
  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
212
  return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), symbolId: row?.symbolId, edge };
208
213
  }
209
- function contextImplementationMethodId(edge: GraphRow | undefined, callerRepoId: number | undefined, remoteEvidence: Record<string, unknown> = {}): { methodId?: string; evidence: Record<string, unknown> } {
214
+ function implementationMethodIdFromHint(edge: GraphRow | undefined, implementationRepo: string | undefined): ImplementationSelection {
215
+ if (!edge || edge.status !== 'ambiguous' || !implementationRepo) return { evidence: { status: 'not_applicable' } };
216
+ const evidence = parseEvidence(edge.evidence_json) as { ambiguityReasons?: string[]; candidates?: Array<{ accepted?: boolean; methodId?: number; handlerPackage?: { name?: string; packageName?: string }; sourceFile?: string }> };
217
+ const matches = (evidence.candidates ?? []).filter((item) => item.accepted && (
218
+ item.handlerPackage?.name === implementationRepo ||
219
+ item.handlerPackage?.packageName === implementationRepo ||
220
+ item.sourceFile?.startsWith(implementationRepo)
221
+ ));
222
+ if (matches.length !== 1 || matches[0]?.methodId === undefined) return { evidence: { status: matches.length > 1 ? 'tied' : 'not_matched', strategy: 'implementation_repo_hint', selectedRepo: implementationRepo, candidateCount: matches.length } };
223
+ return {
224
+ methodId: String(matches[0].methodId),
225
+ evidence: {
226
+ status: 'selected',
227
+ guided: true,
228
+ strategy: 'implementation_repo_hint',
229
+ selectedRepo: implementationRepo,
230
+ selectedMethodId: matches[0].methodId,
231
+ ambiguityReason: evidence.ambiguityReasons?.[0],
232
+ },
233
+ };
234
+ }
235
+
236
+ function contextImplementationMethodId(edge: GraphRow | undefined, callerRepoId: number | undefined, remoteEvidence: Record<string, unknown> = {}, implementationRepo?: string): ImplementationSelection {
237
+ const hinted = implementationMethodIdFromHint(edge, implementationRepo);
238
+ if (hinted.methodId) return hinted;
210
239
  if (!edge || edge.status !== 'ambiguous' || callerRepoId === undefined) return { evidence: { status: 'not_applicable' } };
211
240
  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
241
  const scores = (evidence.candidates ?? []).filter((item) => item.accepted).map((item) => {
@@ -262,38 +291,6 @@ function graphForCalls(db: Db, callIds: number[]): Map<number, GraphRow[]> {
262
291
  }
263
292
  return map;
264
293
  }
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
294
  function symbolNode(db: Db, symbolId: number): Record<string, unknown> | undefined {
298
295
  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
296
  if (!row) return undefined;
@@ -309,24 +306,6 @@ function operationNode(db: Db, operationId: string): Record<string, unknown> | u
309
306
  function workspaceIdForCall(db: Db, callId: string): number | undefined {
310
307
  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
308
  }
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
309
  function parseEvidence(value: unknown): Record<string, unknown> {
331
310
  try {
332
311
  const parsed = JSON.parse(String(value || '{}')) as unknown;
@@ -429,39 +408,6 @@ function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBind
429
408
  if (persistedResolved) return { row: undefined, evidence: { ...resolvedEvidence, contextualPreservedPersistedResolvedEdge: true }, unresolvedReason: undefined };
430
409
  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
410
  }
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
411
  export function trace(
466
412
  db: Db,
467
413
  start: TraceStart,
@@ -471,9 +417,10 @@ export function trace(
471
417
  includeExternal?: boolean;
472
418
  includeDb?: boolean;
473
419
  includeAsync?: boolean;
420
+ implementationRepo?: string;
474
421
  },
475
422
  ): TraceResult {
476
- const scope = startScope(db, start);
423
+ const scope = startScope(db, start, options.implementationRepo);
477
424
  const diagnostics = db
478
425
  .prepare(
479
426
  'SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics WHERE (? IS NULL OR repo_id=?)',
@@ -501,12 +448,14 @@ export function trace(
501
448
  const op = operationNode(db, scope.startOperationId);
502
449
  const impl = implementationScope(db, scope.startOperationId);
503
450
  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;
451
+ const startSelection = implementationMethodIdFromHint(impl.edge, options.implementationRepo);
452
+ if (impl.edge && (impl.edge.status === 'resolved' || startSelection.methodId)) {
453
+ const selectedMethodId = impl.edge.status === 'resolved' ? impl.edge.to_id : startSelection.methodId;
454
+ const implEvidence = { ...parseEvidence(impl.edge.evidence_json), startResolution: { strategy: 'indexed_operation_graph', matchedOperationId: scope.startOperationId, implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, selectedHandlerMethodId: selectedMethodId }, implementationSelection: startSelection.methodId ? startSelection.evidence : undefined };
455
+ const handlerNode = selectedMethodId ? handlerMethodNode(db, selectedMethodId) : undefined;
507
456
  if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
508
457
  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) });
458
+ 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' || startSelection.methodId ? undefined : String(impl.edge.unresolved_reason ?? impl.edge.status) });
510
459
  }
511
460
  }
512
461
  const seenScopes = new Set<string>();
@@ -568,8 +517,8 @@ export function trace(
568
517
  const persistedEvidence = JSON.parse(
569
518
  String(row.evidence_json || '{}'),
570
519
  ) 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);
520
+ const rawEvidence = baseTraceEvidence(row as TraceGraphRow, call, persistedEvidence, contextual.evidence);
521
+ const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, options.vars, workspaceIdForCall(db, String(call.id)));
573
522
  const evidence = effective.evidence;
574
523
  const effectiveRow = effective.row;
575
524
  const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
@@ -591,7 +540,7 @@ export function trace(
591
540
  });
592
541
  if (effectiveRow.to_kind === 'operation') {
593
542
  const implementation = implementationScope(db, effectiveRow.to_id);
594
- const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId, evidence);
543
+ const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId, evidence, options.implementationRepo);
595
544
  const contextMethodId = contextSelection.methodId;
596
545
  const contextNode = contextMethodId ? handlerMethodNode(db, contextMethodId) : undefined;
597
546
  if (implementation.edge) {
@@ -604,7 +553,7 @@ export function trace(
604
553
  type: 'operation_implemented_by_handler',
605
554
  from: to,
606
555
  to: implTo,
607
- evidence: contextMethodId ? { ...implEvidence, contextualImplementationSelected: true, contextualImplementation: contextSelection.evidence } : { ...implEvidence, contextualImplementation: contextSelection.evidence },
556
+ evidence: contextMethodId ? { ...implEvidence, contextualImplementationSelected: contextSelection.evidence.strategy !== 'implementation_repo_hint', contextualImplementation: contextSelection.evidence, implementationSelection: contextSelection.evidence } : { ...implEvidence, contextualImplementation: contextSelection.evidence },
608
557
  confidence: Number(implementation.edge.confidence ?? 0),
609
558
  unresolvedReason: implementation.edge.status === 'resolved' || contextMethodId ? undefined : String(implementation.edge.unresolved_reason ?? implementation.edge.status),
610
559
  });
@@ -643,5 +592,7 @@ export function trace(
643
592
  }
644
593
  }
645
594
  }
595
+ const runtimeDiagnostic = runtimeVariableDiagnostic(edges);
596
+ if (runtimeDiagnostic) diagnostics.unshift(runtimeDiagnostic);
646
597
  return { start, nodes: [...nodes.values()], edges, diagnostics };
647
598
  }