@saptools/service-flow 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  stripQuotes,
17
17
  substituteVariables,
18
18
  trace
19
- } from "./chunk-7L4QKKGN.js";
19
+ } from "./chunk-KHQK7CFH.js";
20
20
 
21
21
  // src/parsers/generated-constants-parser.ts
22
22
  import fs from "fs/promises";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
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": {
@@ -12,17 +12,79 @@ interface ExtensionRow {
12
12
  }
13
13
  interface BaseRow { id: number; repoId: number }
14
14
 
15
+ interface DesiredOperation { id: number; operationType: string; operationName: string; operationPath: string; paramsJson: string; returnType: string | null; sourceFile: string; sourceLine: number }
16
+ interface ExistingOperation extends DesiredOperation { baseOperationId: number | null }
17
+
15
18
  export function materializeCdsExtensionOperations(db: Db, workspaceId: number): void {
16
19
  const extensions = db.prepare(`SELECT s.id,r.id repoId,s.service_name serviceName,s.qualified_name qualifiedName,s.source_file sourceFile,s.extension_module_specifier moduleSpecifier,s.extension_imported_symbol importedSymbol,s.extension_import_kind importKind
17
20
  FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND s.is_extend=1`).all(workspaceId) as unknown as ExtensionRow[];
18
- const insert = db.prepare(`INSERT INTO cds_operations(service_id,operation_type,operation_name,operation_path,params_json,return_type,source_file,source_line,provenance,base_operation_id)
19
- SELECT ?,operation_type,operation_name,operation_path,params_json,return_type,source_file,source_line,'inherited',id FROM cds_operations WHERE service_id=? AND NOT EXISTS (SELECT 1 FROM cds_operations existing WHERE existing.service_id=? AND existing.operation_name=cds_operations.operation_name AND existing.operation_path=cds_operations.operation_path)`);
20
- for (const extension of extensions) {
21
- const bases = resolveBase(db, workspaceId, extension);
22
- const status = bases.length === 1 ? 'resolved' : bases.length > 1 ? 'ambiguous' : 'unresolved';
23
- db.prepare('UPDATE cds_services SET extension_base_status=?, extension_base_service_id=? WHERE id=?').run(status, bases[0]?.id ?? null, extension.id);
24
- if (bases.length === 1) insert.run(extension.id, bases[0]?.id, extension.id);
21
+ db.transaction(() => {
22
+ const changedRepos = new Set<number>();
23
+ for (const extension of extensions) {
24
+ if (reconcileExtension(db, workspaceId, extension)) changedRepos.add(extension.repoId);
25
+ }
26
+ for (const repoId of changedRepos) markRepositoryDerivedFactsChanged(db, repoId);
27
+ });
28
+ }
29
+
30
+ function reconcileExtension(db: Db, workspaceId: number, extension: ExtensionRow): boolean {
31
+ const bases = resolveBase(db, workspaceId, extension);
32
+ const status = bases.length === 1 ? 'resolved' : bases.length > 1 ? 'ambiguous' : 'unresolved';
33
+ const baseId = status === 'resolved' ? bases[0]?.id ?? null : null;
34
+ const desired = baseId === null ? [] : desiredInheritedOperations(db, extension, baseId);
35
+ const statusChanged = updateExtensionStatus(db, extension.id, status, baseId);
36
+ const operationChanged = reconcileInheritedOperations(db, extension, desired);
37
+ reconcileOperationSearchRows(db, extension.repoId);
38
+ return statusChanged || operationChanged;
39
+ }
40
+
41
+ function desiredInheritedOperations(db: Db, extension: ExtensionRow, baseId: number): DesiredOperation[] {
42
+ return db.prepare("SELECT id,operation_type operationType,operation_name operationName,operation_path operationPath,params_json paramsJson,return_type returnType,source_file sourceFile,source_line sourceLine FROM cds_operations WHERE service_id=? AND provenance='direct' AND NOT EXISTS (SELECT 1 FROM cds_operations direct WHERE direct.service_id=? AND direct.provenance='direct' AND direct.operation_name=cds_operations.operation_name AND direct.operation_path=cds_operations.operation_path) ORDER BY operation_name,operation_path,id").all(baseId, extension.id) as unknown as DesiredOperation[];
43
+ }
44
+
45
+ function updateExtensionStatus(db: Db, serviceId: number, status: string, baseId: number | null): boolean {
46
+ const row = db.prepare('SELECT extension_base_status status,extension_base_service_id baseId FROM cds_services WHERE id=?').get(serviceId) as { status?: string | null; baseId?: number | null } | undefined;
47
+ if (row?.status === status && (row.baseId ?? null) === baseId) return false;
48
+ db.prepare('UPDATE cds_services SET extension_base_status=?, extension_base_service_id=? WHERE id=?').run(status, baseId, serviceId);
49
+ return true;
50
+ }
51
+
52
+ function reconcileInheritedOperations(db: Db, extension: ExtensionRow, desired: DesiredOperation[]): boolean {
53
+ const existing = db.prepare("SELECT id,operation_type operationType,operation_name operationName,operation_path operationPath,params_json paramsJson,return_type returnType,source_file sourceFile,source_line sourceLine,base_operation_id baseOperationId FROM cds_operations WHERE service_id=? AND provenance='inherited'").all(extension.id) as unknown as ExistingOperation[];
54
+ const desiredKeys = new Set(desired.map((row) => `${row.operationName}\0${row.operationPath}`));
55
+ const byKey = new Map(existing.map((row) => [`${row.operationName}\0${row.operationPath}`, row]));
56
+ let changed = false;
57
+ for (const row of existing) {
58
+ if (desiredKeys.has(`${row.operationName}\0${row.operationPath}`)) continue;
59
+ db.prepare('DELETE FROM cds_operations WHERE id=?').run(row.id);
60
+ changed = true;
61
+ }
62
+ const update = db.prepare('UPDATE cds_operations SET operation_type=?,params_json=?,return_type=?,source_file=?,source_line=?,base_operation_id=? WHERE id=?');
63
+ const add = db.prepare("INSERT INTO cds_operations(service_id,operation_type,operation_name,operation_path,params_json,return_type,source_file,source_line,provenance,base_operation_id) VALUES(?,?,?,?,?,?,?,?,'inherited',?)");
64
+ for (const row of desired) {
65
+ const current = byKey.get(`${row.operationName}\0${row.operationPath}`);
66
+ if (current && operationMatches(current, row)) continue;
67
+ if (current) update.run(row.operationType, row.paramsJson, row.returnType, row.sourceFile, row.sourceLine, row.id, current.id);
68
+ else add.run(extension.id, row.operationType, row.operationName, row.operationPath, row.paramsJson, row.returnType, row.sourceFile, row.sourceLine, row.id);
69
+ changed = true;
25
70
  }
71
+ return changed;
72
+ }
73
+
74
+ function operationMatches(current: ExistingOperation, desired: DesiredOperation): boolean {
75
+ return current.operationType === desired.operationType && current.paramsJson === desired.paramsJson && current.returnType === desired.returnType && current.sourceFile === desired.sourceFile && current.sourceLine === desired.sourceLine && current.baseOperationId === desired.id;
76
+ }
77
+
78
+ function reconcileOperationSearchRows(db: Db, repoId: number): void {
79
+ db.prepare("DELETE FROM search_index WHERE repo=? AND kind='operation'").run(String(repoId));
80
+ db.prepare(`INSERT INTO search_index(kind,name,path,repo)
81
+ SELECT 'operation',o.operation_name,o.operation_path,?
82
+ FROM cds_operations o JOIN cds_services s ON s.id=o.service_id
83
+ WHERE s.repo_id=? ORDER BY o.operation_name,o.operation_path,o.id`).run(String(repoId), repoId);
84
+ }
85
+
86
+ function markRepositoryDerivedFactsChanged(db: Db, repoId: number): void {
87
+ db.prepare("UPDATE repositories SET fact_generation=COALESCE(fact_generation,0)+1, graph_stale_reason='derived_facts_changed', graph_stale_at=? WHERE id=?").run(new Date().toISOString(), repoId);
26
88
  }
27
89
 
28
90
  function resolveBase(db: Db, workspaceId: number, extension: ExtensionRow): BaseRow[] {
@@ -150,13 +150,14 @@ function callEvidence(call: Record<string, unknown>, resolution: { target?: { re
150
150
  }
151
151
 
152
152
  function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
153
- const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind 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=?`).all(workspaceId) as Array<Record<string, unknown>>;
153
+ const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,o.provenance provenance,o.base_operation_id baseOperationId,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind 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=?`).all(workspaceId) as Array<Record<string, unknown>>;
154
154
  let edgeCount = 0;
155
155
  let resolvedCount = 0;
156
156
  let ambiguousCount = 0;
157
157
  let unresolvedCount = 0;
158
158
  for (const operation of operations) {
159
- const candidates = rankedImplementationCandidates(db, workspaceId, operation);
159
+ const implementationContext = implementationContextForOperation(db, operation);
160
+ const candidates = rankedImplementationCandidates(db, workspaceId, implementationContext);
160
161
  if (candidates.length === 0) continue;
161
162
  const accepted = candidates.filter((candidate) => candidate.accepted);
162
163
  const topScore = accepted[0]?.score ?? 0;
@@ -167,6 +168,9 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
167
168
  operationPath: operation.operationPath,
168
169
  operationName: operation.operationName,
169
170
  modelPackage: { id: operation.modelRepoId, name: operation.modelRepo, packageName: operation.modelPackage },
171
+ implementationSource: implementationContext.operationId === operation.operationId ? 'direct_or_concrete_override' : 'inherited_from_base_operation',
172
+ baseOperationId: operation.baseOperationId,
173
+ implementationOperationId: implementationContext.operationId,
170
174
  candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
171
175
  };
172
176
  if (accepted.length === 0) {
@@ -190,6 +194,12 @@ interface ImplementationCandidate extends Record<string, unknown> {
190
194
  acceptedReasons: string[];
191
195
  rejectedReasons: string[];
192
196
  }
197
+ function implementationContextForOperation(db: Db, operation: Record<string, unknown>): Record<string, unknown> {
198
+ if (operation.provenance !== 'inherited' || !operation.baseOperationId) return operation;
199
+ const base = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,o.provenance provenance,o.base_operation_id baseOperationId,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operation.baseOperationId) as Record<string, unknown> | undefined;
200
+ if (!base) return operation;
201
+ return { ...base, effectiveOperationId: operation.operationId, effectiveServicePath: operation.servicePath, effectiveOperationPath: operation.operationPath };
202
+ }
193
203
  function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
194
204
  const rows = implementationCandidates(db, workspaceId, operation);
195
205
  return deduplicateCandidates(rows.map((row) => scoreImplementationCandidate(row, operation))).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
@@ -45,6 +45,23 @@ function maskCommentsAndStrings(text: string): string {
45
45
  return out;
46
46
  }
47
47
 
48
+ function maskComments(text: string): string {
49
+ let out = '';
50
+ let mode: 'code' | 'line' | 'block' | 'single' | 'double' | 'template' = 'code';
51
+ for (let i = 0; i < text.length; i += 1) {
52
+ const c = text[i] ?? '';
53
+ const n = text[i + 1] ?? '';
54
+ if (mode === 'code' && c === '/' && n === '/') { mode = 'line'; out += ' '; i += 1; continue; }
55
+ if (mode === 'code' && c === '/' && n === '*') { mode = 'block'; out += ' '; i += 1; continue; }
56
+ if (mode === 'line' && c === '\n') mode = 'code';
57
+ if (mode === 'block' && c === '*' && n === '/') { mode = 'code'; out += ' '; i += 1; continue; }
58
+ if (mode === 'code' && (c === "'" || c === '"' || c === '`')) mode = c === "'" ? 'single' : c === '"' ? 'double' : 'template';
59
+ else if ((mode === 'single' && c === "'") || (mode === 'double' && c === '"') || (mode === 'template' && c === '`')) mode = 'code';
60
+ out += mode === 'line' || mode === 'block' ? (c === '\n' ? '\n' : ' ') : c;
61
+ }
62
+ return out;
63
+ }
64
+
48
65
  function readAnnotation(text: string, index: number): { end: number; raw: string } | undefined {
49
66
  if (text[index] !== '@') return undefined;
50
67
  let i = index + 1;
@@ -126,7 +143,7 @@ export async function parseCdsFile(repoPath: string, filePath: string): Promise<
126
143
  const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
127
144
  const services: CdsServiceFact[] = [];
128
145
  const pendingAnnotations: Array<{ end: number; raw: string }> = [];
129
- const usings = collectUsings(text);
146
+ const usings = collectUsings(maskComments(text));
130
147
  for (const a of masked.matchAll(/@\s*\(/g)) pendingAnnotations.push(annotationRawAt(text, masked, a.index ?? 0));
131
148
  const serviceRegex = /\b(?:(extend)\s+)?(?:(service)\s+)?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\b/g;
132
149
  let match: RegExpExecArray | null;
@@ -111,6 +111,7 @@ function nodeContains(parent: ts.Node, child: ts.Node): boolean {
111
111
  }
112
112
  function declarationScope(node: ts.VariableDeclaration | ts.ParameterDeclaration): ts.Node {
113
113
  if (ts.isParameter(node)) return node.parent;
114
+ if (ts.isCatchClause(node.parent) && node.parent.variableDeclaration === node) return node.parent;
114
115
  const list = node.parent;
115
116
  const blockScoped = (list.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) !== 0;
116
117
  let current: ts.Node = list.parent;
@@ -118,13 +119,24 @@ function declarationScope(node: ts.VariableDeclaration | ts.ParameterDeclaration
118
119
  while (current.parent && !isFunctionLikeScope(current)) current = current.parent;
119
120
  return current;
120
121
  }
121
- while (current.parent && !ts.isBlock(current) && !ts.isSourceFile(current) && !ts.isModuleBlock(current) && !ts.isCaseBlock(current) && !isFunctionLikeScope(current)) current = current.parent;
122
+ while (current.parent && !ts.isBlock(current) && !ts.isSourceFile(current) && !ts.isModuleBlock(current) && !ts.isCaseBlock(current) && !isLoopInitializerScope(node, current) && !isFunctionLikeScope(current)) current = current.parent;
122
123
  return current;
123
124
  }
125
+ function isLoopInitializerScope(declaration: ts.VariableDeclaration, scope: ts.Node): boolean {
126
+ const list = declaration.parent;
127
+ return (ts.isForStatement(scope) && scope.initializer === list) || ((ts.isForInStatement(scope) || ts.isForOfStatement(scope)) && scope.initializer === list);
128
+ }
129
+ function catchBindingScope(declaration: ts.VariableDeclaration | ts.ParameterDeclaration): ts.CatchClause | undefined {
130
+ if (ts.isParameter(declaration)) return undefined;
131
+ return ts.isCatchClause(declaration.parent) && declaration.parent.variableDeclaration === declaration ? declaration.parent : undefined;
132
+ }
124
133
  function isAccessibleDeclaration(declaration: ts.VariableDeclaration | ts.ParameterDeclaration, use: ts.Node): boolean {
125
134
  const source = use.getSourceFile();
126
135
  if (declaration.name.getStart(source) >= use.getStart(source)) return false;
136
+ const catchScope = catchBindingScope(declaration);
137
+ if (catchScope) return nodeContains(catchScope.block, use);
127
138
  const scope = declarationScope(declaration);
139
+ if (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) return nodeContains(scope.statement, use);
128
140
  return ts.isSourceFile(scope) || nodeContains(scope, use);
129
141
  }
130
142
  function resolveBinding(identifier: ts.Identifier, use: ts.Node): BindingResolution {
@@ -233,6 +245,11 @@ function httpMethodFromObject(object: ts.ObjectLiteralExpression, use: ts.Node):
233
245
  const text = resolveExpression(propertyInitializer(object, 'method'), use, 'literal').value;
234
246
  return text ? stripQuotes(text).toUpperCase() : undefined;
235
247
  }
248
+ const supportedHttpMethods = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']);
249
+ function safeOperationName(value: string | undefined): string | undefined {
250
+ if (!value || !/^[A-Za-z_$][\w$]*(?:[./][A-Za-z_$][\w$]*)*$/.test(value)) return undefined;
251
+ return operationPathFromStatic(value);
252
+ }
236
253
  function hasTemplatePlaceholder(value: string): boolean { return /\$\{|%7B|%7D/i.test(value); }
237
254
  function urlTargetFromExpression(expr: ts.Expression | undefined, use: ts.Node): Record<string, unknown> {
238
255
  const resolved = resolveExpression(expr, use, 'external');
@@ -409,6 +426,22 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
409
426
  const entityCallType = entityCallTypes[intent.kind];
410
427
  const isODataQueryRead = method.toUpperCase() === 'GET' && ['entity_query', 'entity_key_read', 'entity_navigation_query'].includes(intent.kind);
411
428
  add(node, { callType: query ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : undefined, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, rawPathExpression: resolvedPath.rawExpression, literalPathSource: resolvedPath.text ? (resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : resolvedPath.sourceKind) : undefined, odataPathIntent: operationPathExpr ? intent : undefined, staticPathCandidates: candidateEvidence, parserWarning: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined });
429
+ } else {
430
+ const receiver = receiverName(expr.expression);
431
+ const rootReceiver = rootReceiverName(expr.expression);
432
+ const firstArg = resolveExpression(node.arguments[0], node, 'literal');
433
+ const method = firstArg.value?.toUpperCase();
434
+ const pathArg = node.arguments[1];
435
+ const supported = method && supportedHttpMethods.has(method);
436
+ if (receiver && supported && serviceVariables.has(rootReceiver ?? receiver)) {
437
+ const resolvedPath = staticPathExpression(pathArg, node);
438
+ const operationPathExpr = operationPathFromStatic(resolvedPath.text);
439
+ const intent = classifyODataPathIntent(operationPathExpr, method);
440
+ add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.8 : 0.45, unresolvedReason: operationPathExpr ? undefined : 'dynamic_operation_path_identifier' }, { receiver, rootReceiver, classifier: 'service_client_send_method_path', rawPathExpression: resolvedPath.rawExpression, literalPathSource: resolvedPath.text ? (resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : resolvedPath.sourceKind) : undefined, odataPathIntent: operationPathExpr ? intent : undefined, parserWarning: operationPathExpr ? undefined : 'dynamic_operation_path_identifier' });
441
+ } else if (receiver && serviceVariables.has(rootReceiver ?? receiver)) {
442
+ const operationPathExpr = safeOperationName(firstArg.value);
443
+ add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.75 : 0.35, unresolvedReason: operationPathExpr ? undefined : 'unsupported_cap_send_signature' }, { receiver, rootReceiver, classifier: operationPathExpr ? 'service_client_send_operation_event' : 'service_client_send_unsupported_signature', rawOperationExpression: firstArg.rawExpression, literalOperationSource: firstArg.value ? firstArg.sourceKind : undefined, parserWarning: operationPathExpr ? undefined : 'unsupported_cap_send_signature' });
444
+ }
412
445
  }
413
446
  } else if (((ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text)) || (ts.isIdentifier(expr) && wrapperSpecs.has(expr.text)))) {
414
447
  const wrapperName = ts.isIdentifier(expr) ? expr.text : ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) ? expr.expression.text : '';