@saptools/service-flow 0.1.51 → 0.1.52

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +10 -5
  3. package/TECHNICAL-NOTE.md +8 -4
  4. package/dist/{chunk-YZJKE5UX.js → chunk-PTLDSHRC.js} +2412 -553
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +280 -506
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +45 -7
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/000-clean.ts +82 -0
  12. package/src/cli.ts +75 -57
  13. package/src/db/connection.ts +1 -1
  14. package/src/db/migrations.ts +5 -3
  15. package/src/db/repositories.ts +120 -22
  16. package/src/db/schema.ts +7 -2
  17. package/src/indexer/repository-indexer.ts +57 -29
  18. package/src/indexer/workspace-indexer.ts +84 -6
  19. package/src/linker/cross-repo-linker.ts +13 -1
  20. package/src/output/table-output.ts +29 -3
  21. package/src/parsers/cds-parser.ts +8 -2
  22. package/src/parsers/decorator-parser.ts +382 -48
  23. package/src/parsers/handler-registration-parser.ts +38 -21
  24. package/src/parsers/imported-wrapper-parser.ts +18 -5
  25. package/src/parsers/outbound-call-parser.ts +25 -8
  26. package/src/parsers/package-json-parser.ts +36 -11
  27. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  28. package/src/parsers/service-binding-parser.ts +6 -3
  29. package/src/parsers/symbol-parser.ts +13 -3
  30. package/src/parsers/ts-project.ts +54 -0
  31. package/src/trace/000-dynamic-target-types.ts +84 -0
  32. package/src/trace/001-dynamic-identity.ts +280 -0
  33. package/src/trace/002-trace-diagnostics.ts +54 -0
  34. package/src/trace/003-dynamic-references.ts +82 -0
  35. package/src/trace/dynamic-targets.ts +459 -229
  36. package/src/trace/evidence.ts +305 -46
  37. package/src/trace/selectors.ts +483 -0
  38. package/src/trace/trace-engine.ts +90 -83
  39. package/src/types.ts +27 -1
  40. package/dist/chunk-YZJKE5UX.js.map +0 -1
@@ -2,48 +2,36 @@ import type { Db } from '../db/connection.js';
2
2
  import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
3
3
  import type { OperationTarget } from '../linker/service-resolver.js';
4
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;
5
+ import { uniqueIdentityDerivations } from './001-dynamic-identity.js';
6
+ import {
7
+ dynamicReferenceProvenance,
8
+ dynamicReferenceRows,
9
+ type DynamicReferenceRow,
10
+ } from './003-dynamic-references.js';
11
+ import type {
12
+ DynamicTargetAnalysis,
13
+ DynamicTargetCandidate,
14
+ DynamicTemplates,
15
+ DynamicVariableProvenance,
16
+ } from './000-dynamic-target-types.js';
17
+
18
+ export type {
19
+ DynamicTargetAnalysis,
20
+ DynamicTargetCandidate,
21
+ } from './000-dynamic-target-types.js';
22
+
23
+ type Templates = DynamicTemplates;
24
+ type VariableProvenance = DynamicVariableProvenance;
25
+
26
+ interface AnalysisInputs {
27
+ original: Templates;
28
+ effective: Templates;
29
+ required: string[];
30
+ requiredSources: Record<string, string[]>;
31
+ supplied: Record<string, string>;
32
+ order: string[];
33
+ callerRepo?: string;
34
+ callerRepoId?: number;
47
35
  }
48
36
 
49
37
  export function analyzeDynamicTargetCandidates(
@@ -53,32 +41,65 @@ export function analyzeDynamicTargetCandidates(
53
41
  mode: DynamicMode,
54
42
  maxCandidates: number,
55
43
  ): 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,
44
+ const inputs = analysisInputs(evidence);
45
+ if (inputs.required.length === 0) return undefined;
46
+ const targets = candidateTargets(db, evidence, workspaceId);
47
+ const references = dynamicReferenceRows(
48
+ db, workspaceId, inputs.callerRepoId, inputs.callerRepo,
66
49
  );
67
- const inference = inferenceDecision(candidates);
68
- const shownCandidates = candidates.slice(0, maxCandidates);
50
+ const candidates = buildCandidates(db, targets, references, inputs);
51
+ applyUniqueIdentityEvidence(db, candidates, inputs);
52
+ finalizeCandidates(candidates, inputs.order);
53
+ const ranked = stableRank(candidates);
54
+ const inference = inferenceDecision(ranked);
55
+ applyModeState(ranked, mode, inference);
56
+ const viable = ranked.filter((candidate) => candidate.viable);
57
+ const rejected = ranked.filter((candidate) => candidate.rejected);
58
+ const shown = viable.slice(0, maxCandidates)
59
+ .map((candidate) => boundedCandidate(candidate, maxCandidates));
60
+ const shownRejected = rejected.slice(0, maxCandidates)
61
+ .map((candidate) => boundedCandidate(candidate, maxCandidates));
69
62
  return {
70
63
  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),
64
+ maxCandidates,
65
+ candidateCount: ranked.length,
66
+ viableCandidateCount: viable.length,
67
+ rejectedCandidateCount: rejected.length,
68
+ shownCandidateCount: shown.length,
69
+ omittedCandidateCount: Math.max(0, viable.length - shown.length),
70
+ shownRejectedCandidateCount: shownRejected.length,
71
+ omittedRejectedCandidateCount: Math.max(0, rejected.length - shownRejected.length),
72
+ missingVariables: inputs.required.filter((key) => inputs.supplied[key] === undefined),
73
+ requiredVariables: inputs.required,
74
+ suppliedVariables: inputs.supplied,
75
+ appliedSuppliedVariables: requiredSuppliedVariables(inputs),
76
+ substitutedSignals: inputs.effective,
77
+ candidates: shown,
78
+ shownCandidates: shown,
79
+ rejectedCandidates: shownRejected,
80
+ suggestedVarSets: suggestedVarSets(viable, inputs.order, maxCandidates),
78
81
  inference,
79
82
  };
80
83
  }
81
84
 
85
+ function analysisInputs(evidence: Record<string, unknown>): AnalysisInputs {
86
+ const original = templatesFromEvidence(evidence, 'original');
87
+ const effective = templatesFromEvidence(evidence, 'effective');
88
+ const requiredSources = placeholderSources(original);
89
+ const required = Object.keys(requiredSources);
90
+ const supplied = stringRecord(evidence.suppliedRuntimeVariables);
91
+ return {
92
+ original,
93
+ effective,
94
+ required,
95
+ requiredSources,
96
+ supplied,
97
+ order: variableOrder(original, required),
98
+ callerRepo: stringValue(evidence.repo),
99
+ callerRepoId: numberValue(evidence.repoId),
100
+ };
101
+ }
102
+
82
103
  function candidateTargets(
83
104
  db: Db,
84
105
  evidence: Record<string, unknown>,
@@ -86,7 +107,7 @@ function candidateTargets(
86
107
  ): OperationTarget[] {
87
108
  const embedded = rowsFromEvidence(evidence.candidates);
88
109
  if (embedded.length > 0) return embedded;
89
- const operationPath = stringValue(evidence.operationPath);
110
+ const operationPath = effectiveSignal(evidence, 'operationPath');
90
111
  if (!operationPath || extractPlaceholders(operationPath).length > 0) return [];
91
112
  return queryOperationTargets(db, operationPath, workspaceId);
92
113
  }
@@ -103,7 +124,9 @@ function rowsFromEvidence(value: unknown): OperationTarget[] {
103
124
  if (operationId === undefined || !repoName || !servicePath || !operationPath || !operationName) return [];
104
125
  return [{
105
126
  operationId,
127
+ repoId: numberValue(row.repoId),
106
128
  repoName,
129
+ packageName: stringValue(row.packageName),
107
130
  serviceName: stringValue(row.serviceName) ?? '',
108
131
  qualifiedName: stringValue(row.qualifiedName) ?? '',
109
132
  servicePath,
@@ -111,8 +134,6 @@ function rowsFromEvidence(value: unknown): OperationTarget[] {
111
134
  operationName,
112
135
  sourceFile: stringValue(row.sourceFile) ?? '',
113
136
  sourceLine: numberValue(row.sourceLine) ?? 0,
114
- repoId: numberValue(row.repoId),
115
- packageName: stringValue(row.packageName),
116
137
  score: numberValue(row.score) ?? 0,
117
138
  reasons: stringArray(row.reasons),
118
139
  }];
@@ -129,214 +150,392 @@ function queryOperationTargets(
129
150
  `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,
130
151
  s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,
131
152
  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
153
+ o.source_line sourceLine FROM cds_operations o
154
+ JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
135
155
  WHERE (? IS NULL OR r.workspace_id=?)
136
156
  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),
157
+ ORDER BY r.name,s.service_path,o.operation_name,o.id`,
158
+ ).all(workspaceId, workspaceId, operationPath, `/${simple}`, simple);
159
+ return rows.flatMap(operationTargetFromRow);
160
+ }
161
+
162
+ function operationTargetFromRow(row: Record<string, unknown>): OperationTarget[] {
163
+ const operationId = numberValue(row.operationId);
164
+ const repoName = stringValue(row.repoName);
165
+ const servicePath = stringValue(row.servicePath);
166
+ const operationPath = stringValue(row.operationPath);
167
+ const operationName = stringValue(row.operationName);
168
+ if (operationId === undefined || !repoName || !servicePath || !operationPath || !operationName) return [];
169
+ return [{
170
+ operationId,
141
171
  repoId: numberValue(row.repoId),
142
- repoName: String(row.repoName),
172
+ repoName,
143
173
  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),
174
+ serviceName: stringValue(row.serviceName) ?? '',
175
+ qualifiedName: stringValue(row.qualifiedName) ?? '',
176
+ servicePath,
177
+ operationPath,
178
+ operationName,
179
+ sourceFile: stringValue(row.sourceFile) ?? '',
180
+ sourceLine: numberValue(row.sourceLine) ?? 0,
151
181
  score: 0.2,
152
182
  reasons: ['operation_path_match'],
153
- }));
183
+ }];
154
184
  }
155
185
 
156
- function rankedCandidates(
186
+ function buildCandidates(
157
187
  db: Db,
158
- candidates: OperationTarget[],
159
- references: ReferenceRow[],
160
- templates: Templates,
161
- order: string[],
188
+ targets: OperationTarget[],
189
+ references: DynamicReferenceRow[],
190
+ inputs: AnalysisInputs,
162
191
  ): 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));
192
+ return targets.map((target) => {
193
+ const state = emptyCandidate(target, inputs);
194
+ applyDirectSignal(state, inputs, 'operationPath', target.operationPath, 0.25);
195
+ applyDirectSignal(state, inputs, 'servicePath', target.servicePath, 0.35);
196
+ const matchingReferences = references.filter((reference) =>
197
+ reference.servicePath === target.servicePath);
198
+ applyReferenceSignal(state, inputs, matchingReferences, 'alias');
199
+ applyReferenceSignal(state, inputs, matchingReferences, 'destination');
200
+ if (hasResolvedImplementation(db, target.operationId))
201
+ addScore(state, 0.1, 'implementation_edge_resolved');
202
+ return state;
203
+ });
169
204
  }
170
205
 
171
- function candidateEvidence(
172
- db: Db,
173
- candidate: OperationTarget,
174
- references: ReferenceRow[],
175
- templates: Templates,
176
- order: string[],
206
+ function emptyCandidate(
207
+ target: OperationTarget,
208
+ inputs: AnalysisInputs,
177
209
  ): 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
210
  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,
211
+ candidateOperationId: target.operationId,
212
+ repoId: target.repoId,
213
+ repoName: target.repoName,
214
+ packageName: target.packageName ?? undefined,
215
+ serviceName: target.serviceName,
216
+ qualifiedName: target.qualifiedName,
217
+ servicePath: target.servicePath,
218
+ operationPath: target.operationPath,
219
+ operationName: target.operationName,
220
+ sourceFile: target.sourceFile,
221
+ sourceLine: target.sourceLine,
222
+ originalTemplates: inputs.original,
223
+ effectiveValues: inputs.effective,
224
+ requiredVariables: inputs.required,
225
+ requiredVariableSources: inputs.requiredSources,
226
+ suppliedVariables: inputs.supplied,
227
+ completeVariables: { ...inputs.supplied },
201
228
  derivedVariables: {},
202
229
  derivedVariableSources: {},
230
+ derivationProvenance: {},
203
231
  missingVariables: [],
204
- score: Math.max(0.2, Number(candidate.score ?? 0)),
205
- reasons: nonEmptyStrings(candidate.reasons, ['operation_path_match']),
232
+ conflicts: [],
233
+ score: Math.max(0.2, Number(target.score ?? 0)),
234
+ explicitSignalStrength: 0,
235
+ reasons: nonEmptyStrings(target.reasons, ['operation_path_match']),
206
236
  rejectedReasons: [],
237
+ inferenceBlockReasons: [],
238
+ viable: true,
239
+ rejected: false,
240
+ selected: false,
241
+ exploratory: false,
207
242
  };
208
243
  }
209
244
 
210
- function applyDirectTemplate(
245
+ function applyDirectSignal(
211
246
  state: DynamicTargetCandidate,
212
- template: string | undefined,
247
+ inputs: AnalysisInputs,
248
+ kind: 'servicePath' | 'operationPath',
213
249
  concrete: string,
214
- kind: 'operation_path' | 'service_path',
250
+ score: number,
215
251
  ): void {
216
- const matched = matchTemplate(template, concrete);
217
- if (!template) return;
218
- if (!matched) {
219
- state.rejectedReasons.push(`${kind}_template_mismatch`);
252
+ const effective = inputs.effective[kind];
253
+ const original = inputs.original[kind];
254
+ if (effective && !matchTemplate(effective, concrete)) {
255
+ reject(state, `${signalCode(kind)}_contradicts_runtime_substitution`);
220
256
  return;
221
257
  }
222
- mergeDerived(state, matched, `${kind}_template`);
223
- addScore(state, kind === 'service_path' ? 0.35 : 0.25, `${kind}_template_match`);
258
+ if (!effective) return;
259
+ const suppliedKeys = extractPlaceholders(original)
260
+ .filter((key) => inputs.supplied[key] !== undefined);
261
+ state.explicitSignalStrength += suppliedKeys.length;
262
+ const matched = matchTemplate(original, concrete) ?? {};
263
+ for (const [key, value] of Object.entries(matched)) {
264
+ addDerivation(state, key, value, {
265
+ sourceKind: `${signalCode(kind)}_template`,
266
+ value,
267
+ rule: 'exact_template_match',
268
+ template: original,
269
+ });
270
+ }
271
+ addScore(state, score, `${signalCode(kind)}_template_match`);
224
272
  }
225
273
 
226
- function applyReferenceTemplate(
274
+ function applyReferenceSignal(
227
275
  state: DynamicTargetCandidate,
228
- template: string | undefined,
229
- refs: ReferenceRow[],
276
+ inputs: AnalysisInputs,
277
+ references: DynamicReferenceRow[],
230
278
  kind: 'alias' | 'destination',
231
279
  ): 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;
280
+ const original = inputs.original[kind];
281
+ const effective = inputs.effective[kind];
282
+ if (!original || extractPlaceholders(original).length === 0) return;
283
+ const values = references.flatMap((reference) => {
284
+ const concrete = kind === 'alias' ? reference.alias : reference.destination;
285
+ return isConcrete(concrete) ? [{ reference, concrete }] : [];
286
+ });
287
+ if (effective && extractPlaceholders(effective).length === 0
288
+ && values.length > 0 && !values.some(({ concrete }) => concrete === effective)) {
289
+ reject(state, `${kind}_contradicts_runtime_substitution`);
290
+ }
291
+ let matchedSignal = false;
292
+ for (const { reference, concrete } of values) {
293
+ const matched = matchTemplate(original, concrete);
236
294
  if (!matched) continue;
237
- mergeDerived(state, matched, `${ref.sourceKind}.${kind}`);
295
+ matchedSignal = true;
296
+ for (const [key, value] of Object.entries(matched)) {
297
+ addDerivation(
298
+ state, key, value,
299
+ dynamicReferenceProvenance(reference, kind, original, value),
300
+ );
301
+ }
302
+ }
303
+ if (matchedSignal) {
304
+ state.explicitSignalStrength += extractPlaceholders(original)
305
+ .filter((key) => inputs.supplied[key] !== undefined).length;
238
306
  addScore(state, 0.2, `${kind}_template_match`);
307
+ }
308
+ }
309
+
310
+ function applyUniqueIdentityEvidence(
311
+ db: Db,
312
+ candidates: DynamicTargetCandidate[],
313
+ inputs: AnalysisInputs,
314
+ ): void {
315
+ for (const derivation of uniqueIdentityDerivations(db, candidates, inputs.original)) {
316
+ const candidate = candidates.find((item) =>
317
+ item.candidateOperationId === derivation.operationId);
318
+ if (!candidate) continue;
319
+ addDerivation(candidate, derivation.key, derivation.value, derivation.provenance);
320
+ addScore(candidate, 0.2, 'exact_identity_template_match');
321
+ }
322
+ }
323
+
324
+ function addDerivation(
325
+ state: DynamicTargetCandidate,
326
+ key: string,
327
+ value: string,
328
+ provenance: VariableProvenance,
329
+ ): void {
330
+ const priorProvenance = state.derivationProvenance[key] ?? [];
331
+ state.derivationProvenance[key] = uniqueProvenance([...priorProvenance, provenance]);
332
+ const supplied = state.suppliedVariables[key];
333
+ if (supplied !== undefined && supplied !== value) {
334
+ addConflict(state, key, [supplied, value], 'explicit_value_conflicts_with_derived_value');
239
335
  return;
240
336
  }
241
- if (refs.some((ref) => isConcrete(kind === 'alias' ? ref.alias : ref.destination)))
242
- state.rejectedReasons.push(`${kind}_template_mismatch`);
337
+ const prior = state.derivedVariables[key];
338
+ if (prior !== undefined && prior !== value) {
339
+ addConflict(state, key, [prior, value], 'conflicting_strong_derivations');
340
+ return;
341
+ }
342
+ if (supplied === undefined) state.derivedVariables[key] = value;
343
+ state.completeVariables[key] = supplied ?? value;
344
+ state.derivedVariableSources[key] ??= provenance;
345
+ }
346
+
347
+ function addConflict(
348
+ state: DynamicTargetCandidate,
349
+ key: string,
350
+ values: string[],
351
+ reason: string,
352
+ ): void {
353
+ const sources = (state.derivationProvenance[key] ?? [])
354
+ .map((item) => item.sourceKind).sort();
355
+ state.conflicts.push({ key, values: [...new Set(values)].sort(), reason, sources });
356
+ reject(state, reason);
357
+ }
358
+
359
+ function uniqueProvenance(rows: VariableProvenance[]): VariableProvenance[] {
360
+ const sorted = [...rows].sort((left, right) =>
361
+ left.sourceKind.localeCompare(right.sourceKind)
362
+ || String(left.matchedName ?? '').localeCompare(String(right.matchedName ?? ''))
363
+ || left.value.localeCompare(right.value));
364
+ const seen = new Set<string>();
365
+ return sorted.filter((row) => {
366
+ const key = JSON.stringify(row);
367
+ if (seen.has(key)) return false;
368
+ seen.add(key);
369
+ return true;
370
+ });
371
+ }
372
+
373
+ function finalizeCandidates(candidates: DynamicTargetCandidate[], order: string[]): void {
374
+ for (const candidate of candidates) {
375
+ candidate.missingVariables = order.filter((key) =>
376
+ candidate.completeVariables[key] === undefined);
377
+ candidate.viable = candidate.rejectedReasons.length === 0;
378
+ candidate.rejected = !candidate.viable;
379
+ if (candidate.missingVariables.length === 0 && candidate.viable)
380
+ addScore(candidate, 0.15, 'all_runtime_variables_derived');
381
+ else if (candidate.missingVariables.length > 0)
382
+ addReason(candidate, 'missing_required_runtime_variable');
383
+ candidate.score = Math.max(0, Math.min(1, candidate.score));
384
+ candidate.cli = candidate.missingVariables.length === 0 && candidate.viable
385
+ ? cliFor(candidate.completeVariables, order)
386
+ : undefined;
387
+ }
388
+ }
389
+
390
+ function stableRank(candidates: DynamicTargetCandidate[]): DynamicTargetCandidate[] {
391
+ return [...candidates].sort((left, right) =>
392
+ Number(right.viable) - Number(left.viable)
393
+ || right.score - left.score
394
+ || right.explicitSignalStrength - left.explicitSignalStrength
395
+ || left.repoName.localeCompare(right.repoName)
396
+ || String(left.packageName ?? '').localeCompare(String(right.packageName ?? ''))
397
+ || left.servicePath.localeCompare(right.servicePath)
398
+ || left.operationPath.localeCompare(right.operationPath)
399
+ || left.operationName.localeCompare(right.operationName)
400
+ || left.candidateOperationId - right.candidateOperationId);
243
401
  }
244
402
 
245
403
  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' };
404
+ const viable = candidates.filter((candidate) => candidate.viable);
405
+ const first = viable[0];
406
+ const second = viable[1];
407
+ if (!first || first.missingVariables.length > 0)
408
+ return { status: 'unresolved', reason: 'missing_required_runtime_variable' };
409
+ if (first.score < 0.85)
410
+ return { status: 'unresolved', reason: 'candidate_score_below_inference_threshold' };
411
+ const scoreGap = second
412
+ ? Number((first.score - second.score).toFixed(12))
413
+ : undefined;
414
+ if (second && scoreGap !== undefined && scoreGap <= 0.05) {
415
+ const reason = scoreGap === 0
416
+ ? 'candidate_tied_with_equal_score'
417
+ : 'candidate_within_inference_margin';
418
+ for (const candidate of viable.filter((item) => first.score - item.score <= 0.05))
419
+ addInferenceBlock(candidate, reason);
420
+ return { status: 'ambiguous', reason, scoreGap, requiredMargin: 0.05 };
257
421
  }
258
422
  return {
259
423
  status: 'resolved',
260
424
  candidateOperationId: first.candidateOperationId,
261
- inferredVariables: first.derivedVariables,
425
+ inferredVariables: first.completeVariables,
262
426
  score: first.score,
263
427
  reasons: first.reasons,
264
428
  };
265
429
  }
266
430
 
431
+ function boundedCandidate(
432
+ candidate: DynamicTargetCandidate,
433
+ limit: number,
434
+ ): DynamicTargetCandidate {
435
+ const derivationProvenance = Object.fromEntries(
436
+ Object.entries(candidate.derivationProvenance)
437
+ .sort(([left], [right]) => left.localeCompare(right))
438
+ .map(([key, rows]) => [key, rows.slice(0, limit)]),
439
+ );
440
+ const conflicts = candidate.conflicts.slice(0, limit).map((conflict) => ({
441
+ ...conflict,
442
+ sources: conflict.sources.slice(0, limit),
443
+ }));
444
+ return { ...candidate, derivationProvenance, conflicts };
445
+ }
446
+
447
+ function applyModeState(
448
+ candidates: DynamicTargetCandidate[],
449
+ mode: DynamicMode,
450
+ inference: Record<string, unknown>,
451
+ ): void {
452
+ const selectedId = mode === 'infer' && inference.status === 'resolved'
453
+ ? numberValue(inference.candidateOperationId)
454
+ : undefined;
455
+ for (const candidate of candidates) {
456
+ candidate.selected = selectedId === candidate.candidateOperationId;
457
+ candidate.exploratory = mode === 'candidates' && candidate.viable;
458
+ }
459
+ }
460
+
267
461
  function suggestedVarSets(
268
462
  candidates: DynamicTargetCandidate[],
269
- required: string[],
270
463
  order: string[],
464
+ limit: number,
271
465
  ): Array<{ variables: Record<string, string>; cli: string }> {
272
466
  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
- });
467
+ const rows: Array<{ variables: Record<string, string>; cli: string }> = [];
468
+ for (const candidate of candidates) {
469
+ if (!candidate.cli || candidate.missingVariables.length > 0) continue;
470
+ if (seen.has(candidate.cli)) continue;
471
+ seen.add(candidate.cli);
472
+ rows.push({ variables: orderedVariables(candidate.completeVariables, order), cli: candidate.cli });
473
+ if (rows.length >= limit) break;
474
+ }
475
+ return rows;
280
476
  }
281
477
 
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
- }));
478
+ function hasResolvedImplementation(db: Db, operationId: number): boolean {
479
+ return Boolean(db.prepare(
480
+ "SELECT 1 FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND status='resolved' AND from_kind='operation' AND from_id=? LIMIT 1",
481
+ ).get(String(operationId)));
301
482
  }
302
483
 
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();
484
+ function templatesFromEvidence(
485
+ evidence: Record<string, unknown>,
486
+ phase: 'original' | 'effective',
487
+ ): Templates {
488
+ return {
489
+ servicePath: substitutionSignal(evidence, 'servicePath', phase),
490
+ operationPath: substitutionSignal(evidence, 'operationPath', phase),
491
+ alias: substitutionSignal(evidence,
492
+ evidence.serviceAliasExpr !== undefined ? 'serviceAliasExpr' : 'serviceAlias', phase),
493
+ destination: substitutionSignal(evidence, 'destination', phase),
494
+ };
495
+ }
496
+
497
+ function substitutionSignal(
498
+ evidence: Record<string, unknown>,
499
+ key: string,
500
+ phase: 'original' | 'effective',
501
+ ): string | undefined {
502
+ const substitution = record(record(evidence.runtimeSubstitutions)[key]);
503
+ return stringValue(substitution[phase]) ?? stringValue(evidence[key]);
313
504
  }
314
505
 
315
- function variableOrder(templates: Templates, missingVariables: string[]): string[] {
506
+ function effectiveSignal(evidence: Record<string, unknown>, key: string): string | undefined {
507
+ return substitutionSignal(evidence, key, 'effective');
508
+ }
509
+
510
+ function placeholderSources(templates: Templates): Record<string, string[]> {
511
+ const sources: Record<string, string[]> = {};
512
+ for (const [kind, template] of Object.entries(templates)) {
513
+ if (typeof template !== 'string') continue;
514
+ for (const key of extractPlaceholders(template))
515
+ sources[key] = [...new Set([...(sources[key] ?? []), `${kind}:${template}`])].sort();
516
+ }
517
+ return Object.fromEntries(Object.entries(sources).sort(([left], [right]) =>
518
+ left.localeCompare(right)));
519
+ }
520
+
521
+ function variableOrder(templates: Templates, required: string[]): string[] {
316
522
  const ordered = [
317
523
  templates.servicePath,
318
524
  templates.operationPath,
319
525
  templates.alias,
320
526
  templates.destination,
321
527
  ].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
- };
528
+ return [...new Set([...ordered, ...required])];
332
529
  }
333
530
 
334
- function matchTemplate(template: string | undefined, concrete: string | undefined): Record<string, string> | undefined {
531
+ function matchTemplate(
532
+ template: string | undefined,
533
+ concrete: string | undefined,
534
+ ): Record<string, string> | undefined {
335
535
  if (!template || !concrete) return undefined;
336
536
  const keys = extractPlaceholders(template);
337
537
  if (keys.length === 0) return template === concrete ? {} : undefined;
338
- const regex = new RegExp(`^${templateToPattern(template)}$`);
339
- const match = regex.exec(concrete);
538
+ const match = new RegExp(`^${templateToPattern(template)}$`).exec(concrete);
340
539
  if (!match) return undefined;
341
540
  const values: Record<string, string> = {};
342
541
  for (let index = 0; index < keys.length; index += 1) {
@@ -360,42 +559,70 @@ function templateToPattern(template: string): string {
360
559
  return `${pattern}${escapeRegex(template.slice(lastIndex))}`;
361
560
  }
362
561
 
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
- }
562
+ function cliFor(variables: Record<string, string>, order: string[]): string {
563
+ return order.filter((key) => variables[key] !== undefined)
564
+ .map((key) => `--var ${shellArgument(`${key}=${variables[key]}`)}`).join(' ');
565
+ }
566
+
567
+ function shellArgument(value: string): string {
568
+ return /^[A-Za-z0-9_./:=+-]+$/.test(value)
569
+ ? value
570
+ : `'${value.replaceAll("'", `'"'"'`)}'`;
571
+ }
572
+
573
+ function orderedVariables(
574
+ variables: Record<string, string>,
575
+ order: string[],
576
+ ): Record<string, string> {
577
+ return Object.fromEntries(order.flatMap((key) =>
578
+ variables[key] === undefined ? [] : [[key, variables[key]]]));
376
579
  }
377
580
 
378
581
  function addScore(state: DynamicTargetCandidate, amount: number, reason: string): void {
379
582
  state.score += amount;
583
+ addReason(state, reason);
584
+ }
585
+
586
+ function addReason(state: DynamicTargetCandidate, reason: string): void {
380
587
  if (!state.reasons.includes(reason)) state.reasons.push(reason);
381
588
  }
382
589
 
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);
590
+ function reject(state: DynamicTargetCandidate, reason: string): void {
591
+ rejectReasonOnly(state, reason);
592
+ state.viable = false;
593
+ state.rejected = true;
388
594
  }
389
595
 
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(' ');
596
+ function rejectReasonOnly(state: DynamicTargetCandidate, reason: string): void {
597
+ if (!state.rejectedReasons.includes(reason)) state.rejectedReasons.push(reason);
598
+ }
599
+
600
+ function addInferenceBlock(state: DynamicTargetCandidate, reason: string): void {
601
+ addReason(state, reason);
602
+ if (!state.inferenceBlockReasons.includes(reason))
603
+ state.inferenceBlockReasons.push(reason);
604
+ }
605
+
606
+ function requiredSuppliedVariables(inputs: AnalysisInputs): Record<string, string> {
607
+ return Object.fromEntries(inputs.required.flatMap((key) =>
608
+ inputs.supplied[key] === undefined ? [] : [[key, inputs.supplied[key]]]));
609
+ }
610
+
611
+ function signalCode(kind: 'servicePath' | 'operationPath'): string {
612
+ return kind === 'servicePath' ? 'service_path' : 'operation_path';
395
613
  }
396
614
 
397
615
  function record(value: unknown): Record<string, unknown> {
398
- return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
616
+ return value && typeof value === 'object' && !Array.isArray(value)
617
+ ? value as Record<string, unknown>
618
+ : {};
619
+ }
620
+
621
+ function stringRecord(value: unknown): Record<string, string> {
622
+ const entries = Object.entries(record(value))
623
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
624
+ .sort(([left], [right]) => left.localeCompare(right));
625
+ return Object.fromEntries(entries);
399
626
  }
400
627
 
401
628
  function stringValue(value: unknown): string | undefined {
@@ -407,7 +634,9 @@ function numberValue(value: unknown): number | undefined {
407
634
  }
408
635
 
409
636
  function stringArray(value: unknown): string[] {
410
- return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
637
+ return Array.isArray(value)
638
+ ? value.filter((item): item is string => typeof item === 'string')
639
+ : [];
411
640
  }
412
641
 
413
642
  function nonEmptyStrings(value: unknown, fallback: string[]): string[] {
@@ -416,7 +645,8 @@ function nonEmptyStrings(value: unknown, fallback: string[]): string[] {
416
645
  }
417
646
 
418
647
  function isConcrete(value: unknown): value is string {
419
- return typeof value === 'string' && value.length > 0 && extractPlaceholders(value).length === 0;
648
+ return typeof value === 'string' && value.length > 0
649
+ && extractPlaceholders(value).length === 0;
420
650
  }
421
651
 
422
652
  function escapeRegex(value: string): string {