@saptools/service-flow 0.1.48 → 0.1.50

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.
@@ -5,13 +5,14 @@ import { resolveOperation } from '../linker/service-resolver.js';
5
5
  import type { ImplementationHint, TraceEdge, TraceResult, TraceStart } from '../types.js';
6
6
  import { baseTraceEvidence, edgeTarget, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
7
7
  import { implementationHintDiagnostic, selectImplementation, type ImplementationSelection } from './implementation-hints.js';
8
-
8
+ import { ambiguousStartDiagnostic } from './selectors.js';
9
9
  interface RepoRef {
10
10
  id: number;
11
11
  name: string;
12
12
  }
13
13
  interface StartScope {
14
14
  repo?: RepoRef;
15
+ executionRepoId?: number;
15
16
  sourceFiles?: Set<string>;
16
17
  symbolIds?: Set<number>;
17
18
  selectorMatched: boolean;
@@ -40,7 +41,7 @@ interface GraphRow extends Record<string, unknown> {
40
41
  status?: string;
41
42
  }
42
43
  interface ContextBinding {
43
- bindingId: number;
44
+ bindingId?: number;
44
45
  alias?: string;
45
46
  aliasExpr?: string;
46
47
  destinationExpr?: string;
@@ -62,6 +63,8 @@ interface ContextBinding {
62
63
  calleeReceiver: string;
63
64
  callerSite?: { sourceFile?: string; sourceLine?: number };
64
65
  calleeSite?: { sourceFile?: string; sourceLine?: number };
66
+ resolutionStatus?: 'selected' | 'ambiguous';
67
+ bindingCandidates?: Array<Record<string, unknown>>;
65
68
  }
66
69
  interface ImplementationHintOptions {
67
70
  implementationRepo?: string;
@@ -74,42 +77,55 @@ function normalizeOperation(value: string | undefined): string | undefined {
74
77
  function positiveDepth(value: number): number {
75
78
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 25;
76
79
  }
77
-
78
- function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart, hintOptions: ImplementationHintOptions): { files?: Set<string>; symbols?: Set<number>; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
80
+ function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart, hintOptions: ImplementationHintOptions): { files?: Set<string>; symbols?: Set<number>; repoId?: number; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
79
81
  const requested = normalizeOperation(start.operationPath ?? start.operation);
80
82
  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
83
+ const rows = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.service_path servicePath,r.id repoId,r.name repoName
82
84
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
83
85
  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
86
  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
87
  if (rows.length === 0) return undefined;
86
88
  const repoCount = new Set(rows.map((row) => String(row.repoName))).size;
87
89
  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 }] };
90
+ if (!repoId && repoCount > 1)
91
+ return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple repositories; add --repo to disambiguate')] };
92
+ if (!start.servicePath && serviceCount > 1)
93
+ return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple services; add --service to disambiguate')] };
94
+ if (rows.length !== 1)
95
+ return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple indexed operations')] };
91
96
  const operationId = String(rows[0]?.operationId);
92
97
  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: [] };
98
+ if (impl.edge?.status === 'resolved' && impl.files.size > 0) return { files: impl.files, symbols: impl.symbolId ? new Set([impl.symbolId]) : undefined, repoId: impl.repoId, operationId, diagnostics: [] };
94
99
  const hinted = implementationMethodIdFromHint(impl.edge, hintOptions);
95
100
  if (hinted.methodId) {
96
101
  const hintedScope = handlerScope(db, hinted.methodId);
97
- if (hintedScope?.files.size) return { files: hintedScope.files, symbols: hintedScope.symbolId ? new Set([hintedScope.symbolId]) : undefined, operationId, diagnostics: [] };
102
+ if (hintedScope?.files.size) return { files: hintedScope.files, symbols: hintedScope.symbolId ? new Set([hintedScope.symbolId]) : undefined, repoId: hintedScope.repoId, operationId, diagnostics: [] };
98
103
  }
99
104
  if (impl.edge) {
100
105
  const evidence = parseEvidence(impl.edge.evidence_json);
101
106
  const hintDiagnostic = implementationHintDiagnostic(hinted, evidence.implementationHintSuggestions);
102
- const 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, implementationHintSuggestions: evidence.implementationHintSuggestions, candidates: evidence.candidates }];
107
+ const 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, implementationRejectionReasons: implementationRejectionReasons(evidence), implementationHintSuggestions: evidence.implementationHintSuggestions, candidates: evidence.candidates }];
103
108
  return { operationId, diagnostics: hintDiagnostic ? [hintDiagnostic, ...diagnostics] : diagnostics };
104
109
  }
105
110
  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' }] };
106
111
  }
107
-
112
+ function implementationRejectionReasons(evidence: Record<string, unknown>): string[] {
113
+ const candidates = Array.isArray(evidence.candidates)
114
+ ? evidence.candidates.filter((item): item is Record<string, unknown> =>
115
+ Boolean(item && typeof item === 'object' && !Array.isArray(item)))
116
+ : [];
117
+ const reasons = candidates.flatMap((candidate) =>
118
+ Array.isArray(candidate.rejectedReasons)
119
+ ? candidate.rejectedReasons.filter((reason): reason is string =>
120
+ typeof reason === 'string')
121
+ : []);
122
+ return [...new Set(reasons)].sort();
123
+ }
108
124
  function sourceFilesForStart(
109
125
  db: Db,
110
126
  repoId: number | undefined,
111
127
  start: TraceStart,
112
- ): { files?: Set<string>; symbols?: Set<number> } | undefined {
128
+ ): { files?: Set<string>; symbols?: Set<number>; repoId?: number } | undefined {
113
129
  const handler = start.handler;
114
130
  const operation = normalizeOperation(start.operation ?? start.operationPath);
115
131
  if (!handler && !operation) return undefined;
@@ -138,7 +154,7 @@ function sourceFilesForStart(
138
154
  operation,
139
155
  operation,
140
156
  ) as Array<{ sourceFile?: string; symbolId?: number }>;
141
- 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)) };
157
+ 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)), repoId };
142
158
  if (start.servicePath && operation) {
143
159
  const implRows = db.prepare(`SELECT DISTINCT hc.source_file sourceFile,sym.id symbolId
144
160
  FROM cds_services s JOIN cds_operations o ON o.service_id=s.id
@@ -147,7 +163,7 @@ function sourceFilesForStart(
147
163
  JOIN handler_classes hc ON hc.id=hm.handler_class_id
148
164
  LEFT JOIN symbols sym ON sym.repo_id=hc.repo_id AND sym.source_file=hc.source_file AND sym.name=hm.method_name
149
165
  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 }>;
150
- 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)) };
166
+ 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)), repoId };
151
167
  }
152
168
  return undefined;
153
169
  }
@@ -171,6 +187,7 @@ function startScope(db: Db, start: TraceStart, hintOptions: ImplementationHintOp
171
187
  return { repo, selectorMatched: false };
172
188
  return {
173
189
  repo,
190
+ executionRepoId: sourceScope?.repoId ?? repo?.id,
174
191
  sourceFiles,
175
192
  symbolIds: sourceScope?.symbols,
176
193
  selectorMatched: !terminalOperationScope && (!hasSelector || sourceFiles !== undefined),
@@ -201,7 +218,6 @@ function handlerFilesForOperation(db: Db, operationId: string): Set<string> {
201
218
  }>;
202
219
  return new Set(rows.map((row) => row.sourceFile).filter(Boolean) as string[]);
203
220
  }
204
-
205
221
  function implementationEdge(db: Db, operationId: string): GraphRow | undefined {
206
222
  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;
207
223
  }
@@ -220,7 +236,6 @@ function implementationMethodIdFromHint(edge: GraphRow | undefined, options: Imp
220
236
  if (!edge || edge.status !== 'ambiguous') return { blocksAutomatic: false, evidence: { status: 'not_applicable' } };
221
237
  return selectImplementation(parseEvidence(edge.evidence_json), options.implementationHints, options.implementationRepo);
222
238
  }
223
-
224
239
  function contextImplementationMethodId(edge: GraphRow | undefined, callerRepoId: number | undefined, remoteEvidence: Record<string, unknown>, hintOptions: ImplementationHintOptions): ImplementationSelection {
225
240
  const hinted = implementationMethodIdFromHint(edge, hintOptions);
226
241
  if (hinted.methodId) return hinted;
@@ -331,21 +346,65 @@ function knownBindingsForCalls(db: Db, calls: CallRow[]): Map<string, ContextBin
331
346
  function knownBindingsForScope(db: Db, repoId: number | undefined, symbolIds: Set<number> | undefined, files: Set<string> | undefined): Map<string, ContextBinding> {
332
347
  const map = new Map<string, ContextBinding>();
333
348
  if (repoId === undefined) return map;
334
- type BindingRow = Omit<ContextBinding, 'bindingId' | 'source' | 'calleeReceiver'> & { id?: number; variableName?: string };
335
- 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
349
+ type BindingRow = Omit<ContextBinding, 'bindingId' | 'source' | 'calleeReceiver'> & { id?: number; symbolId?: number | null; variableName?: string };
350
+ const rows = db.prepare(`SELECT b.id,b.symbol_id symbolId,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
336
351
  FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
337
352
  WHERE b.repo_id=?`).all(repoId) as BindingRow[];
338
353
  for (const row of rows) {
339
354
  if (!row.variableName) continue;
340
355
  if (files && !files.has(String(row.sourceFile))) continue;
341
- if (symbolIds && symbolIds.size > 0) {
342
- 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;
343
- if (!owner) continue;
356
+ if (symbolIds && symbolIds.size > 0 && row.symbolId != null
357
+ && !symbolIds.has(Number(row.symbolId))) continue;
358
+ const candidate = enrichBinding({
359
+ ...row,
360
+ bindingId: Number(row.id),
361
+ source: 'local_service_binding',
362
+ calleeReceiver: row.variableName,
363
+ resolutionStatus: 'selected',
364
+ });
365
+ const existing = map.get(row.variableName);
366
+ if (!existing) {
367
+ map.set(row.variableName, candidate);
368
+ continue;
344
369
  }
345
- map.set(row.variableName, enrichBinding({ ...row, bindingId: Number(row.id), source: 'local_service_binding', calleeReceiver: row.variableName }));
370
+ const candidates = uniqueBindingCandidates([
371
+ ...(existing.bindingCandidates ?? [bindingCandidateEvidence(existing)]),
372
+ bindingCandidateEvidence(candidate),
373
+ ]);
374
+ map.set(row.variableName, {
375
+ ...candidate,
376
+ bindingId: undefined,
377
+ source: 'ambiguous_local_service_bindings',
378
+ resolutionStatus: 'ambiguous',
379
+ bindingCandidates: candidates,
380
+ });
346
381
  }
347
382
  return map;
348
383
  }
384
+
385
+ function bindingCandidateEvidence(binding: ContextBinding): Record<string, unknown> {
386
+ return {
387
+ bindingId: binding.bindingId,
388
+ sourceFile: binding.sourceFile,
389
+ sourceLine: binding.sourceLine,
390
+ alias: binding.alias,
391
+ aliasExpr: binding.aliasExpr,
392
+ destinationExpr: binding.destinationExpr,
393
+ servicePathExpr: binding.servicePathExpr,
394
+ };
395
+ }
396
+
397
+ function uniqueBindingCandidates(
398
+ candidates: Array<Record<string, unknown>>,
399
+ ): Array<Record<string, unknown>> {
400
+ const seen = new Set<string>();
401
+ return candidates.filter((candidate) => {
402
+ const key = JSON.stringify(candidate);
403
+ if (seen.has(key)) return false;
404
+ seen.add(key);
405
+ return true;
406
+ });
407
+ }
349
408
  function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, callerBindings: Map<string, ContextBinding>): Map<string, ContextBinding> {
350
409
  const next = new Map<string, ContextBinding>();
351
410
  if (callerBindings.size === 0) return next;
@@ -397,6 +456,21 @@ function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, calle
397
456
  }
398
457
  function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBinding | undefined, workspaceId: number | undefined, persistedRows: GraphRow[] = []): { row?: GraphRow; evidence?: Record<string, unknown>; unresolvedReason?: string } {
399
458
  if (!binding || String(call.call_type) !== 'remote_action' || call.operation_path_expr === undefined || call.operation_path_expr === null) return {};
459
+ if (binding.resolutionStatus === 'ambiguous') {
460
+ return {
461
+ evidence: {
462
+ contextualServiceBindingAttempted: true,
463
+ contextualBinding: {
464
+ source: binding.source,
465
+ status: 'tied',
466
+ candidates: binding.bindingCandidates,
467
+ },
468
+ contextualResolutionStatus: 'ambiguous',
469
+ contextualCandidateCount: binding.bindingCandidates?.length ?? 0,
470
+ },
471
+ unresolvedReason: 'Ambiguous contextual service binding candidates',
472
+ };
473
+ }
400
474
  const normalized = normalizeODataOperationInvocationPath(String(call.operation_path_expr));
401
475
  const op = normalized?.normalizedOperationPath ?? (String(call.operation_path_expr).startsWith('/') ? String(call.operation_path_expr) : `/${String(call.operation_path_expr)}`);
402
476
  const servicePath = binding.effectiveServicePath ?? binding.servicePathExpr ?? binding.requireServicePath;
@@ -445,7 +519,7 @@ export function trace(
445
519
  const seenEdges = new Set<number>();
446
520
  const queue: Array<{ repoId?: number; files?: Set<string>; symbolIds?: Set<number>; depth: number; context?: Map<string, ContextBinding> }> =
447
521
  scope.selectorMatched
448
- ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: new Map() }]
522
+ ? [{ repoId: scope.executionRepoId, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: new Map() }]
449
523
  : [];
450
524
  if (scope.startOperationId && scope.selectorMatched) {
451
525
  const op = operationNode(db, scope.startOperationId);
@@ -521,7 +595,7 @@ export function trace(
521
595
  String(row.evidence_json || '{}'),
522
596
  ) as Record<string, unknown>;
523
597
  const rawEvidence = baseTraceEvidence(row as TraceGraphRow, call, persistedEvidence, contextual.evidence);
524
- const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, options.vars, workspaceIdForCall(db, String(call.id)));
598
+ const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, options.vars, workspaceIdForCall(db, String(call.id)), contextual.unresolvedReason);
525
599
  const evidence = effective.evidence;
526
600
  const effectiveRow = effective.row;
527
601
  const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
package/src/types.ts CHANGED
@@ -101,6 +101,18 @@ export interface HandlerMethodFact {
101
101
  decoratorKind: string;
102
102
  decoratorValue?: string;
103
103
  decoratorRawExpression: string;
104
+ decoratorResolution: {
105
+ rawExpression: string;
106
+ resolvedValue?: string;
107
+ resolutionKind:
108
+ | 'literal'
109
+ | 'const_identifier'
110
+ | 'enum_member'
111
+ | 'const_object_property'
112
+ | 'generated_constant_name'
113
+ | 'unresolved';
114
+ unresolvedReason?: string;
115
+ };
104
116
  sourceFile: string;
105
117
  sourceLine: number;
106
118
  }