@saptools/service-flow 0.1.67 → 0.1.69

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 (101) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +27 -9
  3. package/TECHNICAL-NOTE.md +36 -0
  4. package/dist/chunk-3N3B5KHV.js +19596 -0
  5. package/dist/chunk-3N3B5KHV.js.map +1 -0
  6. package/dist/cli.js +2645 -521
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +67 -2
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/001-index-summary.ts +22 -0
  12. package/src/cli/003-doctor-package-resolution.ts +68 -0
  13. package/src/cli/doctor.ts +25 -14
  14. package/src/cli.ts +151 -87
  15. package/src/db/000-call-fact-repository.ts +499 -340
  16. package/src/db/001-fact-lifecycle.ts +60 -30
  17. package/src/db/002-fact-json-inventory.ts +169 -0
  18. package/src/db/003-current-fact-semantics.ts +699 -0
  19. package/src/db/004-package-target-invalidation.ts +183 -0
  20. package/src/db/005-schema-structure.ts +201 -0
  21. package/src/db/006-relative-symbol-resolution.ts +464 -0
  22. package/src/db/007-package-fact-semantics.ts +573 -0
  23. package/src/db/008-relative-fact-semantics.ts +210 -0
  24. package/src/db/009-binding-fact-semantics.ts +352 -0
  25. package/src/db/010-package-symbol-surface-semantics.ts +320 -0
  26. package/src/db/011-symbol-call-semantics.ts +144 -0
  27. package/src/db/012-binding-reference-proof.ts +268 -0
  28. package/src/db/013-index-publication-failure.ts +91 -0
  29. package/src/db/014-binding-helper-provenance.ts +17 -0
  30. package/src/db/migrations.ts +16 -3
  31. package/src/db/repositories.ts +130 -6
  32. package/src/db/schema.ts +4 -2
  33. package/src/index.ts +12 -0
  34. package/src/indexer/cds-extension-resolver.ts +27 -3
  35. package/src/indexer/repository-indexer.ts +135 -13
  36. package/src/indexer/workspace-indexer.ts +237 -34
  37. package/src/linker/003-package-import-symbol-resolver.ts +363 -131
  38. package/src/linker/004-event-subscription-handler-linker.ts +34 -11
  39. package/src/linker/005-odata-path-structure.ts +371 -0
  40. package/src/linker/006-event-template-link.ts +72 -0
  41. package/src/linker/007-call-edge-insertion.ts +568 -0
  42. package/src/linker/cross-repo-linker.ts +4 -166
  43. package/src/linker/odata-path-normalizer.ts +273 -180
  44. package/src/linker/service-resolver.ts +197 -77
  45. package/src/parsers/000-direct-query-execution.ts +11 -0
  46. package/src/parsers/002-symbol-import-bindings.ts +516 -0
  47. package/src/parsers/003-package-public-surface.ts +661 -0
  48. package/src/parsers/004-fact-identity.ts +108 -0
  49. package/src/parsers/005-event-subscription-facts.ts +281 -0
  50. package/src/parsers/006-binding-identity.ts +348 -0
  51. package/src/parsers/007-source-fact-reconciliation.ts +105 -0
  52. package/src/parsers/008-package-surface-publication.ts +82 -0
  53. package/src/parsers/009-symbol-call-facts.ts +528 -0
  54. package/src/parsers/010-package-public-surface-analysis.ts +352 -0
  55. package/src/parsers/011-binding-lexical-scope.ts +583 -0
  56. package/src/parsers/012-package-fact-contract.ts +306 -0
  57. package/src/parsers/013-executable-body-eligibility.ts +35 -0
  58. package/src/parsers/014-service-binding-helper-flow.ts +306 -0
  59. package/src/parsers/015-service-binding-collector.ts +693 -0
  60. package/src/parsers/016-local-symbol-reference.ts +261 -0
  61. package/src/parsers/017-symbol-derived-contexts.ts +268 -0
  62. package/src/parsers/018-package-commonjs-syntax.ts +142 -0
  63. package/src/parsers/019-binding-assignment-targets.ts +76 -0
  64. package/src/parsers/020-stable-local-value.ts +217 -0
  65. package/src/parsers/021-binding-visibility.ts +168 -0
  66. package/src/parsers/022-outbound-expression-analysis.ts +700 -0
  67. package/src/parsers/023-outbound-call-classifier.ts +692 -0
  68. package/src/parsers/operation-path-analysis.ts +6 -1
  69. package/src/parsers/outbound-call-parser.ts +162 -512
  70. package/src/parsers/package-json-parser.ts +45 -3
  71. package/src/parsers/service-binding-parser-helpers.ts +86 -15
  72. package/src/parsers/service-binding-parser.ts +147 -597
  73. package/src/parsers/symbol-parser.ts +513 -352
  74. package/src/trace/002-trace-diagnostics.ts +36 -1
  75. package/src/trace/007-implementation-start-diagnostic.ts +1 -0
  76. package/src/trace/011-event-subscriber-traversal.ts +100 -8
  77. package/src/trace/013-trace-root-scopes.ts +2 -3
  78. package/src/trace/014-compact-contract.ts +6 -0
  79. package/src/trace/015-trace-edge-recorder.ts +61 -4
  80. package/src/trace/016-compact-projector.ts +15 -17
  81. package/src/trace/019-trace-edge-semantics.ts +6 -10
  82. package/src/trace/020-compact-field-projection.ts +122 -38
  83. package/src/trace/021-compact-decision-normalization.ts +171 -0
  84. package/src/trace/022-trace-fact-preflight.ts +21 -0
  85. package/src/trace/023-nested-event-scopes.ts +23 -0
  86. package/src/trace/024-compact-observation-decision.ts +81 -0
  87. package/src/trace/025-trace-implementation-scope.ts +123 -0
  88. package/src/trace/026-trace-start-scope.ts +336 -0
  89. package/src/trace/027-trace-scope-execution.ts +566 -0
  90. package/src/trace/028-trace-operation-execution.ts +336 -0
  91. package/src/trace/029-trace-start-implementation.ts +172 -0
  92. package/src/trace/030-event-runtime-resolution.ts +151 -0
  93. package/src/trace/031-local-call-expansion.ts +37 -0
  94. package/src/trace/implementation-hints.ts +9 -6
  95. package/src/trace/selectors.ts +1 -0
  96. package/src/trace/trace-engine.ts +122 -624
  97. package/src/types.ts +57 -0
  98. package/src/utils/001-placeholders.ts +188 -10
  99. package/src/version.ts +1 -1
  100. package/dist/chunk-ZQABU7MR.js +0 -12151
  101. package/dist/chunk-ZQABU7MR.js.map +0 -1
@@ -0,0 +1,692 @@
1
+ import ts from 'typescript';
2
+ import { externalHttpTarget } from '../linker/external-http-target.js';
3
+ import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
4
+ import type { OutboundCallFact } from '../types.js';
5
+ import { normalizePath, stripQuotes } from '../utils/path-utils.js';
6
+ import { summarizeExpression } from '../utils/redaction.js';
7
+ import { directQueryBuilderStatement } from './000-direct-query-execution.js';
8
+ import {
9
+ expressionName,
10
+ queryEntityFromAst,
11
+ variableInitializers,
12
+ } from './001-query-entity-resolution.js';
13
+ import {
14
+ CDS_LIFECYCLE_EVENTS,
15
+ calledWrapperNames,
16
+ collectServiceVariables,
17
+ externalHttpEvidence,
18
+ isSupportedEventReceiver,
19
+ legacyPathCandidates,
20
+ lineOf,
21
+ literalPathSource,
22
+ objectPropertyIsShorthand,
23
+ parserEvidence,
24
+ propertyInitializer,
25
+ queryBuilderEvidence,
26
+ queryRunEvidence,
27
+ queryWarning,
28
+ receiverName,
29
+ resolveExpression,
30
+ rootReceiverName,
31
+ safeOperationName,
32
+ supportedHttpMethods,
33
+ wrapperSourceKind,
34
+ wrapperSpec,
35
+ type ExpressionResolution,
36
+ type WrapperSpec,
37
+ } from './022-outbound-expression-analysis.js';
38
+ import {
39
+ analyzeOperationPath,
40
+ operationPathExpression,
41
+ pathUnresolvedReason,
42
+ type OperationPathAnalysis,
43
+ } from './operation-path-analysis.js';
44
+
45
+ export interface ClassifiedOutboundCall {
46
+ fact: OutboundCallFact;
47
+ node: ts.CallExpression;
48
+ }
49
+
50
+ function namedFunctionLike(
51
+ node: ts.Node,
52
+ ): { name: string; fn: ts.FunctionLikeDeclaration } | undefined {
53
+ if (ts.isFunctionDeclaration(node) && node.name)
54
+ return { name: node.name.text, fn: node };
55
+ if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name)
56
+ || !node.initializer) return undefined;
57
+ return ts.isArrowFunction(node.initializer)
58
+ || ts.isFunctionExpression(node.initializer)
59
+ ? { name: node.name.text, fn: node.initializer } : undefined;
60
+ }
61
+
62
+ function collectWrapperSpecs(
63
+ source: ts.SourceFile,
64
+ ): Map<string, WrapperSpec> {
65
+ const specs = new Map<string, WrapperSpec>();
66
+ const calledNames = calledWrapperNames(source);
67
+ const serviceVariables = collectServiceVariables(source);
68
+ const visit = (node: ts.Node): void => {
69
+ const named = namedFunctionLike(node);
70
+ if (named) {
71
+ const spec = wrapperSpec(
72
+ named.name, named.fn, source, calledNames, serviceVariables, specs,
73
+ );
74
+ if (spec) specs.set(named.name, spec);
75
+ }
76
+ ts.forEachChild(node, visit);
77
+ };
78
+ visit(source);
79
+ return specs;
80
+ }
81
+
82
+ interface EventCallClassification {
83
+ fact: Pick<OutboundCallFact,
84
+ 'callType' | 'serviceVariableName' | 'eventNameExpr'
85
+ | 'confidence' | 'unresolvedReason'>;
86
+ evidence: Record<string, unknown>;
87
+ }
88
+
89
+ interface EventReceiver {
90
+ effectiveReceiver: string;
91
+ receiver?: string;
92
+ rootReceiver?: string;
93
+ }
94
+
95
+ function eventReceiver(
96
+ expression: ts.PropertyAccessExpression,
97
+ serviceVariables: Set<string>,
98
+ ): EventReceiver | undefined {
99
+ const receiver = receiverName(expression.expression);
100
+ const rootReceiver = rootReceiverName(expression.expression);
101
+ if (!isSupportedEventReceiver(receiver, rootReceiver, serviceVariables))
102
+ return undefined;
103
+ const effectiveReceiver = rootReceiver ?? receiver;
104
+ return effectiveReceiver
105
+ ? { receiver, rootReceiver, effectiveReceiver } : undefined;
106
+ }
107
+
108
+ function eventUnresolvedReason(
109
+ resolved: ExpressionResolution,
110
+ ): string | undefined {
111
+ if (resolved.status === 'static') return undefined;
112
+ return resolved.value !== undefined && resolved.placeholderKeys.length > 0
113
+ ? 'dynamic_event_name_identifier'
114
+ : 'dynamic_event_name_unsupported_expression';
115
+ }
116
+
117
+ function eventConfidence(reason: string | undefined): number {
118
+ if (!reason) return 0.8;
119
+ return reason === 'dynamic_event_name_identifier' ? 0.6 : 0.3;
120
+ }
121
+
122
+ function eventClassificationEvidence(
123
+ expression: ts.PropertyAccessExpression,
124
+ receiver: EventReceiver,
125
+ resolved: ExpressionResolution,
126
+ ): Record<string, unknown> {
127
+ return {
128
+ receiver: receiver.receiver,
129
+ rootReceiver: receiver.rootReceiver,
130
+ classifier: expression.name.text === 'on'
131
+ ? 'cap_service_event_subscription' : 'cap_service_event_emit',
132
+ receiverClassification: 'cap_evidence',
133
+ ...(resolved.status === 'static' ? {} : {
134
+ eventNameStatus: resolved.status,
135
+ eventNameSourceKind: resolved.sourceKind,
136
+ eventNamePlaceholderKeys: resolved.placeholderKeys,
137
+ }),
138
+ };
139
+ }
140
+
141
+ interface EventNameState {
142
+ eventName: string;
143
+ resolved: ExpressionResolution;
144
+ unresolvedReason?: string;
145
+ }
146
+
147
+ function eventNameState(node: ts.CallExpression): EventNameState | undefined {
148
+ const expression = node.arguments[0];
149
+ const resolved = resolveExpression(expression, node, 'operation_path');
150
+ let eventName = resolved.value;
151
+ if (eventName === undefined) eventName = resolved.rawExpression;
152
+ if (eventName === undefined && expression)
153
+ eventName = expression.getText(node.getSourceFile());
154
+ if (!eventName) return undefined;
155
+ return {
156
+ eventName,
157
+ resolved,
158
+ unresolvedReason: eventUnresolvedReason(resolved),
159
+ };
160
+ }
161
+
162
+ function excludedEventName(
163
+ expression: ts.PropertyAccessExpression,
164
+ receiver: EventReceiver,
165
+ state: EventNameState,
166
+ ): boolean {
167
+ if (state.resolved.status !== 'static') return false;
168
+ if (receiver.effectiveReceiver === 'cds'
169
+ && CDS_LIFECYCLE_EVENTS.has(state.eventName)) return true;
170
+ return expression.name.text === 'on' && state.eventName === 'error';
171
+ }
172
+
173
+ function eventCallClassification(
174
+ node: ts.CallExpression,
175
+ expression: ts.PropertyAccessExpression,
176
+ serviceVariables: Set<string>,
177
+ ): EventCallClassification | undefined {
178
+ const receiver = eventReceiver(expression, serviceVariables);
179
+ if (!receiver) return undefined;
180
+ const state = eventNameState(node);
181
+ if (!state || excludedEventName(expression, receiver, state))
182
+ return undefined;
183
+ return {
184
+ fact: {
185
+ callType: expression.name.text === 'on'
186
+ ? 'async_subscribe' : 'async_emit',
187
+ serviceVariableName: receiver.effectiveReceiver,
188
+ eventNameExpr: state.eventName,
189
+ confidence: eventConfidence(state.unresolvedReason),
190
+ unresolvedReason: state.unresolvedReason,
191
+ },
192
+ evidence: eventClassificationEvidence(
193
+ expression, receiver, state.resolved,
194
+ ),
195
+ };
196
+ }
197
+
198
+ type OutboundFactInput = Omit<
199
+ OutboundCallFact, 'sourceFile' | 'sourceLine' | 'confidence'
200
+ > & { confidence?: number };
201
+ type AddOutboundCall = (
202
+ node: ts.CallExpression,
203
+ fact: OutboundFactInput,
204
+ extra?: Record<string, unknown>,
205
+ ) => void;
206
+
207
+ interface OutboundCallContext {
208
+ source: ts.SourceFile;
209
+ initializers: Map<string, ts.Expression>;
210
+ serviceVariables: Set<string>;
211
+ wrapperSpecs: Map<string, WrapperSpec>;
212
+ add: AddOutboundCall;
213
+ }
214
+
215
+ function classifyQueryDispatch(
216
+ node: ts.CallExpression,
217
+ context: OutboundCallContext,
218
+ ): boolean {
219
+ const { source, initializers, add } = context;
220
+ if (expressionName(node.expression) === 'cds.run') {
221
+ const argument = node.arguments[0];
222
+ const entity = argument
223
+ ? queryEntityFromAst(argument, initializers) : undefined;
224
+ const payload = argument?.getText(source) ?? '';
225
+ add(node, {
226
+ callType: 'local_db_query',
227
+ queryEntity: entity,
228
+ payloadSummary: summarizeExpression(payload),
229
+ confidence: entity ? 0.9 : 0.55,
230
+ unresolvedReason: entity ? undefined : queryWarning(payload),
231
+ }, queryRunEvidence(source, argument));
232
+ return true;
233
+ }
234
+ const direct = directQueryBuilderStatement(node);
235
+ if (!direct) return false;
236
+ const entity = queryEntityFromAst(direct.logicalCall, initializers);
237
+ const payload = direct.logicalCall.getText(source);
238
+ add(direct.logicalCall, {
239
+ callType: 'local_db_query',
240
+ queryEntity: entity,
241
+ payloadSummary: summarizeExpression(payload),
242
+ confidence: entity ? 0.9 : 0.55,
243
+ unresolvedReason: entity ? undefined : queryWarning(payload),
244
+ }, queryBuilderEvidence(source, direct));
245
+ return true;
246
+ }
247
+
248
+ function sendExpression(
249
+ node: ts.CallExpression,
250
+ ): ts.PropertyAccessExpression | undefined {
251
+ const expression = node.expression;
252
+ if (!ts.isPropertyAccessExpression(expression)
253
+ || expression.name.text !== 'send') return undefined;
254
+ return ts.isIdentifier(expression.expression)
255
+ || ts.isPropertyAccessExpression(expression.expression)
256
+ ? expression : undefined;
257
+ }
258
+
259
+ function objectSendMethod(
260
+ object: ts.ObjectLiteralExpression,
261
+ node: ts.CallExpression,
262
+ ): { method: string; dynamicDefaulted: boolean } {
263
+ const expression = propertyInitializer(object, 'method');
264
+ const resolution = resolveExpression(expression, node, 'literal');
265
+ return {
266
+ method: stripQuotes(resolution.value ?? 'POST'),
267
+ dynamicDefaulted: Boolean(expression && resolution.value === undefined),
268
+ };
269
+ }
270
+
271
+ function objectSendCallType(
272
+ query: ts.Expression | undefined,
273
+ entityType: OutboundCallFact['callType'] | undefined,
274
+ queryRead: boolean,
275
+ ): OutboundCallFact['callType'] {
276
+ if (query) return 'remote_query';
277
+ if (entityType) return entityType;
278
+ return queryRead ? 'remote_query' : 'remote_action';
279
+ }
280
+
281
+ function objectSendQueryEntity(
282
+ query: ts.Expression | undefined,
283
+ initializers: Map<string, ts.Expression>,
284
+ queryRead: boolean,
285
+ entitySegment: string | undefined,
286
+ ): string | undefined {
287
+ if (query) return queryEntityFromAst(query, initializers);
288
+ return queryRead ? entitySegment : undefined;
289
+ }
290
+
291
+ function objectSendUnresolvedReason(
292
+ query: ts.Expression | undefined,
293
+ queryEntity: string | undefined,
294
+ pathExpression: ts.Expression | undefined,
295
+ pathAnalysis: OperationPathAnalysis,
296
+ source: ts.SourceFile,
297
+ ): string | undefined {
298
+ if (query)
299
+ return queryEntity ? undefined : queryWarning(query.getText(source));
300
+ return pathExpression ? pathUnresolvedReason(pathAnalysis) : undefined;
301
+ }
302
+
303
+ const objectSendEntityTypes: Readonly<Record<
304
+ string, OutboundCallFact['callType']
305
+ >> = {
306
+ entity_mutation: 'remote_entity_mutation',
307
+ entity_delete: 'remote_entity_delete',
308
+ entity_media: 'remote_entity_media',
309
+ entity_candidate: 'remote_entity_candidate',
310
+ };
311
+
312
+ function objectSendEvidence(
313
+ object: ts.ObjectLiteralExpression,
314
+ receiver: string | undefined,
315
+ rawOperation: string | undefined,
316
+ operationPath: string | undefined,
317
+ intent: ReturnType<typeof classifyODataPathIntent>,
318
+ pathAnalysis: OperationPathAnalysis,
319
+ unresolvedReason: string | undefined,
320
+ dynamicMethodDefaulted: boolean,
321
+ ): Record<string, unknown> {
322
+ return {
323
+ receiver,
324
+ classifier: 'service_client_send_object',
325
+ operationPathExpression: objectPropertyIsShorthand(object, 'path')
326
+ ? rawOperation : undefined,
327
+ rawPathExpression: pathAnalysis.rawExpression,
328
+ literalPathSource: literalPathSource(pathAnalysis),
329
+ odataPathIntent: operationPath ? intent : undefined,
330
+ pathAnalysis,
331
+ staticPathCandidates: legacyPathCandidates(pathAnalysis),
332
+ parserWarning: unresolvedReason,
333
+ ...(dynamicMethodDefaulted ? { dynamicMethodDefaulted: true } : {}),
334
+ };
335
+ }
336
+
337
+ function classifyObjectSend(
338
+ node: ts.CallExpression,
339
+ expression: ts.PropertyAccessExpression,
340
+ object: ts.ObjectLiteralExpression,
341
+ context: OutboundCallContext,
342
+ ): void {
343
+ const { source, initializers, add } = context;
344
+ const receiver = receiverName(expression.expression);
345
+ const query = propertyInitializer(object, 'query');
346
+ const methodState = objectSendMethod(object, node);
347
+ const path = propertyInitializer(object, 'path')
348
+ ?? propertyInitializer(object, 'event');
349
+ const pathAnalysis = analyzeOperationPath(path, node, methodState.method);
350
+ const operationPath = operationPathExpression(pathAnalysis);
351
+ const intent = classifyODataPathIntent(operationPath, methodState.method);
352
+ const queryRead = methodState.method.toUpperCase() === 'GET'
353
+ && ['entity_query', 'entity_key_read', 'entity_navigation_query']
354
+ .includes(intent.kind);
355
+ const queryEntity = objectSendQueryEntity(
356
+ query, initializers, queryRead, intent.entitySegment,
357
+ );
358
+ const unresolvedReason = objectSendUnresolvedReason(
359
+ query, queryEntity, path, pathAnalysis, source,
360
+ );
361
+ const rawOperation = path
362
+ ? operationPath ?? path.getText(source) : undefined;
363
+ add(node, {
364
+ callType: objectSendCallType(
365
+ query, objectSendEntityTypes[intent.kind], queryRead,
366
+ ),
367
+ serviceVariableName: receiver,
368
+ method: methodState.method,
369
+ operationPathExpr: operationPath,
370
+ queryEntity,
371
+ payloadSummary: summarizeExpression(object.getText(source)),
372
+ confidence: rawOperation || query ? 0.8 : 0.4,
373
+ unresolvedReason,
374
+ }, objectSendEvidence(
375
+ object, receiver, rawOperation, operationPath, intent, pathAnalysis,
376
+ unresolvedReason, methodState.dynamicDefaulted,
377
+ ));
378
+ }
379
+
380
+ function classifyPositionalSend(
381
+ node: ts.CallExpression,
382
+ expression: ts.PropertyAccessExpression,
383
+ context: OutboundCallContext,
384
+ ): void {
385
+ const { source, serviceVariables, add } = context;
386
+ const receiver = receiverName(expression.expression);
387
+ const rootReceiver = rootReceiverName(expression.expression);
388
+ const serviceVariable = rootReceiver ?? receiver;
389
+ if (!receiver || !serviceVariable
390
+ || !serviceVariables.has(serviceVariable)) return;
391
+ const first = resolveExpression(node.arguments[0], node, 'literal');
392
+ const method = first.value?.toUpperCase();
393
+ if (!method || !supportedHttpMethods.has(method)) {
394
+ const operationPath = safeOperationName(first.value);
395
+ addUnsupportedPositionalSend(
396
+ node, receiver, rootReceiver, first, operationPath, context,
397
+ );
398
+ return;
399
+ }
400
+ const pathAnalysis = analyzeOperationPath(node.arguments[1], node, method);
401
+ const operationPath = operationPathExpression(pathAnalysis);
402
+ const intent = classifyODataPathIntent(operationPath, method);
403
+ const unresolvedReason = pathUnresolvedReason(pathAnalysis);
404
+ add(node, {
405
+ callType: 'remote_action',
406
+ serviceVariableName: serviceVariable,
407
+ method,
408
+ operationPathExpr: operationPath,
409
+ payloadSummary: summarizeExpression(node.getText(source)),
410
+ confidence: operationPath ? 0.8 : 0.45,
411
+ unresolvedReason,
412
+ }, {
413
+ receiver, rootReceiver,
414
+ classifier: 'service_client_send_method_path',
415
+ rawPathExpression: pathAnalysis.rawExpression,
416
+ literalPathSource: literalPathSource(pathAnalysis),
417
+ odataPathIntent: operationPath ? intent : undefined,
418
+ pathAnalysis,
419
+ staticPathCandidates: legacyPathCandidates(pathAnalysis),
420
+ parserWarning: unresolvedReason,
421
+ });
422
+ }
423
+
424
+ function addUnsupportedPositionalSend(
425
+ node: ts.CallExpression,
426
+ receiver: string,
427
+ rootReceiver: string | undefined,
428
+ first: ExpressionResolution,
429
+ operationPath: string | undefined,
430
+ context: OutboundCallContext,
431
+ ): void {
432
+ const unresolvedReason = operationPath
433
+ ? undefined : 'unsupported_cap_send_signature';
434
+ context.add(node, {
435
+ callType: 'remote_action',
436
+ serviceVariableName: rootReceiver ?? receiver,
437
+ operationPathExpr: operationPath,
438
+ payloadSummary: summarizeExpression(node.getText(context.source)),
439
+ confidence: operationPath ? 0.75 : 0.35,
440
+ unresolvedReason,
441
+ }, {
442
+ receiver, rootReceiver,
443
+ classifier: operationPath
444
+ ? 'service_client_send_operation_event'
445
+ : 'service_client_send_unsupported_signature',
446
+ rawOperationExpression: first.rawExpression,
447
+ literalOperationSource: first.value ? first.sourceKind : undefined,
448
+ parserWarning: unresolvedReason,
449
+ });
450
+ }
451
+
452
+ function classifySendCall(
453
+ node: ts.CallExpression,
454
+ context: OutboundCallContext,
455
+ ): boolean {
456
+ const expression = sendExpression(node);
457
+ if (!expression) return false;
458
+ const object = node.arguments[0];
459
+ if (object && ts.isObjectLiteralExpression(object))
460
+ classifyObjectSend(node, expression, object, context);
461
+ else classifyPositionalSend(node, expression, context);
462
+ return true;
463
+ }
464
+
465
+ interface WrapperInvocation {
466
+ name: string;
467
+ arguments: readonly ts.Expression[];
468
+ spec: WrapperSpec;
469
+ }
470
+
471
+ function wrapperInvocation(
472
+ expression: ts.LeftHandSideExpression,
473
+ specs: Map<string, WrapperSpec>,
474
+ ): WrapperInvocation | undefined {
475
+ if (ts.isIdentifier(expression)) {
476
+ const spec = specs.get(expression.text);
477
+ return spec
478
+ ? { name: expression.text, arguments: [], spec } : undefined;
479
+ }
480
+ if (!ts.isCallExpression(expression)
481
+ || !ts.isIdentifier(expression.expression)) return undefined;
482
+ const spec = specs.get(expression.expression.text);
483
+ return spec ? {
484
+ name: expression.expression.text,
485
+ arguments: expression.arguments,
486
+ spec,
487
+ } : undefined;
488
+ }
489
+
490
+ function wrapperArguments(
491
+ node: ts.CallExpression,
492
+ invocation: WrapperInvocation,
493
+ ): readonly ts.Expression[] {
494
+ return invocation.arguments.length > 0
495
+ ? invocation.arguments : node.arguments;
496
+ }
497
+
498
+ function classifyWrapperCall(
499
+ node: ts.CallExpression,
500
+ context: OutboundCallContext,
501
+ ): boolean {
502
+ const invocation = wrapperInvocation(node.expression, context.wrapperSpecs);
503
+ if (!invocation) return false;
504
+ const args = wrapperArguments(node, invocation);
505
+ const client = invocation.spec.clientIndex === undefined
506
+ ? undefined : args[invocation.spec.clientIndex];
507
+ const path = args[invocation.spec.pathIndex];
508
+ const methodArgument = invocation.spec.methodIndex === undefined
509
+ ? undefined : args[invocation.spec.methodIndex];
510
+ const receiver = client && ts.isIdentifier(client)
511
+ ? client.text : invocation.spec.clientName;
512
+ const method = stripQuotes(resolveExpression(
513
+ methodArgument, node, 'literal',
514
+ ).value ?? invocation.spec.methodLiteral ?? 'POST');
515
+ const analysis = analyzeOperationPath(path, node, method);
516
+ if (receiver) addWrapperCall(
517
+ node, receiver, method, analysis, invocation, context,
518
+ );
519
+ return true;
520
+ }
521
+
522
+ function addWrapperCall(
523
+ node: ts.CallExpression,
524
+ receiver: string,
525
+ method: string,
526
+ analysis: OperationPathAnalysis,
527
+ invocation: WrapperInvocation,
528
+ context: OutboundCallContext,
529
+ ): void {
530
+ const operationPath = operationPathExpression(analysis);
531
+ const unresolvedReason = pathUnresolvedReason(analysis);
532
+ const staticPath = Boolean(operationPath);
533
+ context.add(node, {
534
+ callType: 'remote_action',
535
+ serviceVariableName: receiver,
536
+ method,
537
+ operationPathExpr: operationPath,
538
+ payloadSummary: summarizeExpression(node.getText(context.source)),
539
+ confidence: staticPath ? 0.75 : 0.45,
540
+ unresolvedReason,
541
+ }, wrapperEvidence(
542
+ node, receiver, method, analysis, invocation, context, staticPath,
543
+ ));
544
+ }
545
+
546
+ function wrapperEvidence(
547
+ node: ts.CallExpression,
548
+ receiver: string,
549
+ method: string,
550
+ analysis: OperationPathAnalysis,
551
+ invocation: WrapperInvocation,
552
+ context: OutboundCallContext,
553
+ staticPath: boolean,
554
+ ): Record<string, unknown> {
555
+ const operationPath = operationPathExpression(analysis);
556
+ return {
557
+ receiver,
558
+ classifier: staticPath
559
+ ? analysis.sourceKind.includes('string_literal')
560
+ ? 'higher_order_wrapper_literal_path'
561
+ : 'higher_order_wrapper_static_path'
562
+ : analysis.status === 'ambiguous'
563
+ ? 'higher_order_wrapper_ambiguous_path'
564
+ : 'higher_order_wrapper_dynamic_path',
565
+ wrapperFunction: invocation.name,
566
+ nestedWrapperFunction: invocation.spec.nestedWrapperFunction,
567
+ wrapperDefinitionLine: invocation.spec.definitionLine,
568
+ callerLine: lineOf(context.source.text, node.getStart(context.source)),
569
+ wrapperPathSourceKind: wrapperSourceKind(analysis.sourceKind),
570
+ rawPathExpression: analysis.rawExpression,
571
+ normalizedOperationPath: operationPath
572
+ ? classifyODataPathIntent(operationPath, method).topLevelOperationName
573
+ : undefined,
574
+ literalPathSource: staticPath
575
+ ? analysis.sourceKind.includes('const_alias')
576
+ ? 'same_scope_const_initializer'
577
+ : `wrapper_call_${wrapperSourceKind(analysis.sourceKind)}`
578
+ : undefined,
579
+ literalCallerArgumentDetected: staticPath || undefined,
580
+ pathAnalysis: analysis,
581
+ parserWarning: staticPath ? undefined : pathUnresolvedReason(analysis),
582
+ };
583
+ }
584
+
585
+ function classifyEventCall(
586
+ node: ts.CallExpression,
587
+ context: OutboundCallContext,
588
+ ): boolean {
589
+ const expression = node.expression;
590
+ if (!ts.isPropertyAccessExpression(expression)
591
+ || !['emit', 'publish', 'on'].includes(expression.name.text)) return false;
592
+ const event = eventCallClassification(
593
+ node, expression, context.serviceVariables,
594
+ );
595
+ if (event) context.add(node, event.fact, event.evidence);
596
+ return true;
597
+ }
598
+
599
+ function classifyExternalCall(
600
+ node: ts.CallExpression,
601
+ context: OutboundCallContext,
602
+ ): void {
603
+ const external = externalHttpEvidence(node, context.source);
604
+ if (!external) return;
605
+ const evidenceTarget = {
606
+ ...external.externalTarget,
607
+ method: external.method,
608
+ parserClassifier: external.classifier,
609
+ sourceCallShape: external.sourceCallShape,
610
+ };
611
+ const safeTarget = externalHttpTarget({
612
+ method: external.method,
613
+ evidence_json: JSON.stringify({ externalTarget: evidenceTarget }),
614
+ });
615
+ context.add(node, {
616
+ callType: 'external_http',
617
+ method: external.method,
618
+ payloadSummary: undefined,
619
+ confidence: 0.7,
620
+ unresolvedReason:
621
+ 'External HTTP destination is outside indexed CAP services',
622
+ externalTarget: {
623
+ kind: safeTarget.kind,
624
+ stableId: safeTarget.toId,
625
+ label: safeTarget.label,
626
+ dynamic: safeTarget.dynamic,
627
+ },
628
+ }, {
629
+ classifier: external.classifier,
630
+ externalTarget: safeTarget,
631
+ sourceCallShape: external.sourceCallShape,
632
+ });
633
+ }
634
+
635
+ function classifyOutboundCall(
636
+ node: ts.CallExpression,
637
+ context: OutboundCallContext,
638
+ ): void {
639
+ if (classifyQueryDispatch(node, context)) return;
640
+ if (classifySendCall(node, context)) return;
641
+ if (classifyWrapperCall(node, context)) return;
642
+ if (classifyEventCall(node, context)) return;
643
+ classifyExternalCall(node, context);
644
+ }
645
+
646
+ function visitOutboundCalls(
647
+ source: ts.SourceFile,
648
+ internalRanges: readonly { start: number; end: number }[],
649
+ classify: (node: ts.CallExpression) => void,
650
+ ): void {
651
+ const visit = (node: ts.Node): void => {
652
+ if (ts.isCallExpression(node) && internalRanges.some((range) =>
653
+ node.getStart(source) >= range.start && node.getEnd() <= range.end))
654
+ return;
655
+ if (ts.isCallExpression(node)) classify(node);
656
+ ts.forEachChild(node, visit);
657
+ };
658
+ visit(source);
659
+ }
660
+
661
+ export function classifyOutboundCallsInSource(
662
+ source: ts.SourceFile,
663
+ filePath: string,
664
+ ): ClassifiedOutboundCall[] {
665
+ const calls: ClassifiedOutboundCall[] = [];
666
+ const sourceFile = normalizePath(filePath);
667
+ const initializers = variableInitializers(source);
668
+ const serviceVariables = collectServiceVariables(source);
669
+ const wrapperSpecs = collectWrapperSpecs(source);
670
+ const internalRanges = [...wrapperSpecs.values()].map((spec) => ({
671
+ start: spec.internalStart, end: spec.internalEnd,
672
+ }));
673
+ const add: AddOutboundCall = (node, fact, extra): void => {
674
+ calls.push({ node, fact: {
675
+ ...fact,
676
+ sourceFile,
677
+ sourceLine: lineOf(source.text, node.getStart(source)),
678
+ callSiteStartOffset: node.getStart(source),
679
+ callSiteEndOffset: node.getEnd(),
680
+ confidence: fact.confidence ?? 0.8,
681
+ evidence: parserEvidence(source, node, extra),
682
+ } });
683
+ };
684
+ const context = {
685
+ source, initializers, serviceVariables, wrapperSpecs, add,
686
+ };
687
+ visitOutboundCalls(
688
+ source, internalRanges,
689
+ (node) => classifyOutboundCall(node, context),
690
+ );
691
+ return calls;
692
+ }
@@ -3,6 +3,7 @@ import {
3
3
  classifyODataPathIntent,
4
4
  normalizeODataOperationInvocationPath,
5
5
  } from '../linker/odata-path-normalizer.js';
6
+ import { analyzeODataPathStructure } from '../linker/005-odata-path-structure.js';
6
7
 
7
8
  export type OperationPathStatus = 'static' | 'ambiguous' | 'dynamic' | 'unknown';
8
9
 
@@ -284,7 +285,11 @@ function normalizedCandidate(value: string, method: string): string[] {
284
285
  if (invocation?.wasInvocation) return [invocation.normalizedOperationPath];
285
286
  const intent = classifyODataPathIntent(value, method);
286
287
  if (intent.kind.startsWith('entity_')) return [];
287
- if (!value.startsWith('/') || value.slice(1).includes('/') || value.includes('?')) return [];
288
+ const structure = analyzeODataPathStructure(value);
289
+ if (structure.status !== 'valid' || !value.startsWith('/')
290
+ || structure.segments.length !== 1
291
+ || structure.queryIndex !== undefined)
292
+ return [];
288
293
  return [value];
289
294
  }
290
295