@saptools/service-flow 0.1.50 → 0.1.51

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.
@@ -0,0 +1,424 @@
1
+ import type { Db } from '../db/connection.js';
2
+ import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
3
+ import type { OperationTarget } from '../linker/service-resolver.js';
4
+ import type { DynamicMode } from '../types.js';
5
+
6
+ export interface DynamicTargetCandidate {
7
+ candidateOperationId: number;
8
+ repoName: string;
9
+ packageName?: string;
10
+ servicePath: string;
11
+ operationPath: string;
12
+ operationName: string;
13
+ derivedVariables: Record<string, string>;
14
+ derivedVariableSources: Record<string, Record<string, unknown>>;
15
+ missingVariables: string[];
16
+ score: number;
17
+ reasons: string[];
18
+ rejectedReasons: string[];
19
+ cli?: string;
20
+ }
21
+
22
+ export interface DynamicTargetAnalysis {
23
+ mode: DynamicMode;
24
+ candidateCount: number;
25
+ shownCandidateCount: number;
26
+ omittedCandidateCount: number;
27
+ missingVariables: string[];
28
+ candidates: DynamicTargetCandidate[];
29
+ shownCandidates: DynamicTargetCandidate[];
30
+ suggestedVarSets: Array<{ variables: Record<string, string>; cli: string }>;
31
+ inference: Record<string, unknown>;
32
+ }
33
+
34
+ interface ReferenceRow {
35
+ alias?: string;
36
+ destination?: string;
37
+ servicePath?: string;
38
+ sourceKind: string;
39
+ repoName: string;
40
+ }
41
+
42
+ interface Templates {
43
+ servicePath?: string;
44
+ operationPath?: string;
45
+ alias?: string;
46
+ destination?: string;
47
+ }
48
+
49
+ export function analyzeDynamicTargetCandidates(
50
+ db: Db,
51
+ evidence: Record<string, unknown>,
52
+ workspaceId: number | undefined,
53
+ mode: DynamicMode,
54
+ maxCandidates: number,
55
+ ): DynamicTargetAnalysis | undefined {
56
+ const templates = templatesFromEvidence(evidence);
57
+ const missingVariables = allMissingVariables(evidence, templates);
58
+ if (missingVariables.length === 0) return undefined;
59
+ const order = variableOrder(templates, missingVariables);
60
+ const candidates = rankedCandidates(
61
+ db,
62
+ candidateTargets(db, evidence, workspaceId),
63
+ referenceRows(db, workspaceId),
64
+ templates,
65
+ order,
66
+ );
67
+ const inference = inferenceDecision(candidates);
68
+ const shownCandidates = candidates.slice(0, maxCandidates);
69
+ return {
70
+ mode,
71
+ candidateCount: candidates.length,
72
+ shownCandidateCount: shownCandidates.length,
73
+ omittedCandidateCount: Math.max(0, candidates.length - shownCandidates.length),
74
+ missingVariables,
75
+ candidates,
76
+ shownCandidates,
77
+ suggestedVarSets: suggestedVarSets(candidates, missingVariables, order),
78
+ inference,
79
+ };
80
+ }
81
+
82
+ function candidateTargets(
83
+ db: Db,
84
+ evidence: Record<string, unknown>,
85
+ workspaceId: number | undefined,
86
+ ): OperationTarget[] {
87
+ const embedded = rowsFromEvidence(evidence.candidates);
88
+ if (embedded.length > 0) return embedded;
89
+ const operationPath = stringValue(evidence.operationPath);
90
+ if (!operationPath || extractPlaceholders(operationPath).length > 0) return [];
91
+ return queryOperationTargets(db, operationPath, workspaceId);
92
+ }
93
+
94
+ function rowsFromEvidence(value: unknown): OperationTarget[] {
95
+ if (!Array.isArray(value)) return [];
96
+ return value.flatMap((item): OperationTarget[] => {
97
+ const row = record(item);
98
+ const operationId = numberValue(row.operationId);
99
+ const repoName = stringValue(row.repoName);
100
+ const servicePath = stringValue(row.servicePath);
101
+ const operationPath = stringValue(row.operationPath);
102
+ const operationName = stringValue(row.operationName) ?? operationPath?.replace(/^\//, '');
103
+ if (operationId === undefined || !repoName || !servicePath || !operationPath || !operationName) return [];
104
+ return [{
105
+ operationId,
106
+ repoName,
107
+ serviceName: stringValue(row.serviceName) ?? '',
108
+ qualifiedName: stringValue(row.qualifiedName) ?? '',
109
+ servicePath,
110
+ operationPath,
111
+ operationName,
112
+ sourceFile: stringValue(row.sourceFile) ?? '',
113
+ sourceLine: numberValue(row.sourceLine) ?? 0,
114
+ repoId: numberValue(row.repoId),
115
+ packageName: stringValue(row.packageName),
116
+ score: numberValue(row.score) ?? 0,
117
+ reasons: stringArray(row.reasons),
118
+ }];
119
+ });
120
+ }
121
+
122
+ function queryOperationTargets(
123
+ db: Db,
124
+ operationPath: string,
125
+ workspaceId: number | undefined,
126
+ ): OperationTarget[] {
127
+ const simple = operationPath.replace(/^\//, '').split('.').at(-1) ?? operationPath;
128
+ const rows = db.prepare(
129
+ `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,
130
+ s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,
131
+ o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,
132
+ o.source_line sourceLine
133
+ FROM cds_operations o JOIN cds_services s ON s.id=o.service_id
134
+ JOIN repositories r ON r.id=s.repo_id
135
+ WHERE (? IS NULL OR r.workspace_id=?)
136
+ AND (o.operation_path IN (?,?) OR o.operation_name=?)
137
+ ORDER BY r.name,s.service_path,o.operation_name`,
138
+ ).all(workspaceId, workspaceId, operationPath, `/${simple}`, simple) as Array<Record<string, unknown>>;
139
+ return rows.map((row) => ({
140
+ operationId: Number(row.operationId),
141
+ repoId: numberValue(row.repoId),
142
+ repoName: String(row.repoName),
143
+ packageName: stringValue(row.packageName),
144
+ serviceName: String(row.serviceName),
145
+ qualifiedName: String(row.qualifiedName),
146
+ servicePath: String(row.servicePath),
147
+ operationPath: String(row.operationPath),
148
+ operationName: String(row.operationName),
149
+ sourceFile: String(row.sourceFile),
150
+ sourceLine: Number(row.sourceLine),
151
+ score: 0.2,
152
+ reasons: ['operation_path_match'],
153
+ }));
154
+ }
155
+
156
+ function rankedCandidates(
157
+ db: Db,
158
+ candidates: OperationTarget[],
159
+ references: ReferenceRow[],
160
+ templates: Templates,
161
+ order: string[],
162
+ ): DynamicTargetCandidate[] {
163
+ const ranked = candidates.map((candidate) =>
164
+ candidateEvidence(db, candidate, references, templates, order));
165
+ return ranked.sort((a, b) =>
166
+ b.score - a.score
167
+ || a.repoName.localeCompare(b.repoName)
168
+ || a.servicePath.localeCompare(b.servicePath));
169
+ }
170
+
171
+ function candidateEvidence(
172
+ db: Db,
173
+ candidate: OperationTarget,
174
+ references: ReferenceRow[],
175
+ templates: Templates,
176
+ order: string[],
177
+ ): DynamicTargetCandidate {
178
+ const state = emptyCandidate(candidate);
179
+ applyDirectTemplate(state, templates.operationPath, candidate.operationPath, 'operation_path');
180
+ applyDirectTemplate(state, templates.servicePath, candidate.servicePath, 'service_path');
181
+ const refs = references.filter((item) => item.servicePath === candidate.servicePath);
182
+ applyReferenceTemplate(state, templates.alias, refs, 'alias');
183
+ applyReferenceTemplate(state, templates.destination, refs, 'destination');
184
+ if (hasResolvedImplementation(db, candidate.operationId)) addScore(state, 0.1, 'implementation_edge_resolved');
185
+ state.missingVariables = order.filter((key) => state.derivedVariables[key] === undefined);
186
+ if (state.missingVariables.length === 0) addScore(state, 0.15, 'all_runtime_variables_derived');
187
+ else state.rejectedReasons.push('missing_required_runtime_variable');
188
+ state.score = Math.max(0, Math.min(1, state.score));
189
+ state.cli = state.missingVariables.length === 0 ? cliFor(state.derivedVariables, order) : undefined;
190
+ return state;
191
+ }
192
+
193
+ function emptyCandidate(candidate: OperationTarget): DynamicTargetCandidate {
194
+ return {
195
+ candidateOperationId: candidate.operationId,
196
+ repoName: candidate.repoName,
197
+ packageName: candidate.packageName ?? undefined,
198
+ servicePath: candidate.servicePath,
199
+ operationPath: candidate.operationPath,
200
+ operationName: candidate.operationName,
201
+ derivedVariables: {},
202
+ derivedVariableSources: {},
203
+ missingVariables: [],
204
+ score: Math.max(0.2, Number(candidate.score ?? 0)),
205
+ reasons: nonEmptyStrings(candidate.reasons, ['operation_path_match']),
206
+ rejectedReasons: [],
207
+ };
208
+ }
209
+
210
+ function applyDirectTemplate(
211
+ state: DynamicTargetCandidate,
212
+ template: string | undefined,
213
+ concrete: string,
214
+ kind: 'operation_path' | 'service_path',
215
+ ): void {
216
+ const matched = matchTemplate(template, concrete);
217
+ if (!template) return;
218
+ if (!matched) {
219
+ state.rejectedReasons.push(`${kind}_template_mismatch`);
220
+ return;
221
+ }
222
+ mergeDerived(state, matched, `${kind}_template`);
223
+ addScore(state, kind === 'service_path' ? 0.35 : 0.25, `${kind}_template_match`);
224
+ }
225
+
226
+ function applyReferenceTemplate(
227
+ state: DynamicTargetCandidate,
228
+ template: string | undefined,
229
+ refs: ReferenceRow[],
230
+ kind: 'alias' | 'destination',
231
+ ): void {
232
+ if (!template || extractPlaceholders(template).length === 0) return;
233
+ for (const ref of refs) {
234
+ const concrete = kind === 'alias' ? ref.alias : ref.destination;
235
+ const matched = isConcrete(concrete) ? matchTemplate(template, concrete) : undefined;
236
+ if (!matched) continue;
237
+ mergeDerived(state, matched, `${ref.sourceKind}.${kind}`);
238
+ addScore(state, 0.2, `${kind}_template_match`);
239
+ return;
240
+ }
241
+ if (refs.some((ref) => isConcrete(kind === 'alias' ? ref.alias : ref.destination)))
242
+ state.rejectedReasons.push(`${kind}_template_mismatch`);
243
+ }
244
+
245
+ function inferenceDecision(candidates: DynamicTargetCandidate[]): Record<string, unknown> {
246
+ const complete = candidates.filter((candidate) =>
247
+ candidate.missingVariables.length === 0
248
+ && candidate.rejectedReasons.length === 0);
249
+ const first = complete[0];
250
+ const second = complete[1];
251
+ if (!first) return { status: 'unresolved', reason: 'missing_required_runtime_variable' };
252
+ if (first.score < 0.85) return { status: 'unresolved', reason: 'candidate_score_below_inference_threshold' };
253
+ if (second && first.score - second.score <= 0.05) {
254
+ for (const candidate of complete.filter((item) => first.score - item.score <= 0.05))
255
+ candidate.rejectedReasons.push('candidate_tied_with_equal_score');
256
+ return { status: 'ambiguous', reason: 'candidate_tied_with_equal_score' };
257
+ }
258
+ return {
259
+ status: 'resolved',
260
+ candidateOperationId: first.candidateOperationId,
261
+ inferredVariables: first.derivedVariables,
262
+ score: first.score,
263
+ reasons: first.reasons,
264
+ };
265
+ }
266
+
267
+ function suggestedVarSets(
268
+ candidates: DynamicTargetCandidate[],
269
+ required: string[],
270
+ order: string[],
271
+ ): Array<{ variables: Record<string, string>; cli: string }> {
272
+ const seen = new Set<string>();
273
+ return candidates.flatMap((candidate) => {
274
+ if (required.some((key) => candidate.derivedVariables[key] === undefined)) return [];
275
+ const cli = cliFor(candidate.derivedVariables, order);
276
+ if (seen.has(cli)) return [];
277
+ seen.add(cli);
278
+ return [{ variables: candidate.derivedVariables, cli }];
279
+ });
280
+ }
281
+
282
+ function referenceRows(db: Db, workspaceId: number | undefined): ReferenceRow[] {
283
+ const rows = db.prepare(
284
+ `SELECT req.alias alias,req.destination destination,req.service_path servicePath,
285
+ 'cds_require' sourceKind,r.name repoName
286
+ FROM cds_requires req JOIN repositories r ON r.id=req.repo_id
287
+ WHERE (? IS NULL OR r.workspace_id=?)
288
+ UNION ALL
289
+ SELECT COALESCE(b.alias,b.alias_expr) alias,b.destination_expr destination,
290
+ b.service_path_expr servicePath,'service_binding' sourceKind,r.name repoName
291
+ FROM service_bindings b JOIN repositories r ON r.id=b.repo_id
292
+ WHERE (? IS NULL OR r.workspace_id=?)`,
293
+ ).all(workspaceId, workspaceId, workspaceId, workspaceId) as Array<Record<string, unknown>>;
294
+ return rows.map((row) => ({
295
+ alias: stringValue(row.alias),
296
+ destination: stringValue(row.destination),
297
+ servicePath: stringValue(row.servicePath),
298
+ sourceKind: String(row.sourceKind),
299
+ repoName: String(row.repoName),
300
+ }));
301
+ }
302
+
303
+ function allMissingVariables(evidence: Record<string, unknown>, templates: Templates): string[] {
304
+ const fromSubstitutions = Object.values(record(evidence.runtimeSubstitutions))
305
+ .flatMap((value) => stringArray(record(value).missing));
306
+ const fromTemplates = [
307
+ templates.servicePath,
308
+ templates.operationPath,
309
+ templates.alias,
310
+ templates.destination,
311
+ ].flatMap((value) => extractPlaceholders(value));
312
+ return [...new Set([...fromSubstitutions, ...fromTemplates])].sort();
313
+ }
314
+
315
+ function variableOrder(templates: Templates, missingVariables: string[]): string[] {
316
+ const ordered = [
317
+ templates.servicePath,
318
+ templates.operationPath,
319
+ templates.alias,
320
+ templates.destination,
321
+ ].flatMap((value) => extractPlaceholders(value));
322
+ return [...new Set([...ordered, ...missingVariables])];
323
+ }
324
+
325
+ function templatesFromEvidence(evidence: Record<string, unknown>): Templates {
326
+ return {
327
+ servicePath: stringValue(evidence.servicePath),
328
+ operationPath: stringValue(evidence.operationPath),
329
+ alias: stringValue(evidence.serviceAliasExpr ?? evidence.serviceAlias),
330
+ destination: stringValue(evidence.destination),
331
+ };
332
+ }
333
+
334
+ function matchTemplate(template: string | undefined, concrete: string | undefined): Record<string, string> | undefined {
335
+ if (!template || !concrete) return undefined;
336
+ const keys = extractPlaceholders(template);
337
+ if (keys.length === 0) return template === concrete ? {} : undefined;
338
+ const regex = new RegExp(`^${templateToPattern(template)}$`);
339
+ const match = regex.exec(concrete);
340
+ if (!match) return undefined;
341
+ const values: Record<string, string> = {};
342
+ for (let index = 0; index < keys.length; index += 1) {
343
+ const key = keys[index];
344
+ const value = match[index + 1];
345
+ if (!key || value === undefined) return undefined;
346
+ if (values[key] !== undefined && values[key] !== value) return undefined;
347
+ values[key] = value;
348
+ }
349
+ return values;
350
+ }
351
+
352
+ function templateToPattern(template: string): string {
353
+ let pattern = '';
354
+ let lastIndex = 0;
355
+ for (const match of template.matchAll(/\$\{([^}]*)\}/g)) {
356
+ pattern += escapeRegex(template.slice(lastIndex, match.index));
357
+ pattern += '([^/]+?)';
358
+ lastIndex = (match.index ?? 0) + match[0].length;
359
+ }
360
+ return `${pattern}${escapeRegex(template.slice(lastIndex))}`;
361
+ }
362
+
363
+ function mergeDerived(
364
+ state: DynamicTargetCandidate,
365
+ values: Record<string, string>,
366
+ sourceKind: string,
367
+ ): void {
368
+ for (const [key, value] of Object.entries(values)) {
369
+ if (state.derivedVariables[key] !== undefined && state.derivedVariables[key] !== value) {
370
+ state.rejectedReasons.push('template_variable_conflict');
371
+ continue;
372
+ }
373
+ state.derivedVariables[key] = value;
374
+ state.derivedVariableSources[key] = { sourceKind, value };
375
+ }
376
+ }
377
+
378
+ function addScore(state: DynamicTargetCandidate, amount: number, reason: string): void {
379
+ state.score += amount;
380
+ if (!state.reasons.includes(reason)) state.reasons.push(reason);
381
+ }
382
+
383
+ function hasResolvedImplementation(db: Db, operationId: number): boolean {
384
+ const row = db.prepare(
385
+ "SELECT 1 FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND status='resolved' AND from_kind='operation' AND from_id=? LIMIT 1",
386
+ ).get(String(operationId));
387
+ return Boolean(row);
388
+ }
389
+
390
+ function cliFor(variables: Record<string, string>, order: string[]): string {
391
+ return order
392
+ .filter((key) => variables[key] !== undefined)
393
+ .map((key) => `--var ${key}=${variables[key]}`)
394
+ .join(' ');
395
+ }
396
+
397
+ function record(value: unknown): Record<string, unknown> {
398
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
399
+ }
400
+
401
+ function stringValue(value: unknown): string | undefined {
402
+ return typeof value === 'string' ? value : undefined;
403
+ }
404
+
405
+ function numberValue(value: unknown): number | undefined {
406
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
407
+ }
408
+
409
+ function stringArray(value: unknown): string[] {
410
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
411
+ }
412
+
413
+ function nonEmptyStrings(value: unknown, fallback: string[]): string[] {
414
+ const values = stringArray(value).filter((item) => item.length > 0);
415
+ return values.length > 0 ? values : fallback;
416
+ }
417
+
418
+ function isConcrete(value: unknown): value is string {
419
+ return typeof value === 'string' && value.length > 0 && extractPlaceholders(value).length === 0;
420
+ }
421
+
422
+ function escapeRegex(value: string): string {
423
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
424
+ }
@@ -2,6 +2,8 @@ import type { Db } from '../db/connection.js';
2
2
  import { extractPlaceholders, substituteVariables, type RuntimeSubstitution } from '../linker/dynamic-edge-resolver.js';
3
3
  import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
4
4
  import { resolveOperation, type OperationTarget } from '../linker/service-resolver.js';
5
+ import type { DynamicMode } from '../types.js';
6
+ import { analyzeDynamicTargetCandidates, type DynamicTargetAnalysis, type DynamicTargetCandidate } from './dynamic-targets.js';
5
7
 
6
8
  export interface TraceGraphRow extends Record<string, unknown> {
7
9
  id: number;
@@ -50,27 +52,49 @@ export function runtimeResolution(
50
52
  db: Db,
51
53
  row: TraceGraphRow,
52
54
  evidence: Record<string, unknown>,
53
- vars: Record<string, string> | undefined,
55
+ options: { vars?: Record<string, string>; dynamicMode?: DynamicMode; maxDynamicCandidates?: number },
54
56
  workspaceId: number | undefined,
55
57
  contextualUnresolvedReason?: string,
56
58
  ): { row: TraceGraphRow; evidence: Record<string, unknown>; target?: OperationTarget; unresolvedReason?: string } {
57
- const substituted = evidenceWithRuntimeVariables(evidence, vars);
58
- if (!isRemoteRuntimeCandidate(row, evidence, vars)) {
59
+ const substituted = evidenceWithRuntimeVariables(evidence, options.vars);
60
+ const dynamicMode = options.dynamicMode ?? 'strict';
61
+ const analysis = analyzeDynamicTargetCandidates(
62
+ db,
63
+ substituted,
64
+ workspaceId,
65
+ dynamicMode,
66
+ positiveCandidateCap(options.maxDynamicCandidates),
67
+ );
68
+ const enriched = analysis ? evidenceWithDynamicAnalysis(substituted, analysis) : substituted;
69
+ if (dynamicMode === 'infer') {
70
+ const inferred = inferredTarget(analysis);
71
+ if (inferred) {
72
+ const resolvedRow = { ...row, status: 'resolved', to_kind: 'operation', to_id: String(inferred.operationId), unresolved_reason: undefined, confidence: inferred.score };
73
+ const resolution = { status: 'resolved' as const, target: inferred, candidates: [], reasons: inferred.reasons };
74
+ return { row: resolvedRow, evidence: withEffectiveResolution(enriched, resolvedRow, undefined, resolution), target: inferred };
75
+ }
76
+ }
77
+ if (!isRemoteRuntimeCandidate(row, evidence, options.vars)) {
59
78
  const unresolvedReason = contextualUnresolvedReason ?? row.unresolved_reason;
60
- const withSections = withEffectiveResolution(substituted, row, unresolvedReason);
79
+ const withSections = withEffectiveResolution(enriched, row, unresolvedReason);
61
80
  return { row, evidence: withSections, unresolvedReason };
62
81
  }
63
- const resolution = resolveRuntimeOperation(db, substituted, workspaceId);
82
+ const resolution = resolveRuntimeOperation(db, enriched, workspaceId);
64
83
  if (resolution.target) {
65
84
  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)) };
66
- return { row: resolvedRow, evidence: withEffectiveResolution(substituted, resolvedRow, undefined, resolution), target: resolution.target };
85
+ return { row: resolvedRow, evidence: withEffectiveResolution(enriched, resolvedRow, undefined, resolution), target: resolution.target };
67
86
  }
68
87
  const unresolvedReason = runtimeUnresolvedReason(resolution);
69
- return { row, evidence: withEffectiveResolution(substituted, row, unresolvedReason, resolution), unresolvedReason };
88
+ return { row, evidence: withEffectiveResolution(enriched, row, unresolvedReason, resolution), unresolvedReason };
70
89
  }
71
90
 
72
91
  export function runtimeVariableDiagnostic(edges: Array<{ evidence: Record<string, unknown> }>): Record<string, unknown> | undefined {
73
92
  const missing = new Set<string>();
93
+ let candidateCount = 0;
94
+ let shownCandidateCount = 0;
95
+ let omittedCandidateCount = 0;
96
+ const candidateSuggestions: Record<string, unknown>[] = [];
97
+ const suggestedVarSets: Record<string, unknown>[] = [];
74
98
  for (const edge of edges) {
75
99
  const effective = parseObject(edge.evidence.effectiveResolution);
76
100
  if (!['dynamic', 'unresolved', 'ambiguous'].includes(String(effective.status ?? '')))
@@ -79,6 +103,12 @@ export function runtimeVariableDiagnostic(edges: Array<{ evidence: Record<string
79
103
  if (!substitutions || typeof substitutions !== 'object' || Array.isArray(substitutions)) continue;
80
104
  for (const value of Object.values(substitutions as Record<string, RuntimeSubstitution>))
81
105
  for (const key of value.missing ?? []) missing.add(key);
106
+ const exploration = parseObject(edge.evidence.dynamicTargetExploration);
107
+ candidateCount += numeric(exploration.candidateCount);
108
+ shownCandidateCount += numeric(exploration.shownCandidateCount);
109
+ omittedCandidateCount += numeric(exploration.omittedCandidateCount);
110
+ candidateSuggestions.push(...recordArray(edge.evidence.dynamicTargetCandidateSuggestions));
111
+ suggestedVarSets.push(...recordArray(exploration.suggestedVarSets));
82
112
  }
83
113
  const missingVariables = [...missing].sort();
84
114
  if (missingVariables.length === 0) return undefined;
@@ -88,6 +118,16 @@ export function runtimeVariableDiagnostic(edges: Array<{ evidence: Record<string
88
118
  message: `Runtime variables are required to resolve dynamic trace targets: ${missingVariables.join(', ')}`,
89
119
  missingVariables,
90
120
  suggestions: missingVariables.map((key) => `--var ${key}=<value>`),
121
+ candidateCount,
122
+ shownCandidateCount,
123
+ omittedCandidateCount,
124
+ candidateSuggestions: candidateSuggestions.slice(0, 5),
125
+ suggestedVarSets: uniqueCliRows(suggestedVarSets).slice(0, 5),
126
+ copyableExamples: [
127
+ ...uniqueCliRows(suggestedVarSets).slice(0, 3).flatMap((row) =>
128
+ typeof row.cli === 'string' ? [row.cli] : []),
129
+ ...(candidateCount > 0 ? ['--dynamic-mode candidates --max-dynamic-candidates 20'] : []),
130
+ ],
91
131
  };
92
132
  }
93
133
 
@@ -180,6 +220,26 @@ function evidenceWithRuntimeVariables(evidence: Record<string, unknown>, vars: R
180
220
  return next;
181
221
  }
182
222
 
223
+ function evidenceWithDynamicAnalysis(
224
+ evidence: Record<string, unknown>,
225
+ analysis: DynamicTargetAnalysis,
226
+ ): Record<string, unknown> {
227
+ return {
228
+ ...evidence,
229
+ dynamicTargetExploration: {
230
+ mode: analysis.mode,
231
+ missingVariables: analysis.missingVariables,
232
+ candidateCount: analysis.candidateCount,
233
+ shownCandidateCount: analysis.shownCandidateCount,
234
+ omittedCandidateCount: analysis.omittedCandidateCount,
235
+ suggestedVarSets: analysis.suggestedVarSets,
236
+ },
237
+ dynamicTargetCandidates: analysis.candidates,
238
+ dynamicTargetCandidateSuggestions: analysis.shownCandidates,
239
+ dynamicTargetInference: analysis.inference,
240
+ };
241
+ }
242
+
183
243
  function runtimeSubstitutions(evidence: Record<string, unknown>, vars: Record<string, string>): Record<string, RuntimeSubstitution> {
184
244
  const substitutions: Record<string, RuntimeSubstitution> = {};
185
245
  for (const key of ['servicePath', 'operationPath', 'serviceAliasExpr', 'serviceAlias', 'destination']) {
@@ -206,6 +266,31 @@ function isRemoteRuntimeCandidate(row: TraceGraphRow, evidence: Record<string, u
206
266
  return ['servicePath', 'operationPath', 'serviceAliasExpr', 'serviceAlias', 'destination'].some((key) => hasRuntimeVariable(evidence[key], vars));
207
267
  }
208
268
 
269
+ function inferredTarget(analysis: DynamicTargetAnalysis | undefined): OperationTarget | undefined {
270
+ if (analysis?.inference.status !== 'resolved') return undefined;
271
+ const id = Number(analysis.inference.candidateOperationId);
272
+ const candidate = analysis.candidates.find((item) => item.candidateOperationId === id);
273
+ if (!candidate) return undefined;
274
+ return targetFromCandidate(candidate);
275
+ }
276
+
277
+ function targetFromCandidate(candidate: DynamicTargetCandidate): OperationTarget {
278
+ return {
279
+ operationId: candidate.candidateOperationId,
280
+ repoName: candidate.repoName,
281
+ serviceName: '',
282
+ qualifiedName: '',
283
+ servicePath: candidate.servicePath,
284
+ operationPath: candidate.operationPath,
285
+ operationName: candidate.operationName,
286
+ packageName: candidate.packageName,
287
+ score: candidate.score,
288
+ reasons: candidate.reasons,
289
+ sourceFile: '',
290
+ sourceLine: 0,
291
+ };
292
+ }
293
+
209
294
  function hasRuntimeVariable(value: unknown, vars: Record<string, string>): boolean {
210
295
  return typeof value === 'string' && extractPlaceholders(value).some((key) => Object.hasOwn(vars, key));
211
296
  }
@@ -223,3 +308,28 @@ function parseObject(value: unknown): Record<string, unknown> {
223
308
  function stringValue(value: unknown): string | undefined {
224
309
  return typeof value === 'string' ? value : undefined;
225
310
  }
311
+
312
+ function numeric(value: unknown): number {
313
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
314
+ }
315
+
316
+ function recordArray(value: unknown): Record<string, unknown>[] {
317
+ return Array.isArray(value)
318
+ ? value.filter((item): item is Record<string, unknown> =>
319
+ Boolean(item && typeof item === 'object' && !Array.isArray(item)))
320
+ : [];
321
+ }
322
+
323
+ function uniqueCliRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
324
+ const seen = new Set<string>();
325
+ return rows.filter((row) => {
326
+ const cli = typeof row.cli === 'string' ? row.cli : JSON.stringify(row);
327
+ if (seen.has(cli)) return false;
328
+ seen.add(cli);
329
+ return true;
330
+ });
331
+ }
332
+
333
+ function positiveCandidateCap(value: number | undefined): number {
334
+ return Number.isFinite(value) && Number(value) > 0 ? Math.floor(Number(value)) : 5;
335
+ }
@@ -2,8 +2,9 @@ import type { Db } from '../db/connection.js';
2
2
  import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
3
3
  import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
4
4
  import { resolveOperation } from '../linker/service-resolver.js';
5
- import type { ImplementationHint, TraceEdge, TraceResult, TraceStart } from '../types.js';
5
+ import type { ImplementationHint, TraceEdge, TraceOptions, TraceResult, TraceStart } from '../types.js';
6
6
  import { baseTraceEvidence, edgeTarget, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
7
+ import { dynamicCandidateBranches } from './dynamic-branches.js';
7
8
  import { implementationHintDiagnostic, selectImplementation, type ImplementationSelection } from './implementation-hints.js';
8
9
  import { ambiguousStartDiagnostic } from './selectors.js';
9
10
  interface RepoRef {
@@ -307,6 +308,7 @@ function operationNode(db: Db, operationId: string): Record<string, unknown> | u
307
308
  if (!row) return undefined;
308
309
  return { id: `operation:${operationId}`, kind: 'operation', label: `${String(row.repoName)}:${String(row.servicePath)}${String(row.operationPath)}`, ...row };
309
310
  }
311
+
310
312
  function workspaceIdForCall(db: Db, callId: string): number | undefined {
311
313
  return (db.prepare('SELECT r.workspace_id workspaceId FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE c.id=?').get(callId) as { workspaceId?: number } | undefined)?.workspaceId;
312
314
  }
@@ -486,15 +488,7 @@ function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBind
486
488
  export function trace(
487
489
  db: Db,
488
490
  start: TraceStart,
489
- options: {
490
- depth: number;
491
- vars?: Record<string, string>;
492
- includeExternal?: boolean;
493
- includeDb?: boolean;
494
- includeAsync?: boolean;
495
- implementationRepo?: string;
496
- implementationHints?: ImplementationHint[];
497
- },
491
+ options: TraceOptions,
498
492
  ): TraceResult {
499
493
  const hintOptions = { implementationRepo: options.implementationRepo, implementationHints: options.implementationHints };
500
494
  const scope = startScope(db, start, hintOptions);
@@ -595,7 +589,11 @@ export function trace(
595
589
  String(row.evidence_json || '{}'),
596
590
  ) as Record<string, unknown>;
597
591
  const rawEvidence = baseTraceEvidence(row as TraceGraphRow, call, persistedEvidence, contextual.evidence);
598
- const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, options.vars, workspaceIdForCall(db, String(call.id)), contextual.unresolvedReason);
592
+ const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, {
593
+ vars: options.vars,
594
+ dynamicMode: options.dynamicMode ?? 'strict',
595
+ maxDynamicCandidates: options.maxDynamicCandidates,
596
+ }, workspaceIdForCall(db, String(call.id)), contextual.unresolvedReason);
599
597
  const evidence = effective.evidence;
600
598
  const effectiveRow = effective.row;
601
599
  const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
@@ -615,6 +613,8 @@ export function trace(
615
613
  confidence: Number(effectiveRow.confidence ?? call.confidence),
616
614
  unresolvedReason: effective.unresolvedReason,
617
615
  });
616
+ if ((options.dynamicMode ?? 'strict') === 'candidates')
617
+ edges.push(...dynamicCandidateBranches(current.depth, call, evidence));
618
618
  if (effectiveRow.to_kind === 'operation') {
619
619
  const implementation = implementationScope(db, effectiveRow.to_id);
620
620
  const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId, evidence, hintOptions);
package/src/types.ts CHANGED
@@ -199,6 +199,18 @@ export interface ImplementationHint {
199
199
  candidateFamily?: string;
200
200
  implementationRepo: string;
201
201
  }
202
+ export type DynamicMode = 'strict' | 'candidates' | 'infer';
203
+ export interface TraceOptions {
204
+ depth: number;
205
+ vars?: Record<string, string>;
206
+ includeExternal?: boolean;
207
+ includeDb?: boolean;
208
+ includeAsync?: boolean;
209
+ implementationRepo?: string;
210
+ implementationHints?: ImplementationHint[];
211
+ dynamicMode?: DynamicMode;
212
+ maxDynamicCandidates?: number;
213
+ }
202
214
  export interface TraceEdge {
203
215
  step: number;
204
216
  type: string;