@saptools/service-flow 0.1.54 → 0.1.55

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-GXYVIHJ5.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.55",
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
+ }
@@ -40,19 +40,121 @@ function variableInitializers(source: ts.SourceFile): Map<string, ts.Expression>
40
40
  }
41
41
  return initializers;
42
42
  }
43
+ function unwrapQueryExpression(expr: ts.Expression): ts.Expression {
44
+ if (ts.isParenthesizedExpression(expr) || ts.isAwaitExpression(expr)
45
+ || ts.isAsExpression(expr) || ts.isTypeAssertionExpression(expr)
46
+ || ts.isNonNullExpression(expr) || ts.isSatisfiesExpression(expr))
47
+ return unwrapQueryExpression(expr.expression);
48
+ return expr;
49
+ }
43
50
  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;
51
+ const unwrapped = unwrapQueryExpression(expr);
52
+ if (ts.isIdentifier(unwrapped) && initializers.has(unwrapped.text)) return queryEntityFromAst(initializers.get(unwrapped.text) as ts.Expression, initializers);
53
+ if (ts.isCallExpression(unwrapped)) {
54
+ const name = expressionName(unwrapped.expression);
55
+ if (name === 'cds.run') return queryEntityFromAst(unwrapped.arguments[0], initializers);
56
+ if (capQueryBuilderRoots.has(name)) return entityFromExpression(unwrapped.arguments[0]);
57
+ const receiver = ts.isPropertyAccessExpression(unwrapped.expression) ? unwrapped.expression.expression : undefined;
52
58
  if (receiver) return queryEntityFromAst(receiver, initializers);
53
59
  }
54
60
  return undefined;
55
61
  }
62
+ const capQueryBuilderRoots = new Set([
63
+ 'SELECT.from',
64
+ 'SELECT.one.from',
65
+ 'SELECT.one',
66
+ 'INSERT.into',
67
+ 'UPSERT.into',
68
+ 'UPDATE.entity',
69
+ 'UPDATE',
70
+ 'DELETE.from',
71
+ ]);
72
+ interface DirectQueryBuilderStatement {
73
+ root: ts.CallExpression;
74
+ logicalCall: ts.CallExpression;
75
+ awaitExpression: ts.AwaitExpression;
76
+ }
77
+ function wrapperParent(node: ts.Expression): ts.Expression | undefined {
78
+ const parent = node.parent;
79
+ if ((ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent)
80
+ || ts.isTypeAssertionExpression(parent) || ts.isNonNullExpression(parent)
81
+ || ts.isSatisfiesExpression(parent)) && parent.expression === node)
82
+ return parent;
83
+ return undefined;
84
+ }
85
+ function fluentCallParent(node: ts.Expression): ts.CallExpression | undefined {
86
+ const property = node.parent;
87
+ if (!ts.isPropertyAccessExpression(property) || property.expression !== node) return undefined;
88
+ const call = property.parent;
89
+ return ts.isCallExpression(call) && call.expression === property ? call : undefined;
90
+ }
91
+ function queryBuilderRoot(expr: ts.Expression): ts.CallExpression | undefined {
92
+ const unwrapped = unwrapQueryExpression(expr);
93
+ if (!ts.isCallExpression(unwrapped)) return undefined;
94
+ if (capQueryBuilderRoots.has(expressionName(unwrapped.expression))) return unwrapped;
95
+ return ts.isPropertyAccessExpression(unwrapped.expression)
96
+ ? queryBuilderRoot(unwrapped.expression.expression)
97
+ : undefined;
98
+ }
99
+ function outerFluentQueryCall(root: ts.CallExpression): ts.CallExpression {
100
+ let current: ts.Expression = root;
101
+ let outer = root;
102
+ while (true) {
103
+ const wrapper = wrapperParent(current);
104
+ if (wrapper) {
105
+ current = wrapper;
106
+ continue;
107
+ }
108
+ const next = fluentCallParent(current);
109
+ if (!next) return outer;
110
+ outer = next;
111
+ current = next;
112
+ }
113
+ }
114
+ function directQueryBuilderStatement(node: ts.CallExpression): DirectQueryBuilderStatement | undefined {
115
+ const root = queryBuilderRoot(node);
116
+ if (!root) return undefined;
117
+ const logicalCall = outerFluentQueryCall(root);
118
+ if (logicalCall !== node) return undefined;
119
+ let current: ts.Expression = logicalCall;
120
+ while (true) {
121
+ const wrapper = wrapperParent(current);
122
+ if (wrapper) {
123
+ current = wrapper;
124
+ continue;
125
+ }
126
+ const parent = current.parent;
127
+ return ts.isAwaitExpression(parent) && parent.expression === current
128
+ ? { root, logicalCall, awaitExpression: parent }
129
+ : undefined;
130
+ }
131
+ }
132
+ function queryBuilderEvidence(source: ts.SourceFile, statement: DirectQueryBuilderStatement): Record<string, unknown> {
133
+ return {
134
+ classifier: 'cap_query_builder_direct',
135
+ queryDispatch: 'direct_query_builder',
136
+ queryRoot: expressionName(statement.root.expression),
137
+ queryRootStartOffset: statement.root.getStart(source),
138
+ queryRootEndOffset: statement.root.getEnd(),
139
+ queryStatementStartOffset: statement.awaitExpression.getStart(source),
140
+ queryStatementEndOffset: statement.awaitExpression.getEnd(),
141
+ };
142
+ }
143
+ function queryRunEvidence(
144
+ source: ts.SourceFile,
145
+ argument: ts.Expression | undefined,
146
+ ): Record<string, unknown> {
147
+ const root = argument ? queryBuilderRoot(argument) : undefined;
148
+ return {
149
+ classifier: 'cap_query_run_wrapper',
150
+ queryDispatch: 'cds_run_wrapper',
151
+ ...(root ? {
152
+ queryRoot: expressionName(root.expression),
153
+ queryRootStartOffset: root.getStart(source),
154
+ queryRootEndOffset: root.getEnd(),
155
+ } : {}),
156
+ };
157
+ }
56
158
  function extractQueryEntity(expr: string): string | undefined {
57
159
  const source = ts.createSourceFile('query.ts', `const __query = (${expr});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
58
160
  const initializers = variableInitializers(source);
@@ -98,7 +200,6 @@ function nameOfProperty(name: ts.PropertyName): string | undefined {
98
200
  if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
99
201
  return undefined;
100
202
  }
101
-
102
203
  type ExpressionStatus = 'static' | 'dynamic' | 'ambiguous' | 'unknown';
103
204
  type ExpressionSourceKind = 'string_literal' | 'no_substitution_template' | 'template_with_substitutions' | 'const_alias' | 'conditional_candidates' | 'dynamic_expression';
104
205
  interface ExpressionResolution { status: ExpressionStatus; sourceKind: ExpressionSourceKind; value?: string; rawExpression?: string; placeholderKeys: string[]; evidence: string[]; constName?: string }
@@ -303,7 +404,6 @@ function externalHttpEvidence(node: ts.CallExpression, source: ts.SourceFile): {
303
404
  }
304
405
  return undefined;
305
406
  }
306
-
307
407
  function collectServiceVariables(source: ts.SourceFile): Set<string> {
308
408
  const vars = new Set<string>(['cds', 'messaging', 'messageClient', 'eventClient']);
309
409
  const visit = (node: ts.Node): void => {
@@ -423,11 +523,16 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
423
523
  }
424
524
  const expr = node.expression;
425
525
  const exprText = expr.getText(source);
526
+ const directQuery = directQueryBuilderStatement(node);
426
527
  if (exprText === 'cds.run') {
427
528
  const arg = node.arguments[0];
428
529
  const entity = arg ? queryEntityFromAst(arg, initializers) : undefined;
429
530
  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) });
531
+ 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));
532
+ } else if (directQuery) {
533
+ const entity = queryEntityFromAst(directQuery.logicalCall, initializers);
534
+ const payload = directQuery.logicalCall.getText(source);
535
+ 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
536
  } else if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'send' && (ts.isIdentifier(expr.expression) || ts.isPropertyAccessExpression(expr.expression))) {
432
537
  const objectArg = node.arguments[0];
433
538
  if (objectArg && ts.isObjectLiteralExpression(objectArg)) {