@saptools/service-flow 0.1.37 → 0.1.38

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.
Files changed (45) hide show
  1. package/dist/cli.js +3 -2
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +3 -2
  4. package/src/cli.ts +865 -0
  5. package/src/config/defaults.ts +14 -0
  6. package/src/config/workspace-config.ts +61 -0
  7. package/src/db/connection.ts +131 -0
  8. package/src/db/migrations.ts +81 -0
  9. package/src/db/repositories.ts +353 -0
  10. package/src/db/schema.ts +24 -0
  11. package/src/discovery/classify-repository.ts +50 -0
  12. package/src/discovery/discover-repositories.ts +58 -0
  13. package/src/index.ts +13 -0
  14. package/src/indexer/incremental-index.ts +25 -0
  15. package/src/indexer/repository-indexer.ts +137 -0
  16. package/src/indexer/workspace-indexer.ts +29 -0
  17. package/src/linker/cross-repo-linker.ts +406 -0
  18. package/src/linker/dynamic-edge-resolver.ts +45 -0
  19. package/src/linker/external-http-target.ts +38 -0
  20. package/src/linker/helper-package-linker.ts +57 -0
  21. package/src/linker/odata-path-normalizer.ts +236 -0
  22. package/src/linker/operation-decorator-normalizer.ts +47 -0
  23. package/src/linker/remote-query-target.ts +39 -0
  24. package/src/linker/service-resolver.ts +223 -0
  25. package/src/output/json-output.ts +7 -0
  26. package/src/output/mermaid-output.ts +16 -0
  27. package/src/output/table-output.ts +21 -0
  28. package/src/parsers/cds-parser.ts +147 -0
  29. package/src/parsers/decorator-parser.ts +76 -0
  30. package/src/parsers/generated-constants-parser.ts +23 -0
  31. package/src/parsers/handler-registration-parser.ts +177 -0
  32. package/src/parsers/outbound-call-parser.ts +398 -0
  33. package/src/parsers/package-json-parser.ts +63 -0
  34. package/src/parsers/service-binding-parser.ts +826 -0
  35. package/src/parsers/symbol-parser.ts +328 -0
  36. package/src/parsers/ts-project.ts +13 -0
  37. package/src/trace/selectors.ts +20 -0
  38. package/src/trace/trace-engine.ts +647 -0
  39. package/src/trace/traversal.ts +4 -0
  40. package/src/types.ts +186 -0
  41. package/src/utils/diagnostics.ts +10 -0
  42. package/src/utils/hashing.ts +10 -0
  43. package/src/utils/path-utils.ts +13 -0
  44. package/src/utils/redaction.ts +20 -0
  45. package/src/version.ts +4 -0
@@ -0,0 +1,398 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import ts from 'typescript';
4
+ import { externalHttpTarget } from '../linker/external-http-target.js';
5
+ import type { OutboundCallFact } from '../types.js';
6
+ import { normalizePath, stripQuotes } from '../utils/path-utils.js';
7
+ import { summarizeExpression } from '../utils/redaction.js';
8
+ import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
9
+ function lineOf(text: string, idx: number): number {
10
+ return text.slice(0, idx).split('\n').length;
11
+ }
12
+ function entityFromExpression(expr: ts.Expression | undefined): string | undefined {
13
+ if (!expr) return undefined;
14
+ if (ts.isIdentifier(expr) || ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return expr.text;
15
+ if (ts.isPropertyAccessExpression(expr) && expr.expression.kind === ts.SyntaxKind.ThisKeyword) return expr.name.text;
16
+ if (ts.isElementAccessExpression(expr) && expr.argumentExpression && (ts.isStringLiteral(expr.argumentExpression) || ts.isNoSubstitutionTemplateLiteral(expr.argumentExpression))) return expr.argumentExpression.text;
17
+ return undefined;
18
+ }
19
+ function expressionName(expr: ts.Expression): string {
20
+ if (ts.isIdentifier(expr)) return expr.text;
21
+ if (ts.isPropertyAccessExpression(expr)) return `${expressionName(expr.expression)}.${expr.name.text}`;
22
+ return expr.getText();
23
+ }
24
+ function variableInitializers(source: ts.SourceFile): Map<string, ts.Expression> {
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);
31
+ return initializers;
32
+ }
33
+ function queryEntityFromAst(expr: ts.Expression, initializers = new Map<string, ts.Expression>()): string | undefined {
34
+ if (ts.isParenthesizedExpression(expr) || ts.isAwaitExpression(expr)) return queryEntityFromAst(expr.expression, initializers);
35
+ if (ts.isIdentifier(expr) && initializers.has(expr.text)) return queryEntityFromAst(initializers.get(expr.text) as ts.Expression, initializers);
36
+ if (ts.isCallExpression(expr)) {
37
+ const name = expressionName(expr.expression);
38
+ if (name === 'cds.run') return queryEntityFromAst(expr.arguments[0], initializers);
39
+ if (['SELECT.one.from', 'SELECT.from', 'SELECT.one', 'INSERT.into', 'UPSERT.into', 'DELETE.from', 'UPDATE.entity'].includes(name)) return entityFromExpression(expr.arguments[0]);
40
+ if (name === 'UPDATE') return entityFromExpression(expr.arguments[0]);
41
+ const receiver = ts.isPropertyAccessExpression(expr.expression) ? expr.expression.expression : undefined;
42
+ if (receiver) return queryEntityFromAst(receiver, initializers);
43
+ }
44
+ return undefined;
45
+ }
46
+ function extractQueryEntity(expr: string): string | undefined {
47
+ const source = ts.createSourceFile('query.ts', `const __query = (${expr});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
48
+ const initializers = variableInitializers(source);
49
+ let found: string | undefined;
50
+ const visit = (node: ts.Node): void => {
51
+ if (found) return;
52
+ if (ts.isParenthesizedExpression(node)) found = queryEntityFromAst(node.expression, initializers);
53
+ ts.forEachChild(node, visit);
54
+ };
55
+ visit(source);
56
+ return found;
57
+ }
58
+ function queryWarning(expr: string): string {
59
+ if (/^\s*[`'"]/.test(expr)) return 'raw_sql_or_cql_expression';
60
+ if (/^\s*\w+\s*$/.test(expr)) return 'query_variable_without_static_initializer';
61
+ return 'dynamic_entity_expression';
62
+ }
63
+ export interface ClassifiedOutboundCall {
64
+ fact: OutboundCallFact;
65
+ node: ts.CallExpression;
66
+ }
67
+ function parserEvidence(source: ts.SourceFile, node: ts.CallExpression, extra?: Record<string, unknown>): Record<string, unknown> {
68
+ return { parser: 'typescript_ast', startOffset: node.getStart(source), endOffset: node.getEnd(), ...extra };
69
+ }
70
+ function isStringLike(expr: ts.Expression | undefined): expr is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral {
71
+ return Boolean(expr && (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)));
72
+ }
73
+ function literalText(expr: ts.Expression | undefined): string | undefined {
74
+ if (isStringLike(expr)) return expr.text;
75
+ return undefined;
76
+ }
77
+ function objectPropertyText(object: ts.ObjectLiteralExpression, key: string): string | undefined {
78
+ const prop = object.properties.find((property): property is ts.PropertyAssignment | ts.ShorthandPropertyAssignment =>
79
+ (ts.isPropertyAssignment(property) && nameOfProperty(property.name) === key) || (ts.isShorthandPropertyAssignment(property) && property.name.text === key),
80
+ );
81
+ if (!prop) return undefined;
82
+ return ts.isShorthandPropertyAssignment(prop) ? prop.name.text : prop.initializer.getText();
83
+ }
84
+ function objectPropertyIsShorthand(object: ts.ObjectLiteralExpression, key: string): boolean {
85
+ return object.properties.some((property) => ts.isShorthandPropertyAssignment(property) && property.name.text === key);
86
+ }
87
+ function nameOfProperty(name: ts.PropertyName): string | undefined {
88
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
89
+ return undefined;
90
+ }
91
+
92
+ function staticExpressionText(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): string | undefined {
93
+ if (!expr) return undefined;
94
+ if (isStringLike(expr)) return expr.text;
95
+ if (ts.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
96
+ return undefined;
97
+ }
98
+ function destinationExpressionShape(expr: ts.Expression | undefined): string | undefined {
99
+ if (!expr) return undefined;
100
+ if (ts.isIdentifier(expr)) return 'identifier';
101
+ if (ts.isPropertyAccessExpression(expr) || ts.isElementAccessExpression(expr)) return 'property_read';
102
+ if (ts.isCallExpression(expr)) return 'function_call';
103
+ if (ts.isConditionalExpression(expr)) return 'conditional';
104
+ if (ts.isBinaryExpression(expr)) return 'binary_expression';
105
+ if (ts.isTemplateExpression(expr)) return 'template_expression';
106
+ return ts.SyntaxKind[expr.kind] ?? 'expression';
107
+ }
108
+ function staticConditionalCandidates(expr: ts.Expression | undefined, initializers: Map<string, ts.Expression>): string[] | undefined {
109
+ const resolved = expr && ts.isIdentifier(expr) && initializers.has(expr.text) ? initializers.get(expr.text) : expr;
110
+ if (!resolved || !ts.isConditionalExpression(resolved)) return undefined;
111
+ const left = staticExpressionText(resolved.whenTrue, initializers);
112
+ const right = staticExpressionText(resolved.whenFalse, initializers);
113
+ if (!left || !right) return undefined;
114
+ return [...new Set([left, right])];
115
+ }
116
+ function propertyInitializer(object: ts.ObjectLiteralExpression, key: string): ts.Expression | undefined {
117
+ for (const property of object.properties) {
118
+ if (ts.isPropertyAssignment(property) && nameOfProperty(property.name) === key) return property.initializer;
119
+ if (ts.isShorthandPropertyAssignment(property) && property.name.text === key) return property.name;
120
+ }
121
+ return undefined;
122
+ }
123
+ function httpMethodFromObject(object: ts.ObjectLiteralExpression, initializers: Map<string, ts.Expression>): string | undefined {
124
+ const text = staticExpressionText(propertyInitializer(object, 'method'), initializers);
125
+ return text ? stripQuotes(text).toUpperCase() : undefined;
126
+ }
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 };
131
+ return { kind: 'unknown', dynamic: false };
132
+ }
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);
137
+ if (candidates) return { kind: 'destination', dynamic: true, expressionShape: 'conditional', candidateLiterals: candidates };
138
+ const shape = destinationExpressionShape(expr);
139
+ if (shape) return { kind: 'destination', dynamic: true, expressionShape: shape };
140
+ return undefined;
141
+ }
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 {
143
+ const expr = node.expression;
144
+ const exprText = expr.getText(source);
145
+ if (exprText === 'useOrFetchDestination') {
146
+ const objectArg = node.arguments[0];
147
+ if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
148
+ const destination = destinationTargetFromExpression(propertyInitializer(objectArg, 'destinationName'), initializers);
149
+ return { externalTarget: destination ?? { kind: 'unknown', dynamic: false }, classifier: 'sap_destination_lookup', sourceCallShape: 'useOrFetchDestination' };
150
+ }
151
+ }
152
+ if (exprText === 'executeHttpRequest') {
153
+ const destination = destinationTargetFromExpression(node.arguments[0], initializers);
154
+ 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 };
157
+ return { method, externalTarget: destination ? { ...url, destination } : url, classifier: 'sap_execute_http_request', sourceCallShape: 'executeHttpRequest' };
158
+ }
159
+ if (exprText === 'axios') {
160
+ const config = node.arguments[0];
161
+ 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)' };
164
+ }
165
+ return { externalTarget: { kind: 'unknown', dynamic: false }, classifier: 'axios_unknown_call', sourceCallShape: 'axios(...)' };
166
+ }
167
+ if (exprText === 'fetch') {
168
+ 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' };
171
+ }
172
+ 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}` };
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ function collectServiceVariables(source: ts.SourceFile): Set<string> {
179
+ const vars = new Set<string>(['cds', 'messaging', 'messageClient', 'eventClient']);
180
+ const visit = (node: ts.Node): void => {
181
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
182
+ const text = node.initializer.getText(source);
183
+ if (/cds\.connect\.(to|messaging)\s*\(/.test(text)) vars.add(node.name.text);
184
+ }
185
+ ts.forEachChild(node, visit);
186
+ };
187
+ visit(source);
188
+ return vars;
189
+ }
190
+ function receiverName(expr: ts.Expression): string | undefined {
191
+ if (ts.isIdentifier(expr)) return expr.text;
192
+ if (ts.isPropertyAccessExpression(expr)) return expr.getText(sourceOf(expr));
193
+ return undefined;
194
+ }
195
+ function sourceOf(node: ts.Node): ts.SourceFile {
196
+ return node.getSourceFile();
197
+ }
198
+ function rootReceiverName(expr: ts.Expression): string | undefined {
199
+ if (ts.isIdentifier(expr)) return expr.text;
200
+ if (ts.isPropertyAccessExpression(expr)) return rootReceiverName(expr.expression);
201
+ if (ts.isCallExpression(expr)) return rootReceiverName(expr.expression);
202
+ return undefined;
203
+ }
204
+ function isSupportedEventReceiver(receiver: string | undefined, rootReceiver: string | undefined, serviceVariables: Set<string>): boolean {
205
+ const candidate = rootReceiver ?? receiver;
206
+ if (!candidate) return false;
207
+ if (candidate === 'cds') return true;
208
+ if (serviceVariables.has(candidate)) return true;
209
+ if (receiver && serviceVariables.has(receiver)) return true;
210
+ if (/^(srv|service|serviceClient|messaging|messageClient|eventClient)$/.test(candidate)) return true;
211
+ return false;
212
+ }
213
+ interface WrapperSpec { clientIndex?: number; clientName?: string; pathIndex: number; methodIndex?: number; methodName?: string; methodLiteral?: string; definitionLine: number; internalStart: number; internalEnd: number }
214
+ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
215
+ const specs = new Map<string, WrapperSpec>();
216
+ const serviceVariables = collectServiceVariables(source);
217
+ const calledNames = new Set<string>();
218
+ const collectCalls = (node: ts.Node): void => {
219
+ if (ts.isCallExpression(node)) {
220
+ if (ts.isIdentifier(node.expression)) calledNames.add(node.expression.text);
221
+ if (ts.isCallExpression(node.expression) && ts.isIdentifier(node.expression.expression)) calledNames.add(node.expression.expression.text);
222
+ }
223
+ ts.forEachChild(node, collectCalls);
224
+ };
225
+ collectCalls(source);
226
+ const scanFunction = (name: string, fn: ts.FunctionLikeDeclaration): void => {
227
+ if (!calledNames.has(name)) return;
228
+ const params = fn.parameters.map((param) => ts.isIdentifier(param.name) ? param.name.text : undefined);
229
+ const sends: Array<{ client: string; path: string; method?: string; methodLiteral?: string; start: number; end: number }> = [];
230
+ const visit = (node: ts.Node): void => {
231
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send' && ts.isIdentifier(node.expression.expression)) {
232
+ const objectArg = node.arguments[0];
233
+ if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
234
+ const pathProp = propertyInitializer(objectArg, 'path');
235
+ const methodProp = propertyInitializer(objectArg, 'method');
236
+ const pathName = pathProp && ts.isIdentifier(pathProp) ? pathProp.text : undefined;
237
+ const methodName = methodProp && ts.isIdentifier(methodProp) ? methodProp.text : undefined;
238
+ const methodLiteral = staticExpressionText(methodProp, variableInitializers(source));
239
+ if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
240
+ }
241
+ }
242
+ ts.forEachChild(node, visit);
243
+ };
244
+ visit(fn);
245
+ if (sends.length !== 1) return;
246
+ const found = sends[0];
247
+ const clientIndex = params.indexOf(found.client);
248
+ const pathIndex = params.indexOf(found.path);
249
+ const methodIndex = found.method ? params.indexOf(found.method) : -1;
250
+ const capturesKnownClient = serviceVariables.has(found.client) || /^(srv|service|serviceClient|client|.*Client)$/.test(found.client);
251
+ if (pathIndex >= 0 && (clientIndex >= 0 || capturesKnownClient)) specs.set(name, { clientIndex: clientIndex >= 0 ? clientIndex : undefined, clientName: clientIndex >= 0 ? undefined : found.client, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodName: found.method, methodLiteral: found.methodLiteral, definitionLine: lineOf(source.text, fn.getStart(source)), internalStart: found.start, internalEnd: found.end });
252
+ };
253
+ const visitTop = (node: ts.Node): void => {
254
+ if (ts.isFunctionDeclaration(node) && node.name) scanFunction(node.name.text, node);
255
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) scanFunction(node.name.text, node.initializer);
256
+ ts.forEachChild(node, visitTop);
257
+ };
258
+ visitTop(source);
259
+ return specs;
260
+ }
261
+ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: string): ClassifiedOutboundCall[] {
262
+ const calls: ClassifiedOutboundCall[] = [];
263
+ const sourceFile = normalizePath(filePath);
264
+ const initializers = variableInitializers(source);
265
+ const serviceVariables = collectServiceVariables(source);
266
+ const wrapperSpecs = collectWrapperSpecs(source);
267
+ const wrapperInternalRanges = [...wrapperSpecs.values()].map((spec) => ({ start: spec.internalStart, end: spec.internalEnd }));
268
+ const add = (node: ts.CallExpression, fact: Omit<OutboundCallFact, 'sourceFile' | 'sourceLine' | 'confidence'> & { confidence?: number }, extra?: Record<string, unknown>): void => {
269
+ calls.push({ node, fact: { ...fact, sourceFile, sourceLine: lineOf(source.text, node.getStart(source)), confidence: fact.confidence ?? 0.8, evidence: parserEvidence(source, node, extra) } });
270
+ };
271
+ const visit = (node: ts.Node): void => {
272
+ if (ts.isCallExpression(node)) {
273
+ if (wrapperInternalRanges.some((range) => node.getStart(source) >= range.start && node.getEnd() <= range.end)) {
274
+ return;
275
+ }
276
+ const expr = node.expression;
277
+ const exprText = expr.getText(source);
278
+ if (exprText === 'cds.run') {
279
+ const arg = node.arguments[0];
280
+ const entity = arg ? queryEntityFromAst(arg, initializers) : undefined;
281
+ const payload = arg?.getText(source) ?? '';
282
+ add(node, { callType: 'local_db_query', queryEntity: entity, payloadSummary: summarizeExpression(payload), confidence: entity ? 0.9 : 0.55, unresolvedReason: entity ? undefined : queryWarning(payload) });
283
+ } else if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'send' && (ts.isIdentifier(expr.expression) || ts.isPropertyAccessExpression(expr.expression))) {
284
+ const objectArg = node.arguments[0];
285
+ if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
286
+ const receiver = receiverName(expr.expression);
287
+ const query = objectPropertyText(objectArg, 'query');
288
+ const method = stripQuotes(objectPropertyText(objectArg, 'method') ?? 'POST');
289
+ const pathExpr = propertyInitializer(objectArg, 'path') ?? propertyInitializer(objectArg, 'event');
290
+ const op = pathExpr ? staticExpressionText(pathExpr, initializers) ?? pathExpr.getText(source) : undefined;
291
+ 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;
295
+ const intent = classifyODataPathIntent(operationPathExpr, method);
296
+ 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
+ const entityCallType = entityCallTypes[intent.kind];
298
+ 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 });
300
+ }
301
+ } else if (((ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text)) || (ts.isIdentifier(expr) && wrapperSpecs.has(expr.text)))) {
302
+ const wrapperName = ts.isIdentifier(expr) ? expr.text : ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) ? expr.expression.text : '';
303
+ const wrapperArgs = ts.isIdentifier(expr) ? node.arguments : ts.isCallExpression(expr) ? expr.arguments : node.arguments;
304
+ const spec = wrapperSpecs.get(wrapperName);
305
+ const clientArg = spec?.clientIndex === undefined ? undefined : wrapperArgs[spec.clientIndex];
306
+ const pathArg = spec ? wrapperArgs[spec.pathIndex] : undefined;
307
+ const methodArg = spec?.methodIndex === undefined ? undefined : wrapperArgs[spec.methodIndex];
308
+ 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 });
312
+ } 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' });
314
+ }
315
+ } else if (ts.isPropertyAccessExpression(expr) && ['emit', 'publish', 'on'].includes(expr.name.text)) {
316
+ const receiver = receiverName(expr.expression);
317
+ const rootReceiver = rootReceiverName(expr.expression);
318
+ if (isSupportedEventReceiver(receiver, rootReceiver, serviceVariables)) {
319
+ const eventName = literalText(node.arguments[0]);
320
+ 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
+ }
322
+ } else {
323
+ const external = externalHttpEvidence(node, source, initializers);
324
+ if (external) {
325
+ const evidenceTarget = { ...external.externalTarget, method: external.method, parserClassifier: external.classifier, sourceCallShape: external.sourceCallShape };
326
+ const safeTarget = externalHttpTarget({ method: external.method, evidence_json: JSON.stringify({ externalTarget: evidenceTarget }) });
327
+ add(node, { callType: 'external_http', method: external.method, payloadSummary: undefined, confidence: 0.7, unresolvedReason: 'External HTTP destination is outside indexed CAP services', externalTarget: { kind: safeTarget.kind, stableId: safeTarget.toId, label: safeTarget.label, dynamic: safeTarget.dynamic } }, { classifier: external.classifier, externalTarget: safeTarget, sourceCallShape: external.sourceCallShape });
328
+ }
329
+ }
330
+ }
331
+ ts.forEachChild(node, visit);
332
+ };
333
+ visit(source);
334
+ return calls;
335
+ }
336
+ export function containsSupportedOutboundCall(node: ts.Node): boolean {
337
+ const source = node.getSourceFile();
338
+ const start = node.getFullStart();
339
+ const end = node.getEnd();
340
+ return classifyOutboundCallsInSource(source, source.fileName).some((call) => call.node.getStart(source) >= start && call.node.getEnd() <= end);
341
+ }
342
+ export async function parseOutboundCalls(
343
+ repoPath: string,
344
+ filePath: string,
345
+ ): Promise<OutboundCallFact[]> {
346
+ const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
347
+ const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
348
+ return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...parseLocalServiceCalls(text, filePath)];
349
+ }
350
+ function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFact[] {
351
+ const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
352
+ const aliases = new Map<string, { service: string; lookup: string; chain: string[] }>();
353
+ const calls: OutboundCallFact[] = [];
354
+ const visit = (node: ts.Node): void => {
355
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
356
+ const origin = serviceLookup(node.initializer, aliases);
357
+ if (origin) aliases.set(node.name.text, { ...origin, chain: [...origin.chain, node.name.text] });
358
+ }
359
+ if (ts.isCallExpression(node)) {
360
+ const parsed = serviceOperationCall(node.expression, aliases);
361
+ if (parsed && parsed.operation !== 'entities') calls.push({
362
+ callType: 'local_service_call',
363
+ operationPathExpr: `/${parsed.operation}`,
364
+ payloadSummary: parsed.service,
365
+ localServiceName: parsed.service,
366
+ localServiceLookup: parsed.lookup,
367
+ aliasChain: parsed.chain,
368
+ sourceFile: normalizePath(filePath),
369
+ sourceLine: lineOf(text, node.getStart(source)),
370
+ confidence: 0.9,
371
+ unresolvedReason: ['send', 'emit', 'publish', 'on'].includes(parsed.operation) ? 'transport_client_method' : undefined,
372
+ evidence: parserEvidence(source, node, {
373
+ classifier: 'local_cap_service_call',
374
+ localServiceLookup: parsed.lookup,
375
+ localServiceName: parsed.service,
376
+ operation: parsed.operation,
377
+ aliasChain: parsed.chain,
378
+ }),
379
+ });
380
+ }
381
+ ts.forEachChild(node, visit);
382
+ };
383
+ visit(source);
384
+ return calls;
385
+ }
386
+ function serviceLookup(expr: ts.Expression, aliases: Map<string, { service: string; lookup: string; chain: string[] }>): { service: string; lookup: string; chain: string[] } | undefined {
387
+ if (ts.isIdentifier(expr)) return aliases.get(expr.text);
388
+ if (ts.isPropertyAccessExpression(expr) && expr.expression.getText() === 'cds.services') return { service: expr.name.text, lookup: expr.getText(), chain: [expr.getText()] };
389
+ if (ts.isElementAccessExpression(expr) && expr.expression.getText() === 'cds.services' && ts.isStringLiteral(expr.argumentExpression)) return { service: expr.argumentExpression.text, lookup: expr.getText(), chain: [expr.getText()] };
390
+ return undefined;
391
+ }
392
+ function serviceOperationCall(expr: ts.Expression, aliases: Map<string, { service: string; lookup: string; chain: string[] }>): { service: string; lookup: string; chain: string[]; operation: string } | undefined {
393
+ if (!ts.isPropertyAccessExpression(expr)) return undefined;
394
+ const operation = expr.name.text;
395
+ const origin = serviceLookup(expr.expression, aliases);
396
+ if (!origin) return undefined;
397
+ return { ...origin, operation };
398
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { CdsRequire, PackageFacts } from '../types.js';
4
+ function recordOfString(value: unknown): Record<string, string> {
5
+ if (!value || typeof value !== 'object') return {};
6
+ return Object.fromEntries(
7
+ Object.entries(value).filter(([, v]) => typeof v === 'string')
8
+ ) as Record<string, string>;
9
+ }
10
+ function readRequires(cds: unknown): CdsRequire[] {
11
+ const requires =
12
+ cds && typeof cds === 'object' && 'requires' in cds
13
+ ? (cds as { requires?: unknown }).requires
14
+ : undefined;
15
+ if (!requires || typeof requires !== 'object') return [];
16
+ return Object.entries(requires).flatMap(([alias, raw]) => {
17
+ if (!raw || typeof raw !== 'object') return [];
18
+ const obj = raw as Record<string, unknown>;
19
+ const credentials =
20
+ obj.credentials && typeof obj.credentials === 'object'
21
+ ? (obj.credentials as Record<string, unknown>)
22
+ : {};
23
+ return [
24
+ {
25
+ alias,
26
+ kind: typeof obj.kind === 'string' ? obj.kind : undefined,
27
+ model: typeof obj.model === 'string' ? obj.model : undefined,
28
+ destination:
29
+ typeof credentials.destination === 'string'
30
+ ? credentials.destination
31
+ : undefined,
32
+ servicePath:
33
+ typeof credentials.path === 'string' ? credentials.path : undefined,
34
+ requestTimeout:
35
+ typeof credentials.requestTimeout === 'number'
36
+ ? credentials.requestTimeout
37
+ : undefined,
38
+ rawJson: JSON.stringify(raw)
39
+ }
40
+ ];
41
+ });
42
+ }
43
+ export async function parsePackageJson(
44
+ repoPath: string
45
+ ): Promise<PackageFacts> {
46
+ try {
47
+ const raw = await fs.readFile(path.join(repoPath, 'package.json'), 'utf8');
48
+ const json = JSON.parse(raw) as Record<string, unknown>;
49
+ return {
50
+ packageName: typeof json.name === 'string' ? json.name : undefined,
51
+ packageVersion:
52
+ typeof json.version === 'string' ? json.version : undefined,
53
+ dependencies: {
54
+ ...recordOfString(json.dependencies),
55
+ ...recordOfString(json.devDependencies)
56
+ },
57
+ cdsRequires: readRequires(json.cds),
58
+ scripts: recordOfString(json.scripts)
59
+ };
60
+ } catch {
61
+ return { dependencies: {}, cdsRequires: [], scripts: {} };
62
+ }
63
+ }