@saptools/service-flow 0.1.38 → 0.1.40

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.40
4
+
5
+ - Hardened operation path expression analysis to respect lexical scope, declaration order, aliases, and bounded branch candidates.
6
+ - Kept external URL and destination templates with substitutions dynamic with sanitized labels.
7
+ - Fixed CDS path annotation parsing for prefix/suffix annotations and supported service extension syntax.
8
+
3
9
  ## 0.1.35
4
10
 
5
11
  - 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 +267,4 @@
261
267
  ## 0.1.0
262
268
 
263
269
  - Initial `@saptools/service-flow` CLI package for indexing and tracing SAP CAP service flows.
270
+
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,20 @@ 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) };
200
204
  }
201
205
  function operationsFromBody(text, maskedBody, bodyOffset, filePath) {
202
206
  return [...maskedBody.matchAll(/\b(action|function|event)\s+(\w+)\s*(?:\(([^)]*)\))?\s*(?:returns\s+([^;{]+))?/g)].map((m) => ({
@@ -216,22 +220,24 @@ async function parseCdsFile(repoPath, filePath) {
216
220
  const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
217
221
  const services = [];
218
222
  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;
223
+ for (const a of masked.matchAll(/@\s*\(/g)) pendingAnnotations.push(annotationRawAt(text, masked, a.index ?? 0));
224
+ const serviceRegex = /\b(?:(extend)\s+)?(?:(service)\s+)?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\b/g;
224
225
  let match;
225
226
  while (match = serviceRegex.exec(masked)) {
226
- const afterName = collectAnnotations(masked, serviceRegex.lastIndex);
227
+ const isExtend = match[1] === "extend";
228
+ const hasServiceKeyword = match[2] === "service";
229
+ if (!isExtend && !hasServiceKeyword) continue;
230
+ const afterName = annotationRawAt(text, masked, serviceRegex.lastIndex);
227
231
  const open = masked.indexOf("{", afterName.end);
228
232
  if (open === -1) continue;
233
+ const between = masked.slice(afterName.end, open).trim();
234
+ if (between.length > 0) continue;
229
235
  const matchIndex = match.index;
230
236
  const prefix = pendingAnnotations.filter((a) => a.end <= matchIndex && matchIndex - a.end < 8).map((a) => a.raw).join("");
231
237
  const annotations = `${prefix}${afterName.raw}`;
232
238
  const end = matchingBrace(masked, open);
233
239
  const body = masked.slice(open + 1, end);
234
- const name = match[2] ?? "UnknownService";
240
+ const name = match[3] ?? "UnknownService";
235
241
  const serviceName = name.split(".").pop() ?? name;
236
242
  const servicePath = ensureLeadingSlash(pathAnnotation(annotations) ?? serviceName);
237
243
  services.push({
@@ -239,7 +245,7 @@ async function parseCdsFile(repoPath, filePath) {
239
245
  serviceName,
240
246
  qualifiedName: name.includes(".") ? name : namespace ? `${namespace}.${name}` : name,
241
247
  servicePath,
242
- isExtend: Boolean(match[1]),
248
+ isExtend,
243
249
  sourceFile: normalizePath(filePath),
244
250
  sourceLine: lineOf(text, match.index),
245
251
  operations: operationsFromBody(text, body, open + 1, filePath)
@@ -815,11 +821,12 @@ function expressionName(expr) {
815
821
  }
816
822
  function variableInitializers(source) {
817
823
  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);
824
+ for (const statement of source.statements) {
825
+ if (!ts4.isVariableStatement(statement) || (statement.declarationList.flags & ts4.NodeFlags.Const) === 0) continue;
826
+ for (const declaration of statement.declarationList.declarations) {
827
+ if (ts4.isIdentifier(declaration.name) && declaration.initializer) initializers.set(declaration.name.text, declaration.initializer);
828
+ }
829
+ }
823
830
  return initializers;
824
831
  }
825
832
  function queryEntityFromAst(expr, initializers = /* @__PURE__ */ new Map()) {
@@ -876,12 +883,109 @@ function nameOfProperty(name) {
876
883
  if (ts4.isIdentifier(name) || ts4.isStringLiteral(name) || ts4.isNumericLiteral(name)) return name.text;
877
884
  return void 0;
878
885
  }
886
+ var maxAliasDepth = 5;
887
+ function safeRaw(expr) {
888
+ if (ts4.isStringLiteral(expr) || ts4.isNoSubstitutionTemplateLiteral(expr) || ts4.isIdentifier(expr) || ts4.isTemplateExpression(expr)) return expr.getText(expr.getSourceFile());
889
+ return void 0;
890
+ }
891
+ function placeholders(expr) {
892
+ return expr.templateSpans.map((span) => span.expression.getText(expr.getSourceFile()));
893
+ }
894
+ function isFunctionLikeScope(node) {
895
+ return ts4.isFunctionLike(node) || ts4.isSourceFile(node);
896
+ }
897
+ function containingScope(node) {
898
+ let current = node.parent;
899
+ while (current && !isFunctionLikeScope(current)) current = current.parent;
900
+ return current ?? node.getSourceFile();
901
+ }
902
+ function nodeContains(parent, child) {
903
+ return child.getStart(child.getSourceFile()) >= parent.getStart(parent.getSourceFile()) && child.getEnd() <= parent.getEnd();
904
+ }
905
+ function nestedFunctionContains(scope, candidate, use) {
906
+ let current = candidate.parent;
907
+ while (current && current !== scope) {
908
+ if (isFunctionLikeScope(current) && !nodeContains(current, use)) return true;
909
+ current = current.parent;
910
+ }
911
+ return false;
912
+ }
913
+ function resolveBinding(identifier, use) {
914
+ const scope = containingScope(use);
915
+ const source = use.getSourceFile();
916
+ let best;
917
+ const visit = (node) => {
918
+ if (node !== scope && isFunctionLikeScope(node)) return;
919
+ if (node.getStart(source) >= identifier.getStart(source)) return;
920
+ if (ts4.isVariableDeclaration(node) && ts4.isIdentifier(node.name) && node.name.text === identifier.text && !nestedFunctionContains(scope, node, use)) best = node;
921
+ if (ts4.isParameter(node) && ts4.isIdentifier(node.name) && node.name.text === identifier.text && !nestedFunctionContains(scope, node, use)) best = node;
922
+ ts4.forEachChild(node, visit);
923
+ };
924
+ visit(scope);
925
+ if (!best) return { immutable: false, evidence: ["binding_not_found"] };
926
+ const immutable = ts4.isVariableDeclaration(best) && (best.parent.flags & ts4.NodeFlags.Const) !== 0;
927
+ return { declaration: best, initializer: ts4.isVariableDeclaration(best) ? best.initializer : void 0, immutable, evidence: [immutable ? "const_binding_before_use" : "mutable_or_parameter_binding"] };
928
+ }
929
+ function resolveExpression(expr, use, policy, depth = 0, seen = /* @__PURE__ */ new Set()) {
930
+ if (!expr) return { status: "unknown", sourceKind: "dynamic_expression", placeholderKeys: [], evidence: ["expression_missing"] };
931
+ if (ts4.isStringLiteral(expr)) return { status: "static", sourceKind: "string_literal", value: expr.text, rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ["string_literal"] };
932
+ if (ts4.isNoSubstitutionTemplateLiteral(expr)) return { status: "static", sourceKind: "no_substitution_template", value: expr.text, rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ["no_substitution_template"] };
933
+ if (ts4.isTemplateExpression(expr)) {
934
+ const keys = placeholders(expr);
935
+ 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"] };
936
+ return { status: "dynamic", sourceKind: "template_with_substitutions", placeholderKeys: keys, evidence: ["template_substitutions_not_static_external_target"] };
937
+ }
938
+ if (ts4.isIdentifier(expr)) {
939
+ if (depth >= maxAliasDepth) return { status: "unknown", sourceKind: "const_alias", rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ["alias_depth_exceeded"], constName: expr.text };
940
+ const binding = resolveBinding(expr, use);
941
+ if (!binding.declaration || !binding.initializer || !binding.immutable) return { status: "dynamic", sourceKind: "dynamic_expression", rawExpression: safeRaw(expr), placeholderKeys: [], evidence: binding.evidence, constName: expr.text };
942
+ if (seen.has(binding.declaration)) return { status: "unknown", sourceKind: "const_alias", rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ["alias_cycle_detected"], constName: expr.text };
943
+ seen.add(binding.declaration);
944
+ const resolved2 = resolveExpression(binding.initializer, binding.declaration, policy, depth + 1, seen);
945
+ return { ...resolved2, sourceKind: "const_alias", rawExpression: safeRaw(expr), constName: expr.text, evidence: [...binding.evidence, ...resolved2.evidence] };
946
+ }
947
+ return { status: "dynamic", sourceKind: "dynamic_expression", rawExpression: safeRaw(expr), placeholderKeys: [], evidence: [`unsupported_${ts4.SyntaxKind[expr.kind] ?? "expression"}`] };
948
+ }
879
949
  function staticExpressionText(expr, initializers) {
880
950
  if (!expr) return void 0;
881
951
  if (isStringLike(expr)) return expr.text;
882
952
  if (ts4.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
883
953
  return void 0;
884
954
  }
955
+ function staticPathExpression(expr, use) {
956
+ const resolution = resolveExpression(expr, use, "operation_path");
957
+ const sourceKind = resolution.sourceKind === "const_alias" ? "const" : resolution.sourceKind === "template_with_substitutions" || resolution.sourceKind === "no_substitution_template" ? "template" : resolution.status === "static" ? "literal" : "dynamic";
958
+ return { text: resolution.value, sourceKind, rawExpression: resolution.rawExpression, constName: resolution.constName, resolution };
959
+ }
960
+ function operationPathFromStatic(text) {
961
+ return text ? `/${stripQuotes(text).replace(/^\//, "")}` : void 0;
962
+ }
963
+ function staticPathCandidates(identifier, call, method) {
964
+ const binding = resolveBinding(identifier, call);
965
+ const declaration = binding.declaration;
966
+ if (!declaration) return void 0;
967
+ const scope = containingScope(call);
968
+ const source = call.getSourceFile();
969
+ const paths = [];
970
+ let hasDynamicAssignments = false;
971
+ const addExpr = (expr, origin) => {
972
+ const resolved2 = staticPathExpression(expr, origin);
973
+ if (resolved2.text) paths.push(operationPathFromStatic(resolved2.text) ?? resolved2.text);
974
+ else hasDynamicAssignments = true;
975
+ };
976
+ if (ts4.isVariableDeclaration(declaration)) addExpr(declaration.initializer, declaration);
977
+ const visit = (node) => {
978
+ if (node !== scope && isFunctionLikeScope(node)) return;
979
+ if (node.getStart(source) >= call.getStart(source)) return;
980
+ if (ts4.isBinaryExpression(node) && node.operatorToken.kind === ts4.SyntaxKind.EqualsToken && ts4.isIdentifier(node.left) && node.left.text === identifier.text) addExpr(node.right, node);
981
+ ts4.forEachChild(node, visit);
982
+ };
983
+ visit(scope);
984
+ const candidatePaths = [...new Set(paths)];
985
+ if (candidatePaths.length === 0) return void 0;
986
+ const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, method).topLevelOperationName).filter((value) => Boolean(value)))];
987
+ 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 };
988
+ }
885
989
  function destinationExpressionShape(expr) {
886
990
  if (!expr) return void 0;
887
991
  if (ts4.isIdentifier(expr)) return "identifier";
@@ -907,57 +1011,61 @@ function propertyInitializer(object, key) {
907
1011
  }
908
1012
  return void 0;
909
1013
  }
910
- function httpMethodFromObject(object, initializers) {
911
- const text = staticExpressionText(propertyInitializer(object, "method"), initializers);
1014
+ function httpMethodFromObject(object, use) {
1015
+ const text = resolveExpression(propertyInitializer(object, "method"), use, "literal").value;
912
1016
  return text ? stripQuotes(text).toUpperCase() : void 0;
913
1017
  }
914
- function urlTargetFromExpression(expr, initializers) {
915
- const text = staticExpressionText(expr, initializers);
916
- if (text) return { kind: "static_url", expression: text, dynamic: false };
917
- 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 };
1018
+ function hasTemplatePlaceholder(value) {
1019
+ return /\$\{|%7B|%7D/i.test(value);
1020
+ }
1021
+ function urlTargetFromExpression(expr, use) {
1022
+ const resolved2 = resolveExpression(expr, use, "external");
1023
+ if (resolved2.status === "static" && resolved2.value && !hasTemplatePlaceholder(resolved2.value)) return { kind: "static_url", expression: resolved2.value, dynamic: false, sourceKind: resolved2.sourceKind };
1024
+ if (expr) return { kind: "url_expression", dynamic: true, expression: `${resolved2.sourceKind}:${resolved2.placeholderKeys.join("|")}`, expressionShape: resolved2.sourceKind, placeholderKeys: resolved2.placeholderKeys };
918
1025
  return { kind: "unknown", dynamic: false };
919
1026
  }
920
- function destinationTargetFromExpression(expr, initializers) {
921
- const text = staticExpressionText(expr, initializers);
922
- if (text) return { kind: "destination", expression: text, dynamic: false };
923
- const candidates = staticConditionalCandidates(expr, initializers);
1027
+ function destinationTargetFromExpression(expr, use) {
1028
+ const resolved2 = resolveExpression(expr, use, "external");
1029
+ const text = resolved2.value;
1030
+ if (resolved2.status === "static" && text && !hasTemplatePlaceholder(text)) return { kind: "destination", expression: text, dynamic: false, sourceKind: resolved2.sourceKind };
1031
+ const candidates = staticConditionalCandidates(expr, /* @__PURE__ */ new Map());
924
1032
  if (candidates) return { kind: "destination", dynamic: true, expressionShape: "conditional", candidateLiterals: candidates };
925
1033
  const shape = destinationExpressionShape(expr);
926
1034
  if (shape) return { kind: "destination", dynamic: true, expressionShape: shape };
927
1035
  return void 0;
928
1036
  }
929
- function externalHttpEvidence(node, source, initializers) {
1037
+ function externalHttpEvidence(node, source) {
930
1038
  const expr = node.expression;
931
1039
  const exprText = expr.getText(source);
932
1040
  if (exprText === "useOrFetchDestination") {
933
1041
  const objectArg = node.arguments[0];
934
1042
  if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
935
- const destination = destinationTargetFromExpression(propertyInitializer(objectArg, "destinationName"), initializers);
1043
+ const destination = destinationTargetFromExpression(propertyInitializer(objectArg, "destinationName"), node);
936
1044
  return { externalTarget: destination ?? { kind: "unknown", dynamic: false }, classifier: "sap_destination_lookup", sourceCallShape: "useOrFetchDestination" };
937
1045
  }
938
1046
  }
939
1047
  if (exprText === "executeHttpRequest") {
940
- const destination = destinationTargetFromExpression(node.arguments[0], initializers);
1048
+ const destination = destinationTargetFromExpression(node.arguments[0], node);
941
1049
  const config = node.arguments[1];
942
- const method = config && ts4.isObjectLiteralExpression(config) ? httpMethodFromObject(config, initializers) : void 0;
943
- const url = config && ts4.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, "url"), initializers) : { kind: "unknown", dynamic: false };
1050
+ const method = config && ts4.isObjectLiteralExpression(config) ? httpMethodFromObject(config, node) : void 0;
1051
+ const url = config && ts4.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, "url"), node) : { kind: "unknown", dynamic: false };
944
1052
  return { method, externalTarget: destination ? { ...url, destination } : url, classifier: "sap_execute_http_request", sourceCallShape: "executeHttpRequest" };
945
1053
  }
946
1054
  if (exprText === "axios") {
947
1055
  const config = node.arguments[0];
948
1056
  if (config && ts4.isObjectLiteralExpression(config)) {
949
- const method = httpMethodFromObject(config, initializers);
950
- return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, "url"), initializers), classifier: "axios_config_call", sourceCallShape: "axios(config)" };
1057
+ const method = httpMethodFromObject(config, node);
1058
+ return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, "url"), node), classifier: "axios_config_call", sourceCallShape: "axios(config)" };
951
1059
  }
952
1060
  return { externalTarget: { kind: "unknown", dynamic: false }, classifier: "axios_unknown_call", sourceCallShape: "axios(...)" };
953
1061
  }
954
1062
  if (exprText === "fetch") {
955
1063
  const init = node.arguments[1];
956
- const method = init && ts4.isObjectLiteralExpression(init) ? httpMethodFromObject(init, initializers) : void 0;
957
- return { method, externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: "fetch_call", sourceCallShape: "fetch" };
1064
+ const method = init && ts4.isObjectLiteralExpression(init) ? httpMethodFromObject(init, node) : void 0;
1065
+ return { method, externalTarget: urlTargetFromExpression(node.arguments[0], node), classifier: "fetch_call", sourceCallShape: "fetch" };
958
1066
  }
959
1067
  if (ts4.isPropertyAccessExpression(expr) && ["get", "post", "put", "patch", "delete", "head"].includes(expr.name.text) && expr.expression.getText(source) === "axios") {
960
- return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: "axios_member_call", sourceCallShape: `axios.${expr.name.text}` };
1068
+ return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], node), classifier: "axios_member_call", sourceCallShape: `axios.${expr.name.text}` };
961
1069
  }
962
1070
  return void 0;
963
1071
  }
@@ -1020,7 +1128,7 @@ function collectWrapperSpecs(source) {
1020
1128
  const methodProp = propertyInitializer(objectArg, "method");
1021
1129
  const pathName = pathProp && ts4.isIdentifier(pathProp) ? pathProp.text : void 0;
1022
1130
  const methodName = methodProp && ts4.isIdentifier(methodProp) ? methodProp.text : void 0;
1023
- const methodLiteral = staticExpressionText(methodProp, variableInitializers(source));
1131
+ const methodLiteral = resolveExpression(methodProp, node, "literal").value;
1024
1132
  if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
1025
1133
  }
1026
1134
  }
@@ -1070,18 +1178,19 @@ function classifyOutboundCallsInSource(source, filePath) {
1070
1178
  if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
1071
1179
  const receiver = receiverName(expr.expression);
1072
1180
  const query = objectPropertyText(objectArg, "query");
1073
- const method = stripQuotes(objectPropertyText(objectArg, "method") ?? "POST");
1181
+ const method = stripQuotes(resolveExpression(propertyInitializer(objectArg, "method"), node, "literal").value ?? objectPropertyText(objectArg, "method") ?? "POST");
1074
1182
  const pathExpr = propertyInitializer(objectArg, "path") ?? propertyInitializer(objectArg, "event");
1075
- const op = pathExpr ? staticExpressionText(pathExpr, initializers) ?? pathExpr.getText(source) : void 0;
1183
+ const resolvedPath = staticPathExpression(pathExpr, node);
1184
+ const op = pathExpr ? resolvedPath.text ?? pathExpr.getText(source) : void 0;
1076
1185
  const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
1077
- const staticPath = staticExpressionText(pathExpr, initializers);
1078
- const literalPath = isStringLike(pathExpr) ? pathExpr.text : pathExpr && ts4.isTemplateExpression(pathExpr) ? pathExpr.getText(source) : void 0;
1079
- const operationPathExpr = staticPath ? `/${stripQuotes(staticPath).replace(/^\//, "")}` : literalPath ? `/${stripQuotes(literalPath).replace(/^\//, "")}` : void 0;
1186
+ const candidateEvidence2 = pathExpr && ts4.isIdentifier(pathExpr) && !resolvedPath.text ? staticPathCandidates(pathExpr, node, method) : void 0;
1187
+ const candidateOperationPath = candidateEvidence2 && !candidateEvidence2.hasDynamicAssignments && candidateEvidence2.normalizedCandidateOperations.length === 1 && candidateEvidence2.candidatePaths.length > 0 ? candidateEvidence2.candidatePaths[0] : void 0;
1188
+ const operationPathExpr = operationPathFromStatic(resolvedPath.text) ?? candidateOperationPath;
1080
1189
  const intent = classifyODataPathIntent(operationPathExpr, method);
1081
1190
  const entityCallTypes = { entity_mutation: "remote_entity_mutation", entity_delete: "remote_entity_delete", entity_media: "remote_entity_media", entity_candidate: "remote_entity_candidate" };
1082
1191
  const entityCallType = entityCallTypes[intent.kind];
1083
1192
  const isODataQueryRead = method.toUpperCase() === "GET" && ["entity_query", "entity_key_read", "entity_navigation_query"].includes(intent.kind);
1084
- add(node, { callType: query ? "remote_query" : entityCallType ?? (isODataQueryRead ? "remote_query" : "remote_action"), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : void 0, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && pathExpr && !operationPathExpr ? "dynamic_operation_path_identifier" : void 0 }, { receiver, classifier: "service_client_send_object", operationPathExpression: shorthandPath ? op : void 0, literalPathSource: shorthandPath && staticPath ? "same_scope_const_initializer" : void 0, odataPathIntent: operationPathExpr ? intent : void 0, parserWarning: !query && pathExpr && !operationPathExpr ? "dynamic_operation_path_identifier" : void 0 });
1193
+ add(node, { callType: query ? "remote_query" : entityCallType ?? (isODataQueryRead ? "remote_query" : "remote_action"), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : void 0, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && pathExpr && !operationPathExpr ? "dynamic_operation_path_identifier" : void 0 }, { receiver, classifier: "service_client_send_object", operationPathExpression: shorthandPath ? op : void 0, rawPathExpression: resolvedPath.rawExpression, literalPathSource: resolvedPath.text ? resolvedPath.sourceKind === "const" ? "same_scope_const_initializer" : resolvedPath.sourceKind : void 0, odataPathIntent: operationPathExpr ? intent : void 0, staticPathCandidates: candidateEvidence2, parserWarning: !query && pathExpr && !operationPathExpr ? "dynamic_operation_path_identifier" : void 0 });
1085
1194
  }
1086
1195
  } else if (ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text) || ts4.isIdentifier(expr) && wrapperSpecs.has(expr.text)) {
1087
1196
  const wrapperName = ts4.isIdentifier(expr) ? expr.text : ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) ? expr.expression.text : "";
@@ -1091,11 +1200,14 @@ function classifyOutboundCallsInSource(source, filePath) {
1091
1200
  const pathArg = spec ? wrapperArgs[spec.pathIndex] : void 0;
1092
1201
  const methodArg = spec?.methodIndex === void 0 ? void 0 : wrapperArgs[spec.methodIndex];
1093
1202
  const receiver = clientArg && ts4.isIdentifier(clientArg) ? clientArg.text : spec?.clientName;
1094
- const method = stripQuotes(staticExpressionText(methodArg, initializers) ?? spec?.methodLiteral ?? "POST");
1095
- if (spec && receiver && isStringLike(pathArg)) {
1096
- add(node, { callType: "remote_action", serviceVariableName: receiver, method, operationPathExpr: `/${pathArg.text.replace(/^\//, "")}`, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver, classifier: "higher_order_wrapper_literal_path", wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf3(source.text, node.getStart(source)), literalPathSource: "wrapper_call_argument", literalCallerArgumentDetected: true });
1203
+ const method = stripQuotes(resolveExpression(methodArg, node, "literal").value ?? spec?.methodLiteral ?? "POST");
1204
+ const resolvedPath = staticPathExpression(pathArg, node);
1205
+ const operationPathExpr = operationPathFromStatic(resolvedPath.text);
1206
+ const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : void 0;
1207
+ if (spec && receiver && operationPathExpr) {
1208
+ 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, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf3(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 });
1097
1209
  } else if (spec && receiver) {
1098
- 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: lineOf3(source.text, node.getStart(source)), parserWarning: "dynamic_operation_path_identifier" });
1210
+ 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: lineOf3(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, parserWarning: "dynamic_operation_path_identifier" });
1099
1211
  }
1100
1212
  } else if (ts4.isPropertyAccessExpression(expr) && ["emit", "publish", "on"].includes(expr.name.text)) {
1101
1213
  const receiver = receiverName(expr.expression);
@@ -1105,7 +1217,7 @@ function classifyOutboundCallsInSource(source, filePath) {
1105
1217
  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" });
1106
1218
  }
1107
1219
  } else {
1108
- const external = externalHttpEvidence(node, source, initializers);
1220
+ const external = externalHttpEvidence(node, source);
1109
1221
  if (external) {
1110
1222
  const evidenceTarget = { ...external.externalTarget, method: external.method, parserClassifier: external.classifier, sourceCallShape: external.sourceCallShape };
1111
1223
  const safeTarget = externalHttpTarget({ method: external.method, evidence_json: JSON.stringify({ externalTarget: evidenceTarget }) });
@@ -1194,7 +1306,7 @@ function stringValue(node) {
1194
1306
  return node.getText().replace(/^`|`$/g, "");
1195
1307
  return node.getText();
1196
1308
  }
1197
- function placeholders(value) {
1309
+ function placeholders2(value) {
1198
1310
  return [...(value ?? "").matchAll(/\$\{([^}]*)\}/g)].map((m) => (m[1] ?? "").trim()).filter(Boolean);
1199
1311
  }
1200
1312
  function connectFactFromCall(call) {
@@ -1220,7 +1332,7 @@ function connectFactFromCall(call) {
1220
1332
  return {
1221
1333
  aliasExpr,
1222
1334
  isDynamic: true,
1223
- placeholders: placeholders(aliasExpr)
1335
+ placeholders: placeholders2(aliasExpr)
1224
1336
  };
1225
1337
  let destinationExpr;
1226
1338
  let servicePathExpr;
@@ -1238,9 +1350,9 @@ function connectFactFromCall(call) {
1238
1350
  }
1239
1351
  if (objectArg) visitObject(objectArg);
1240
1352
  const ph = [
1241
- ...placeholders(aliasExpr ?? alias),
1242
- ...placeholders(destinationExpr),
1243
- ...placeholders(servicePathExpr)
1353
+ ...placeholders2(aliasExpr ?? alias),
1354
+ ...placeholders2(destinationExpr),
1355
+ ...placeholders2(servicePathExpr)
1244
1356
  ];
1245
1357
  return {
1246
1358
  alias,
@@ -1890,9 +2002,9 @@ function extractPlaceholders(template) {
1890
2002
  }
1891
2003
  function substituteVariables(template, vars) {
1892
2004
  if (!template) return { placeholders: [], missing: [], supplied: [], changed: false };
1893
- const placeholders2 = [...new Set(extractPlaceholders(template))];
1894
- const supplied = placeholders2.filter((key) => Object.hasOwn(vars, key));
1895
- const missing = placeholders2.filter((key) => !Object.hasOwn(vars, key));
2005
+ const placeholders3 = [...new Set(extractPlaceholders(template))];
2006
+ const supplied = placeholders3.filter((key) => Object.hasOwn(vars, key));
2007
+ const missing = placeholders3.filter((key) => !Object.hasOwn(vars, key));
1896
2008
  const effective = template.replace(PLACEHOLDER, (_m, key) => {
1897
2009
  const trimmed = key.trim();
1898
2010
  return Object.hasOwn(vars, trimmed) ? vars[trimmed] ?? "" : `\${${trimmed}}`;
@@ -1900,7 +2012,7 @@ function substituteVariables(template, vars) {
1900
2012
  return {
1901
2013
  original: template,
1902
2014
  effective,
1903
- placeholders: placeholders2,
2015
+ placeholders: placeholders3,
1904
2016
  missing,
1905
2017
  supplied,
1906
2018
  changed: effective !== template
@@ -3079,4 +3191,4 @@ export {
3079
3191
  linkWorkspace,
3080
3192
  trace
3081
3193
  };
3082
- //# sourceMappingURL=chunk-WE3A6TOJ.js.map
3194
+ //# sourceMappingURL=chunk-774PPGGK.js.map