@saptools/service-flow 0.1.39 → 0.1.41

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.
@@ -23,11 +23,12 @@ function expressionName(expr: ts.Expression): string {
23
23
  }
24
24
  function variableInitializers(source: ts.SourceFile): Map<string, ts.Expression> {
25
25
  const initializers = new Map<string, ts.Expression>();
26
- const visit = (node: ts.Node): void => {
27
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (node.parent.flags & ts.NodeFlags.Const) !== 0) initializers.set(node.name.text, node.initializer);
28
- ts.forEachChild(node, visit);
29
- };
30
- visit(source);
26
+ for (const statement of source.statements) {
27
+ if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
28
+ for (const declaration of statement.declarationList.declarations) {
29
+ if (ts.isIdentifier(declaration.name) && declaration.initializer) initializers.set(declaration.name.text, declaration.initializer);
30
+ }
31
+ }
31
32
  return initializers;
32
33
  }
33
34
  function queryEntityFromAst(expr: ts.Expression, initializers = new Map<string, ts.Expression>()): string | undefined {
@@ -89,50 +90,119 @@ function nameOfProperty(name: ts.PropertyName): string | undefined {
89
90
  return undefined;
90
91
  }
91
92
 
93
+ type ExpressionStatus = 'static' | 'dynamic' | 'ambiguous' | 'unknown';
94
+ type ExpressionSourceKind = 'string_literal' | 'no_substitution_template' | 'template_with_substitutions' | 'const_alias' | 'conditional_candidates' | 'dynamic_expression';
95
+ interface ExpressionResolution { status: ExpressionStatus; sourceKind: ExpressionSourceKind; value?: string; rawExpression?: string; placeholderKeys: string[]; evidence: string[]; constName?: string }
96
+ interface BindingResolution { declaration?: ts.VariableDeclaration | ts.ParameterDeclaration; initializer?: ts.Expression; immutable: boolean; evidence: string[] }
97
+ const maxAliasDepth = 5;
98
+ function safeRaw(expr: ts.Expression): string | undefined {
99
+ if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr) || ts.isIdentifier(expr) || ts.isTemplateExpression(expr)) return expr.getText(expr.getSourceFile());
100
+ return undefined;
101
+ }
102
+ function placeholders(expr: ts.TemplateExpression): string[] {
103
+ return expr.templateSpans.map((span) => span.expression.getText(expr.getSourceFile()));
104
+ }
105
+ function isFunctionLikeScope(node: ts.Node): boolean {
106
+ return ts.isFunctionLike(node) || ts.isSourceFile(node);
107
+ }
108
+ function nodeContains(parent: ts.Node, child: ts.Node): boolean {
109
+ const source = child.getSourceFile();
110
+ return child.getStart(source) >= parent.getStart(source) && child.getEnd() <= parent.getEnd();
111
+ }
112
+ function declarationScope(node: ts.VariableDeclaration | ts.ParameterDeclaration): ts.Node {
113
+ if (ts.isParameter(node)) return node.parent;
114
+ const list = node.parent;
115
+ const blockScoped = (list.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) !== 0;
116
+ let current: ts.Node = list.parent;
117
+ if (!blockScoped) {
118
+ while (current.parent && !isFunctionLikeScope(current)) current = current.parent;
119
+ return current;
120
+ }
121
+ while (current.parent && !ts.isBlock(current) && !ts.isSourceFile(current) && !ts.isModuleBlock(current) && !ts.isCaseBlock(current) && !isFunctionLikeScope(current)) current = current.parent;
122
+ return current;
123
+ }
124
+ function isAccessibleDeclaration(declaration: ts.VariableDeclaration | ts.ParameterDeclaration, use: ts.Node): boolean {
125
+ const source = use.getSourceFile();
126
+ if (declaration.name.getStart(source) >= use.getStart(source)) return false;
127
+ const scope = declarationScope(declaration);
128
+ return ts.isSourceFile(scope) || nodeContains(scope, use);
129
+ }
130
+ function resolveBinding(identifier: ts.Identifier, use: ts.Node): BindingResolution {
131
+ const source = use.getSourceFile();
132
+ let best: ts.VariableDeclaration | ts.ParameterDeclaration | undefined;
133
+ const visit = (node: ts.Node): void => {
134
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === identifier.text && isAccessibleDeclaration(node, use)) best = node;
135
+ if (ts.isParameter(node) && ts.isIdentifier(node.name) && node.name.text === identifier.text && isAccessibleDeclaration(node, use)) best = node;
136
+ ts.forEachChild(node, visit);
137
+ };
138
+ visit(source);
139
+ if (!best) return { immutable: false, evidence: ['binding_not_found'] };
140
+ const immutable = ts.isVariableDeclaration(best) && (best.parent.flags & ts.NodeFlags.Const) !== 0;
141
+ return { declaration: best, initializer: ts.isVariableDeclaration(best) ? best.initializer : undefined, immutable, evidence: [immutable ? 'lexical_const_binding_before_use' : 'lexical_mutable_or_parameter_binding'] };
142
+ }
143
+ function resolveExpression(expr: ts.Expression | undefined, use: ts.Node, policy: 'operation_path' | 'external' | 'literal', depth = 0, seen = new Set<ts.Node>()): ExpressionResolution {
144
+ if (!expr) return { status: 'unknown', sourceKind: 'dynamic_expression', placeholderKeys: [], evidence: ['expression_missing'] };
145
+ if (ts.isStringLiteral(expr)) return { status: 'static', sourceKind: 'string_literal', value: expr.text, rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ['string_literal'] };
146
+ if (ts.isNoSubstitutionTemplateLiteral(expr)) return { status: 'static', sourceKind: 'no_substitution_template', value: expr.text, rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ['no_substitution_template'] };
147
+ if (ts.isTemplateExpression(expr)) {
148
+ const keys = placeholders(expr);
149
+ if (policy === 'operation_path') return { status: 'dynamic', sourceKind: 'template_with_substitutions', value: stripQuotes(expr.getText(expr.getSourceFile())), rawExpression: safeRaw(expr), placeholderKeys: keys, evidence: ['operation_path_template_placeholders_retained'] };
150
+ return { status: 'dynamic', sourceKind: 'template_with_substitutions', placeholderKeys: keys, evidence: ['template_substitutions_not_static_external_target'] };
151
+ }
152
+ if (ts.isIdentifier(expr)) {
153
+ if (depth >= maxAliasDepth) return { status: 'unknown', sourceKind: 'const_alias', rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ['alias_depth_exceeded'], constName: expr.text };
154
+ const binding = resolveBinding(expr, use);
155
+ if (!binding.declaration || !binding.initializer || !binding.immutable) return { status: 'dynamic', sourceKind: 'dynamic_expression', rawExpression: safeRaw(expr), placeholderKeys: [], evidence: binding.evidence, constName: expr.text };
156
+ if (seen.has(binding.declaration)) return { status: 'unknown', sourceKind: 'const_alias', rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ['alias_cycle_detected'], constName: expr.text };
157
+ seen.add(binding.declaration);
158
+ const resolved = resolveExpression(binding.initializer, binding.declaration, policy, depth + 1, seen);
159
+ return { ...resolved, sourceKind: 'const_alias', rawExpression: safeRaw(expr), constName: expr.text, evidence: [...binding.evidence, ...resolved.evidence] };
160
+ }
161
+ return { status: 'dynamic', sourceKind: 'dynamic_expression', rawExpression: safeRaw(expr), placeholderKeys: [], evidence: [`unsupported_${ts.SyntaxKind[expr.kind] ?? 'expression'}`] };
162
+ }
92
163
  function staticExpressionText(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): string | undefined {
93
164
  if (!expr) return undefined;
94
165
  if (isStringLike(expr)) return expr.text;
95
- if (ts.isTemplateExpression(expr)) return stripQuotes(expr.getText(expr.getSourceFile()));
96
166
  if (ts.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
97
167
  return undefined;
98
168
  }
99
- interface StaticPathResolution { text?: string; sourceKind: 'literal' | 'template' | 'const' | 'dynamic'; rawExpression?: string; constName?: string }
100
- function staticPathExpression(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): StaticPathResolution {
101
- if (!expr) return { sourceKind: 'dynamic' };
102
- const rawExpression = expr.getText(expr.getSourceFile());
103
- if (isStringLike(expr)) return { text: expr.text, sourceKind: 'literal', rawExpression };
104
- if (ts.isTemplateExpression(expr)) return { text: stripQuotes(rawExpression), sourceKind: 'template', rawExpression };
105
- if (ts.isIdentifier(expr) && initializers.has(expr.text)) {
106
- const resolved = staticPathExpression(initializers.get(expr.text), initializers);
107
- return resolved.text ? { ...resolved, sourceKind: 'const', rawExpression, constName: expr.text } : { sourceKind: 'dynamic', rawExpression };
108
- }
109
- return { sourceKind: 'dynamic', rawExpression };
169
+ interface StaticPathResolution { text?: string; sourceKind: 'literal' | 'template' | 'const' | 'dynamic'; rawExpression?: string; constName?: string; resolution: ExpressionResolution }
170
+ function staticPathExpression(expr: ts.Expression | undefined, use: ts.Node): StaticPathResolution {
171
+ const resolution = resolveExpression(expr, use, 'operation_path');
172
+ const sourceKind = resolution.sourceKind === 'const_alias' ? 'const' : resolution.sourceKind === 'template_with_substitutions' || resolution.sourceKind === 'no_substitution_template' ? 'template' : resolution.status === 'static' ? 'literal' : 'dynamic';
173
+ return { text: resolution.value, sourceKind, rawExpression: resolution.rawExpression, constName: resolution.constName, resolution };
110
174
  }
111
175
  function operationPathFromStatic(text: string | undefined): string | undefined {
112
176
  return text ? `/${stripQuotes(text).replace(/^\//, '')}` : undefined;
113
177
  }
114
- interface PathCandidateEvidence { candidatePaths: string[]; normalizedCandidateOperations: string[]; candidateSourceKind: string; candidateIdentifier: string; hasDynamicAssignments: boolean }
115
- function staticPathCandidates(source: ts.SourceFile, identifier: string, initializers: Map<string, ts.Expression>): PathCandidateEvidence | undefined {
178
+ interface PathCandidateEvidence { candidatePaths: string[]; normalizedCandidateOperations: string[]; candidateSourceKind: string; candidateIdentifier: string; hasDynamicAssignments: boolean; conservativeReason?: string }
179
+ function staticPathCandidates(identifier: ts.Identifier, call: ts.Node, method: string): PathCandidateEvidence | undefined {
180
+ const binding = resolveBinding(identifier, call);
181
+ const declaration = binding.declaration;
182
+ if (!declaration) return undefined;
183
+ const source = call.getSourceFile();
116
184
  const paths: string[] = [];
117
185
  let hasDynamicAssignments = false;
186
+ const addExpr = (expr: ts.Expression | undefined, origin: ts.Node): void => {
187
+ const resolved = staticPathExpression(expr, origin);
188
+ if (resolved.text) paths.push(operationPathFromStatic(resolved.text) ?? resolved.text);
189
+ else hasDynamicAssignments = true;
190
+ };
191
+ if (ts.isVariableDeclaration(declaration)) addExpr(declaration.initializer, declaration);
118
192
  const visit = (node: ts.Node): void => {
119
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === identifier && node.initializer) {
120
- const resolved = staticPathExpression(node.initializer, initializers);
121
- if (resolved.text) paths.push(operationPathFromStatic(resolved.text) ?? resolved.text);
122
- else hasDynamicAssignments = true;
123
- }
124
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left) && node.left.text === identifier) {
125
- const resolved = staticPathExpression(node.right, initializers);
126
- if (resolved.text) paths.push(operationPathFromStatic(resolved.text) ?? resolved.text);
127
- else hasDynamicAssignments = true;
193
+ if (node !== source && isFunctionLikeScope(node) && !nodeContains(node, call)) return;
194
+ if (node.getStart(source) >= call.getStart(source)) return;
195
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)) {
196
+ const target = resolveBinding(node.left, node);
197
+ if (target.declaration === declaration) addExpr(node.right, node);
128
198
  }
129
199
  ts.forEachChild(node, visit);
130
200
  };
131
201
  visit(source);
132
202
  const candidatePaths = [...new Set(paths)];
133
203
  if (candidatePaths.length === 0) return undefined;
134
- const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, 'POST').topLevelOperationName).filter((value): value is string => Boolean(value)))];
135
- return { candidatePaths, normalizedCandidateOperations, candidateSourceKind: 'static candidates observed', candidateIdentifier: identifier, hasDynamicAssignments };
204
+ const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, method).topLevelOperationName).filter((value): value is string => Boolean(value)))];
205
+ 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 };
136
206
  }
137
207
  function destinationExpressionShape(expr: ts.Expression | undefined): string | undefined {
138
208
  if (!expr) return undefined;
@@ -159,57 +229,59 @@ function propertyInitializer(object: ts.ObjectLiteralExpression, key: string): t
159
229
  }
160
230
  return undefined;
161
231
  }
162
- function httpMethodFromObject(object: ts.ObjectLiteralExpression, initializers: Map<string, ts.Expression>): string | undefined {
163
- const text = staticExpressionText(propertyInitializer(object, 'method'), initializers);
232
+ function httpMethodFromObject(object: ts.ObjectLiteralExpression, use: ts.Node): string | undefined {
233
+ const text = resolveExpression(propertyInitializer(object, 'method'), use, 'literal').value;
164
234
  return text ? stripQuotes(text).toUpperCase() : undefined;
165
235
  }
166
- function urlTargetFromExpression(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): Record<string, unknown> {
167
- const text = staticExpressionText(expr, initializers);
168
- if (text) return { kind: 'static_url', expression: text, dynamic: false };
169
- if (expr && (ts.isTemplateExpression(expr) || ts.isIdentifier(expr) || ts.isPropertyAccessExpression(expr) || ts.isCallExpression(expr))) return { kind: 'url_expression', expression: expr.getText(expr.getSourceFile()), dynamic: true };
236
+ function hasTemplatePlaceholder(value: string): boolean { return /\$\{|%7B|%7D/i.test(value); }
237
+ function urlTargetFromExpression(expr: ts.Expression | undefined, use: ts.Node): Record<string, unknown> {
238
+ const resolved = resolveExpression(expr, use, 'external');
239
+ if (resolved.status === 'static' && resolved.value && !hasTemplatePlaceholder(resolved.value)) return { kind: 'static_url', expression: resolved.value, dynamic: false, sourceKind: resolved.sourceKind };
240
+ if (expr) return { kind: 'url_expression', dynamic: true, expression: `${resolved.sourceKind}:${resolved.placeholderKeys.join('|')}`, expressionShape: resolved.sourceKind, placeholderKeys: resolved.placeholderKeys };
170
241
  return { kind: 'unknown', dynamic: false };
171
242
  }
172
- function destinationTargetFromExpression(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): Record<string, unknown> | undefined {
173
- const text = staticExpressionText(expr, initializers);
174
- if (text) return { kind: 'destination', expression: text, dynamic: false };
175
- const candidates = staticConditionalCandidates(expr, initializers);
243
+ function destinationTargetFromExpression(expr: ts.Expression | undefined, use: ts.Node): Record<string, unknown> | undefined {
244
+ const resolved = resolveExpression(expr, use, 'external');
245
+ const text = resolved.value;
246
+ if (resolved.status === 'static' && text && !hasTemplatePlaceholder(text)) return { kind: 'destination', expression: text, dynamic: false, sourceKind: resolved.sourceKind };
247
+ const candidates = staticConditionalCandidates(expr, new Map<string, ts.Expression>());
176
248
  if (candidates) return { kind: 'destination', dynamic: true, expressionShape: 'conditional', candidateLiterals: candidates };
177
249
  const shape = destinationExpressionShape(expr);
178
250
  if (shape) return { kind: 'destination', dynamic: true, expressionShape: shape };
179
251
  return undefined;
180
252
  }
181
- function externalHttpEvidence(node: ts.CallExpression, source: ts.SourceFile, initializers: Map<string, ts.Expression>): { method?: string; externalTarget: Record<string, unknown>; classifier: string; sourceCallShape: string } | undefined {
253
+ function externalHttpEvidence(node: ts.CallExpression, source: ts.SourceFile): { method?: string; externalTarget: Record<string, unknown>; classifier: string; sourceCallShape: string } | undefined {
182
254
  const expr = node.expression;
183
255
  const exprText = expr.getText(source);
184
256
  if (exprText === 'useOrFetchDestination') {
185
257
  const objectArg = node.arguments[0];
186
258
  if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
187
- const destination = destinationTargetFromExpression(propertyInitializer(objectArg, 'destinationName'), initializers);
259
+ const destination = destinationTargetFromExpression(propertyInitializer(objectArg, 'destinationName'), node);
188
260
  return { externalTarget: destination ?? { kind: 'unknown', dynamic: false }, classifier: 'sap_destination_lookup', sourceCallShape: 'useOrFetchDestination' };
189
261
  }
190
262
  }
191
263
  if (exprText === 'executeHttpRequest') {
192
- const destination = destinationTargetFromExpression(node.arguments[0], initializers);
264
+ const destination = destinationTargetFromExpression(node.arguments[0], node);
193
265
  const config = node.arguments[1];
194
- const method = config && ts.isObjectLiteralExpression(config) ? httpMethodFromObject(config, initializers) : undefined;
195
- const url = config && ts.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, 'url'), initializers) : { kind: 'unknown', dynamic: false };
266
+ const method = config && ts.isObjectLiteralExpression(config) ? httpMethodFromObject(config, node) : undefined;
267
+ const url = config && ts.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, 'url'), node) : { kind: 'unknown', dynamic: false };
196
268
  return { method, externalTarget: destination ? { ...url, destination } : url, classifier: 'sap_execute_http_request', sourceCallShape: 'executeHttpRequest' };
197
269
  }
198
270
  if (exprText === 'axios') {
199
271
  const config = node.arguments[0];
200
272
  if (config && ts.isObjectLiteralExpression(config)) {
201
- const method = httpMethodFromObject(config, initializers);
202
- return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, 'url'), initializers), classifier: 'axios_config_call', sourceCallShape: 'axios(config)' };
273
+ const method = httpMethodFromObject(config, node);
274
+ return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, 'url'), node), classifier: 'axios_config_call', sourceCallShape: 'axios(config)' };
203
275
  }
204
276
  return { externalTarget: { kind: 'unknown', dynamic: false }, classifier: 'axios_unknown_call', sourceCallShape: 'axios(...)' };
205
277
  }
206
278
  if (exprText === 'fetch') {
207
279
  const init = node.arguments[1];
208
- const method = init && ts.isObjectLiteralExpression(init) ? httpMethodFromObject(init, initializers) : undefined;
209
- return { method, externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: 'fetch_call', sourceCallShape: 'fetch' };
280
+ const method = init && ts.isObjectLiteralExpression(init) ? httpMethodFromObject(init, node) : undefined;
281
+ return { method, externalTarget: urlTargetFromExpression(node.arguments[0], node), classifier: 'fetch_call', sourceCallShape: 'fetch' };
210
282
  }
211
283
  if (ts.isPropertyAccessExpression(expr) && ['get','post','put','patch','delete','head'].includes(expr.name.text) && expr.expression.getText(source) === 'axios') {
212
- return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: 'axios_member_call', sourceCallShape: `axios.${expr.name.text}` };
284
+ return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], node), classifier: 'axios_member_call', sourceCallShape: `axios.${expr.name.text}` };
213
285
  }
214
286
  return undefined;
215
287
  }
@@ -274,7 +346,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
274
346
  const methodProp = propertyInitializer(objectArg, 'method');
275
347
  const pathName = pathProp && ts.isIdentifier(pathProp) ? pathProp.text : undefined;
276
348
  const methodName = methodProp && ts.isIdentifier(methodProp) ? methodProp.text : undefined;
277
- const methodLiteral = staticExpressionText(methodProp, variableInitializers(source));
349
+ const methodLiteral = resolveExpression(methodProp, node, 'literal').value;
278
350
  if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
279
351
  }
280
352
  }
@@ -324,12 +396,12 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
324
396
  if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
325
397
  const receiver = receiverName(expr.expression);
326
398
  const query = objectPropertyText(objectArg, 'query');
327
- const method = stripQuotes(objectPropertyText(objectArg, 'method') ?? 'POST');
399
+ const method = stripQuotes(resolveExpression(propertyInitializer(objectArg, 'method'), node, 'literal').value ?? objectPropertyText(objectArg, 'method') ?? 'POST');
328
400
  const pathExpr = propertyInitializer(objectArg, 'path') ?? propertyInitializer(objectArg, 'event');
329
- const resolvedPath = staticPathExpression(pathExpr, initializers);
401
+ const resolvedPath = staticPathExpression(pathExpr, node);
330
402
  const op = pathExpr ? resolvedPath.text ?? pathExpr.getText(source) : undefined;
331
403
  const shorthandPath = objectPropertyIsShorthand(objectArg, 'path');
332
- const candidateEvidence = pathExpr && ts.isIdentifier(pathExpr) && !resolvedPath.text ? staticPathCandidates(source, pathExpr.text, initializers) : undefined;
404
+ const candidateEvidence = pathExpr && ts.isIdentifier(pathExpr) && !resolvedPath.text ? staticPathCandidates(pathExpr, node, method) : undefined;
333
405
  const candidateOperationPath = candidateEvidence && !candidateEvidence.hasDynamicAssignments && candidateEvidence.normalizedCandidateOperations.length === 1 && candidateEvidence.candidatePaths.length > 0 ? candidateEvidence.candidatePaths[0] : undefined;
334
406
  const operationPathExpr = operationPathFromStatic(resolvedPath.text) ?? candidateOperationPath;
335
407
  const intent = classifyODataPathIntent(operationPathExpr, method);
@@ -346,8 +418,8 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
346
418
  const pathArg = spec ? wrapperArgs[spec.pathIndex] : undefined;
347
419
  const methodArg = spec?.methodIndex === undefined ? undefined : wrapperArgs[spec.methodIndex];
348
420
  const receiver = clientArg && ts.isIdentifier(clientArg) ? clientArg.text : spec?.clientName;
349
- const method = stripQuotes(staticExpressionText(methodArg, initializers) ?? spec?.methodLiteral ?? 'POST');
350
- const resolvedPath = staticPathExpression(pathArg, initializers);
421
+ const method = stripQuotes(resolveExpression(methodArg, node, 'literal').value ?? spec?.methodLiteral ?? 'POST');
422
+ const resolvedPath = staticPathExpression(pathArg, node);
351
423
  const operationPathExpr = operationPathFromStatic(resolvedPath.text);
352
424
  const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : undefined;
353
425
  if (spec && receiver && operationPathExpr) {
@@ -363,7 +435,7 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
363
435
  if (eventName) add(node, { callType: expr.name.text === 'on' ? 'async_subscribe' : 'async_emit', serviceVariableName: rootReceiver ?? receiver, eventNameExpr: eventName }, { receiver, rootReceiver, classifier: expr.name.text === 'on' ? 'cap_service_event_subscription' : 'cap_service_event_emit', receiverClassification: 'cap_evidence' });
364
436
  }
365
437
  } else {
366
- const external = externalHttpEvidence(node, source, initializers);
438
+ const external = externalHttpEvidence(node, source);
367
439
  if (external) {
368
440
  const evidenceTarget = { ...external.externalTarget, method: external.method, parserClassifier: external.classifier, sourceCallShape: external.sourceCallShape };
369
441
  const safeTarget = externalHttpTarget({ method: external.method, evidence_json: JSON.stringify({ externalTarget: evidenceTarget }) });
@@ -400,7 +472,7 @@ function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFac
400
472
  if (origin) aliases.set(node.name.text, { ...origin, chain: [...origin.chain, node.name.text] });
401
473
  }
402
474
  if (ts.isCallExpression(node)) {
403
- const parsed = serviceOperationCall(node.expression, aliases);
475
+ const parsed = serviceOperationCall(node, aliases);
404
476
  if (parsed && parsed.operation !== 'entities') calls.push({
405
477
  callType: 'local_service_call',
406
478
  operationPathExpr: `/${parsed.operation}`,
@@ -413,7 +485,8 @@ function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFac
413
485
  confidence: 0.9,
414
486
  unresolvedReason: ['send', 'emit', 'publish', 'on'].includes(parsed.operation) ? 'transport_client_method' : undefined,
415
487
  evidence: parserEvidence(source, node, {
416
- classifier: 'local_cap_service_call',
488
+ classifier: parsed.classifier,
489
+ parserCallType: parsed.operation === 'send' ? 'transport_client_method' : parsed.classifier,
417
490
  localServiceLookup: parsed.lookup,
418
491
  localServiceName: parsed.service,
419
492
  operation: parsed.operation,
@@ -432,10 +505,17 @@ function serviceLookup(expr: ts.Expression, aliases: Map<string, { service: stri
432
505
  if (ts.isElementAccessExpression(expr) && expr.expression.getText() === 'cds.services' && ts.isStringLiteral(expr.argumentExpression)) return { service: expr.argumentExpression.text, lookup: expr.getText(), chain: [expr.getText()] };
433
506
  return undefined;
434
507
  }
435
- function serviceOperationCall(expr: ts.Expression, aliases: Map<string, { service: string; lookup: string; chain: string[] }>): { service: string; lookup: string; chain: string[]; operation: string } | undefined {
508
+ function serviceOperationCall(node: ts.CallExpression, aliases: Map<string, { service: string; lookup: string; chain: string[] }>): { service: string; lookup: string; chain: string[]; operation: string; classifier: string } | undefined {
509
+ const expr = node.expression;
436
510
  if (!ts.isPropertyAccessExpression(expr)) return undefined;
437
- const operation = expr.name.text;
438
511
  const origin = serviceLookup(expr.expression, aliases);
439
512
  if (!origin) return undefined;
440
- return { ...origin, operation };
513
+ if (expr.name.text === 'send') {
514
+ const first = literalText(node.arguments[0]);
515
+ const second = literalText(node.arguments[1]);
516
+ const method = first?.toUpperCase();
517
+ if (method && ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(method) && second) return { ...origin, operation: second.replace(/^\//, ''), classifier: 'cap_service_send_method_path' };
518
+ if (first) return { ...origin, operation: first.replace(/^\//, ''), classifier: 'cap_service_send_local_dispatch' };
519
+ }
520
+ return { ...origin, operation: expr.name.text, classifier: 'local_cap_service_call' };
441
521
  }
package/src/types.ts CHANGED
@@ -70,6 +70,14 @@ export interface CdsServiceFact {
70
70
  sourceFile: string;
71
71
  sourceLine: number;
72
72
  operations: CdsOperationFact[];
73
+ extension?: CdsExtensionFact;
74
+ }
75
+ export interface CdsExtensionFact {
76
+ localReference: string;
77
+ importedSymbol?: string;
78
+ localAlias?: string;
79
+ moduleSpecifier?: string;
80
+ importKind?: 'relative' | 'package' | 'none';
73
81
  }
74
82
  export interface CdsOperationFact {
75
83
  operationType: 'action' | 'function' | 'event';
@@ -79,6 +87,8 @@ export interface CdsOperationFact {
79
87
  returnType?: string;
80
88
  sourceFile: string;
81
89
  sourceLine: number;
90
+ provenance?: 'direct' | 'inherited';
91
+ baseOperationId?: number;
82
92
  }
83
93
  export interface HandlerClassFact {
84
94
  className: string;