@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.
- package/CHANGELOG.md +7 -0
- package/README.md +9 -1
- package/TECHNICAL-NOTE.md +6 -1
- package/dist/{chunk-52OUS3MO.js → chunk-YZJKE5UX.js} +470 -29
- package/dist/chunk-YZJKE5UX.js.map +1 -0
- package/dist/cli.js +42 -8
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +14 -10
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli.ts +22 -0
- package/src/index.ts +1 -1
- package/src/linker/cross-repo-linker.ts +9 -1
- package/src/linker/service-resolver.ts +15 -4
- package/src/output/table-output.ts +29 -2
- package/src/trace/dynamic-branches.ts +45 -0
- package/src/trace/dynamic-targets.ts +424 -0
- package/src/trace/evidence.ts +117 -7
- package/src/trace/trace-engine.ts +11 -11
- package/src/types.ts +12 -0
- package/dist/chunk-52OUS3MO.js.map +0 -1
|
@@ -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
|
+
}
|
package/src/trace/evidence.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
79
|
+
const withSections = withEffectiveResolution(enriched, row, unresolvedReason);
|
|
61
80
|
return { row, evidence: withSections, unresolvedReason };
|
|
62
81
|
}
|
|
63
|
-
const resolution = resolveRuntimeOperation(db,
|
|
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(
|
|
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(
|
|
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,
|
|
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;
|