@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.41
4
+
5
+ - Added imported CDS extension provenance and materialized inherited operations at concrete extension paths without guessing by simple service name.
6
+ - Resolved TypeScript identifier expressions through lexical bindings so module constants work and inaccessible shadowed block values are excluded.
7
+ - Classified positional CAP `Service.send(...)` dispatch only for proven CAP service receivers while leaving generic `send` calls untouched.
8
+ - Bumped the SQLite schema to persist extension/base and operation provenance metadata.
9
+
10
+ ## 0.1.40
11
+
12
+ - Hardened operation path expression analysis to respect lexical scope, declaration order, aliases, and bounded branch candidates.
13
+ - Kept external URL and destination templates with substitutions dynamic with sanitized labels.
14
+ - Fixed CDS path annotation parsing for prefix/suffix annotations and supported service extension syntax.
15
+
3
16
  ## 0.1.35
4
17
 
5
18
  - Hardened OData path precedence so entity key, navigation, and media/property paths with placeholders remain terminal entity evidence instead of dynamic operation candidates.
@@ -261,3 +274,4 @@
261
274
  ## 0.1.0
262
275
 
263
276
  - Initial `@saptools/service-flow` CLI package for indexing and tracing SAP CAP service flows.
277
+
package/README.md CHANGED
@@ -505,3 +505,7 @@ Decorator signals are interpreted as resolved, absent, unsupported, or malformed
505
505
  External HTTP facts persist queryable target kind, stable id, safe label, and dynamic flag on `outbound_calls`; graph evidence uses the same sanitized model. URL user info, fragments, query values, headers, cookies, credentials, tokens, API keys, and request/response bodies are not persisted. Legacy workspaces may relink from old evidence when possible, but full semantic enrichment requires re-index plus relink when old parser facts did not contain safe target metadata.
506
506
 
507
507
  Graph taxonomy now keeps CAP operation candidates separate from external HTTP targets. Dynamic, ambiguous, and unresolved CAP calls use operation-candidate semantics, external destinations/endpoints are produced only by `external_http` calls, and terminal local transport-client methods use transport-method semantics rather than external HTTP edges. `doctor --strict` reports external target quality and taxonomy consistency.
508
+ ### Runtime and parser hardening
509
+
510
+ `service-flow` treats URL and destination templates with substitutions as dynamic external targets, preserves operation-path template evidence without promoting unrelated variables, and parses CDS `@(path: ...)` annotations from original source text. Service extension declarations using both `extend Name` and `extend service Name` are recognized for routing provenance.
511
+
package/TECHNICAL-NOTE.md CHANGED
@@ -142,3 +142,7 @@ Outbound call extraction is AST-based and ignores comments, block comments, and
142
142
  ## 0.1.35 OData placeholder semantics
143
143
 
144
144
  Service-flow now records OData placeholders by semantic layer. Service-routing placeholders belong to service bindings and can make an operation edge dynamic until runtime variables are supplied. Operation invocation argument placeholders, such as action/function call arguments, remain operation evidence but are not service-routing variables. Entity key placeholders belong to entity addressing, so key, navigation, and media/property paths remain terminal remote entity/query edges unless indexed CDS operation evidence provides a credible operation match.
145
+ ## 0.1.40 analyzer hardening
146
+
147
+ Expression resolution is consumer-specific: operation paths may retain template placeholders for OData normalization, while external URL and destination classifiers require literal or no-substitution-template evidence before a static target is persisted. Identifier resolution is bounded to the call-site lexical scope and ignores sibling scopes, later declarations, nested function bodies, and mutable or computed writes unless they are recorded as conservative candidate evidence.
148
+
@@ -187,16 +187,35 @@ function collectAnnotations(text, index) {
187
187
  return { end: i, raw };
188
188
  }
189
189
  function pathAnnotation(raw) {
190
- return /path\s*:\s*['"]([^'"]+)['"]/s.exec(raw)?.[1];
190
+ return /path\s*:\s*(['"])(.*?)\1/s.exec(raw)?.[2];
191
191
  }
192
- function matchingBrace(text, open) {
192
+ function matchingBrace(maskedText, open) {
193
193
  let depth = 0;
194
- for (let i = open; i < text.length; i += 1) {
195
- if (text[i] === "{") depth += 1;
196
- if (text[i] === "}") depth -= 1;
194
+ for (let i = open; i < maskedText.length; i += 1) {
195
+ if (maskedText[i] === "{") depth += 1;
196
+ if (maskedText[i] === "}") depth -= 1;
197
197
  if (depth === 0) return i;
198
198
  }
199
- return text.length - 1;
199
+ return maskedText.length - 1;
200
+ }
201
+ function annotationRawAt(original, masked, index) {
202
+ const collected = collectAnnotations(masked, index);
203
+ return { end: collected.end, raw: original.slice(index, collected.end) };
204
+ }
205
+ function collectUsings(masked) {
206
+ const imports = /* @__PURE__ */ new Map();
207
+ for (const m of masked.matchAll(/\busing\s*\{([^}]*)\}\s*from\s*(['"])(.*?)\2\s*;/gs)) {
208
+ const moduleSpecifier = m[3] ?? "";
209
+ for (const part of (m[1] ?? "").split(",")) {
210
+ const text = part.trim();
211
+ if (!text) continue;
212
+ const alias = /^(\w+)\s+as\s+(\w+)$/.exec(text) ?? /^(\w+)\s*:\s*(\w+)$/.exec(text);
213
+ const importedSymbol = alias?.[1] ?? text;
214
+ const localAlias = alias?.[2] ?? importedSymbol;
215
+ imports.set(localAlias, { importedSymbol, localAlias, moduleSpecifier, importKind: moduleSpecifier.startsWith(".") ? "relative" : "package" });
216
+ }
217
+ }
218
+ return imports;
200
219
  }
201
220
  function operationsFromBody(text, maskedBody, bodyOffset, filePath) {
202
221
  return [...maskedBody.matchAll(/\b(action|function|event)\s+(\w+)\s*(?:\(([^)]*)\))?\s*(?:returns\s+([^;{]+))?/g)].map((m) => ({
@@ -216,40 +235,46 @@ async function parseCdsFile(repoPath, filePath) {
216
235
  const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
217
236
  const services = [];
218
237
  const pendingAnnotations = [];
219
- for (const a of masked.matchAll(/@\s*\(/g)) {
220
- const annotation = collectAnnotations(masked, a.index ?? 0);
221
- pendingAnnotations.push(annotation);
222
- }
223
- const serviceRegex = /\b(extend\s+)?service\s+([\w.]+)\b/g;
238
+ const usings = collectUsings(text);
239
+ for (const a of masked.matchAll(/@\s*\(/g)) pendingAnnotations.push(annotationRawAt(text, masked, a.index ?? 0));
240
+ const serviceRegex = /\b(?:(extend)\s+)?(?:(service)\s+)?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\b/g;
224
241
  let match;
225
242
  while (match = serviceRegex.exec(masked)) {
226
- const afterName = collectAnnotations(masked, serviceRegex.lastIndex);
243
+ const isExtend = match[1] === "extend";
244
+ const hasServiceKeyword = match[2] === "service";
245
+ if (!isExtend && !hasServiceKeyword) continue;
246
+ const afterName = annotationRawAt(text, masked, serviceRegex.lastIndex);
227
247
  const open = masked.indexOf("{", afterName.end);
228
248
  if (open === -1) continue;
249
+ const between = masked.slice(afterName.end, open).trim();
250
+ if (between.length > 0) continue;
229
251
  const matchIndex = match.index;
230
252
  const prefix = pendingAnnotations.filter((a) => a.end <= matchIndex && matchIndex - a.end < 8).map((a) => a.raw).join("");
231
253
  const annotations = `${prefix}${afterName.raw}`;
232
254
  const end = matchingBrace(masked, open);
233
255
  const body = masked.slice(open + 1, end);
234
- const name = match[2] ?? "UnknownService";
256
+ const name = match[3] ?? "UnknownService";
235
257
  const serviceName = name.split(".").pop() ?? name;
258
+ const imported = isExtend ? usings.get(name) ?? usings.get(serviceName) : void 0;
236
259
  const servicePath = ensureLeadingSlash(pathAnnotation(annotations) ?? serviceName);
237
260
  services.push({
238
261
  namespace,
239
262
  serviceName,
240
263
  qualifiedName: name.includes(".") ? name : namespace ? `${namespace}.${name}` : name,
241
264
  servicePath,
242
- isExtend: Boolean(match[1]),
265
+ isExtend,
243
266
  sourceFile: normalizePath(filePath),
244
267
  sourceLine: lineOf(text, match.index),
245
- operations: operationsFromBody(text, body, open + 1, filePath)
268
+ operations: operationsFromBody(text, body, open + 1, filePath),
269
+ extension: isExtend ? { localReference: name, importedSymbol: imported?.importedSymbol, localAlias: imported?.localAlias, moduleSpecifier: imported?.moduleSpecifier, importKind: imported?.importKind ?? "none" } : void 0
246
270
  });
247
271
  serviceRegex.lastIndex = end + 1;
248
272
  }
249
273
  const baseOps = new Map(services.filter((s) => !s.isExtend).map((s) => [s.qualifiedName, s.operations]));
250
274
  for (const service of services.filter((s) => s.isExtend && s.operations.length === 0)) {
275
+ if (service.extension?.moduleSpecifier) continue;
251
276
  const inherited = baseOps.get(service.qualifiedName) ?? baseOps.get(service.serviceName);
252
- if (inherited) service.operations = inherited.map((op) => ({ ...op, sourceFile: service.sourceFile, sourceLine: service.sourceLine }));
277
+ if (inherited) service.operations = inherited.map((op) => ({ ...op, provenance: "inherited" }));
253
278
  }
254
279
  return services;
255
280
  }
@@ -815,11 +840,12 @@ function expressionName(expr) {
815
840
  }
816
841
  function variableInitializers(source) {
817
842
  const initializers = /* @__PURE__ */ new Map();
818
- const visit = (node) => {
819
- if (ts4.isVariableDeclaration(node) && ts4.isIdentifier(node.name) && node.initializer && (node.parent.flags & ts4.NodeFlags.Const) !== 0) initializers.set(node.name.text, node.initializer);
820
- ts4.forEachChild(node, visit);
821
- };
822
- visit(source);
843
+ for (const statement of source.statements) {
844
+ if (!ts4.isVariableStatement(statement) || (statement.declarationList.flags & ts4.NodeFlags.Const) === 0) continue;
845
+ for (const declaration of statement.declarationList.declarations) {
846
+ if (ts4.isIdentifier(declaration.name) && declaration.initializer) initializers.set(declaration.name.text, declaration.initializer);
847
+ }
848
+ }
823
849
  return initializers;
824
850
  }
825
851
  function queryEntityFromAst(expr, initializers = /* @__PURE__ */ new Map()) {
@@ -876,48 +902,113 @@ function nameOfProperty(name) {
876
902
  if (ts4.isIdentifier(name) || ts4.isStringLiteral(name) || ts4.isNumericLiteral(name)) return name.text;
877
903
  return void 0;
878
904
  }
905
+ var maxAliasDepth = 5;
906
+ function safeRaw(expr) {
907
+ if (ts4.isStringLiteral(expr) || ts4.isNoSubstitutionTemplateLiteral(expr) || ts4.isIdentifier(expr) || ts4.isTemplateExpression(expr)) return expr.getText(expr.getSourceFile());
908
+ return void 0;
909
+ }
910
+ function placeholders(expr) {
911
+ return expr.templateSpans.map((span) => span.expression.getText(expr.getSourceFile()));
912
+ }
913
+ function isFunctionLikeScope(node) {
914
+ return ts4.isFunctionLike(node) || ts4.isSourceFile(node);
915
+ }
916
+ function nodeContains(parent, child) {
917
+ const source = child.getSourceFile();
918
+ return child.getStart(source) >= parent.getStart(source) && child.getEnd() <= parent.getEnd();
919
+ }
920
+ function declarationScope(node) {
921
+ if (ts4.isParameter(node)) return node.parent;
922
+ const list = node.parent;
923
+ const blockScoped = (list.flags & (ts4.NodeFlags.Const | ts4.NodeFlags.Let)) !== 0;
924
+ let current = list.parent;
925
+ if (!blockScoped) {
926
+ while (current.parent && !isFunctionLikeScope(current)) current = current.parent;
927
+ return current;
928
+ }
929
+ while (current.parent && !ts4.isBlock(current) && !ts4.isSourceFile(current) && !ts4.isModuleBlock(current) && !ts4.isCaseBlock(current) && !isFunctionLikeScope(current)) current = current.parent;
930
+ return current;
931
+ }
932
+ function isAccessibleDeclaration(declaration, use) {
933
+ const source = use.getSourceFile();
934
+ if (declaration.name.getStart(source) >= use.getStart(source)) return false;
935
+ const scope = declarationScope(declaration);
936
+ return ts4.isSourceFile(scope) || nodeContains(scope, use);
937
+ }
938
+ function resolveBinding(identifier, use) {
939
+ const source = use.getSourceFile();
940
+ let best;
941
+ const visit = (node) => {
942
+ if (ts4.isVariableDeclaration(node) && ts4.isIdentifier(node.name) && node.name.text === identifier.text && isAccessibleDeclaration(node, use)) best = node;
943
+ if (ts4.isParameter(node) && ts4.isIdentifier(node.name) && node.name.text === identifier.text && isAccessibleDeclaration(node, use)) best = node;
944
+ ts4.forEachChild(node, visit);
945
+ };
946
+ visit(source);
947
+ if (!best) return { immutable: false, evidence: ["binding_not_found"] };
948
+ const immutable = ts4.isVariableDeclaration(best) && (best.parent.flags & ts4.NodeFlags.Const) !== 0;
949
+ return { declaration: best, initializer: ts4.isVariableDeclaration(best) ? best.initializer : void 0, immutable, evidence: [immutable ? "lexical_const_binding_before_use" : "lexical_mutable_or_parameter_binding"] };
950
+ }
951
+ function resolveExpression(expr, use, policy, depth = 0, seen = /* @__PURE__ */ new Set()) {
952
+ if (!expr) return { status: "unknown", sourceKind: "dynamic_expression", placeholderKeys: [], evidence: ["expression_missing"] };
953
+ if (ts4.isStringLiteral(expr)) return { status: "static", sourceKind: "string_literal", value: expr.text, rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ["string_literal"] };
954
+ if (ts4.isNoSubstitutionTemplateLiteral(expr)) return { status: "static", sourceKind: "no_substitution_template", value: expr.text, rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ["no_substitution_template"] };
955
+ if (ts4.isTemplateExpression(expr)) {
956
+ const keys = placeholders(expr);
957
+ 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"] };
958
+ return { status: "dynamic", sourceKind: "template_with_substitutions", placeholderKeys: keys, evidence: ["template_substitutions_not_static_external_target"] };
959
+ }
960
+ if (ts4.isIdentifier(expr)) {
961
+ if (depth >= maxAliasDepth) return { status: "unknown", sourceKind: "const_alias", rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ["alias_depth_exceeded"], constName: expr.text };
962
+ const binding = resolveBinding(expr, use);
963
+ if (!binding.declaration || !binding.initializer || !binding.immutable) return { status: "dynamic", sourceKind: "dynamic_expression", rawExpression: safeRaw(expr), placeholderKeys: [], evidence: binding.evidence, constName: expr.text };
964
+ if (seen.has(binding.declaration)) return { status: "unknown", sourceKind: "const_alias", rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ["alias_cycle_detected"], constName: expr.text };
965
+ seen.add(binding.declaration);
966
+ const resolved2 = resolveExpression(binding.initializer, binding.declaration, policy, depth + 1, seen);
967
+ return { ...resolved2, sourceKind: "const_alias", rawExpression: safeRaw(expr), constName: expr.text, evidence: [...binding.evidence, ...resolved2.evidence] };
968
+ }
969
+ return { status: "dynamic", sourceKind: "dynamic_expression", rawExpression: safeRaw(expr), placeholderKeys: [], evidence: [`unsupported_${ts4.SyntaxKind[expr.kind] ?? "expression"}`] };
970
+ }
879
971
  function staticExpressionText(expr, initializers) {
880
972
  if (!expr) return void 0;
881
973
  if (isStringLike(expr)) return expr.text;
882
- if (ts4.isTemplateExpression(expr)) return stripQuotes(expr.getText(expr.getSourceFile()));
883
974
  if (ts4.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
884
975
  return void 0;
885
976
  }
886
- function staticPathExpression(expr, initializers) {
887
- if (!expr) return { sourceKind: "dynamic" };
888
- const rawExpression = expr.getText(expr.getSourceFile());
889
- if (isStringLike(expr)) return { text: expr.text, sourceKind: "literal", rawExpression };
890
- if (ts4.isTemplateExpression(expr)) return { text: stripQuotes(rawExpression), sourceKind: "template", rawExpression };
891
- if (ts4.isIdentifier(expr) && initializers.has(expr.text)) {
892
- const resolved2 = staticPathExpression(initializers.get(expr.text), initializers);
893
- return resolved2.text ? { ...resolved2, sourceKind: "const", rawExpression, constName: expr.text } : { sourceKind: "dynamic", rawExpression };
894
- }
895
- return { sourceKind: "dynamic", rawExpression };
977
+ function staticPathExpression(expr, use) {
978
+ const resolution = resolveExpression(expr, use, "operation_path");
979
+ const sourceKind = resolution.sourceKind === "const_alias" ? "const" : resolution.sourceKind === "template_with_substitutions" || resolution.sourceKind === "no_substitution_template" ? "template" : resolution.status === "static" ? "literal" : "dynamic";
980
+ return { text: resolution.value, sourceKind, rawExpression: resolution.rawExpression, constName: resolution.constName, resolution };
896
981
  }
897
982
  function operationPathFromStatic(text) {
898
983
  return text ? `/${stripQuotes(text).replace(/^\//, "")}` : void 0;
899
984
  }
900
- function staticPathCandidates(source, identifier, initializers) {
985
+ function staticPathCandidates(identifier, call, method) {
986
+ const binding = resolveBinding(identifier, call);
987
+ const declaration = binding.declaration;
988
+ if (!declaration) return void 0;
989
+ const source = call.getSourceFile();
901
990
  const paths = [];
902
991
  let hasDynamicAssignments = false;
992
+ const addExpr = (expr, origin) => {
993
+ const resolved2 = staticPathExpression(expr, origin);
994
+ if (resolved2.text) paths.push(operationPathFromStatic(resolved2.text) ?? resolved2.text);
995
+ else hasDynamicAssignments = true;
996
+ };
997
+ if (ts4.isVariableDeclaration(declaration)) addExpr(declaration.initializer, declaration);
903
998
  const visit = (node) => {
904
- if (ts4.isVariableDeclaration(node) && ts4.isIdentifier(node.name) && node.name.text === identifier && node.initializer) {
905
- const resolved2 = staticPathExpression(node.initializer, initializers);
906
- if (resolved2.text) paths.push(operationPathFromStatic(resolved2.text) ?? resolved2.text);
907
- else hasDynamicAssignments = true;
908
- }
909
- if (ts4.isBinaryExpression(node) && node.operatorToken.kind === ts4.SyntaxKind.EqualsToken && ts4.isIdentifier(node.left) && node.left.text === identifier) {
910
- const resolved2 = staticPathExpression(node.right, initializers);
911
- if (resolved2.text) paths.push(operationPathFromStatic(resolved2.text) ?? resolved2.text);
912
- else hasDynamicAssignments = true;
999
+ if (node !== source && isFunctionLikeScope(node) && !nodeContains(node, call)) return;
1000
+ if (node.getStart(source) >= call.getStart(source)) return;
1001
+ if (ts4.isBinaryExpression(node) && node.operatorToken.kind === ts4.SyntaxKind.EqualsToken && ts4.isIdentifier(node.left)) {
1002
+ const target = resolveBinding(node.left, node);
1003
+ if (target.declaration === declaration) addExpr(node.right, node);
913
1004
  }
914
1005
  ts4.forEachChild(node, visit);
915
1006
  };
916
1007
  visit(source);
917
1008
  const candidatePaths = [...new Set(paths)];
918
1009
  if (candidatePaths.length === 0) return void 0;
919
- const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, "POST").topLevelOperationName).filter((value) => Boolean(value)))];
920
- return { candidatePaths, normalizedCandidateOperations, candidateSourceKind: "static candidates observed", candidateIdentifier: identifier, hasDynamicAssignments };
1010
+ const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, method).topLevelOperationName).filter((value) => Boolean(value)))];
1011
+ 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" : void 0 };
921
1012
  }
922
1013
  function destinationExpressionShape(expr) {
923
1014
  if (!expr) return void 0;
@@ -944,57 +1035,61 @@ function propertyInitializer(object, key) {
944
1035
  }
945
1036
  return void 0;
946
1037
  }
947
- function httpMethodFromObject(object, initializers) {
948
- const text = staticExpressionText(propertyInitializer(object, "method"), initializers);
1038
+ function httpMethodFromObject(object, use) {
1039
+ const text = resolveExpression(propertyInitializer(object, "method"), use, "literal").value;
949
1040
  return text ? stripQuotes(text).toUpperCase() : void 0;
950
1041
  }
951
- function urlTargetFromExpression(expr, initializers) {
952
- const text = staticExpressionText(expr, initializers);
953
- if (text) return { kind: "static_url", expression: text, dynamic: false };
954
- if (expr && (ts4.isTemplateExpression(expr) || ts4.isIdentifier(expr) || ts4.isPropertyAccessExpression(expr) || ts4.isCallExpression(expr))) return { kind: "url_expression", expression: expr.getText(expr.getSourceFile()), dynamic: true };
1042
+ function hasTemplatePlaceholder(value) {
1043
+ return /\$\{|%7B|%7D/i.test(value);
1044
+ }
1045
+ function urlTargetFromExpression(expr, use) {
1046
+ const resolved2 = resolveExpression(expr, use, "external");
1047
+ if (resolved2.status === "static" && resolved2.value && !hasTemplatePlaceholder(resolved2.value)) return { kind: "static_url", expression: resolved2.value, dynamic: false, sourceKind: resolved2.sourceKind };
1048
+ if (expr) return { kind: "url_expression", dynamic: true, expression: `${resolved2.sourceKind}:${resolved2.placeholderKeys.join("|")}`, expressionShape: resolved2.sourceKind, placeholderKeys: resolved2.placeholderKeys };
955
1049
  return { kind: "unknown", dynamic: false };
956
1050
  }
957
- function destinationTargetFromExpression(expr, initializers) {
958
- const text = staticExpressionText(expr, initializers);
959
- if (text) return { kind: "destination", expression: text, dynamic: false };
960
- const candidates = staticConditionalCandidates(expr, initializers);
1051
+ function destinationTargetFromExpression(expr, use) {
1052
+ const resolved2 = resolveExpression(expr, use, "external");
1053
+ const text = resolved2.value;
1054
+ if (resolved2.status === "static" && text && !hasTemplatePlaceholder(text)) return { kind: "destination", expression: text, dynamic: false, sourceKind: resolved2.sourceKind };
1055
+ const candidates = staticConditionalCandidates(expr, /* @__PURE__ */ new Map());
961
1056
  if (candidates) return { kind: "destination", dynamic: true, expressionShape: "conditional", candidateLiterals: candidates };
962
1057
  const shape = destinationExpressionShape(expr);
963
1058
  if (shape) return { kind: "destination", dynamic: true, expressionShape: shape };
964
1059
  return void 0;
965
1060
  }
966
- function externalHttpEvidence(node, source, initializers) {
1061
+ function externalHttpEvidence(node, source) {
967
1062
  const expr = node.expression;
968
1063
  const exprText = expr.getText(source);
969
1064
  if (exprText === "useOrFetchDestination") {
970
1065
  const objectArg = node.arguments[0];
971
1066
  if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
972
- const destination = destinationTargetFromExpression(propertyInitializer(objectArg, "destinationName"), initializers);
1067
+ const destination = destinationTargetFromExpression(propertyInitializer(objectArg, "destinationName"), node);
973
1068
  return { externalTarget: destination ?? { kind: "unknown", dynamic: false }, classifier: "sap_destination_lookup", sourceCallShape: "useOrFetchDestination" };
974
1069
  }
975
1070
  }
976
1071
  if (exprText === "executeHttpRequest") {
977
- const destination = destinationTargetFromExpression(node.arguments[0], initializers);
1072
+ const destination = destinationTargetFromExpression(node.arguments[0], node);
978
1073
  const config = node.arguments[1];
979
- const method = config && ts4.isObjectLiteralExpression(config) ? httpMethodFromObject(config, initializers) : void 0;
980
- const url = config && ts4.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, "url"), initializers) : { kind: "unknown", dynamic: false };
1074
+ const method = config && ts4.isObjectLiteralExpression(config) ? httpMethodFromObject(config, node) : void 0;
1075
+ const url = config && ts4.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, "url"), node) : { kind: "unknown", dynamic: false };
981
1076
  return { method, externalTarget: destination ? { ...url, destination } : url, classifier: "sap_execute_http_request", sourceCallShape: "executeHttpRequest" };
982
1077
  }
983
1078
  if (exprText === "axios") {
984
1079
  const config = node.arguments[0];
985
1080
  if (config && ts4.isObjectLiteralExpression(config)) {
986
- const method = httpMethodFromObject(config, initializers);
987
- return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, "url"), initializers), classifier: "axios_config_call", sourceCallShape: "axios(config)" };
1081
+ const method = httpMethodFromObject(config, node);
1082
+ return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, "url"), node), classifier: "axios_config_call", sourceCallShape: "axios(config)" };
988
1083
  }
989
1084
  return { externalTarget: { kind: "unknown", dynamic: false }, classifier: "axios_unknown_call", sourceCallShape: "axios(...)" };
990
1085
  }
991
1086
  if (exprText === "fetch") {
992
1087
  const init = node.arguments[1];
993
- const method = init && ts4.isObjectLiteralExpression(init) ? httpMethodFromObject(init, initializers) : void 0;
994
- return { method, externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: "fetch_call", sourceCallShape: "fetch" };
1088
+ const method = init && ts4.isObjectLiteralExpression(init) ? httpMethodFromObject(init, node) : void 0;
1089
+ return { method, externalTarget: urlTargetFromExpression(node.arguments[0], node), classifier: "fetch_call", sourceCallShape: "fetch" };
995
1090
  }
996
1091
  if (ts4.isPropertyAccessExpression(expr) && ["get", "post", "put", "patch", "delete", "head"].includes(expr.name.text) && expr.expression.getText(source) === "axios") {
997
- return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: "axios_member_call", sourceCallShape: `axios.${expr.name.text}` };
1092
+ return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], node), classifier: "axios_member_call", sourceCallShape: `axios.${expr.name.text}` };
998
1093
  }
999
1094
  return void 0;
1000
1095
  }
@@ -1057,7 +1152,7 @@ function collectWrapperSpecs(source) {
1057
1152
  const methodProp = propertyInitializer(objectArg, "method");
1058
1153
  const pathName = pathProp && ts4.isIdentifier(pathProp) ? pathProp.text : void 0;
1059
1154
  const methodName = methodProp && ts4.isIdentifier(methodProp) ? methodProp.text : void 0;
1060
- const methodLiteral = staticExpressionText(methodProp, variableInitializers(source));
1155
+ const methodLiteral = resolveExpression(methodProp, node, "literal").value;
1061
1156
  if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
1062
1157
  }
1063
1158
  }
@@ -1107,12 +1202,12 @@ function classifyOutboundCallsInSource(source, filePath) {
1107
1202
  if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
1108
1203
  const receiver = receiverName(expr.expression);
1109
1204
  const query = objectPropertyText(objectArg, "query");
1110
- const method = stripQuotes(objectPropertyText(objectArg, "method") ?? "POST");
1205
+ const method = stripQuotes(resolveExpression(propertyInitializer(objectArg, "method"), node, "literal").value ?? objectPropertyText(objectArg, "method") ?? "POST");
1111
1206
  const pathExpr = propertyInitializer(objectArg, "path") ?? propertyInitializer(objectArg, "event");
1112
- const resolvedPath = staticPathExpression(pathExpr, initializers);
1207
+ const resolvedPath = staticPathExpression(pathExpr, node);
1113
1208
  const op = pathExpr ? resolvedPath.text ?? pathExpr.getText(source) : void 0;
1114
1209
  const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
1115
- const candidateEvidence2 = pathExpr && ts4.isIdentifier(pathExpr) && !resolvedPath.text ? staticPathCandidates(source, pathExpr.text, initializers) : void 0;
1210
+ const candidateEvidence2 = pathExpr && ts4.isIdentifier(pathExpr) && !resolvedPath.text ? staticPathCandidates(pathExpr, node, method) : void 0;
1116
1211
  const candidateOperationPath = candidateEvidence2 && !candidateEvidence2.hasDynamicAssignments && candidateEvidence2.normalizedCandidateOperations.length === 1 && candidateEvidence2.candidatePaths.length > 0 ? candidateEvidence2.candidatePaths[0] : void 0;
1117
1212
  const operationPathExpr = operationPathFromStatic(resolvedPath.text) ?? candidateOperationPath;
1118
1213
  const intent = classifyODataPathIntent(operationPathExpr, method);
@@ -1129,8 +1224,8 @@ function classifyOutboundCallsInSource(source, filePath) {
1129
1224
  const pathArg = spec ? wrapperArgs[spec.pathIndex] : void 0;
1130
1225
  const methodArg = spec?.methodIndex === void 0 ? void 0 : wrapperArgs[spec.methodIndex];
1131
1226
  const receiver = clientArg && ts4.isIdentifier(clientArg) ? clientArg.text : spec?.clientName;
1132
- const method = stripQuotes(staticExpressionText(methodArg, initializers) ?? spec?.methodLiteral ?? "POST");
1133
- const resolvedPath = staticPathExpression(pathArg, initializers);
1227
+ const method = stripQuotes(resolveExpression(methodArg, node, "literal").value ?? spec?.methodLiteral ?? "POST");
1228
+ const resolvedPath = staticPathExpression(pathArg, node);
1134
1229
  const operationPathExpr = operationPathFromStatic(resolvedPath.text);
1135
1230
  const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : void 0;
1136
1231
  if (spec && receiver && operationPathExpr) {
@@ -1146,7 +1241,7 @@ function classifyOutboundCallsInSource(source, filePath) {
1146
1241
  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" });
1147
1242
  }
1148
1243
  } else {
1149
- const external = externalHttpEvidence(node, source, initializers);
1244
+ const external = externalHttpEvidence(node, source);
1150
1245
  if (external) {
1151
1246
  const evidenceTarget = { ...external.externalTarget, method: external.method, parserClassifier: external.classifier, sourceCallShape: external.sourceCallShape };
1152
1247
  const safeTarget = externalHttpTarget({ method: external.method, evidence_json: JSON.stringify({ externalTarget: evidenceTarget }) });
@@ -1180,7 +1275,7 @@ function parseLocalServiceCalls(text, filePath) {
1180
1275
  if (origin) aliases.set(node.name.text, { ...origin, chain: [...origin.chain, node.name.text] });
1181
1276
  }
1182
1277
  if (ts4.isCallExpression(node)) {
1183
- const parsed = serviceOperationCall(node.expression, aliases);
1278
+ const parsed = serviceOperationCall(node, aliases);
1184
1279
  if (parsed && parsed.operation !== "entities") calls.push({
1185
1280
  callType: "local_service_call",
1186
1281
  operationPathExpr: `/${parsed.operation}`,
@@ -1193,7 +1288,8 @@ function parseLocalServiceCalls(text, filePath) {
1193
1288
  confidence: 0.9,
1194
1289
  unresolvedReason: ["send", "emit", "publish", "on"].includes(parsed.operation) ? "transport_client_method" : void 0,
1195
1290
  evidence: parserEvidence(source, node, {
1196
- classifier: "local_cap_service_call",
1291
+ classifier: parsed.classifier,
1292
+ parserCallType: parsed.operation === "send" ? "transport_client_method" : parsed.classifier,
1197
1293
  localServiceLookup: parsed.lookup,
1198
1294
  localServiceName: parsed.service,
1199
1295
  operation: parsed.operation,
@@ -1212,12 +1308,19 @@ function serviceLookup(expr, aliases) {
1212
1308
  if (ts4.isElementAccessExpression(expr) && expr.expression.getText() === "cds.services" && ts4.isStringLiteral(expr.argumentExpression)) return { service: expr.argumentExpression.text, lookup: expr.getText(), chain: [expr.getText()] };
1213
1309
  return void 0;
1214
1310
  }
1215
- function serviceOperationCall(expr, aliases) {
1311
+ function serviceOperationCall(node, aliases) {
1312
+ const expr = node.expression;
1216
1313
  if (!ts4.isPropertyAccessExpression(expr)) return void 0;
1217
- const operation = expr.name.text;
1218
1314
  const origin = serviceLookup(expr.expression, aliases);
1219
1315
  if (!origin) return void 0;
1220
- return { ...origin, operation };
1316
+ if (expr.name.text === "send") {
1317
+ const first = literalText(node.arguments[0]);
1318
+ const second = literalText(node.arguments[1]);
1319
+ const method = first?.toUpperCase();
1320
+ if (method && ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(method) && second) return { ...origin, operation: second.replace(/^\//, ""), classifier: "cap_service_send_method_path" };
1321
+ if (first) return { ...origin, operation: first.replace(/^\//, ""), classifier: "cap_service_send_local_dispatch" };
1322
+ }
1323
+ return { ...origin, operation: expr.name.text, classifier: "local_cap_service_call" };
1221
1324
  }
1222
1325
 
1223
1326
  // src/parsers/service-binding-parser.ts
@@ -1235,7 +1338,7 @@ function stringValue(node) {
1235
1338
  return node.getText().replace(/^`|`$/g, "");
1236
1339
  return node.getText();
1237
1340
  }
1238
- function placeholders(value) {
1341
+ function placeholders2(value) {
1239
1342
  return [...(value ?? "").matchAll(/\$\{([^}]*)\}/g)].map((m) => (m[1] ?? "").trim()).filter(Boolean);
1240
1343
  }
1241
1344
  function connectFactFromCall(call) {
@@ -1261,7 +1364,7 @@ function connectFactFromCall(call) {
1261
1364
  return {
1262
1365
  aliasExpr,
1263
1366
  isDynamic: true,
1264
- placeholders: placeholders(aliasExpr)
1367
+ placeholders: placeholders2(aliasExpr)
1265
1368
  };
1266
1369
  let destinationExpr;
1267
1370
  let servicePathExpr;
@@ -1279,9 +1382,9 @@ function connectFactFromCall(call) {
1279
1382
  }
1280
1383
  if (objectArg) visitObject(objectArg);
1281
1384
  const ph = [
1282
- ...placeholders(aliasExpr ?? alias),
1283
- ...placeholders(destinationExpr),
1284
- ...placeholders(servicePathExpr)
1385
+ ...placeholders2(aliasExpr ?? alias),
1386
+ ...placeholders2(destinationExpr),
1387
+ ...placeholders2(servicePathExpr)
1285
1388
  ];
1286
1389
  return {
1287
1390
  alias,
@@ -1931,9 +2034,9 @@ function extractPlaceholders(template) {
1931
2034
  }
1932
2035
  function substituteVariables(template, vars) {
1933
2036
  if (!template) return { placeholders: [], missing: [], supplied: [], changed: false };
1934
- const placeholders2 = [...new Set(extractPlaceholders(template))];
1935
- const supplied = placeholders2.filter((key) => Object.hasOwn(vars, key));
1936
- const missing = placeholders2.filter((key) => !Object.hasOwn(vars, key));
2037
+ const placeholders3 = [...new Set(extractPlaceholders(template))];
2038
+ const supplied = placeholders3.filter((key) => Object.hasOwn(vars, key));
2039
+ const missing = placeholders3.filter((key) => !Object.hasOwn(vars, key));
1937
2040
  const effective = template.replace(PLACEHOLDER, (_m, key) => {
1938
2041
  const trimmed = key.trim();
1939
2042
  return Object.hasOwn(vars, trimmed) ? vars[trimmed] ?? "" : `\${${trimmed}}`;
@@ -1941,7 +2044,7 @@ function substituteVariables(template, vars) {
1941
2044
  return {
1942
2045
  original: template,
1943
2046
  effective,
1944
- placeholders: placeholders2,
2047
+ placeholders: placeholders3,
1945
2048
  missing,
1946
2049
  supplied,
1947
2050
  changed: effective !== template
@@ -3120,4 +3223,4 @@ export {
3120
3223
  linkWorkspace,
3121
3224
  trace
3122
3225
  };
3123
- //# sourceMappingURL=chunk-SAZ5OK7R.js.map
3226
+ //# sourceMappingURL=chunk-7L4QKKGN.js.map