@saptools/service-flow 0.1.47 → 0.1.49

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.
@@ -8,6 +8,12 @@ import { summarizeExpression } from '../utils/redaction.js';
8
8
  import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
9
9
  import { parseServiceBindings } from './service-binding-parser.js';
10
10
  import { parseImportedWrapperCalls } from './imported-wrapper-parser.js';
11
+ import {
12
+ analyzeOperationPath,
13
+ operationPathExpression,
14
+ pathUnresolvedReason,
15
+ type OperationPathAnalysis,
16
+ } from './operation-path-analysis.js';
11
17
  function lineOf(text: string, idx: number): number {
12
18
  return text.slice(0, idx).split('\n').length;
13
19
  }
@@ -180,44 +186,9 @@ function staticExpressionText(expr: ts.Expression | undefined, initializers: Map
180
186
  if (ts.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
181
187
  return undefined;
182
188
  }
183
- interface StaticPathResolution { text?: string; sourceKind: 'literal' | 'template' | 'const' | 'dynamic'; rawExpression?: string; constName?: string; resolution: ExpressionResolution }
184
- function staticPathExpression(expr: ts.Expression | undefined, use: ts.Node): StaticPathResolution {
185
- const resolution = resolveExpression(expr, use, 'operation_path');
186
- const sourceKind = resolution.sourceKind === 'const_alias' ? 'const' : resolution.sourceKind === 'template_with_substitutions' || resolution.sourceKind === 'no_substitution_template' ? 'template' : resolution.status === 'static' ? 'literal' : 'dynamic';
187
- return { text: resolution.value, sourceKind, rawExpression: resolution.rawExpression, constName: resolution.constName, resolution };
188
- }
189
189
  function operationPathFromStatic(text: string | undefined): string | undefined {
190
190
  return text ? `/${stripQuotes(text).replace(/^\//, '')}` : undefined;
191
191
  }
192
- interface PathCandidateEvidence { candidatePaths: string[]; normalizedCandidateOperations: string[]; candidateSourceKind: string; candidateIdentifier: string; hasDynamicAssignments: boolean; conservativeReason?: string }
193
- function staticPathCandidates(identifier: ts.Identifier, call: ts.Node, method: string): PathCandidateEvidence | undefined {
194
- const binding = resolveBinding(identifier, call);
195
- const declaration = binding.declaration;
196
- if (!declaration) return undefined;
197
- const source = call.getSourceFile();
198
- const paths: string[] = [];
199
- let hasDynamicAssignments = false;
200
- const addExpr = (expr: ts.Expression | undefined, origin: ts.Node): void => {
201
- const resolved = staticPathExpression(expr, origin);
202
- if (resolved.text) paths.push(operationPathFromStatic(resolved.text) ?? resolved.text);
203
- else hasDynamicAssignments = true;
204
- };
205
- if (ts.isVariableDeclaration(declaration)) addExpr(declaration.initializer, declaration);
206
- const visit = (node: ts.Node): void => {
207
- if (node !== source && isFunctionLikeScope(node) && !nodeContains(node, call)) return;
208
- if (node.getStart(source) >= call.getStart(source)) return;
209
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)) {
210
- const target = resolveBinding(node.left, node);
211
- if (target.declaration === declaration) addExpr(node.right, node);
212
- }
213
- ts.forEachChild(node, visit);
214
- };
215
- visit(source);
216
- const candidatePaths = [...new Set(paths)];
217
- if (candidatePaths.length === 0) return undefined;
218
- const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, method).topLevelOperationName).filter((value): value is string => Boolean(value)))];
219
- return { candidatePaths, normalizedCandidateOperations, candidateSourceKind: 'static candidates observed before call in lexical scope', candidateIdentifier: identifier.text, hasDynamicAssignments, conservativeReason: hasDynamicAssignments ? 'dynamic_assignment_observed' : normalizedCandidateOperations.length > 1 ? 'candidate_tie' : undefined };
220
- }
221
192
  function destinationExpressionShape(expr: ts.Expression | undefined): string | undefined {
222
193
  if (!expr) return undefined;
223
194
  if (ts.isIdentifier(expr)) return 'identifier';
@@ -252,6 +223,33 @@ function safeOperationName(value: string | undefined): string | undefined {
252
223
  if (!value || !/^[A-Za-z_$][\w$]*(?:[./][A-Za-z_$][\w$]*)*$/.test(value)) return undefined;
253
224
  return operationPathFromStatic(value);
254
225
  }
226
+ function wrapperSourceKind(sourceKind: string): string {
227
+ if (sourceKind.includes('const_alias')) return 'const';
228
+ if (sourceKind.includes('template')) return 'template';
229
+ if (sourceKind.includes('string_literal')) return 'literal';
230
+ return sourceKind.includes('conditional') ? 'ambiguous' : 'dynamic';
231
+ }
232
+ function literalPathSource(analysis: OperationPathAnalysis): string | undefined {
233
+ if (analysis.status !== 'static') return undefined;
234
+ if (analysis.sourceKind.includes('const_alias')) return 'same_scope_const_initializer';
235
+ if (analysis.sourceKind.includes('no_substitution_template')) return 'template';
236
+ return analysis.sourceKind.includes('string_literal') ? 'literal' : analysis.sourceKind;
237
+ }
238
+ function legacyPathCandidates(analysis: OperationPathAnalysis): Record<string, unknown> | undefined {
239
+ if (analysis.candidateRawPaths.length < 2 && analysis.dynamicReassignments.length === 0)
240
+ return undefined;
241
+ return {
242
+ candidatePaths: analysis.candidateRawPaths,
243
+ normalizedCandidateOperations: analysis.candidateNormalizedOperationPaths
244
+ .map((value) => value.replace(/^\//, '')),
245
+ candidateSourceKind: analysis.sourceKind,
246
+ candidateIdentifier: analysis.candidateIdentifier,
247
+ hasDynamicAssignments: analysis.dynamicReassignments.length > 0,
248
+ conservativeReason: analysis.dynamicReassignments.length > 0
249
+ ? 'dynamic_assignment_observed'
250
+ : 'candidate_tie',
251
+ };
252
+ }
255
253
  function hasTemplatePlaceholder(value: string): boolean { return /\$\{|%7B|%7D/i.test(value); }
256
254
  function urlTargetFromExpression(expr: ts.Expression | undefined, use: ts.Node): Record<string, unknown> {
257
255
  const resolved = resolveExpression(expr, use, 'external');
@@ -346,15 +344,16 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
346
344
  const serviceVariables = collectServiceVariables(source);
347
345
  const calledNames = new Set<string>();
348
346
  const collectCalls = (node: ts.Node): void => {
349
- if (ts.isCallExpression(node)) {
350
- if (ts.isIdentifier(node.expression)) calledNames.add(node.expression.text);
351
- if (ts.isCallExpression(node.expression) && ts.isIdentifier(node.expression.expression)) calledNames.add(node.expression.expression.text);
352
- }
347
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression))
348
+ calledNames.add(node.expression.text);
349
+ if (ts.isCallExpression(node) && ts.isCallExpression(node.expression)
350
+ && ts.isIdentifier(node.expression.expression))
351
+ calledNames.add(node.expression.expression.text);
353
352
  ts.forEachChild(node, collectCalls);
354
353
  };
355
354
  collectCalls(source);
356
355
  const scanFunction = (name: string, fn: ts.FunctionLikeDeclaration): void => {
357
- if (!calledNames.has(name)) return;
356
+ if (!calledNames.has(name) && !isExportedWrapper(fn)) return;
358
357
  const params = fn.parameters.map((param) => ts.isIdentifier(param.name) ? param.name.text : undefined);
359
358
  const sends: Array<{ client: string; path: string; method?: string; methodLiteral?: string; nestedWrapperFunction?: string; start: number; end: number }> = [];
360
359
  const visit = (node: ts.Node): void => {
@@ -396,6 +395,16 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
396
395
  visitTop(source);
397
396
  return specs;
398
397
  }
398
+ function isExportedWrapper(fn: ts.FunctionLikeDeclaration): boolean {
399
+ const declaration = ts.isFunctionDeclaration(fn)
400
+ ? fn
401
+ : ts.isVariableDeclaration(fn.parent)
402
+ ? fn.parent.parent.parent
403
+ : undefined;
404
+ if (!declaration || !ts.canHaveModifiers(declaration)) return false;
405
+ return ts.getModifiers(declaration)?.some((modifier) =>
406
+ modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
407
+ }
399
408
  export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: string): ClassifiedOutboundCall[] {
400
409
  const calls: ClassifiedOutboundCall[] = [];
401
410
  const sourceFile = normalizePath(filePath);
@@ -425,17 +434,16 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
425
434
  const query = objectPropertyText(objectArg, 'query');
426
435
  const method = stripQuotes(resolveExpression(propertyInitializer(objectArg, 'method'), node, 'literal').value ?? objectPropertyText(objectArg, 'method') ?? 'POST');
427
436
  const pathExpr = propertyInitializer(objectArg, 'path') ?? propertyInitializer(objectArg, 'event');
428
- const resolvedPath = staticPathExpression(pathExpr, node);
429
- const op = pathExpr ? resolvedPath.text ?? pathExpr.getText(source) : undefined;
437
+ const pathAnalysis = analyzeOperationPath(pathExpr, node, method);
438
+ const op = pathExpr ? operationPathExpression(pathAnalysis) ?? pathExpr.getText(source) : undefined;
430
439
  const shorthandPath = objectPropertyIsShorthand(objectArg, 'path');
431
- const candidateEvidence = pathExpr && ts.isIdentifier(pathExpr) && !resolvedPath.text ? staticPathCandidates(pathExpr, node, method) : undefined;
432
- const candidateOperationPath = candidateEvidence && !candidateEvidence.hasDynamicAssignments && candidateEvidence.normalizedCandidateOperations.length === 1 && candidateEvidence.candidatePaths.length > 0 ? candidateEvidence.candidatePaths[0] : undefined;
433
- const operationPathExpr = operationPathFromStatic(resolvedPath.text) ?? candidateOperationPath;
440
+ const operationPathExpr = operationPathExpression(pathAnalysis);
434
441
  const intent = classifyODataPathIntent(operationPathExpr, method);
435
442
  const entityCallTypes: Record<string, OutboundCallFact['callType']> = { entity_mutation: 'remote_entity_mutation', entity_delete: 'remote_entity_delete', entity_media: 'remote_entity_media', entity_candidate: 'remote_entity_candidate' };
436
443
  const entityCallType = entityCallTypes[intent.kind];
437
444
  const isODataQueryRead = method.toUpperCase() === 'GET' && ['entity_query', 'entity_key_read', 'entity_navigation_query'].includes(intent.kind);
438
- add(node, { callType: query ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : undefined, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, rawPathExpression: resolvedPath.rawExpression, literalPathSource: resolvedPath.text ? (resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : resolvedPath.sourceKind) : undefined, odataPathIntent: operationPathExpr ? intent : undefined, staticPathCandidates: candidateEvidence, parserWarning: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined });
445
+ const unresolvedReason = !query && pathExpr ? pathUnresolvedReason(pathAnalysis) : undefined;
446
+ add(node, { callType: query ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : undefined, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, rawPathExpression: pathAnalysis.rawExpression, literalPathSource: literalPathSource(pathAnalysis), odataPathIntent: operationPathExpr ? intent : undefined, pathAnalysis, staticPathCandidates: legacyPathCandidates(pathAnalysis), parserWarning: unresolvedReason });
439
447
  } else {
440
448
  const receiver = receiverName(expr.expression);
441
449
  const rootReceiver = rootReceiverName(expr.expression);
@@ -444,10 +452,11 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
444
452
  const pathArg = node.arguments[1];
445
453
  const supported = method && supportedHttpMethods.has(method);
446
454
  if (receiver && supported && serviceVariables.has(rootReceiver ?? receiver)) {
447
- const resolvedPath = staticPathExpression(pathArg, node);
448
- const operationPathExpr = operationPathFromStatic(resolvedPath.text);
455
+ const pathAnalysis = analyzeOperationPath(pathArg, node, method);
456
+ const operationPathExpr = operationPathExpression(pathAnalysis);
449
457
  const intent = classifyODataPathIntent(operationPathExpr, method);
450
- add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.8 : 0.45, unresolvedReason: operationPathExpr ? undefined : 'dynamic_operation_path_identifier' }, { receiver, rootReceiver, classifier: 'service_client_send_method_path', rawPathExpression: resolvedPath.rawExpression, literalPathSource: resolvedPath.text ? (resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : resolvedPath.sourceKind) : undefined, odataPathIntent: operationPathExpr ? intent : undefined, parserWarning: operationPathExpr ? undefined : 'dynamic_operation_path_identifier' });
458
+ const unresolvedReason = pathUnresolvedReason(pathAnalysis);
459
+ add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.8 : 0.45, unresolvedReason }, { receiver, rootReceiver, classifier: 'service_client_send_method_path', rawPathExpression: pathAnalysis.rawExpression, literalPathSource: literalPathSource(pathAnalysis), odataPathIntent: operationPathExpr ? intent : undefined, pathAnalysis, staticPathCandidates: legacyPathCandidates(pathAnalysis), parserWarning: unresolvedReason });
451
460
  } else if (receiver && serviceVariables.has(rootReceiver ?? receiver)) {
452
461
  const operationPathExpr = safeOperationName(firstArg.value);
453
462
  add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.75 : 0.35, unresolvedReason: operationPathExpr ? undefined : 'unsupported_cap_send_signature' }, { receiver, rootReceiver, classifier: operationPathExpr ? 'service_client_send_operation_event' : 'service_client_send_unsupported_signature', rawOperationExpression: firstArg.rawExpression, literalOperationSource: firstArg.value ? firstArg.sourceKind : undefined, parserWarning: operationPathExpr ? undefined : 'unsupported_cap_send_signature' });
@@ -462,13 +471,14 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
462
471
  const methodArg = spec?.methodIndex === undefined ? undefined : wrapperArgs[spec.methodIndex];
463
472
  const receiver = clientArg && ts.isIdentifier(clientArg) ? clientArg.text : spec?.clientName;
464
473
  const method = stripQuotes(resolveExpression(methodArg, node, 'literal').value ?? spec?.methodLiteral ?? 'POST');
465
- const resolvedPath = staticPathExpression(pathArg, node);
466
- const operationPathExpr = operationPathFromStatic(resolvedPath.text);
474
+ const pathAnalysis = analyzeOperationPath(pathArg, node, method);
475
+ const operationPathExpr = operationPathExpression(pathAnalysis);
467
476
  const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : undefined;
477
+ const unresolvedReason = pathUnresolvedReason(pathAnalysis);
468
478
  if (spec && receiver && operationPathExpr) {
469
- add(node, { callType: 'remote_action', serviceVariableName: receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver, classifier: resolvedPath.sourceKind === 'literal' ? 'higher_order_wrapper_literal_path' : 'higher_order_wrapper_static_path', wrapperFunction: wrapperName, nestedWrapperFunction: spec.nestedWrapperFunction, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, normalizedOperationPath, literalPathSource: resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : `wrapper_call_${resolvedPath.sourceKind}`, literalCallerArgumentDetected: true });
479
+ add(node, { callType: 'remote_action', serviceVariableName: receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75, unresolvedReason }, { receiver, classifier: pathAnalysis.sourceKind.includes('string_literal') ? 'higher_order_wrapper_literal_path' : 'higher_order_wrapper_static_path', wrapperFunction: wrapperName, nestedWrapperFunction: spec.nestedWrapperFunction, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: wrapperSourceKind(pathAnalysis.sourceKind), rawPathExpression: pathAnalysis.rawExpression, normalizedOperationPath, literalPathSource: pathAnalysis.sourceKind.includes('const_alias') ? 'same_scope_const_initializer' : `wrapper_call_${wrapperSourceKind(pathAnalysis.sourceKind)}`, literalCallerArgumentDetected: true, pathAnalysis });
470
480
  } else if (spec && receiver) {
471
- add(node, { callType: 'remote_action', serviceVariableName: receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason: 'dynamic_operation_path_identifier' }, { receiver, classifier: 'higher_order_wrapper_dynamic_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, parserWarning: 'dynamic_operation_path_identifier' });
481
+ add(node, { callType: 'remote_action', serviceVariableName: receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason }, { receiver, classifier: pathAnalysis.status === 'ambiguous' ? 'higher_order_wrapper_ambiguous_path' : 'higher_order_wrapper_dynamic_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: wrapperSourceKind(pathAnalysis.sourceKind), rawPathExpression: pathAnalysis.rawExpression, pathAnalysis, parserWarning: unresolvedReason });
472
482
  }
473
483
  } else if (ts.isPropertyAccessExpression(expr) && ['emit', 'publish', 'on'].includes(expr.name.text)) {
474
484
  const receiver = receiverName(expr.expression);
@@ -52,11 +52,13 @@ export function runtimeResolution(
52
52
  evidence: Record<string, unknown>,
53
53
  vars: Record<string, string> | undefined,
54
54
  workspaceId: number | undefined,
55
+ contextualUnresolvedReason?: string,
55
56
  ): { row: TraceGraphRow; evidence: Record<string, unknown>; target?: OperationTarget; unresolvedReason?: string } {
56
57
  const substituted = evidenceWithRuntimeVariables(evidence, vars);
57
58
  if (!isRemoteRuntimeCandidate(row, evidence, vars)) {
58
- const withSections = withEffectiveResolution(substituted, row, row.unresolved_reason);
59
- return { row, evidence: withSections, unresolvedReason: row.unresolved_reason };
59
+ const unresolvedReason = contextualUnresolvedReason ?? row.unresolved_reason;
60
+ const withSections = withEffectiveResolution(substituted, row, unresolvedReason);
61
+ return { row, evidence: withSections, unresolvedReason };
60
62
  }
61
63
  const resolution = resolveRuntimeOperation(db, substituted, workspaceId);
62
64
  if (resolution.target) {
@@ -70,6 +72,9 @@ export function runtimeResolution(
70
72
  export function runtimeVariableDiagnostic(edges: Array<{ evidence: Record<string, unknown> }>): Record<string, unknown> | undefined {
71
73
  const missing = new Set<string>();
72
74
  for (const edge of edges) {
75
+ const effective = parseObject(edge.evidence.effectiveResolution);
76
+ if (!['dynamic', 'unresolved', 'ambiguous'].includes(String(effective.status ?? '')))
77
+ continue;
73
78
  const substitutions = edge.evidence.runtimeSubstitutions;
74
79
  if (!substitutions || typeof substitutions !== 'object' || Array.isArray(substitutions)) continue;
75
80
  for (const value of Object.values(substitutions as Record<string, RuntimeSubstitution>))
@@ -178,12 +183,22 @@ function evidenceWithRuntimeVariables(evidence: Record<string, unknown>, vars: R
178
183
  function runtimeSubstitutions(evidence: Record<string, unknown>, vars: Record<string, string>): Record<string, RuntimeSubstitution> {
179
184
  const substitutions: Record<string, RuntimeSubstitution> = {};
180
185
  for (const key of ['servicePath', 'operationPath', 'serviceAliasExpr', 'serviceAlias', 'destination']) {
181
- const substitution = substituteVariables(stringValue(evidence[key]), vars);
186
+ const substitution = substituteVariables(substitutionValue(evidence, key), vars);
182
187
  if (substitution.placeholders.length > 0) substitutions[key] = substitution;
183
188
  }
184
189
  return substitutions;
185
190
  }
186
191
 
192
+ function substitutionValue(
193
+ evidence: Record<string, unknown>,
194
+ key: string,
195
+ ): string | undefined {
196
+ const value = stringValue(evidence[key]);
197
+ if (key !== 'operationPath') return value;
198
+ const normalized = normalizeODataOperationInvocationPath(value);
199
+ return normalized?.wasInvocation ? normalized.normalizedOperationPath : value;
200
+ }
201
+
187
202
  function isRemoteRuntimeCandidate(row: TraceGraphRow, evidence: Record<string, unknown>, vars: Record<string, string> | undefined): boolean {
188
203
  if (!vars || Object.keys(vars).length === 0) return false;
189
204
  if (!['dynamic', 'ambiguous', 'unresolved'].includes(String(row.status ?? ''))) return false;
@@ -5,13 +5,13 @@ 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
-
9
8
  interface RepoRef {
10
9
  id: number;
11
10
  name: string;
12
11
  }
13
12
  interface StartScope {
14
13
  repo?: RepoRef;
14
+ executionRepoId?: number;
15
15
  sourceFiles?: Set<string>;
16
16
  symbolIds?: Set<number>;
17
17
  selectorMatched: boolean;
@@ -40,7 +40,7 @@ interface GraphRow extends Record<string, unknown> {
40
40
  status?: string;
41
41
  }
42
42
  interface ContextBinding {
43
- bindingId: number;
43
+ bindingId?: number;
44
44
  alias?: string;
45
45
  aliasExpr?: string;
46
46
  destinationExpr?: string;
@@ -62,6 +62,8 @@ interface ContextBinding {
62
62
  calleeReceiver: string;
63
63
  callerSite?: { sourceFile?: string; sourceLine?: number };
64
64
  calleeSite?: { sourceFile?: string; sourceLine?: number };
65
+ resolutionStatus?: 'selected' | 'ambiguous';
66
+ bindingCandidates?: Array<Record<string, unknown>>;
65
67
  }
66
68
  interface ImplementationHintOptions {
67
69
  implementationRepo?: string;
@@ -74,42 +76,75 @@ function normalizeOperation(value: string | undefined): string | undefined {
74
76
  function positiveDepth(value: number): number {
75
77
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 25;
76
78
  }
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 {
79
+ 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
80
  const requested = normalizeOperation(start.operationPath ?? start.operation);
80
81
  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
+ 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
83
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
83
84
  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
85
  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
86
  if (rows.length === 0) return undefined;
86
87
  const repoCount = new Set(rows.map((row) => String(row.repoName))).size;
87
88
  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 }] };
89
+ if (!repoId && repoCount > 1)
90
+ return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple repositories; add --repo to disambiguate')] };
91
+ if (!start.servicePath && serviceCount > 1)
92
+ return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple services; add --service to disambiguate')] };
93
+ if (rows.length !== 1)
94
+ return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple indexed operations')] };
91
95
  const operationId = String(rows[0]?.operationId);
92
96
  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: [] };
97
+ 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
98
  const hinted = implementationMethodIdFromHint(impl.edge, hintOptions);
95
99
  if (hinted.methodId) {
96
100
  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: [] };
101
+ if (hintedScope?.files.size) return { files: hintedScope.files, symbols: hintedScope.symbolId ? new Set([hintedScope.symbolId]) : undefined, repoId: hintedScope.repoId, operationId, diagnostics: [] };
98
102
  }
99
103
  if (impl.edge) {
100
104
  const evidence = parseEvidence(impl.edge.evidence_json);
101
105
  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 }];
106
+ 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
107
  return { operationId, diagnostics: hintDiagnostic ? [hintDiagnostic, ...diagnostics] : diagnostics };
104
108
  }
105
109
  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
110
  }
107
-
111
+ function implementationRejectionReasons(evidence: Record<string, unknown>): string[] {
112
+ const candidates = Array.isArray(evidence.candidates)
113
+ ? evidence.candidates.filter((item): item is Record<string, unknown> =>
114
+ Boolean(item && typeof item === 'object' && !Array.isArray(item)))
115
+ : [];
116
+ const reasons = candidates.flatMap((candidate) =>
117
+ Array.isArray(candidate.rejectedReasons)
118
+ ? candidate.rejectedReasons.filter((reason): reason is string =>
119
+ typeof reason === 'string')
120
+ : []);
121
+ return [...new Set(reasons)].sort();
122
+ }
123
+ function ambiguousStartDiagnostic(
124
+ requested: string,
125
+ candidates: Array<Record<string, unknown>>,
126
+ message: string,
127
+ ): Record<string, unknown> {
128
+ const serviceSuggestions = [...new Set(candidates
129
+ .flatMap((row) => typeof row.servicePath === 'string'
130
+ ? [`--service ${row.servicePath}`]
131
+ : []))].sort();
132
+ return {
133
+ severity: 'warning',
134
+ code: 'trace_start_ambiguous',
135
+ message,
136
+ normalizedSelectorValue: requested,
137
+ resolutionStage: 'operation',
138
+ resolutionStatus: 'ambiguous_operation',
139
+ candidates,
140
+ serviceSuggestions,
141
+ };
142
+ }
108
143
  function sourceFilesForStart(
109
144
  db: Db,
110
145
  repoId: number | undefined,
111
146
  start: TraceStart,
112
- ): { files?: Set<string>; symbols?: Set<number> } | undefined {
147
+ ): { files?: Set<string>; symbols?: Set<number>; repoId?: number } | undefined {
113
148
  const handler = start.handler;
114
149
  const operation = normalizeOperation(start.operation ?? start.operationPath);
115
150
  if (!handler && !operation) return undefined;
@@ -138,7 +173,7 @@ function sourceFilesForStart(
138
173
  operation,
139
174
  operation,
140
175
  ) 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)) };
176
+ 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
177
  if (start.servicePath && operation) {
143
178
  const implRows = db.prepare(`SELECT DISTINCT hc.source_file sourceFile,sym.id symbolId
144
179
  FROM cds_services s JOIN cds_operations o ON o.service_id=s.id
@@ -147,7 +182,7 @@ function sourceFilesForStart(
147
182
  JOIN handler_classes hc ON hc.id=hm.handler_class_id
148
183
  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
184
  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)) };
185
+ 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
186
  }
152
187
  return undefined;
153
188
  }
@@ -171,6 +206,7 @@ function startScope(db: Db, start: TraceStart, hintOptions: ImplementationHintOp
171
206
  return { repo, selectorMatched: false };
172
207
  return {
173
208
  repo,
209
+ executionRepoId: sourceScope?.repoId ?? repo?.id,
174
210
  sourceFiles,
175
211
  symbolIds: sourceScope?.symbols,
176
212
  selectorMatched: !terminalOperationScope && (!hasSelector || sourceFiles !== undefined),
@@ -201,7 +237,6 @@ function handlerFilesForOperation(db: Db, operationId: string): Set<string> {
201
237
  }>;
202
238
  return new Set(rows.map((row) => row.sourceFile).filter(Boolean) as string[]);
203
239
  }
204
-
205
240
  function implementationEdge(db: Db, operationId: string): GraphRow | undefined {
206
241
  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
242
  }
@@ -220,7 +255,6 @@ function implementationMethodIdFromHint(edge: GraphRow | undefined, options: Imp
220
255
  if (!edge || edge.status !== 'ambiguous') return { blocksAutomatic: false, evidence: { status: 'not_applicable' } };
221
256
  return selectImplementation(parseEvidence(edge.evidence_json), options.implementationHints, options.implementationRepo);
222
257
  }
223
-
224
258
  function contextImplementationMethodId(edge: GraphRow | undefined, callerRepoId: number | undefined, remoteEvidence: Record<string, unknown>, hintOptions: ImplementationHintOptions): ImplementationSelection {
225
259
  const hinted = implementationMethodIdFromHint(edge, hintOptions);
226
260
  if (hinted.methodId) return hinted;
@@ -331,21 +365,65 @@ function knownBindingsForCalls(db: Db, calls: CallRow[]): Map<string, ContextBin
331
365
  function knownBindingsForScope(db: Db, repoId: number | undefined, symbolIds: Set<number> | undefined, files: Set<string> | undefined): Map<string, ContextBinding> {
332
366
  const map = new Map<string, ContextBinding>();
333
367
  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
368
+ type BindingRow = Omit<ContextBinding, 'bindingId' | 'source' | 'calleeReceiver'> & { id?: number; symbolId?: number | null; variableName?: string };
369
+ 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
370
  FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
337
371
  WHERE b.repo_id=?`).all(repoId) as BindingRow[];
338
372
  for (const row of rows) {
339
373
  if (!row.variableName) continue;
340
374
  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;
375
+ if (symbolIds && symbolIds.size > 0 && row.symbolId != null
376
+ && !symbolIds.has(Number(row.symbolId))) continue;
377
+ const candidate = enrichBinding({
378
+ ...row,
379
+ bindingId: Number(row.id),
380
+ source: 'local_service_binding',
381
+ calleeReceiver: row.variableName,
382
+ resolutionStatus: 'selected',
383
+ });
384
+ const existing = map.get(row.variableName);
385
+ if (!existing) {
386
+ map.set(row.variableName, candidate);
387
+ continue;
344
388
  }
345
- map.set(row.variableName, enrichBinding({ ...row, bindingId: Number(row.id), source: 'local_service_binding', calleeReceiver: row.variableName }));
389
+ const candidates = uniqueBindingCandidates([
390
+ ...(existing.bindingCandidates ?? [bindingCandidateEvidence(existing)]),
391
+ bindingCandidateEvidence(candidate),
392
+ ]);
393
+ map.set(row.variableName, {
394
+ ...candidate,
395
+ bindingId: undefined,
396
+ source: 'ambiguous_local_service_bindings',
397
+ resolutionStatus: 'ambiguous',
398
+ bindingCandidates: candidates,
399
+ });
346
400
  }
347
401
  return map;
348
402
  }
403
+
404
+ function bindingCandidateEvidence(binding: ContextBinding): Record<string, unknown> {
405
+ return {
406
+ bindingId: binding.bindingId,
407
+ sourceFile: binding.sourceFile,
408
+ sourceLine: binding.sourceLine,
409
+ alias: binding.alias,
410
+ aliasExpr: binding.aliasExpr,
411
+ destinationExpr: binding.destinationExpr,
412
+ servicePathExpr: binding.servicePathExpr,
413
+ };
414
+ }
415
+
416
+ function uniqueBindingCandidates(
417
+ candidates: Array<Record<string, unknown>>,
418
+ ): Array<Record<string, unknown>> {
419
+ const seen = new Set<string>();
420
+ return candidates.filter((candidate) => {
421
+ const key = JSON.stringify(candidate);
422
+ if (seen.has(key)) return false;
423
+ seen.add(key);
424
+ return true;
425
+ });
426
+ }
349
427
  function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, callerBindings: Map<string, ContextBinding>): Map<string, ContextBinding> {
350
428
  const next = new Map<string, ContextBinding>();
351
429
  if (callerBindings.size === 0) return next;
@@ -397,6 +475,21 @@ function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, calle
397
475
  }
398
476
  function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBinding | undefined, workspaceId: number | undefined, persistedRows: GraphRow[] = []): { row?: GraphRow; evidence?: Record<string, unknown>; unresolvedReason?: string } {
399
477
  if (!binding || String(call.call_type) !== 'remote_action' || call.operation_path_expr === undefined || call.operation_path_expr === null) return {};
478
+ if (binding.resolutionStatus === 'ambiguous') {
479
+ return {
480
+ evidence: {
481
+ contextualServiceBindingAttempted: true,
482
+ contextualBinding: {
483
+ source: binding.source,
484
+ status: 'tied',
485
+ candidates: binding.bindingCandidates,
486
+ },
487
+ contextualResolutionStatus: 'ambiguous',
488
+ contextualCandidateCount: binding.bindingCandidates?.length ?? 0,
489
+ },
490
+ unresolvedReason: 'Ambiguous contextual service binding candidates',
491
+ };
492
+ }
400
493
  const normalized = normalizeODataOperationInvocationPath(String(call.operation_path_expr));
401
494
  const op = normalized?.normalizedOperationPath ?? (String(call.operation_path_expr).startsWith('/') ? String(call.operation_path_expr) : `/${String(call.operation_path_expr)}`);
402
495
  const servicePath = binding.effectiveServicePath ?? binding.servicePathExpr ?? binding.requireServicePath;
@@ -445,7 +538,7 @@ export function trace(
445
538
  const seenEdges = new Set<number>();
446
539
  const queue: Array<{ repoId?: number; files?: Set<string>; symbolIds?: Set<number>; depth: number; context?: Map<string, ContextBinding> }> =
447
540
  scope.selectorMatched
448
- ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: new Map() }]
541
+ ? [{ repoId: scope.executionRepoId, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: new Map() }]
449
542
  : [];
450
543
  if (scope.startOperationId && scope.selectorMatched) {
451
544
  const op = operationNode(db, scope.startOperationId);
@@ -521,7 +614,7 @@ export function trace(
521
614
  String(row.evidence_json || '{}'),
522
615
  ) as Record<string, unknown>;
523
616
  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)));
617
+ const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, options.vars, workspaceIdForCall(db, String(call.id)), contextual.unresolvedReason);
525
618
  const evidence = effective.evidence;
526
619
  const effectiveRow = effective.row;
527
620
  const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;