@saptools/service-flow 0.1.54 → 0.1.56

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
@@ -17,7 +17,7 @@ import {
17
17
  stripQuotes,
18
18
  substituteVariables,
19
19
  trace
20
- } from "./chunk-ERIZHM5C.js";
20
+ } from "./chunk-Y7H7ZU5B.js";
21
21
 
22
22
  // src/parsers/generated-constants-parser.ts
23
23
  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.54",
3
+ "version": "0.1.56",
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": {
package/src/cli.ts CHANGED
@@ -30,9 +30,17 @@ import { renderTraceTable } from './output/table-output.js';
30
30
  import { renderTraceJson, renderJson } from './output/json-output.js';
31
31
  import { renderDoctorDiagnostics } from './output/doctor-output.js';
32
32
  import { renderMermaid } from './output/mermaid-output.js';
33
+ import { createStdoutWriter } from './output/000-stdout-policy.js';
33
34
  import { VERSION } from './version.js';
34
35
  import type { DynamicMode } from './types.js';
35
36
  import { cleanWorkspaceState } from './cli/000-clean.js';
37
+
38
+ const stdout = createStdoutWriter(process.stdout, fail);
39
+
40
+ function writeStdout(value: string): void {
41
+ stdout.write(value);
42
+ }
43
+
36
44
  async function init(
37
45
  workspace: string,
38
46
  options: { db?: string; ignore?: string[] },
@@ -58,7 +66,7 @@ async function init(
58
66
  });
59
67
  }
60
68
  db.close();
61
- process.stdout.write(
69
+ writeStdout(
62
70
  `Workspace: ${config.rootPath}\nDatabase: ${config.dbPath}\nRepositories: ${repos.length}\nIgnored: ${config.ignore.join(', ')}\nNext: service-flow index --workspace ${config.rootPath}\n`,
63
71
  );
64
72
  }
@@ -148,7 +156,7 @@ export function createProgram(): Command {
148
156
  repo: opts.repo,
149
157
  force: Boolean(opts.force),
150
158
  });
151
- process.stdout.write(
159
+ writeStdout(
152
160
  `Indexed ${r.indexedCount} repositories, skipped ${r.skippedCount}, ${r.fileCount} files, ${r.diagnosticCount} diagnostics\n`,
153
161
  );
154
162
  }).catch(fail),
@@ -162,7 +170,7 @@ export function createProgram(): Command {
162
170
  void withWorkspace(opts.workspace, (db, workspaceId) => {
163
171
  const r = linkWorkspace(db, workspaceId);
164
172
  const upgradeWarnings = linkUpgradeWarnings(db);
165
- process.stdout.write(
173
+ writeStdout(
166
174
  `${upgradeWarnings.length ? `Warnings: ${upgradeWarnings.map((item) => String(item.code)).join(', ')}. Run service-flow doctor --strict for remediation.\n` : ''}Linked ${r.edgeCount} edges: ${r.remoteResolvedCount} remote operation calls resolved, ${r.localResolvedCount} local operation calls resolved, ${r.unresolvedCount} unresolved operation calls, ${r.ambiguousCount} ambiguous operation calls, ${r.dynamicCount} dynamic operation calls, ${r.terminalCount} terminal call edges, ${r.dependencyResolvedCount} dependency resolved, ${r.dependencyAmbiguousCount} dependency ambiguous, ${r.implementationResolvedCount} implementation resolved, ${r.implementationAmbiguousCount} implementation ambiguous, ${r.implementationUnresolvedCount} implementation unresolved\n`,
167
175
  );
168
176
  }).catch(fail),
@@ -227,7 +235,7 @@ export function createProgram(): Command {
227
235
  maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5),
228
236
  },
229
237
  );
230
- process.stdout.write(
238
+ writeStdout(
231
239
  opts.format === 'json'
232
240
  ? renderTraceJson(result)
233
241
  : opts.format === 'mermaid'
@@ -243,7 +251,7 @@ export function createProgram(): Command {
243
251
  .action(
244
252
  (opts: { workspace?: string }) =>
245
253
  void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) =>
246
- process.stdout.write(
254
+ writeStdout(
247
255
  renderJson(
248
256
  listRepositories(db, workspaceId).map((r) => ({
249
257
  name: r.name,
@@ -265,7 +273,7 @@ export function createProgram(): Command {
265
273
  ? selectRepository(db, opts.repo, workspaceId)
266
274
  : {};
267
275
  if (selection.diagnostic) {
268
- process.stdout.write(renderJson([selection.diagnostic]));
276
+ writeStdout(renderJson([selection.diagnostic]));
269
277
  return;
270
278
  }
271
279
  const repo = selection.repo;
@@ -274,7 +282,7 @@ export function createProgram(): Command {
274
282
  'SELECT r.name repo,s.service_path servicePath,s.qualified_name qualifiedName FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (? IS NULL OR s.repo_id=?) ORDER BY r.name,s.service_path',
275
283
  )
276
284
  .all(workspaceId, repo?.id, repo?.id);
277
- process.stdout.write(renderJson(rows));
285
+ writeStdout(renderJson(rows));
278
286
  }).catch(fail),
279
287
  );
280
288
  list
@@ -289,7 +297,7 @@ export function createProgram(): Command {
289
297
  ? selectRepository(db, opts.repo, workspaceId)
290
298
  : {};
291
299
  if (selection.diagnostic) {
292
- process.stdout.write(renderJson([selection.diagnostic]));
300
+ writeStdout(renderJson([selection.diagnostic]));
293
301
  return;
294
302
  }
295
303
  const repo = selection.repo;
@@ -298,7 +306,7 @@ export function createProgram(): Command {
298
306
  'SELECT r.name repo,s.service_path servicePath,o.operation_name operation,o.operation_path path FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (? IS NULL OR s.repo_id=?) AND (? IS NULL OR s.service_path=?)',
299
307
  )
300
308
  .all(workspaceId, repo?.id, repo?.id, opts.service, opts.service);
301
- process.stdout.write(renderJson(rows));
309
+ writeStdout(renderJson(rows));
302
310
  }).catch(fail),
303
311
  );
304
312
  list
@@ -313,7 +321,7 @@ export function createProgram(): Command {
313
321
  ? selectRepository(db, opts.repo, workspaceId)
314
322
  : {};
315
323
  if (selection.diagnostic) {
316
- process.stdout.write(renderJson([selection.diagnostic]));
324
+ writeStdout(renderJson([selection.diagnostic]));
317
325
  return;
318
326
  }
319
327
  const repo = selection.repo;
@@ -330,7 +338,7 @@ export function createProgram(): Command {
330
338
  opts.operation ? `/${opts.operation}` : undefined,
331
339
  opts.operation ? `%${opts.operation}%` : undefined,
332
340
  );
333
- process.stdout.write(renderJson(rows));
341
+ writeStdout(renderJson(rows));
334
342
  }).catch(fail),
335
343
  );
336
344
  program
@@ -382,7 +390,7 @@ export function createProgram(): Command {
382
390
  maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5),
383
391
  },
384
392
  );
385
- process.stdout.write(
393
+ writeStdout(
386
394
  opts.format === 'json'
387
395
  ? renderTraceJson(result)
388
396
  : renderMermaid(result),
@@ -398,7 +406,7 @@ export function createProgram(): Command {
398
406
  (name: string, opts: { workspace?: string }) =>
399
407
  void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
400
408
  const selection = selectRepository(db, name, workspaceId);
401
- process.stdout.write(renderJson(
409
+ writeStdout(renderJson(
402
410
  selection.repo ?? selection.diagnostic ?? { error: 'repo not found' },
403
411
  ));
404
412
  }).catch(fail),
@@ -415,7 +423,7 @@ export function createProgram(): Command {
415
423
  'SELECT o.* FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (o.operation_name=? OR o.operation_path=?)',
416
424
  )
417
425
  .all(workspaceId, selector, selector);
418
- process.stdout.write(renderJson(rows));
426
+ writeStdout(renderJson(rows));
419
427
  }).catch(fail),
420
428
  );
421
429
  program
@@ -428,7 +436,7 @@ export function createProgram(): Command {
428
436
  (opts: { workspace?: string; strict?: boolean; detail?: boolean; format?: string }) =>
429
437
  void withReadOnlyWorkspace(opts.workspace, (db) => {
430
438
  const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
431
- process.stdout.write(renderDoctorDiagnostics(allDiagnostics, opts.format));
439
+ writeStdout(renderDoctorDiagnostics(allDiagnostics, opts.format));
432
440
  }).catch(fail),
433
441
  );
434
442
  program
@@ -440,7 +448,7 @@ export function createProgram(): Command {
440
448
  void (async () => {
441
449
  const config = await loadWorkspaceConfig(opts.workspace);
442
450
  await cleanWorkspaceState(config, Boolean(opts.dbOnly));
443
- process.stdout.write('Cleaned service-flow state\n');
451
+ writeStdout('Cleaned service-flow state\n');
444
452
  })().catch(fail),
445
453
  );
446
454
  return program;
@@ -0,0 +1,65 @@
1
+ import type { Writable } from 'node:stream';
2
+
3
+ export interface StdoutWriter {
4
+ write(value: string): boolean;
5
+ }
6
+
7
+ interface OutputState {
8
+ blocked: boolean;
9
+ unexpectedReported: boolean;
10
+ }
11
+
12
+ interface WriterEntry {
13
+ state: OutputState;
14
+ writer: StdoutWriter;
15
+ }
16
+
17
+ const writers = new WeakMap<Writable, WriterEntry>();
18
+
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
21
+ }
22
+
23
+ function isBrokenPipe(error: Error): boolean {
24
+ return isRecord(error) && error.code === 'EPIPE';
25
+ }
26
+
27
+ function asError(value: unknown): Error {
28
+ return value instanceof Error ? value : new Error(String(value));
29
+ }
30
+
31
+ function handleStreamError(
32
+ state: OutputState,
33
+ error: Error,
34
+ onUnexpectedError: (error: Error) => void,
35
+ ): void {
36
+ state.blocked = true;
37
+ if (isBrokenPipe(error) || state.unexpectedReported) return;
38
+ state.unexpectedReported = true;
39
+ onUnexpectedError(error);
40
+ }
41
+
42
+ export function createStdoutWriter(
43
+ stream: Writable,
44
+ onUnexpectedError: (error: Error) => void,
45
+ ): StdoutWriter {
46
+ const existing = writers.get(stream);
47
+ if (existing) return existing.writer;
48
+ const state: OutputState = { blocked: false, unexpectedReported: false };
49
+ const writer: StdoutWriter = {
50
+ write(value: string): boolean {
51
+ if (state.blocked) return false;
52
+ try {
53
+ stream.write(value);
54
+ return true;
55
+ } catch (error) {
56
+ handleStreamError(state, asError(error), onUnexpectedError);
57
+ return false;
58
+ }
59
+ },
60
+ };
61
+ stream.on('error', (error: Error) =>
62
+ handleStreamError(state, error, onUnexpectedError));
63
+ writers.set(stream, { state, writer });
64
+ return writer;
65
+ }
@@ -0,0 +1,287 @@
1
+ import ts from 'typescript';
2
+
3
+ const capQueryBuilderRoots = new Set([
4
+ 'SELECT.from',
5
+ 'SELECT.one.from',
6
+ 'SELECT.one',
7
+ 'INSERT.into',
8
+ 'UPSERT.into',
9
+ 'UPDATE.entity',
10
+ 'UPDATE',
11
+ 'DELETE.from',
12
+ ]);
13
+ const promiseValueShadowCache = new WeakMap<ts.SourceFile, boolean>();
14
+
15
+ export type DirectQueryExecutionContext =
16
+ | 'await'
17
+ | 'async_return'
18
+ | 'promise_return'
19
+ | 'promise_aggregate';
20
+
21
+ export interface DirectQueryBuilderStatement {
22
+ root: ts.CallExpression;
23
+ logicalCall: ts.CallExpression;
24
+ statement: ts.Expression;
25
+ executionContext: DirectQueryExecutionContext;
26
+ }
27
+
28
+ export function isCapQueryBuilderRootName(name: string): boolean {
29
+ return capQueryBuilderRoots.has(name);
30
+ }
31
+
32
+ export function queryBuilderRoot(
33
+ expression: ts.Expression,
34
+ ): ts.CallExpression | undefined {
35
+ const unwrapped = unwrapQueryExpression(expression);
36
+ if (!ts.isCallExpression(unwrapped)) return undefined;
37
+ if (isCapQueryBuilderRootName(expressionName(unwrapped.expression)))
38
+ return unwrapped;
39
+ return ts.isPropertyAccessExpression(unwrapped.expression)
40
+ ? queryBuilderRoot(unwrapped.expression.expression)
41
+ : undefined;
42
+ }
43
+
44
+ export function directQueryBuilderStatement(
45
+ node: ts.CallExpression,
46
+ ): DirectQueryBuilderStatement | undefined {
47
+ const root = queryBuilderRoot(node);
48
+ if (!root) return undefined;
49
+ const logicalCall = outerFluentQueryCall(root);
50
+ if (logicalCall !== node) return undefined;
51
+ const expression = outerTransparentExpression(logicalCall);
52
+ const awaitExpression = directAwaitExpression(expression);
53
+ if (awaitExpression)
54
+ return { root, logicalCall, statement: awaitExpression, executionContext: 'await' };
55
+ const returnContext = returnExecutionContext(expression);
56
+ if (returnContext)
57
+ return { root, logicalCall, statement: expression, executionContext: returnContext };
58
+ if (isAwaitedPromiseAllElement(expression))
59
+ return { root, logicalCall, statement: expression, executionContext: 'promise_aggregate' };
60
+ return undefined;
61
+ }
62
+
63
+ function expressionName(expression: ts.Expression): string {
64
+ if (ts.isIdentifier(expression)) return expression.text;
65
+ if (ts.isPropertyAccessExpression(expression))
66
+ return `${expressionName(expression.expression)}.${expression.name.text}`;
67
+ return expression.getText();
68
+ }
69
+
70
+ function unwrapQueryExpression(expression: ts.Expression): ts.Expression {
71
+ if (ts.isParenthesizedExpression(expression) || ts.isAwaitExpression(expression)
72
+ || ts.isAsExpression(expression) || ts.isTypeAssertionExpression(expression)
73
+ || ts.isNonNullExpression(expression) || ts.isSatisfiesExpression(expression))
74
+ return unwrapQueryExpression(expression.expression);
75
+ return expression;
76
+ }
77
+
78
+ function wrapperParent(node: ts.Expression): ts.Expression | undefined {
79
+ const parent = node.parent;
80
+ if ((ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent)
81
+ || ts.isTypeAssertionExpression(parent) || ts.isNonNullExpression(parent)
82
+ || ts.isSatisfiesExpression(parent)) && parent.expression === node)
83
+ return parent;
84
+ return undefined;
85
+ }
86
+
87
+ function fluentCallParent(node: ts.Expression): ts.CallExpression | undefined {
88
+ const property = node.parent;
89
+ if (!ts.isPropertyAccessExpression(property) || property.expression !== node)
90
+ return undefined;
91
+ const call = property.parent;
92
+ return ts.isCallExpression(call) && call.expression === property ? call : undefined;
93
+ }
94
+
95
+ function outerFluentQueryCall(root: ts.CallExpression): ts.CallExpression {
96
+ let current: ts.Expression = root;
97
+ let outer = root;
98
+ while (true) {
99
+ const wrapper = wrapperParent(current);
100
+ if (wrapper) {
101
+ current = wrapper;
102
+ continue;
103
+ }
104
+ const next = fluentCallParent(current);
105
+ if (!next) return outer;
106
+ outer = next;
107
+ current = next;
108
+ }
109
+ }
110
+
111
+ function outerTransparentExpression(expression: ts.Expression): ts.Expression {
112
+ let current = expression;
113
+ while (true) {
114
+ const wrapper = wrapperParent(current);
115
+ if (!wrapper) return current;
116
+ current = wrapper;
117
+ }
118
+ }
119
+
120
+ function directAwaitExpression(
121
+ expression: ts.Expression,
122
+ ): ts.AwaitExpression | undefined {
123
+ const parent = expression.parent;
124
+ return ts.isAwaitExpression(parent) && parent.expression === expression
125
+ ? parent
126
+ : undefined;
127
+ }
128
+
129
+ function returnExecutionContext(
130
+ expression: ts.Expression,
131
+ ): DirectQueryExecutionContext | undefined {
132
+ const callable = returnedExpressionCallable(expression);
133
+ if (!callable) return undefined;
134
+ if (hasAsyncModifier(callable)) return 'async_return';
135
+ return hasGuaranteedPromiseReturn(callable) ? 'promise_return' : undefined;
136
+ }
137
+
138
+ function returnedExpressionCallable(
139
+ expression: ts.Expression,
140
+ ): ts.FunctionLikeDeclaration | undefined {
141
+ const parent = expression.parent;
142
+ if (ts.isArrowFunction(parent) && parent.body === expression) return parent;
143
+ if (!ts.isReturnStatement(parent) || parent.expression !== expression)
144
+ return undefined;
145
+ return nearestCallable(parent);
146
+ }
147
+
148
+ function nearestCallable(node: ts.Node): ts.FunctionLikeDeclaration | undefined {
149
+ let current = node.parent;
150
+ while (current) {
151
+ if (isRuntimeCallable(current)) return current;
152
+ current = current.parent;
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ function isRuntimeCallable(node: ts.Node): node is ts.FunctionLikeDeclaration {
158
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)
159
+ || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
160
+ || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node)
161
+ || ts.isSetAccessorDeclaration(node);
162
+ }
163
+
164
+ function hasAsyncModifier(node: ts.Node): boolean {
165
+ return !isGeneratorCallable(node) && ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some(
166
+ (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword,
167
+ ) ?? false);
168
+ }
169
+
170
+ function isGeneratorCallable(node: ts.Node): boolean {
171
+ return (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)
172
+ || ts.isMethodDeclaration(node)) && Boolean(node.asteriskToken);
173
+ }
174
+
175
+ function hasGuaranteedPromiseReturn(
176
+ callable: ts.FunctionLikeDeclaration,
177
+ ): boolean {
178
+ const returnType = declaredReturnType(callable);
179
+ return Boolean(returnType && isGuaranteedPromiseType(returnType));
180
+ }
181
+
182
+ function declaredReturnType(
183
+ callable: ts.FunctionLikeDeclaration,
184
+ ): ts.TypeNode | undefined {
185
+ if (ts.isFunctionDeclaration(callable) || ts.isFunctionExpression(callable)
186
+ || ts.isArrowFunction(callable) || ts.isMethodDeclaration(callable))
187
+ return callable.type;
188
+ return undefined;
189
+ }
190
+
191
+ function isGuaranteedPromiseType(type: ts.TypeNode): boolean {
192
+ if (ts.isParenthesizedTypeNode(type))
193
+ return isGuaranteedPromiseType(type.type);
194
+ if (ts.isTypeReferenceNode(type))
195
+ return isStandardPromiseTypeName(type.typeName);
196
+ if (ts.isUnionTypeNode(type))
197
+ return type.types.length > 0 && type.types.every(isGuaranteedPromiseType);
198
+ if (ts.isIntersectionTypeNode(type))
199
+ return type.types.some(isGuaranteedPromiseType);
200
+ return false;
201
+ }
202
+
203
+ function isStandardPromiseTypeName(name: ts.EntityName): boolean {
204
+ if (ts.isIdentifier(name))
205
+ return name.text === 'Promise' || name.text === 'PromiseLike';
206
+ return ts.isIdentifier(name.left)
207
+ && (name.left.text === 'globalThis' || name.left.text === 'global')
208
+ && (name.right.text === 'Promise' || name.right.text === 'PromiseLike');
209
+ }
210
+
211
+ function isAwaitedPromiseAllElement(expression: ts.Expression): boolean {
212
+ const array = directArrayParent(expression);
213
+ if (!array) return false;
214
+ const aggregate = aggregateCallForArray(array);
215
+ return Boolean(aggregate && isBuiltInPromiseAll(aggregate)
216
+ && directAwaitExpression(outerTransparentExpression(aggregate)));
217
+ }
218
+
219
+ function directArrayParent(
220
+ expression: ts.Expression,
221
+ ): ts.ArrayLiteralExpression | undefined {
222
+ const parent = expression.parent;
223
+ return ts.isArrayLiteralExpression(parent) && parent.elements.some(
224
+ (element) => element === expression,
225
+ ) ? parent : undefined;
226
+ }
227
+
228
+ function aggregateCallForArray(
229
+ array: ts.ArrayLiteralExpression,
230
+ ): ts.CallExpression | undefined {
231
+ const argument = outerTransparentExpression(array);
232
+ const parent = argument.parent;
233
+ return ts.isCallExpression(parent) && parent.arguments.length === 1
234
+ && parent.arguments[0] === argument ? parent : undefined;
235
+ }
236
+
237
+ function isBuiltInPromiseAll(call: ts.CallExpression): boolean {
238
+ return ts.isPropertyAccessExpression(call.expression)
239
+ && ts.isIdentifier(call.expression.expression)
240
+ && call.expression.expression.text === 'Promise'
241
+ && call.expression.name.text === 'all'
242
+ && !hasPromiseValueShadow(call.getSourceFile());
243
+ }
244
+
245
+ function hasPromiseValueShadow(source: ts.SourceFile): boolean {
246
+ const cached = promiseValueShadowCache.get(source);
247
+ if (cached !== undefined) return cached;
248
+ let shadowed = false;
249
+ const visit = (node: ts.Node): void => {
250
+ if (shadowed) return;
251
+ if (declaresPromiseValue(node)) {
252
+ shadowed = true;
253
+ return;
254
+ }
255
+ ts.forEachChild(node, visit);
256
+ };
257
+ visit(source);
258
+ promiseValueShadowCache.set(source, shadowed);
259
+ return shadowed;
260
+ }
261
+
262
+ function declaresPromiseValue(node: ts.Node): boolean {
263
+ if (ts.isVariableDeclaration(node) || ts.isParameter(node))
264
+ return bindingNameIsPromise(node.name);
265
+ if (ts.isImportClause(node))
266
+ return !node.isTypeOnly && nodeIsPromise(node.name);
267
+ if (ts.isImportSpecifier(node))
268
+ return !node.isTypeOnly && nodeIsPromise(node.name);
269
+ if (ts.isNamespaceImport(node) || ts.isImportEqualsDeclaration(node))
270
+ return nodeIsPromise(node.name);
271
+ if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)
272
+ || ts.isEnumDeclaration(node) || ts.isModuleDeclaration(node))
273
+ return nodeIsPromise(node.name);
274
+ return false;
275
+ }
276
+
277
+ function bindingNameIsPromise(name: ts.BindingName): boolean {
278
+ if (ts.isIdentifier(name)) return name.text === 'Promise';
279
+ if (ts.isObjectBindingPattern(name))
280
+ return name.elements.some((element) => bindingNameIsPromise(element.name));
281
+ return name.elements.some((element) => ts.isBindingElement(element)
282
+ && bindingNameIsPromise(element.name));
283
+ }
284
+
285
+ function nodeIsPromise(name: ts.Node | undefined): boolean {
286
+ return Boolean(name && ts.isIdentifier(name) && name.text === 'Promise');
287
+ }
@@ -8,6 +8,12 @@ import { summarizeExpression } from '../utils/redaction.js';
8
8
  import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
9
9
  import { parseServiceBindings } from './service-binding-parser.js';
10
10
  import { parseImportedWrapperCalls } from './imported-wrapper-parser.js';
11
+ import {
12
+ directQueryBuilderStatement,
13
+ isCapQueryBuilderRootName,
14
+ queryBuilderRoot,
15
+ type DirectQueryBuilderStatement,
16
+ } from './000-direct-query-execution.js';
11
17
  import type { RepositorySourceContext } from './ts-project.js';
12
18
  import {
13
19
  analyzeOperationPath,
@@ -40,19 +46,52 @@ function variableInitializers(source: ts.SourceFile): Map<string, ts.Expression>
40
46
  }
41
47
  return initializers;
42
48
  }
49
+ function unwrapQueryExpression(expr: ts.Expression): ts.Expression {
50
+ if (ts.isParenthesizedExpression(expr) || ts.isAwaitExpression(expr)
51
+ || ts.isAsExpression(expr) || ts.isTypeAssertionExpression(expr)
52
+ || ts.isNonNullExpression(expr) || ts.isSatisfiesExpression(expr))
53
+ return unwrapQueryExpression(expr.expression);
54
+ return expr;
55
+ }
43
56
  function queryEntityFromAst(expr: ts.Expression, initializers = new Map<string, ts.Expression>()): string | undefined {
44
- if (ts.isParenthesizedExpression(expr) || ts.isAwaitExpression(expr)) return queryEntityFromAst(expr.expression, initializers);
45
- if (ts.isIdentifier(expr) && initializers.has(expr.text)) return queryEntityFromAst(initializers.get(expr.text) as ts.Expression, initializers);
46
- if (ts.isCallExpression(expr)) {
47
- const name = expressionName(expr.expression);
48
- if (name === 'cds.run') return queryEntityFromAst(expr.arguments[0], initializers);
49
- if (['SELECT.one.from', 'SELECT.from', 'SELECT.one', 'INSERT.into', 'UPSERT.into', 'DELETE.from', 'UPDATE.entity'].includes(name)) return entityFromExpression(expr.arguments[0]);
50
- if (name === 'UPDATE') return entityFromExpression(expr.arguments[0]);
51
- const receiver = ts.isPropertyAccessExpression(expr.expression) ? expr.expression.expression : undefined;
57
+ const unwrapped = unwrapQueryExpression(expr);
58
+ if (ts.isIdentifier(unwrapped) && initializers.has(unwrapped.text)) return queryEntityFromAst(initializers.get(unwrapped.text) as ts.Expression, initializers);
59
+ if (ts.isCallExpression(unwrapped)) {
60
+ const name = expressionName(unwrapped.expression);
61
+ if (name === 'cds.run') return queryEntityFromAst(unwrapped.arguments[0], initializers);
62
+ if (isCapQueryBuilderRootName(name)) return entityFromExpression(unwrapped.arguments[0]);
63
+ const receiver = ts.isPropertyAccessExpression(unwrapped.expression) ? unwrapped.expression.expression : undefined;
52
64
  if (receiver) return queryEntityFromAst(receiver, initializers);
53
65
  }
54
66
  return undefined;
55
67
  }
68
+ function queryBuilderEvidence(source: ts.SourceFile, statement: DirectQueryBuilderStatement): Record<string, unknown> {
69
+ return {
70
+ classifier: 'cap_query_builder_direct',
71
+ queryDispatch: 'direct_query_builder',
72
+ queryRoot: expressionName(statement.root.expression),
73
+ queryRootStartOffset: statement.root.getStart(source),
74
+ queryRootEndOffset: statement.root.getEnd(),
75
+ queryStatementStartOffset: statement.statement.getStart(source),
76
+ queryStatementEndOffset: statement.statement.getEnd(),
77
+ queryExecutionContext: statement.executionContext,
78
+ };
79
+ }
80
+ function queryRunEvidence(
81
+ source: ts.SourceFile,
82
+ argument: ts.Expression | undefined,
83
+ ): Record<string, unknown> {
84
+ const root = argument ? queryBuilderRoot(argument) : undefined;
85
+ return {
86
+ classifier: 'cap_query_run_wrapper',
87
+ queryDispatch: 'cds_run_wrapper',
88
+ ...(root ? {
89
+ queryRoot: expressionName(root.expression),
90
+ queryRootStartOffset: root.getStart(source),
91
+ queryRootEndOffset: root.getEnd(),
92
+ } : {}),
93
+ };
94
+ }
56
95
  function extractQueryEntity(expr: string): string | undefined {
57
96
  const source = ts.createSourceFile('query.ts', `const __query = (${expr});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
58
97
  const initializers = variableInitializers(source);
@@ -98,7 +137,6 @@ function nameOfProperty(name: ts.PropertyName): string | undefined {
98
137
  if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
99
138
  return undefined;
100
139
  }
101
-
102
140
  type ExpressionStatus = 'static' | 'dynamic' | 'ambiguous' | 'unknown';
103
141
  type ExpressionSourceKind = 'string_literal' | 'no_substitution_template' | 'template_with_substitutions' | 'const_alias' | 'conditional_candidates' | 'dynamic_expression';
104
142
  interface ExpressionResolution { status: ExpressionStatus; sourceKind: ExpressionSourceKind; value?: string; rawExpression?: string; placeholderKeys: string[]; evidence: string[]; constName?: string }
@@ -303,7 +341,6 @@ function externalHttpEvidence(node: ts.CallExpression, source: ts.SourceFile): {
303
341
  }
304
342
  return undefined;
305
343
  }
306
-
307
344
  function collectServiceVariables(source: ts.SourceFile): Set<string> {
308
345
  const vars = new Set<string>(['cds', 'messaging', 'messageClient', 'eventClient']);
309
346
  const visit = (node: ts.Node): void => {
@@ -423,11 +460,16 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
423
460
  }
424
461
  const expr = node.expression;
425
462
  const exprText = expr.getText(source);
463
+ const directQuery = directQueryBuilderStatement(node);
426
464
  if (exprText === 'cds.run') {
427
465
  const arg = node.arguments[0];
428
466
  const entity = arg ? queryEntityFromAst(arg, initializers) : undefined;
429
467
  const payload = arg?.getText(source) ?? '';
430
- add(node, { callType: 'local_db_query', queryEntity: entity, payloadSummary: summarizeExpression(payload), confidence: entity ? 0.9 : 0.55, unresolvedReason: entity ? undefined : queryWarning(payload) });
468
+ add(node, { callType: 'local_db_query', queryEntity: entity, payloadSummary: summarizeExpression(payload), confidence: entity ? 0.9 : 0.55, unresolvedReason: entity ? undefined : queryWarning(payload) }, queryRunEvidence(source, arg));
469
+ } else if (directQuery) {
470
+ const entity = queryEntityFromAst(directQuery.logicalCall, initializers);
471
+ const payload = directQuery.logicalCall.getText(source);
472
+ add(directQuery.logicalCall, { callType: 'local_db_query', queryEntity: entity, payloadSummary: summarizeExpression(payload), confidence: entity ? 0.9 : 0.55, unresolvedReason: entity ? undefined : queryWarning(payload) }, queryBuilderEvidence(source, directQuery));
431
473
  } else if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'send' && (ts.isIdentifier(expr.expression) || ts.isPropertyAccessExpression(expr.expression))) {
432
474
  const objectArg = node.arguments[0];
433
475
  if (objectArg && ts.isObjectLiteralExpression(objectArg)) {