@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/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  stripQuotes,
17
17
  substituteVariables,
18
18
  trace
19
- } from "./chunk-WE3A6TOJ.js";
19
+ } from "./chunk-774PPGGK.js";
20
20
 
21
21
  // src/parsers/generated-constants-parser.ts
22
22
  import fs from "fs/promises";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -73,17 +73,21 @@ function collectAnnotations(text: string, index: number): { end: number; raw: st
73
73
  }
74
74
 
75
75
  function pathAnnotation(raw: string): string | undefined {
76
- return /path\s*:\s*['"]([^'"]+)['"]/s.exec(raw)?.[1];
76
+ return /path\s*:\s*(['"])(.*?)\1/s.exec(raw)?.[2];
77
77
  }
78
78
 
79
- function matchingBrace(text: string, open: number): number {
79
+ function matchingBrace(maskedText: string, open: number): number {
80
80
  let depth = 0;
81
- for (let i = open; i < text.length; i += 1) {
82
- if (text[i] === '{') depth += 1;
83
- if (text[i] === '}') depth -= 1;
81
+ for (let i = open; i < maskedText.length; i += 1) {
82
+ if (maskedText[i] === '{') depth += 1;
83
+ if (maskedText[i] === '}') depth -= 1;
84
84
  if (depth === 0) return i;
85
85
  }
86
- return text.length - 1;
86
+ return maskedText.length - 1;
87
+ }
88
+ function annotationRawAt(original: string, masked: string, index: number): { end: number; raw: string } {
89
+ const collected = collectAnnotations(masked, index);
90
+ return { end: collected.end, raw: original.slice(index, collected.end) };
87
91
  }
88
92
 
89
93
  function operationsFromBody(text: string, maskedBody: string, bodyOffset: number, filePath: string): CdsOperationFact[] {
@@ -105,16 +109,18 @@ export async function parseCdsFile(repoPath: string, filePath: string): Promise<
105
109
  const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
106
110
  const services: CdsServiceFact[] = [];
107
111
  const pendingAnnotations: Array<{ end: number; raw: string }> = [];
108
- for (const a of masked.matchAll(/@\s*\(/g)) {
109
- const annotation = collectAnnotations(masked, a.index ?? 0);
110
- pendingAnnotations.push(annotation);
111
- }
112
- const serviceRegex = /\b(extend\s+)?service\s+([\w.]+)\b/g;
112
+ for (const a of masked.matchAll(/@\s*\(/g)) pendingAnnotations.push(annotationRawAt(text, masked, a.index ?? 0));
113
+ const serviceRegex = /\b(?:(extend)\s+)?(?:(service)\s+)?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\b/g;
113
114
  let match: RegExpExecArray | null;
114
115
  while ((match = serviceRegex.exec(masked))) {
115
- const afterName = collectAnnotations(masked, serviceRegex.lastIndex);
116
+ const isExtend = match[1] === 'extend';
117
+ const hasServiceKeyword = match[2] === 'service';
118
+ if (!isExtend && !hasServiceKeyword) continue;
119
+ const afterName = annotationRawAt(text, masked, serviceRegex.lastIndex);
116
120
  const open = masked.indexOf('{', afterName.end);
117
121
  if (open === -1) continue;
122
+ const between = masked.slice(afterName.end, open).trim();
123
+ if (between.length > 0) continue;
118
124
  const matchIndex = match.index;
119
125
  const prefix = pendingAnnotations
120
126
  .filter((a) => a.end <= matchIndex && matchIndex - a.end < 8)
@@ -123,7 +129,7 @@ export async function parseCdsFile(repoPath: string, filePath: string): Promise<
123
129
  const annotations = `${prefix}${afterName.raw}`;
124
130
  const end = matchingBrace(masked, open);
125
131
  const body = masked.slice(open + 1, end);
126
- const name = match[2] ?? 'UnknownService';
132
+ const name = match[3] ?? 'UnknownService';
127
133
  const serviceName = name.split('.').pop() ?? name;
128
134
  const servicePath = ensureLeadingSlash(pathAnnotation(annotations) ?? serviceName);
129
135
  services.push({
@@ -131,7 +137,7 @@ export async function parseCdsFile(repoPath: string, filePath: string): Promise<
131
137
  serviceName,
132
138
  qualifiedName: name.includes('.') ? name : namespace ? `${namespace}.${name}` : name,
133
139
  servicePath,
134
- isExtend: Boolean(match[1]),
140
+ isExtend,
135
141
  sourceFile: normalizePath(filePath),
136
142
  sourceLine: lineOf(text, match.index),
137
143
  operations: operationsFromBody(text, body, open + 1, filePath)
@@ -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,12 +90,115 @@ 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 containingScope(node: ts.Node): ts.Node {
109
+ let current: ts.Node | undefined = node.parent;
110
+ while (current && !isFunctionLikeScope(current)) current = current.parent;
111
+ return current ?? node.getSourceFile();
112
+ }
113
+ function nodeContains(parent: ts.Node, child: ts.Node): boolean {
114
+ return child.getStart(child.getSourceFile()) >= parent.getStart(parent.getSourceFile()) && child.getEnd() <= parent.getEnd();
115
+ }
116
+ function nestedFunctionContains(scope: ts.Node, candidate: ts.Node, use: ts.Node): boolean {
117
+ let current = candidate.parent;
118
+ while (current && current !== scope) {
119
+ if (isFunctionLikeScope(current) && !nodeContains(current, use)) return true;
120
+ current = current.parent;
121
+ }
122
+ return false;
123
+ }
124
+ function resolveBinding(identifier: ts.Identifier, use: ts.Node): BindingResolution {
125
+ const scope = containingScope(use);
126
+ const source = use.getSourceFile();
127
+ let best: ts.VariableDeclaration | ts.ParameterDeclaration | undefined;
128
+ const visit = (node: ts.Node): void => {
129
+ if (node !== scope && isFunctionLikeScope(node)) return;
130
+ if (node.getStart(source) >= identifier.getStart(source)) return;
131
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === identifier.text && !nestedFunctionContains(scope, node, use)) best = node;
132
+ if (ts.isParameter(node) && ts.isIdentifier(node.name) && node.name.text === identifier.text && !nestedFunctionContains(scope, node, use)) best = node;
133
+ ts.forEachChild(node, visit);
134
+ };
135
+ visit(scope);
136
+ if (!best) return { immutable: false, evidence: ['binding_not_found'] };
137
+ const immutable = ts.isVariableDeclaration(best) && (best.parent.flags & ts.NodeFlags.Const) !== 0;
138
+ return { declaration: best, initializer: ts.isVariableDeclaration(best) ? best.initializer : undefined, immutable, evidence: [immutable ? 'const_binding_before_use' : 'mutable_or_parameter_binding'] };
139
+ }
140
+ function resolveExpression(expr: ts.Expression | undefined, use: ts.Node, policy: 'operation_path' | 'external' | 'literal', depth = 0, seen = new Set<ts.Node>()): ExpressionResolution {
141
+ if (!expr) return { status: 'unknown', sourceKind: 'dynamic_expression', placeholderKeys: [], evidence: ['expression_missing'] };
142
+ if (ts.isStringLiteral(expr)) return { status: 'static', sourceKind: 'string_literal', value: expr.text, rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ['string_literal'] };
143
+ if (ts.isNoSubstitutionTemplateLiteral(expr)) return { status: 'static', sourceKind: 'no_substitution_template', value: expr.text, rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ['no_substitution_template'] };
144
+ if (ts.isTemplateExpression(expr)) {
145
+ const keys = placeholders(expr);
146
+ 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'] };
147
+ return { status: 'dynamic', sourceKind: 'template_with_substitutions', placeholderKeys: keys, evidence: ['template_substitutions_not_static_external_target'] };
148
+ }
149
+ if (ts.isIdentifier(expr)) {
150
+ if (depth >= maxAliasDepth) return { status: 'unknown', sourceKind: 'const_alias', rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ['alias_depth_exceeded'], constName: expr.text };
151
+ const binding = resolveBinding(expr, use);
152
+ if (!binding.declaration || !binding.initializer || !binding.immutable) return { status: 'dynamic', sourceKind: 'dynamic_expression', rawExpression: safeRaw(expr), placeholderKeys: [], evidence: binding.evidence, constName: expr.text };
153
+ if (seen.has(binding.declaration)) return { status: 'unknown', sourceKind: 'const_alias', rawExpression: safeRaw(expr), placeholderKeys: [], evidence: ['alias_cycle_detected'], constName: expr.text };
154
+ seen.add(binding.declaration);
155
+ const resolved = resolveExpression(binding.initializer, binding.declaration, policy, depth + 1, seen);
156
+ return { ...resolved, sourceKind: 'const_alias', rawExpression: safeRaw(expr), constName: expr.text, evidence: [...binding.evidence, ...resolved.evidence] };
157
+ }
158
+ return { status: 'dynamic', sourceKind: 'dynamic_expression', rawExpression: safeRaw(expr), placeholderKeys: [], evidence: [`unsupported_${ts.SyntaxKind[expr.kind] ?? 'expression'}`] };
159
+ }
92
160
  function staticExpressionText(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): string | undefined {
93
161
  if (!expr) return undefined;
94
162
  if (isStringLike(expr)) return expr.text;
95
163
  if (ts.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
96
164
  return undefined;
97
165
  }
166
+ interface StaticPathResolution { text?: string; sourceKind: 'literal' | 'template' | 'const' | 'dynamic'; rawExpression?: string; constName?: string; resolution: ExpressionResolution }
167
+ function staticPathExpression(expr: ts.Expression | undefined, use: ts.Node): StaticPathResolution {
168
+ const resolution = resolveExpression(expr, use, 'operation_path');
169
+ const sourceKind = resolution.sourceKind === 'const_alias' ? 'const' : resolution.sourceKind === 'template_with_substitutions' || resolution.sourceKind === 'no_substitution_template' ? 'template' : resolution.status === 'static' ? 'literal' : 'dynamic';
170
+ return { text: resolution.value, sourceKind, rawExpression: resolution.rawExpression, constName: resolution.constName, resolution };
171
+ }
172
+ function operationPathFromStatic(text: string | undefined): string | undefined {
173
+ return text ? `/${stripQuotes(text).replace(/^\//, '')}` : undefined;
174
+ }
175
+ interface PathCandidateEvidence { candidatePaths: string[]; normalizedCandidateOperations: string[]; candidateSourceKind: string; candidateIdentifier: string; hasDynamicAssignments: boolean; conservativeReason?: string }
176
+ function staticPathCandidates(identifier: ts.Identifier, call: ts.Node, method: string): PathCandidateEvidence | undefined {
177
+ const binding = resolveBinding(identifier, call);
178
+ const declaration = binding.declaration;
179
+ if (!declaration) return undefined;
180
+ const scope = containingScope(call);
181
+ const source = call.getSourceFile();
182
+ const paths: string[] = [];
183
+ let hasDynamicAssignments = false;
184
+ const addExpr = (expr: ts.Expression | undefined, origin: ts.Node): void => {
185
+ const resolved = staticPathExpression(expr, origin);
186
+ if (resolved.text) paths.push(operationPathFromStatic(resolved.text) ?? resolved.text);
187
+ else hasDynamicAssignments = true;
188
+ };
189
+ if (ts.isVariableDeclaration(declaration)) addExpr(declaration.initializer, declaration);
190
+ const visit = (node: ts.Node): void => {
191
+ if (node !== scope && isFunctionLikeScope(node)) return;
192
+ if (node.getStart(source) >= call.getStart(source)) return;
193
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left) && node.left.text === identifier.text) addExpr(node.right, node);
194
+ ts.forEachChild(node, visit);
195
+ };
196
+ visit(scope);
197
+ const candidatePaths = [...new Set(paths)];
198
+ if (candidatePaths.length === 0) return undefined;
199
+ const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, method).topLevelOperationName).filter((value): value is string => Boolean(value)))];
200
+ 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 };
201
+ }
98
202
  function destinationExpressionShape(expr: ts.Expression | undefined): string | undefined {
99
203
  if (!expr) return undefined;
100
204
  if (ts.isIdentifier(expr)) return 'identifier';
@@ -120,57 +224,59 @@ function propertyInitializer(object: ts.ObjectLiteralExpression, key: string): t
120
224
  }
121
225
  return undefined;
122
226
  }
123
- function httpMethodFromObject(object: ts.ObjectLiteralExpression, initializers: Map<string, ts.Expression>): string | undefined {
124
- const text = staticExpressionText(propertyInitializer(object, 'method'), initializers);
227
+ function httpMethodFromObject(object: ts.ObjectLiteralExpression, use: ts.Node): string | undefined {
228
+ const text = resolveExpression(propertyInitializer(object, 'method'), use, 'literal').value;
125
229
  return text ? stripQuotes(text).toUpperCase() : undefined;
126
230
  }
127
- function urlTargetFromExpression(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): Record<string, unknown> {
128
- const text = staticExpressionText(expr, initializers);
129
- if (text) return { kind: 'static_url', expression: text, dynamic: false };
130
- 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 };
231
+ function hasTemplatePlaceholder(value: string): boolean { return /\$\{|%7B|%7D/i.test(value); }
232
+ function urlTargetFromExpression(expr: ts.Expression | undefined, use: ts.Node): Record<string, unknown> {
233
+ const resolved = resolveExpression(expr, use, 'external');
234
+ if (resolved.status === 'static' && resolved.value && !hasTemplatePlaceholder(resolved.value)) return { kind: 'static_url', expression: resolved.value, dynamic: false, sourceKind: resolved.sourceKind };
235
+ if (expr) return { kind: 'url_expression', dynamic: true, expression: `${resolved.sourceKind}:${resolved.placeholderKeys.join('|')}`, expressionShape: resolved.sourceKind, placeholderKeys: resolved.placeholderKeys };
131
236
  return { kind: 'unknown', dynamic: false };
132
237
  }
133
- function destinationTargetFromExpression(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): Record<string, unknown> | undefined {
134
- const text = staticExpressionText(expr, initializers);
135
- if (text) return { kind: 'destination', expression: text, dynamic: false };
136
- const candidates = staticConditionalCandidates(expr, initializers);
238
+ function destinationTargetFromExpression(expr: ts.Expression | undefined, use: ts.Node): Record<string, unknown> | undefined {
239
+ const resolved = resolveExpression(expr, use, 'external');
240
+ const text = resolved.value;
241
+ if (resolved.status === 'static' && text && !hasTemplatePlaceholder(text)) return { kind: 'destination', expression: text, dynamic: false, sourceKind: resolved.sourceKind };
242
+ const candidates = staticConditionalCandidates(expr, new Map<string, ts.Expression>());
137
243
  if (candidates) return { kind: 'destination', dynamic: true, expressionShape: 'conditional', candidateLiterals: candidates };
138
244
  const shape = destinationExpressionShape(expr);
139
245
  if (shape) return { kind: 'destination', dynamic: true, expressionShape: shape };
140
246
  return undefined;
141
247
  }
142
- function externalHttpEvidence(node: ts.CallExpression, source: ts.SourceFile, initializers: Map<string, ts.Expression>): { method?: string; externalTarget: Record<string, unknown>; classifier: string; sourceCallShape: string } | undefined {
248
+ function externalHttpEvidence(node: ts.CallExpression, source: ts.SourceFile): { method?: string; externalTarget: Record<string, unknown>; classifier: string; sourceCallShape: string } | undefined {
143
249
  const expr = node.expression;
144
250
  const exprText = expr.getText(source);
145
251
  if (exprText === 'useOrFetchDestination') {
146
252
  const objectArg = node.arguments[0];
147
253
  if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
148
- const destination = destinationTargetFromExpression(propertyInitializer(objectArg, 'destinationName'), initializers);
254
+ const destination = destinationTargetFromExpression(propertyInitializer(objectArg, 'destinationName'), node);
149
255
  return { externalTarget: destination ?? { kind: 'unknown', dynamic: false }, classifier: 'sap_destination_lookup', sourceCallShape: 'useOrFetchDestination' };
150
256
  }
151
257
  }
152
258
  if (exprText === 'executeHttpRequest') {
153
- const destination = destinationTargetFromExpression(node.arguments[0], initializers);
259
+ const destination = destinationTargetFromExpression(node.arguments[0], node);
154
260
  const config = node.arguments[1];
155
- const method = config && ts.isObjectLiteralExpression(config) ? httpMethodFromObject(config, initializers) : undefined;
156
- const url = config && ts.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, 'url'), initializers) : { kind: 'unknown', dynamic: false };
261
+ const method = config && ts.isObjectLiteralExpression(config) ? httpMethodFromObject(config, node) : undefined;
262
+ const url = config && ts.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, 'url'), node) : { kind: 'unknown', dynamic: false };
157
263
  return { method, externalTarget: destination ? { ...url, destination } : url, classifier: 'sap_execute_http_request', sourceCallShape: 'executeHttpRequest' };
158
264
  }
159
265
  if (exprText === 'axios') {
160
266
  const config = node.arguments[0];
161
267
  if (config && ts.isObjectLiteralExpression(config)) {
162
- const method = httpMethodFromObject(config, initializers);
163
- return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, 'url'), initializers), classifier: 'axios_config_call', sourceCallShape: 'axios(config)' };
268
+ const method = httpMethodFromObject(config, node);
269
+ return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, 'url'), node), classifier: 'axios_config_call', sourceCallShape: 'axios(config)' };
164
270
  }
165
271
  return { externalTarget: { kind: 'unknown', dynamic: false }, classifier: 'axios_unknown_call', sourceCallShape: 'axios(...)' };
166
272
  }
167
273
  if (exprText === 'fetch') {
168
274
  const init = node.arguments[1];
169
- const method = init && ts.isObjectLiteralExpression(init) ? httpMethodFromObject(init, initializers) : undefined;
170
- return { method, externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: 'fetch_call', sourceCallShape: 'fetch' };
275
+ const method = init && ts.isObjectLiteralExpression(init) ? httpMethodFromObject(init, node) : undefined;
276
+ return { method, externalTarget: urlTargetFromExpression(node.arguments[0], node), classifier: 'fetch_call', sourceCallShape: 'fetch' };
171
277
  }
172
278
  if (ts.isPropertyAccessExpression(expr) && ['get','post','put','patch','delete','head'].includes(expr.name.text) && expr.expression.getText(source) === 'axios') {
173
- return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: 'axios_member_call', sourceCallShape: `axios.${expr.name.text}` };
279
+ return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], node), classifier: 'axios_member_call', sourceCallShape: `axios.${expr.name.text}` };
174
280
  }
175
281
  return undefined;
176
282
  }
@@ -235,7 +341,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
235
341
  const methodProp = propertyInitializer(objectArg, 'method');
236
342
  const pathName = pathProp && ts.isIdentifier(pathProp) ? pathProp.text : undefined;
237
343
  const methodName = methodProp && ts.isIdentifier(methodProp) ? methodProp.text : undefined;
238
- const methodLiteral = staticExpressionText(methodProp, variableInitializers(source));
344
+ const methodLiteral = resolveExpression(methodProp, node, 'literal').value;
239
345
  if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
240
346
  }
241
347
  }
@@ -285,18 +391,19 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
285
391
  if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
286
392
  const receiver = receiverName(expr.expression);
287
393
  const query = objectPropertyText(objectArg, 'query');
288
- const method = stripQuotes(objectPropertyText(objectArg, 'method') ?? 'POST');
394
+ const method = stripQuotes(resolveExpression(propertyInitializer(objectArg, 'method'), node, 'literal').value ?? objectPropertyText(objectArg, 'method') ?? 'POST');
289
395
  const pathExpr = propertyInitializer(objectArg, 'path') ?? propertyInitializer(objectArg, 'event');
290
- const op = pathExpr ? staticExpressionText(pathExpr, initializers) ?? pathExpr.getText(source) : undefined;
396
+ const resolvedPath = staticPathExpression(pathExpr, node);
397
+ const op = pathExpr ? resolvedPath.text ?? pathExpr.getText(source) : undefined;
291
398
  const shorthandPath = objectPropertyIsShorthand(objectArg, 'path');
292
- const staticPath = staticExpressionText(pathExpr, initializers);
293
- const literalPath = isStringLike(pathExpr) ? pathExpr.text : pathExpr && ts.isTemplateExpression(pathExpr) ? pathExpr.getText(source) : undefined;
294
- const operationPathExpr = staticPath ? `/${stripQuotes(staticPath).replace(/^\//, '')}` : literalPath ? `/${stripQuotes(literalPath).replace(/^\//, '')}` : undefined;
399
+ const candidateEvidence = pathExpr && ts.isIdentifier(pathExpr) && !resolvedPath.text ? staticPathCandidates(pathExpr, node, method) : undefined;
400
+ const candidateOperationPath = candidateEvidence && !candidateEvidence.hasDynamicAssignments && candidateEvidence.normalizedCandidateOperations.length === 1 && candidateEvidence.candidatePaths.length > 0 ? candidateEvidence.candidatePaths[0] : undefined;
401
+ const operationPathExpr = operationPathFromStatic(resolvedPath.text) ?? candidateOperationPath;
295
402
  const intent = classifyODataPathIntent(operationPathExpr, method);
296
403
  const entityCallTypes: Record<string, OutboundCallFact['callType']> = { entity_mutation: 'remote_entity_mutation', entity_delete: 'remote_entity_delete', entity_media: 'remote_entity_media', entity_candidate: 'remote_entity_candidate' };
297
404
  const entityCallType = entityCallTypes[intent.kind];
298
405
  const isODataQueryRead = method.toUpperCase() === 'GET' && ['entity_query', 'entity_key_read', 'entity_navigation_query'].includes(intent.kind);
299
- add(node, { callType: query ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : undefined, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, literalPathSource: shorthandPath && staticPath ? 'same_scope_const_initializer' : undefined, odataPathIntent: operationPathExpr ? intent : undefined, parserWarning: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined });
406
+ add(node, { callType: query ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : undefined, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, rawPathExpression: resolvedPath.rawExpression, literalPathSource: resolvedPath.text ? (resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : resolvedPath.sourceKind) : undefined, odataPathIntent: operationPathExpr ? intent : undefined, staticPathCandidates: candidateEvidence, parserWarning: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined });
300
407
  }
301
408
  } else if (((ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text)) || (ts.isIdentifier(expr) && wrapperSpecs.has(expr.text)))) {
302
409
  const wrapperName = ts.isIdentifier(expr) ? expr.text : ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) ? expr.expression.text : '';
@@ -306,11 +413,14 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
306
413
  const pathArg = spec ? wrapperArgs[spec.pathIndex] : undefined;
307
414
  const methodArg = spec?.methodIndex === undefined ? undefined : wrapperArgs[spec.methodIndex];
308
415
  const receiver = clientArg && ts.isIdentifier(clientArg) ? clientArg.text : spec?.clientName;
309
- const method = stripQuotes(staticExpressionText(methodArg, initializers) ?? spec?.methodLiteral ?? 'POST');
310
- if (spec && receiver && isStringLike(pathArg)) {
311
- 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: lineOf(source.text, node.getStart(source)), literalPathSource: 'wrapper_call_argument', literalCallerArgumentDetected: true });
416
+ const method = stripQuotes(resolveExpression(methodArg, node, 'literal').value ?? spec?.methodLiteral ?? 'POST');
417
+ const resolvedPath = staticPathExpression(pathArg, node);
418
+ const operationPathExpr = operationPathFromStatic(resolvedPath.text);
419
+ const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : undefined;
420
+ if (spec && receiver && operationPathExpr) {
421
+ 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: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, normalizedOperationPath, literalPathSource: resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : `wrapper_call_${resolvedPath.sourceKind}`, literalCallerArgumentDetected: true });
312
422
  } else if (spec && receiver) {
313
- add(node, { callType: 'remote_action', serviceVariableName: receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason: 'dynamic_operation_path_identifier' }, { receiver, classifier: 'higher_order_wrapper_dynamic_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), parserWarning: 'dynamic_operation_path_identifier' });
423
+ add(node, { callType: 'remote_action', serviceVariableName: receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason: 'dynamic_operation_path_identifier' }, { receiver, classifier: 'higher_order_wrapper_dynamic_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, parserWarning: 'dynamic_operation_path_identifier' });
314
424
  }
315
425
  } else if (ts.isPropertyAccessExpression(expr) && ['emit', 'publish', 'on'].includes(expr.name.text)) {
316
426
  const receiver = receiverName(expr.expression);
@@ -320,7 +430,7 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
320
430
  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' });
321
431
  }
322
432
  } else {
323
- const external = externalHttpEvidence(node, source, initializers);
433
+ const external = externalHttpEvidence(node, source);
324
434
  if (external) {
325
435
  const evidenceTarget = { ...external.externalTarget, method: external.method, parserClassifier: external.classifier, sourceCallShape: external.sourceCallShape };
326
436
  const safeTarget = externalHttpTarget({ method: external.method, evidence_json: JSON.stringify({ externalTarget: evidenceTarget }) });