@saptools/service-flow 0.1.42 → 0.1.44

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.
@@ -1,250 +1,21 @@
1
- import fs from 'node:fs/promises';
2
1
  import path from 'node:path';
3
2
  import ts from 'typescript';
4
3
  import type { ServiceBindingFact } from '../types.js';
5
4
  import { normalizePath } from '../utils/path-utils.js';
5
+ import {
6
+ connectFactFromCall,
7
+ findConnectInExpression,
8
+ importsFor,
9
+ lineOf,
10
+ readSource,
11
+ transactionReceiverName,
12
+ unwrapCall,
13
+ unwrapIdentityExpression,
14
+ type ClassHelperReturn,
15
+ type HelperBinding,
16
+ type ImportBinding,
17
+ } from './service-binding-parser-helpers.js';
6
18
 
7
- interface HelperBinding {
8
- exportedName: string;
9
- returnedProperty?: string;
10
- alias?: string;
11
- aliasExpr?: string;
12
- destinationExpr?: string;
13
- servicePathExpr?: string;
14
- isDynamic: boolean;
15
- placeholders: string[];
16
- helperChain?: Array<Record<string, unknown>>;
17
- sourceFile: string;
18
- sourceLine: number;
19
- }
20
- interface ImportBinding {
21
- localName: string;
22
- exportedName: string;
23
- sourceFile?: string;
24
- }
25
- interface ClassHelperReturn {
26
- className: string;
27
- helperName: string;
28
- propertyName: string;
29
- variableName: string;
30
- fact: Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'>;
31
- sourceLine: number;
32
- }
33
-
34
- function lineOf(sf: ts.SourceFile, node: ts.Node): number {
35
- return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
36
- }
37
- function stringValue(node: ts.Expression | undefined): string | undefined {
38
- if (!node) return undefined;
39
- if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node))
40
- return node.text;
41
- if (ts.isTemplateExpression(node))
42
- return node.getText().replace(/^`|`$/g, '');
43
- return node.getText();
44
- }
45
- function placeholders(value?: string): string[] {
46
- return [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)]
47
- .map((m) => (m[1] ?? '').trim())
48
- .filter(Boolean);
49
- }
50
- function connectFactFromCall(
51
- call: ts.CallExpression,
52
- ):
53
- | Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'>
54
- | undefined {
55
- const expr = call.expression;
56
- if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== 'to')
57
- return undefined;
58
- const inner = expr.expression;
59
- if (
60
- !ts.isPropertyAccessExpression(inner) ||
61
- inner.name.text !== 'connect' ||
62
- inner.expression.getText() !== 'cds'
63
- )
64
- return undefined;
65
- const first = call.arguments[0];
66
- if (!first) return undefined;
67
- const second = call.arguments[1];
68
- const objectArg = ts.isObjectLiteralExpression(first)
69
- ? first
70
- : second && ts.isObjectLiteralExpression(second)
71
- ? second
72
- : undefined;
73
- let alias: string | undefined;
74
- let aliasExpr: string | undefined;
75
- if (ts.isStringLiteralLike(first) || ts.isNoSubstitutionTemplateLiteral(first))
76
- alias = first.text;
77
- else if (!ts.isObjectLiteralExpression(first))
78
- aliasExpr = stringValue(first);
79
- if (
80
- (ts.isStringLiteralLike(first) || ts.isNoSubstitutionTemplateLiteral(first)) &&
81
- !objectArg
82
- )
83
- return { alias: first.text, isDynamic: false, placeholders: [] };
84
- if (!objectArg && aliasExpr)
85
- return {
86
- aliasExpr,
87
- isDynamic: true,
88
- placeholders: placeholders(aliasExpr),
89
- };
90
- let destinationExpr: string | undefined;
91
- let servicePathExpr: string | undefined;
92
- function visitObject(obj: ts.ObjectLiteralExpression): void {
93
- for (const prop of obj.properties) {
94
- if (!ts.isPropertyAssignment(prop)) continue;
95
- const name =
96
- ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name)
97
- ? prop.name.text
98
- : undefined;
99
- if (name === 'destination')
100
- destinationExpr = stringValue(prop.initializer);
101
- if (name === 'path' || name === 'servicePath')
102
- servicePathExpr = stringValue(prop.initializer);
103
- if (ts.isObjectLiteralExpression(prop.initializer))
104
- visitObject(prop.initializer);
105
- }
106
- }
107
- if (objectArg) visitObject(objectArg);
108
- const ph = [
109
- ...placeholders(aliasExpr ?? alias),
110
- ...placeholders(destinationExpr),
111
- ...placeholders(servicePathExpr),
112
- ];
113
- return {
114
- alias,
115
- aliasExpr,
116
- destinationExpr,
117
- servicePathExpr,
118
- isDynamic: ph.length > 0 || (!destinationExpr && !servicePathExpr),
119
- placeholders: ph,
120
- };
121
- }
122
- function unwrapCall(expr: ts.Expression): ts.CallExpression | undefined {
123
- if (ts.isAwaitExpression(expr)) return unwrapCall(expr.expression);
124
- if (ts.isParenthesizedExpression(expr)) return unwrapCall(expr.expression);
125
- if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) return unwrapCall(expr.expression);
126
- if (ts.isTypeAssertionExpression(expr)) return unwrapCall(expr.expression);
127
- if (ts.isCallExpression(expr)) return expr;
128
- return undefined;
129
- }
130
- function unwrapIdentityExpression(expr: ts.Expression): ts.Expression {
131
- if (ts.isAwaitExpression(expr)) return unwrapIdentityExpression(expr.expression);
132
- if (ts.isParenthesizedExpression(expr)) return unwrapIdentityExpression(expr.expression);
133
- if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) return unwrapIdentityExpression(expr.expression);
134
- if (ts.isTypeAssertionExpression(expr)) return unwrapIdentityExpression(expr.expression);
135
- return expr;
136
- }
137
-
138
- function transactionReceiverName(expr: ts.Expression): string | undefined {
139
- const call = unwrapCall(expr);
140
- if (call && ts.isPropertyAccessExpression(call.expression) && ['tx', 'transaction'].includes(call.expression.name.text) && ts.isIdentifier(call.expression.expression)) return call.expression.expression.text;
141
- const unwrapped = unwrapIdentityExpression(expr);
142
- if (ts.isConditionalExpression(unwrapped)) {
143
- const left = transactionReceiverName(unwrapped.whenTrue);
144
- const right = transactionReceiverName(unwrapped.whenFalse);
145
- return left && left === right ? left : undefined;
146
- }
147
- return undefined;
148
- }
149
-
150
- function findConnectInExpression(
151
- expr: ts.Expression,
152
- ):
153
- | Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'>
154
- | undefined {
155
- const direct = unwrapCall(expr);
156
- if (direct) {
157
- const fact = connectFactFromCall(direct);
158
- if (fact) return fact;
159
- }
160
- let found:
161
- | Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'>
162
- | undefined;
163
- function visit(node: ts.Node): void {
164
- if (found) return;
165
- if (ts.isCallExpression(node)) found = connectFactFromCall(node);
166
- if (!found) ts.forEachChild(node, visit);
167
- }
168
- visit(expr);
169
- return found;
170
- }
171
- async function readSource(abs: string): Promise<ts.SourceFile | undefined> {
172
- try {
173
- const text = await fs.readFile(abs, 'utf8');
174
- return ts.createSourceFile(
175
- abs,
176
- text,
177
- ts.ScriptTarget.Latest,
178
- true,
179
- ts.ScriptKind.TS,
180
- );
181
- } catch {
182
- return undefined;
183
- }
184
- }
185
- async function resolveImport(
186
- repoPath: string,
187
- fromFile: string,
188
- spec: string,
189
- ): Promise<string | undefined> {
190
- if (!spec.startsWith('.')) return undefined;
191
- const rawBase = path.resolve(repoPath, path.dirname(fromFile), spec);
192
- const parsed = path.parse(rawBase);
193
- const base = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(parsed.ext)
194
- ? path.join(parsed.dir, parsed.name)
195
- : rawBase;
196
- for (const candidate of [
197
- base,
198
- `${base}.ts`,
199
- `${base}.js`,
200
- path.join(base, 'index.ts'),
201
- path.join(base, 'index.js'),
202
- ]) {
203
- try {
204
- const st = await fs.stat(candidate);
205
- if (st.isFile()) return normalizePath(path.relative(repoPath, candidate));
206
- } catch {
207
- /* continue */
208
- }
209
- }
210
- return undefined;
211
- }
212
- async function importsFor(
213
- repoPath: string,
214
- filePath: string,
215
- sf: ts.SourceFile,
216
- ): Promise<ImportBinding[]> {
217
- const imports: ImportBinding[] = [];
218
- for (const stmt of sf.statements) {
219
- if (
220
- !ts.isImportDeclaration(stmt) ||
221
- !ts.isStringLiteralLike(stmt.moduleSpecifier)
222
- )
223
- continue;
224
- const sourceFile = await resolveImport(
225
- repoPath,
226
- filePath,
227
- stmt.moduleSpecifier.text,
228
- );
229
- const clause = stmt.importClause;
230
- if (!clause) continue;
231
- if (clause.name)
232
- imports.push({
233
- localName: clause.name.text,
234
- exportedName: 'default',
235
- sourceFile,
236
- });
237
- const bindings = clause.namedBindings;
238
- if (bindings && ts.isNamedImports(bindings))
239
- for (const el of bindings.elements)
240
- imports.push({
241
- localName: el.name.text,
242
- exportedName: el.propertyName?.text ?? el.name.text,
243
- sourceFile,
244
- });
245
- }
246
- return imports;
247
- }
248
19
  function collectLocalBindingFacts(
249
20
  fn: ts.FunctionLikeDeclaration,
250
21
  ): Map<string, Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'>> {
@@ -434,6 +205,7 @@ export async function parseServiceBindings(
434
205
  const classHelpers = collectClassHelpers(sourceFileAst);
435
206
  const localObjectHelpers = new Map<string, HelperBinding[]>();
436
207
  const localDirectHelpers = new Map<string, HelperBinding>();
208
+ const objectHelperVariables = new Map<string, Array<{ helper: HelperBinding; imp?: ImportBinding }>>();
437
209
  for (const stmt of sourceFileAst.statements) {
438
210
  if (ts.isFunctionDeclaration(stmt) && stmt.name) {
439
211
  const directFact = directConnectFactFromFunctionLike(stmt);
@@ -562,6 +334,46 @@ export async function parseServiceBindings(
562
334
  const imported = await importedHelpers(call.expression.text);
563
335
  return [...local.map((helper) => ({ helper })), ...imported];
564
336
  }
337
+ async function rememberObjectHelperVariable(decl: ts.VariableDeclaration): Promise<void> {
338
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) return;
339
+ const call = unwrapCall(decl.initializer);
340
+ if (!call) return;
341
+ const helpers = (await helpersForCall(call)).filter((row) => row.helper.returnedProperty);
342
+ if (helpers.length > 0) objectHelperVariables.set(decl.name.text, helpers);
343
+ }
344
+ function recordObjectPropertyBinding(targetName: string, expr: ts.Expression, node: ts.Node): boolean {
345
+ const unwrapped = unwrapIdentityExpression(expr);
346
+ if (!ts.isPropertyAccessExpression(unwrapped) || !ts.isIdentifier(unwrapped.expression)) return false;
347
+ const helpers = objectHelperVariables.get(unwrapped.expression.text) ?? [];
348
+ const matches = helpers.filter((row) => row.helper.returnedProperty === unwrapped.name.text);
349
+ if (matches.length !== 1) return false;
350
+ const resolved = matches[0];
351
+ out.push({
352
+ variableName: targetName,
353
+ alias: resolved.helper.alias,
354
+ aliasExpr: resolved.helper.aliasExpr,
355
+ destinationExpr: resolved.helper.destinationExpr,
356
+ servicePathExpr: resolved.helper.servicePathExpr,
357
+ isDynamic: resolved.helper.isDynamic,
358
+ placeholders: resolved.helper.placeholders,
359
+ sourceFile: normalizePath(filePath),
360
+ sourceLine: lineOf(sourceFileAst, node),
361
+ helperChain: [
362
+ ...(resolved.helper.helperChain ?? []),
363
+ {
364
+ callerVariable: targetName,
365
+ sourceVariable: unwrapped.expression.text,
366
+ returnedProperty: unwrapped.name.text,
367
+ assignedFromProperty: unwrapped.getText(sourceFileAst),
368
+ importSource: resolved.imp?.sourceFile,
369
+ exportedSymbol: resolved.imp?.exportedName,
370
+ helperSourceFile: resolved.helper.sourceFile,
371
+ helperSourceLine: resolved.helper.sourceLine,
372
+ },
373
+ ],
374
+ });
375
+ return true;
376
+ }
565
377
  async function recordDestructuredHelper(decl: ts.VariableDeclaration): Promise<void> {
566
378
  if (!ts.isObjectBindingPattern(decl.name) || !decl.initializer) return;
567
379
  const call = unwrapCall(decl.initializer);
@@ -737,6 +549,8 @@ export async function parseServiceBindings(
737
549
  await recordDestructuredHelper(decl);
738
550
  await recordArrayDestructuredVariable(decl);
739
551
  recordDestructuredClassHelper(decl);
552
+ await rememberObjectHelperVariable(decl);
553
+ if (ts.isIdentifier(decl.name) && decl.initializer) recordObjectPropertyBinding(decl.name.text, decl.initializer, decl);
740
554
  await recordVariable(decl);
741
555
  recordIdentityAlias(decl);
742
556
  if (ts.isIdentifier(decl.name) && decl.initializer) {
@@ -752,6 +566,7 @@ export async function parseServiceBindings(
752
566
  cloneAliasBinding(assignment.left.text, rhs.text, 'identity-assignment', assignment);
753
567
  continue;
754
568
  }
569
+ if (recordObjectPropertyBinding(assignment.left.text, assignment.right, assignment)) continue;
755
570
  await recordBindingFromExpression(assignment.left.text, assignment.right, assignment, 'assignment');
756
571
  continue;
757
572
  }
@@ -0,0 +1,205 @@
1
+ import type { Db } from '../db/connection.js';
2
+ import { extractPlaceholders, substituteVariables, type RuntimeSubstitution } from '../linker/dynamic-edge-resolver.js';
3
+ import { resolveOperation, type OperationTarget } from '../linker/service-resolver.js';
4
+
5
+ export interface TraceGraphRow extends Record<string, unknown> {
6
+ id: number;
7
+ edge_type: string;
8
+ from_id: string;
9
+ to_kind: string;
10
+ to_id: string;
11
+ confidence: number;
12
+ evidence_json: string;
13
+ unresolved_reason?: string;
14
+ status?: string;
15
+ }
16
+
17
+ interface Candidate {
18
+ servicePath?: string;
19
+ operationPath?: string;
20
+ repoName?: string;
21
+ operationName?: string;
22
+ score?: number;
23
+ }
24
+
25
+ export function baseTraceEvidence(
26
+ row: TraceGraphRow,
27
+ call: Record<string, unknown>,
28
+ persistedEvidence: Record<string, unknown>,
29
+ contextualEvidence: Record<string, unknown> | undefined,
30
+ ): Record<string, unknown> {
31
+ const evidence = { ...persistedEvidence, ...(contextualEvidence ?? {}) };
32
+ return {
33
+ ...evidence,
34
+ graphEdgeId: row.id,
35
+ persistedGraphEdgeId: row.id > 0 ? row.id : undefined,
36
+ outboundCallId: call.id,
37
+ callSite: { sourceFile: call.source_file, sourceLine: call.source_line },
38
+ sourceFile: call.source_file,
39
+ sourceLine: call.source_line,
40
+ file: call.source_file,
41
+ line: call.source_line,
42
+ persistedTarget: { kind: row.to_kind, id: row.to_id },
43
+ contextualResolutionParticipated: Boolean(contextualEvidence?.contextualServiceBindingAttempted),
44
+ persistedResolution: persistedResolution(row),
45
+ };
46
+ }
47
+
48
+ export function runtimeResolution(
49
+ db: Db,
50
+ row: TraceGraphRow,
51
+ evidence: Record<string, unknown>,
52
+ vars: Record<string, string> | undefined,
53
+ workspaceId: number | undefined,
54
+ ): { row: TraceGraphRow; evidence: Record<string, unknown>; target?: OperationTarget; unresolvedReason?: string } {
55
+ const substituted = evidenceWithRuntimeVariables(evidence, vars);
56
+ if (!isRemoteRuntimeCandidate(row, evidence, vars)) {
57
+ const withSections = withEffectiveResolution(substituted, row, row.unresolved_reason);
58
+ return { row, evidence: withSections, unresolvedReason: row.unresolved_reason };
59
+ }
60
+ const resolution = resolveRuntimeOperation(db, substituted, workspaceId);
61
+ if (resolution.target) {
62
+ const resolvedRow = { ...row, status: 'resolved', to_kind: 'operation', to_id: String(resolution.target.operationId), unresolved_reason: undefined, confidence: Math.max(0, Math.min(1, resolution.target.score)) };
63
+ return { row: resolvedRow, evidence: withEffectiveResolution(substituted, resolvedRow, undefined, resolution), target: resolution.target };
64
+ }
65
+ const unresolvedReason = runtimeUnresolvedReason(resolution);
66
+ return { row, evidence: withEffectiveResolution(substituted, row, unresolvedReason, resolution), unresolvedReason };
67
+ }
68
+
69
+ export function runtimeVariableDiagnostic(edges: Array<{ evidence: Record<string, unknown> }>): Record<string, unknown> | undefined {
70
+ const missing = new Set<string>();
71
+ for (const edge of edges) {
72
+ const substitutions = edge.evidence.runtimeSubstitutions;
73
+ if (!substitutions || typeof substitutions !== 'object' || Array.isArray(substitutions)) continue;
74
+ for (const value of Object.values(substitutions as Record<string, RuntimeSubstitution>))
75
+ for (const key of value.missing ?? []) missing.add(key);
76
+ }
77
+ const missingVariables = [...missing].sort();
78
+ if (missingVariables.length === 0) return undefined;
79
+ return {
80
+ severity: 'warning',
81
+ code: 'trace_runtime_variables_missing',
82
+ message: `Runtime variables are required to resolve dynamic trace targets: ${missingVariables.join(', ')}`,
83
+ missingVariables,
84
+ suggestions: missingVariables.map((key) => `--var ${key}=<value>`),
85
+ };
86
+ }
87
+
88
+ export function edgeTarget(row: TraceGraphRow, evidence: Record<string, unknown>): string {
89
+ const effective = parseObject(evidence.effectiveResolution);
90
+ const targetServicePath = stringValue(effective.targetServicePath ?? evidence.targetServicePath);
91
+ const targetOperationPath = stringValue(effective.targetOperationPath ?? evidence.targetOperationPath);
92
+ if (targetServicePath && targetOperationPath) return `${targetServicePath}${targetOperationPath}`;
93
+ const runtimeCandidate = evidence.runtimeResolvedCandidate as Candidate | undefined;
94
+ if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath) return `${runtimeCandidate.servicePath}${runtimeCandidate.operationPath}`;
95
+ const servicePath = stringValue(evidence.servicePath);
96
+ const operationPath = stringValue(evidence.operationPath);
97
+ const targetOperation = stringValue(evidence.targetOperation);
98
+ const targetRepo = stringValue(evidence.targetRepo) ?? '';
99
+ if (row.edge_type === 'HANDLER_RUNS_DB_QUERY') return `Entity: ${row.to_id || 'unknown'}`;
100
+ if (row.edge_type === 'HANDLER_RUNS_REMOTE_QUERY') return stringValue(evidence.remoteQueryTarget) ?? `Remote query: ${row.to_id || 'unknown'}`;
101
+ if (row.edge_type === 'HANDLER_CALLS_EXTERNAL_HTTP') {
102
+ const target = parseObject(evidence.externalTarget);
103
+ return stringValue(target.label) ?? `External endpoint: ${row.to_id || 'unknown'}`;
104
+ }
105
+ if (servicePath && operationPath) return `${servicePath}${operationPath}`;
106
+ return targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
107
+ }
108
+
109
+ function persistedResolution(row: TraceGraphRow): Record<string, unknown> {
110
+ return {
111
+ status: row.status,
112
+ targetKind: row.to_kind,
113
+ targetId: row.to_id,
114
+ edgeId: row.id > 0 ? row.id : undefined,
115
+ confidence: row.confidence,
116
+ unresolvedReason: row.unresolved_reason,
117
+ edgeType: row.edge_type,
118
+ };
119
+ }
120
+
121
+ function effectiveResolution(
122
+ row: TraceGraphRow,
123
+ evidence: Record<string, unknown>,
124
+ unresolvedReason: string | undefined,
125
+ resolution?: ReturnType<typeof resolveRuntimeOperation>,
126
+ ): Record<string, unknown> {
127
+ const target = resolution?.target;
128
+ return {
129
+ status: target ? 'resolved' : row.status,
130
+ targetKind: target ? 'operation' : row.to_kind,
131
+ targetId: target ? String(target.operationId) : row.to_id,
132
+ targetRepo: target?.repoName ?? evidence.targetRepo,
133
+ targetServicePath: target?.servicePath ?? evidence.targetServicePath,
134
+ targetOperationPath: target?.operationPath ?? evidence.targetOperationPath,
135
+ targetOperation: target?.operationName ?? evidence.targetOperation,
136
+ confidence: target?.score ?? row.confidence,
137
+ reasons: resolution?.reasons ?? evidence.resolutionReasons,
138
+ unresolvedReason,
139
+ edgeType: target ? 'REMOTE_CALL_RESOLVES_TO_OPERATION' : row.edge_type,
140
+ };
141
+ }
142
+
143
+ function withEffectiveResolution(
144
+ evidence: Record<string, unknown>,
145
+ row: TraceGraphRow,
146
+ unresolvedReason: string | undefined,
147
+ resolution?: ReturnType<typeof resolveRuntimeOperation>,
148
+ ): Record<string, unknown> {
149
+ const current = effectiveResolution(row, evidence, unresolvedReason, resolution);
150
+ const rest = { ...evidence };
151
+ delete rest.runtimeResolvedCandidate;
152
+ return { ...rest, effectiveResolution: current, linker: { status: current.status, confidence: current.confidence, reason: unresolvedReason, edgeType: current.edgeType } };
153
+ }
154
+
155
+ function resolveRuntimeOperation(db: Db, evidence: Record<string, unknown>, workspaceId: number | undefined): ReturnType<typeof resolveOperation> {
156
+ const servicePath = stringValue(evidence.servicePath);
157
+ const operationPath = stringValue(evidence.normalizedOperationPath ?? evidence.operationPath);
158
+ const alias = stringValue(evidence.serviceAliasExpr ?? evidence.serviceAlias);
159
+ const destination = stringValue(evidence.destination);
160
+ return resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceId);
161
+ }
162
+
163
+ function evidenceWithRuntimeVariables(evidence: Record<string, unknown>, vars: Record<string, string> | undefined): Record<string, unknown> {
164
+ const substitutions = runtimeSubstitutions(evidence, vars ?? {});
165
+ const next: Record<string, unknown> = { ...evidence, runtimeSubstitutions: substitutions };
166
+ for (const [key, value] of Object.entries(substitutions)) if (value.effective) next[key] = value.effective;
167
+ const missing = Object.values(substitutions).flatMap((value) => value.missing);
168
+ if (missing.length > 0) next.missingRuntimeVariables = [...new Set(missing)].sort();
169
+ if (Object.keys(vars ?? {}).length > 0) next.runtimeVariablesApplied = true;
170
+ return next;
171
+ }
172
+
173
+ function runtimeSubstitutions(evidence: Record<string, unknown>, vars: Record<string, string>): Record<string, RuntimeSubstitution> {
174
+ const substitutions: Record<string, RuntimeSubstitution> = {};
175
+ for (const key of ['servicePath', 'operationPath', 'serviceAliasExpr', 'serviceAlias', 'destination']) {
176
+ const substitution = substituteVariables(stringValue(evidence[key]), vars);
177
+ if (substitution.placeholders.length > 0) substitutions[key] = substitution;
178
+ }
179
+ return substitutions;
180
+ }
181
+
182
+ function isRemoteRuntimeCandidate(row: TraceGraphRow, evidence: Record<string, unknown>, vars: Record<string, string> | undefined): boolean {
183
+ if (!vars || Object.keys(vars).length === 0) return false;
184
+ if (!['dynamic', 'ambiguous', 'unresolved'].includes(String(row.status ?? ''))) return false;
185
+ if (!['DYNAMIC_EDGE_CANDIDATE', 'UNRESOLVED_EDGE', 'REMOTE_CALL_RESOLVES_TO_OPERATION'].includes(row.edge_type)) return false;
186
+ return ['servicePath', 'operationPath', 'serviceAliasExpr', 'serviceAlias', 'destination'].some((key) => hasRuntimeVariable(evidence[key], vars));
187
+ }
188
+
189
+ function hasRuntimeVariable(value: unknown, vars: Record<string, string>): boolean {
190
+ return typeof value === 'string' && extractPlaceholders(value).some((key) => Object.hasOwn(vars, key));
191
+ }
192
+
193
+ function runtimeUnresolvedReason(resolution: ReturnType<typeof resolveRuntimeOperation>): string {
194
+ if (resolution.status === 'dynamic') return `Dynamic target is missing runtime variables: ${resolution.reasons.join(', ')}`;
195
+ if (resolution.status === 'ambiguous') return 'Ambiguous runtime operation candidates';
196
+ return 'No runtime operation candidate matched substituted service and operation path';
197
+ }
198
+
199
+ function parseObject(value: unknown): Record<string, unknown> {
200
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
201
+ }
202
+
203
+ function stringValue(value: unknown): string | undefined {
204
+ return typeof value === 'string' ? value : undefined;
205
+ }