@saptools/service-flow 0.1.63 → 0.1.65

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-EGBTHN7J.js";
20
+ } from "./chunk-OONNRIDL.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.63",
3
+ "version": "0.1.65",
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": {
@@ -7,6 +7,7 @@ import {
7
7
  projectBounded,
8
8
  type BoundedProjection,
9
9
  } from '../utils/000-bounded-projection.js';
10
+ import { extractPlaceholderKeys } from '../utils/001-placeholders.js';
10
11
 
11
12
  export interface LinkedOperationResolution {
12
13
  target?: {
@@ -200,10 +201,7 @@ function compactCandidateScores(
200
201
  }
201
202
 
202
203
  function placeholderKeys(values: Array<string | undefined>): string[] {
203
- const keys = values.flatMap((value) => [...(value ?? '')
204
- .matchAll(/\$\{([^}]*)\}/g)]
205
- .map((match) => (match[1] ?? '').trim())
206
- .filter(Boolean));
204
+ const keys = values.flatMap(extractPlaceholderKeys);
207
205
  return [...new Set(keys)].sort();
208
206
  }
209
207
 
@@ -139,7 +139,7 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
139
139
  }
140
140
  if (isRemoteEntityCall) {
141
141
  const target = buildRemoteQueryTarget({ queryEntity: intent.entitySegment ?? call.query_entity, servicePath, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination: destination ? applyVariables(destination, vars) : undefined, isDynamic, parserWarning: evidence.parserWarning });
142
- const entityKind = callType.replace('remote_entity_', 'remote_entity_');
142
+ const entityKind = callType;
143
143
  db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'HANDLER_ACCESSES_REMOTE_ENTITY', 'terminal', 'call', String(call.id), target.toKind, target.toId, Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, ...target.evidence, remoteEntityAccess: entityKind }), 0, generation);
144
144
  return { status: 'terminal', callType };
145
145
  }
@@ -1,3 +1,5 @@
1
+ import { extractPlaceholderKeys, scanPlaceholders } from '../utils/001-placeholders.js';
2
+
1
3
  export interface RuntimeSubstitution {
2
4
  original?: string;
3
5
  effective?: string;
@@ -7,8 +9,6 @@ export interface RuntimeSubstitution {
7
9
  changed: boolean;
8
10
  }
9
11
 
10
- const PLACEHOLDER = /\$\{([^}]*)\}/g;
11
-
12
12
  export function applyVariables(
13
13
  template: string | undefined,
14
14
  vars: Record<string, string>,
@@ -17,9 +17,7 @@ export function applyVariables(
17
17
  }
18
18
 
19
19
  export function extractPlaceholders(template: string | undefined): string[] {
20
- return [...(template ?? '').matchAll(PLACEHOLDER)]
21
- .map((m) => (m[1] ?? '').trim())
22
- .filter(Boolean);
20
+ return extractPlaceholderKeys(template);
23
21
  }
24
22
 
25
23
  export function matchRuntimeTemplate(
@@ -50,10 +48,15 @@ export function substituteVariables(
50
48
  const placeholders = [...new Set(extractPlaceholders(template))];
51
49
  const supplied = placeholders.filter((key) => Object.hasOwn(vars, key));
52
50
  const missing = placeholders.filter((key) => !Object.hasOwn(vars, key));
53
- const effective = template.replace(PLACEHOLDER, (_m, key: string) => {
54
- const trimmed = key.trim();
55
- return Object.hasOwn(vars, trimmed) ? vars[trimmed] ?? '' : `\${${trimmed}}`;
56
- });
51
+ let lastIndex = 0;
52
+ let effective = '';
53
+ for (const span of scanPlaceholders(template)) {
54
+ const trimmed = span.key.trim();
55
+ const replacement = Object.hasOwn(vars, trimmed) ? vars[trimmed] ?? '' : `\${${trimmed}}`;
56
+ effective += template.slice(lastIndex, span.start) + replacement;
57
+ lastIndex = span.end;
58
+ }
59
+ effective += template.slice(lastIndex);
57
60
  return {
58
61
  original: template,
59
62
  effective,
@@ -67,10 +70,10 @@ export function substituteVariables(
67
70
  function runtimeTemplatePattern(template: string): string {
68
71
  let pattern = '';
69
72
  let lastIndex = 0;
70
- for (const match of template.matchAll(PLACEHOLDER)) {
71
- pattern += escapeRegex(template.slice(lastIndex, match.index));
73
+ for (const span of scanPlaceholders(template)) {
74
+ pattern += escapeRegex(template.slice(lastIndex, span.start));
72
75
  pattern += '([^/]+?)';
73
- lastIndex = (match.index ?? 0) + match[0].length;
76
+ lastIndex = span.end;
74
77
  }
75
78
  return `${pattern}${escapeRegex(template.slice(lastIndex))}`;
76
79
  }
@@ -1,4 +1,5 @@
1
1
  import type { Db } from '../db/connection.js';
2
+ import { extractPlaceholderKeys } from '../utils/001-placeholders.js';
2
3
  import { canonicalImplementationEvidence } from './000-implementation-candidates.js';
3
4
  export interface OperationTarget {
4
5
  operationId: number;
@@ -72,7 +73,8 @@ export function resolveOperation(
72
73
  },
73
74
  workspaceId?: number,
74
75
  ): OperationResolution {
75
- const missing = [signals.servicePath, signals.alias, signals.destination, signals.operationPath].flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim())).filter(Boolean);
76
+ const missing = [signals.servicePath, signals.alias, signals.destination, signals.operationPath]
77
+ .flatMap(extractPlaceholderKeys);
76
78
  if (missing.length > 0)
77
79
  return {
78
80
  status: 'dynamic',
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import ts from 'typescript';
4
4
  import { normalizePath } from '../utils/path-utils.js';
5
+ import { extractPlaceholderKeys } from '../utils/001-placeholders.js';
5
6
  import type { RepositorySourceContext } from './ts-project.js';
6
7
 
7
8
  export interface HelperBinding {
@@ -43,7 +44,7 @@ function stringValue(node: ts.Expression | undefined): string | undefined {
43
44
  }
44
45
 
45
46
  function placeholders(value?: string): string[] {
46
- return [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((m) => (m[1] ?? '').trim()).filter(Boolean);
47
+ return extractPlaceholderKeys(value);
47
48
  }
48
49
 
49
50
  export function connectFactFromCall(call: ts.CallExpression): Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'> | undefined {
@@ -2,7 +2,10 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import ts from 'typescript';
4
4
  import type { ExecutableSymbolFact, SymbolCallFact } from '../types.js';
5
- import { containsSupportedOutboundCall } from './outbound-call-parser.js';
5
+ import {
6
+ classifyOutboundCallsInSource,
7
+ containsSupportedOutboundCall,
8
+ } from './outbound-call-parser.js';
6
9
  import type { RepositorySourceContext } from './ts-project.js';
7
10
  import { normalizePath } from '../utils/path-utils.js';
8
11
 
@@ -38,6 +41,76 @@ function exportDeclarations(source: ts.SourceFile): Map<string, string> {
38
41
  function isRelativeImport(value: string | undefined): boolean {
39
42
  return Boolean(value?.startsWith('.'));
40
43
  }
44
+ type HandlerReferenceRelation =
45
+ | 'relative_import'
46
+ | 'package_import'
47
+ | 'indexed_local_symbol'
48
+ | 'relative_import_namespace_member';
49
+ interface HandlerReferenceTarget {
50
+ calleeExpression: string;
51
+ calleeLocalName: string;
52
+ importSource?: string;
53
+ relation: HandlerReferenceRelation;
54
+ wrapperFunction?: string;
55
+ }
56
+ function directHandlerReferenceTarget(
57
+ expression: ts.Expression,
58
+ source: ts.SourceFile,
59
+ imports: Map<string, string>,
60
+ namespaceImports: Set<string>,
61
+ ): HandlerReferenceTarget | undefined {
62
+ if (ts.isIdentifier(expression)) {
63
+ const importSource = imports.get(expression.text);
64
+ return {
65
+ calleeExpression: expression.text,
66
+ calleeLocalName: expression.text,
67
+ importSource,
68
+ relation: importSource
69
+ ? isRelativeImport(importSource) ? 'relative_import' : 'package_import'
70
+ : 'indexed_local_symbol',
71
+ };
72
+ }
73
+ if (!ts.isPropertyAccessExpression(expression) || expression.questionDotToken
74
+ || !ts.isIdentifier(expression.expression) || !ts.isIdentifier(expression.name))
75
+ return undefined;
76
+ const objectName = expression.expression.text;
77
+ const memberName = expression.name.text;
78
+ const importSource = imports.get(objectName);
79
+ if (namespaceImports.has(objectName)) return {
80
+ calleeExpression: expression.getText(source),
81
+ calleeLocalName: memberName,
82
+ importSource,
83
+ relation: 'relative_import_namespace_member',
84
+ };
85
+ const qualifiedName = `${objectName}.${memberName}`;
86
+ return {
87
+ calleeExpression: qualifiedName,
88
+ calleeLocalName: qualifiedName,
89
+ importSource,
90
+ relation: importSource
91
+ ? isRelativeImport(importSource) ? 'relative_import' : 'package_import'
92
+ : 'indexed_local_symbol',
93
+ };
94
+ }
95
+ function handlerReferenceTarget(
96
+ expression: ts.Expression,
97
+ source: ts.SourceFile,
98
+ imports: Map<string, string>,
99
+ namespaceImports: Set<string>,
100
+ ): HandlerReferenceTarget | undefined {
101
+ const direct = directHandlerReferenceTarget(
102
+ expression, source, imports, namespaceImports,
103
+ );
104
+ if (direct) return direct;
105
+ if (!ts.isCallExpression(expression) || expression.questionDotToken
106
+ || expression.arguments.length !== 1) return undefined;
107
+ const inner = directHandlerReferenceTarget(
108
+ expression.arguments[0], source, imports, namespaceImports,
109
+ );
110
+ return inner
111
+ ? { ...inner, wrapperFunction: expression.expression.getText(source) }
112
+ : undefined;
113
+ }
41
114
  function isObjectFunction(node: ts.Node): boolean {
42
115
  return ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node);
43
116
  }
@@ -185,6 +258,11 @@ export async function parseExecutableSymbols(
185
258
  const calls: SymbolCallFact[] = [];
186
259
  const imports = new Map<string, string>();
187
260
  const namespaceImports = new Set<string>();
261
+ const eventSubscriptionOffsets = new Set(
262
+ classifyOutboundCallsInSource(source, sourceFile)
263
+ .filter((call) => call.fact.callType === 'async_subscribe')
264
+ .map((call) => call.node.getStart(source)),
265
+ );
188
266
  const exportNames = exportDeclarations(source);
189
267
  const objectExports = new Set<string>();
190
268
  const exportedClasses = new Set<string>();
@@ -305,6 +383,33 @@ export async function parseExecutableSymbols(
305
383
  const name = `event:${eventName}:${startLine}`;
306
384
  symbols.push({ kind: 'event_registration', localName: name, qualifiedName: `module:${sourceFile}#${name}`, sourceFile, startLine, endLine: lineOf(source, node.getEnd()), startOffset: node.getStart(source), endOffset: node.getEnd(), exported: false, importExportEvidence: { source: 'synthetic_event_registration', eventName: eventArg.text, registrationLine: startLine, receiver } });
307
385
  }
386
+ if (eventSubscriptionOffsets.has(node.getStart(source))) {
387
+ const startLine = lineOf(source, node.getStart(source));
388
+ const handlerArgument = node.arguments[1];
389
+ const target = handlerArgument
390
+ ? handlerReferenceTarget(
391
+ handlerArgument, source, imports, namespaceImports,
392
+ )
393
+ : undefined;
394
+ const anchor = nearest(symbols, startLine);
395
+ if (target && anchor) calls.push({
396
+ callerQualifiedName: anchor.qualifiedName,
397
+ calleeExpression: target.calleeExpression,
398
+ calleeLocalName: target.calleeLocalName,
399
+ importSource: target.importSource,
400
+ sourceFile,
401
+ sourceLine: startLine,
402
+ evidence: {
403
+ relation: target.relation,
404
+ caller: anchor.qualifiedName,
405
+ targetName: target.calleeLocalName,
406
+ ...(target.wrapperFunction
407
+ ? { wrapperFunction: target.wrapperFunction }
408
+ : {}),
409
+ candidateStrategy: 'event_subscribe_handler_reference',
410
+ },
411
+ });
412
+ }
308
413
  }
309
414
  ts.forEachChild(node, visitEventRegistrationSymbols);
310
415
  };
@@ -1,4 +1,5 @@
1
1
  import type { Db } from '../db/connection.js';
2
+ import { scanPlaceholders } from '../utils/001-placeholders.js';
2
3
  import type {
3
4
  DynamicTargetCandidate,
4
5
  DynamicTemplates,
@@ -131,10 +132,11 @@ function matchIdentityTemplate(
131
132
  identity: string,
132
133
  npmPackage: boolean,
133
134
  ): { key: string; value: string; normalizedIdentity: string } | undefined {
134
- const matches = [...template.matchAll(/\$\{([^}]*)\}/g)];
135
- if (matches.length !== 1 || !matches[0]?.[1]) return undefined;
136
- const placeholder = matches[0][0];
137
- const sentinel = 'dynamicplaceholdertoken';
135
+ const matches = scanPlaceholders(template);
136
+ const matchSpan = matches[0];
137
+ if (matches.length !== 1 || !matchSpan?.key) return undefined;
138
+ const placeholder = template.slice(matchSpan.start, matchSpan.end);
139
+ const sentinel = 'dynamic_placeholder_token';
138
140
  const normalizedTemplate = normalizeIdentity(template.replace(placeholder, sentinel));
139
141
  const [prefix, suffix, extra] = normalizedTemplate.split(sentinel);
140
142
  if (!prefix || !suffix || extra !== undefined) return undefined;
@@ -142,7 +144,7 @@ function matchIdentityTemplate(
142
144
  const match = new RegExp(`^${escapeRegex(prefix)}([a-z0-9]+)${escapeRegex(suffix)}$`)
143
145
  .exec(normalizedIdentity);
144
146
  if (!match?.[1]) return undefined;
145
- return { key: matches[0][1].trim(), value: match[1], normalizedIdentity };
147
+ return { key: matchSpan.key.trim(), value: match[1], normalizedIdentity };
146
148
  }
147
149
 
148
150
  function competingIdentityKeys(
@@ -292,7 +294,7 @@ function normalizeIdentity(value: string, npmPackage = false): string {
292
294
  .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
293
295
  .toLowerCase()
294
296
  .replace(/[^a-z0-9]+/g, '_')
295
- .replace(/^_+|_+$/g, '');
297
+ .replace(/^_+|(?<!_)_+$/g, '');
296
298
  }
297
299
 
298
300
  function stringValue(value: unknown): string | undefined {
@@ -0,0 +1,30 @@
1
+ export interface PlaceholderSpan {
2
+ readonly start: number;
3
+ readonly end: number;
4
+ readonly key: string;
5
+ }
6
+
7
+ export function scanPlaceholders(value: string | undefined): readonly PlaceholderSpan[] {
8
+ const input = value ?? '';
9
+ const spans: PlaceholderSpan[] = [];
10
+ let cursor = 0;
11
+ while (cursor < input.length) {
12
+ const start = input.indexOf('${', cursor);
13
+ if (start < 0) break;
14
+ const closingBrace = input.indexOf('}', start + 2);
15
+ if (closingBrace < 0) break;
16
+ spans.push({
17
+ start,
18
+ end: closingBrace + 1,
19
+ key: input.slice(start + 2, closingBrace),
20
+ });
21
+ cursor = closingBrace + 1;
22
+ }
23
+ return spans;
24
+ }
25
+
26
+ export function extractPlaceholderKeys(value: string | undefined): string[] {
27
+ return scanPlaceholders(value)
28
+ .map((span) => span.key.trim())
29
+ .filter(Boolean);
30
+ }
@@ -1,9 +1,49 @@
1
1
  const SENSITIVE = /authorization|cookie|token|secret|password|key|credential/i;
2
+ const SENSITIVE_KEYWORD = /authorization|cookie|token|secret|password|key|credential/gi;
3
+
4
+ interface RedactionSpan {
5
+ readonly start: number;
6
+ readonly end: number;
7
+ readonly keyword: string;
8
+ }
9
+
10
+ function isWhitespace(value: string | undefined): boolean {
11
+ return value !== undefined && /\s/u.test(value);
12
+ }
13
+
14
+ function redactionSpan(text: string, start: number, keyword: string): RedactionSpan | undefined {
15
+ let cursor = start + keyword.length;
16
+ while (isWhitespace(text[cursor])) cursor += 1;
17
+ if (text[cursor] !== ':' && text[cursor] !== '=') return undefined;
18
+ cursor += 1;
19
+ while (isWhitespace(text[cursor])) cursor += 1;
20
+ const candidateQuote = text[cursor];
21
+ const quote = candidateQuote === "'" || candidateQuote === '"' || candidateQuote === '`'
22
+ ? candidateQuote
23
+ : undefined;
24
+ if (quote !== undefined) cursor += 1;
25
+ const valueStart = cursor;
26
+ while (cursor < text.length && !/[,'"`}\s]/u.test(text[cursor] ?? '')) cursor += 1;
27
+ if (cursor === valueStart) return undefined;
28
+ if (quote !== undefined) {
29
+ if (text[cursor] !== quote) return undefined;
30
+ cursor += 1;
31
+ }
32
+ return { start, end: cursor, keyword };
33
+ }
34
+
2
35
  export function redactText(text: string): string {
3
- return text.replace(
4
- /(authorization|cookie|token|secret|password|key|credential)\s*[:=]\s*(['"`]?)[^,'"`}\s]+\2/gi,
5
- '$1: [REDACTED]'
6
- );
36
+ let cursor = 0;
37
+ let output = '';
38
+ for (const match of text.matchAll(SENSITIVE_KEYWORD)) {
39
+ const start = match.index ?? 0;
40
+ if (start < cursor) continue;
41
+ const span = redactionSpan(text, start, match[0]);
42
+ if (span === undefined) continue;
43
+ output += text.slice(cursor, span.start) + `${span.keyword}: [REDACTED]`;
44
+ cursor = span.end;
45
+ }
46
+ return output + text.slice(cursor);
7
47
  }
8
48
  export function redactValue(value: unknown): unknown {
9
49
  if (Array.isArray(value)) return value.map(redactValue);