@saptools/service-flow 0.1.62 → 0.1.64
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/CHANGELOG.md +5 -0
- package/dist/{chunk-BGD7UYJN.js → chunk-TBH33OYC.js} +101 -34
- package/dist/chunk-TBH33OYC.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/linker/002-call-evidence.ts +2 -4
- package/src/linker/cross-repo-linker.ts +1 -1
- package/src/linker/dynamic-edge-resolver.ts +15 -12
- package/src/linker/service-resolver.ts +3 -1
- package/src/parsers/outbound-call-parser.ts +13 -10
- package/src/parsers/service-binding-parser-helpers.ts +2 -1
- package/src/trace/001-dynamic-identity.ts +8 -6
- package/src/utils/001-placeholders.ts +30 -0
- package/src/utils/redaction.ts +44 -4
- package/dist/chunk-BGD7UYJN.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
71
|
-
pattern += escapeRegex(template.slice(lastIndex,
|
|
73
|
+
for (const span of scanPlaceholders(template)) {
|
|
74
|
+
pattern += escapeRegex(template.slice(lastIndex, span.start));
|
|
72
75
|
pattern += '([^/]+?)';
|
|
73
|
-
lastIndex =
|
|
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]
|
|
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',
|
|
@@ -76,13 +76,9 @@ function literalText(expr: ts.Expression | undefined): string | undefined {
|
|
|
76
76
|
if (isStringLike(expr)) return expr.text;
|
|
77
77
|
return undefined;
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
if (!prop) return undefined;
|
|
84
|
-
return ts.isShorthandPropertyAssignment(prop) ? prop.name.text : prop.initializer.getText();
|
|
85
|
-
}
|
|
79
|
+
const CDS_LIFECYCLE_EVENTS = new Set([
|
|
80
|
+
'bootstrap', 'loaded', 'connect', 'serving', 'served', 'listening', 'shutdown',
|
|
81
|
+
]);
|
|
86
82
|
function objectPropertyIsShorthand(object: ts.ObjectLiteralExpression, key: string): boolean {
|
|
87
83
|
return object.properties.some((property) => ts.isShorthandPropertyAssignment(property) && property.name.text === key);
|
|
88
84
|
}
|
|
@@ -376,7 +372,10 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
376
372
|
if (objectArg && ts.isObjectLiteralExpression(objectArg)) {
|
|
377
373
|
const receiver = receiverName(expr.expression);
|
|
378
374
|
const queryExpression = propertyInitializer(objectArg, 'query');
|
|
379
|
-
const
|
|
375
|
+
const methodExpression = propertyInitializer(objectArg, 'method');
|
|
376
|
+
const methodResolution = resolveExpression(methodExpression, node, 'literal');
|
|
377
|
+
const method = stripQuotes(methodResolution.value ?? 'POST');
|
|
378
|
+
const dynamicMethodDefaulted = Boolean(methodExpression && methodResolution.value === undefined);
|
|
380
379
|
const pathExpr = propertyInitializer(objectArg, 'path') ?? propertyInitializer(objectArg, 'event');
|
|
381
380
|
const pathAnalysis = analyzeOperationPath(pathExpr, node, method);
|
|
382
381
|
const op = pathExpr ? operationPathExpression(pathAnalysis) ?? pathExpr.getText(source) : undefined;
|
|
@@ -392,7 +391,7 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
392
391
|
const unresolvedReason = queryExpression
|
|
393
392
|
? queryEntity ? undefined : queryWarning(queryExpression.getText(source))
|
|
394
393
|
: pathExpr ? pathUnresolvedReason(pathAnalysis) : undefined;
|
|
395
|
-
add(node, { callType: queryExpression ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || queryExpression ? 0.8 : 0.4, unresolvedReason }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, rawPathExpression: pathAnalysis.rawExpression, literalPathSource: literalPathSource(pathAnalysis), odataPathIntent: operationPathExpr ? intent : undefined, pathAnalysis, staticPathCandidates: legacyPathCandidates(pathAnalysis), parserWarning: unresolvedReason });
|
|
394
|
+
add(node, { callType: queryExpression ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || queryExpression ? 0.8 : 0.4, unresolvedReason }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, rawPathExpression: pathAnalysis.rawExpression, literalPathSource: literalPathSource(pathAnalysis), odataPathIntent: operationPathExpr ? intent : undefined, pathAnalysis, staticPathCandidates: legacyPathCandidates(pathAnalysis), parserWarning: unresolvedReason, ...(dynamicMethodDefaulted ? { dynamicMethodDefaulted: true } : {}) });
|
|
396
395
|
} else {
|
|
397
396
|
const receiver = receiverName(expr.expression);
|
|
398
397
|
const rootReceiver = rootReceiverName(expr.expression);
|
|
@@ -434,7 +433,11 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
434
433
|
const rootReceiver = rootReceiverName(expr.expression);
|
|
435
434
|
if (isSupportedEventReceiver(receiver, rootReceiver, serviceVariables)) {
|
|
436
435
|
const eventName = literalText(node.arguments[0]);
|
|
437
|
-
|
|
436
|
+
const effectiveReceiver = rootReceiver ?? receiver;
|
|
437
|
+
const lifecycleHook = effectiveReceiver === 'cds'
|
|
438
|
+
&& CDS_LIFECYCLE_EVENTS.has(eventName ?? '');
|
|
439
|
+
const errorHook = expr.name.text === 'on' && eventName === 'error';
|
|
440
|
+
if (eventName && !lifecycleHook && !errorHook) add(node, { callType: expr.name.text === 'on' ? 'async_subscribe' : 'async_emit', serviceVariableName: effectiveReceiver, eventNameExpr: eventName }, { receiver, rootReceiver, classifier: expr.name.text === 'on' ? 'cap_service_event_subscription' : 'cap_service_event_emit', receiverClassification: 'cap_evidence' });
|
|
438
441
|
}
|
|
439
442
|
} else {
|
|
440
443
|
const external = externalHttpEvidence(node, source);
|
|
@@ -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
|
|
47
|
+
return extractPlaceholderKeys(value);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function connectFactFromCall(call: ts.CallExpression): Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'> | undefined {
|
|
@@ -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 =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
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:
|
|
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
|
+
}
|
package/src/utils/redaction.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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);
|