@saptools/service-flow 0.1.52 → 0.1.54

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 (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +11 -12
  3. package/TECHNICAL-NOTE.md +18 -3
  4. package/dist/{chunk-PTLDSHRC.js → chunk-ERIZHM5C.js} +2646 -1067
  5. package/dist/chunk-ERIZHM5C.js.map +1 -0
  6. package/dist/cli.js +162 -13
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/package.json +1 -1
  10. package/src/cli/001-doctor-projection.ts +136 -0
  11. package/src/cli/doctor.ts +20 -3
  12. package/src/db/repositories.ts +10 -2
  13. package/src/linker/000-implementation-candidates.ts +674 -0
  14. package/src/linker/001-implementation-evidence-projection.ts +191 -0
  15. package/src/linker/002-call-evidence.ts +226 -0
  16. package/src/linker/cross-repo-linker.ts +27 -453
  17. package/src/linker/dynamic-edge-resolver.ts +35 -0
  18. package/src/linker/external-http-target.ts +24 -3
  19. package/src/linker/helper-package-linker.ts +18 -2
  20. package/src/linker/service-resolver.ts +45 -2
  21. package/src/output/doctor-output.ts +13 -4
  22. package/src/output/table-output.ts +15 -4
  23. package/src/trace/000-dynamic-target-types.ts +12 -0
  24. package/src/trace/001-dynamic-identity.ts +45 -17
  25. package/src/trace/003-dynamic-references.ts +236 -35
  26. package/src/trace/004-dynamic-candidate-sources.ts +155 -0
  27. package/src/trace/005-implementation-selection.ts +187 -0
  28. package/src/trace/006-contextual-projection.ts +30 -0
  29. package/src/trace/007-implementation-start-diagnostic.ts +61 -0
  30. package/src/trace/008-contextual-runtime-state.ts +294 -0
  31. package/src/trace/009-selected-handler-provenance.ts +186 -0
  32. package/src/trace/dynamic-targets.ts +205 -159
  33. package/src/trace/evidence.ts +132 -34
  34. package/src/trace/implementation-hints.ts +148 -8
  35. package/src/trace/selectors.ts +74 -9
  36. package/src/trace/trace-engine.ts +97 -125
  37. package/src/utils/000-bounded-projection.ts +166 -0
  38. package/dist/chunk-PTLDSHRC.js.map +0 -1
@@ -1,12 +1,20 @@
1
1
  import type { Db } from '../db/connection.js';
2
- import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
2
+ import {
3
+ applyVariables,
4
+ extractPlaceholders,
5
+ matchRuntimeTemplate,
6
+ } from '../linker/dynamic-edge-resolver.js';
7
+ import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
3
8
  import type { OperationTarget } from '../linker/service-resolver.js';
4
9
  import type { DynamicMode } from '../types.js';
10
+ import { dynamicCandidateTargets } from './004-dynamic-candidate-sources.js';
11
+ import { projectBounded } from '../utils/000-bounded-projection.js';
5
12
  import { uniqueIdentityDerivations } from './001-dynamic-identity.js';
6
13
  import {
7
14
  dynamicReferenceProvenance,
8
- dynamicReferenceRows,
15
+ dynamicRoutingContext,
9
16
  type DynamicReferenceRow,
17
+ type DynamicRoutingContext,
10
18
  } from './003-dynamic-references.js';
11
19
  import type {
12
20
  DynamicTargetAnalysis,
@@ -14,15 +22,12 @@ import type {
14
22
  DynamicTemplates,
15
23
  DynamicVariableProvenance,
16
24
  } from './000-dynamic-target-types.js';
17
-
18
25
  export type {
19
26
  DynamicTargetAnalysis,
20
27
  DynamicTargetCandidate,
21
28
  } from './000-dynamic-target-types.js';
22
-
23
29
  type Templates = DynamicTemplates;
24
30
  type VariableProvenance = DynamicVariableProvenance;
25
-
26
31
  interface AnalysisInputs {
27
32
  original: Templates;
28
33
  effective: Templates;
@@ -32,6 +37,7 @@ interface AnalysisInputs {
32
37
  order: string[];
33
38
  callerRepo?: string;
34
39
  callerRepoId?: number;
40
+ routing: DynamicRoutingContext;
35
41
  }
36
42
 
37
43
  export function analyzeDynamicTargetCandidates(
@@ -41,13 +47,17 @@ export function analyzeDynamicTargetCandidates(
41
47
  mode: DynamicMode,
42
48
  maxCandidates: number,
43
49
  ): DynamicTargetAnalysis | undefined {
44
- const inputs = analysisInputs(evidence);
50
+ const inputs = analysisInputs(db, evidence, workspaceId);
45
51
  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,
52
+ const targets = dynamicCandidateTargets(
53
+ db,
54
+ inputs.effective.operationPath,
55
+ inputs.original.operationPath,
56
+ evidence.candidates,
57
+ workspaceId,
58
+ inputs.routing.outboundCallId !== undefined,
49
59
  );
50
- const candidates = buildCandidates(db, targets, references, inputs);
60
+ const candidates = buildCandidates(db, targets, inputs.routing.references, inputs);
51
61
  applyUniqueIdentityEvidence(db, candidates, inputs);
52
62
  finalizeCandidates(candidates, inputs.order);
53
63
  const ranked = stableRank(candidates);
@@ -59,6 +69,7 @@ export function analyzeDynamicTargetCandidates(
59
69
  .map((candidate) => boundedCandidate(candidate, maxCandidates));
60
70
  const shownRejected = rejected.slice(0, maxCandidates)
61
71
  .map((candidate) => boundedCandidate(candidate, maxCandidates));
72
+ const suggestionProjection = suggestedVarSets(viable, inputs.order, maxCandidates);
62
73
  return {
63
74
  mode,
64
75
  maxCandidates,
@@ -77,17 +88,26 @@ export function analyzeDynamicTargetCandidates(
77
88
  candidates: shown,
78
89
  shownCandidates: shown,
79
90
  rejectedCandidates: shownRejected,
80
- suggestedVarSets: suggestedVarSets(viable, inputs.order, maxCandidates),
91
+ suggestedVarSets: suggestionProjection.items,
92
+ suggestedVarSetCount: suggestionProjection.totalCount,
93
+ shownSuggestedVarSetCount: suggestionProjection.shownCount,
94
+ omittedSuggestedVarSetCount: suggestionProjection.omittedCount,
81
95
  inference,
96
+ routingContext: routingEvidence(inputs.routing),
82
97
  };
83
98
  }
84
99
 
85
- function analysisInputs(evidence: Record<string, unknown>): AnalysisInputs {
86
- const original = templatesFromEvidence(evidence, 'original');
87
- const effective = templatesFromEvidence(evidence, 'effective');
100
+ function analysisInputs(
101
+ db: Db,
102
+ evidence: Record<string, unknown>,
103
+ workspaceId: number | undefined,
104
+ ): AnalysisInputs {
105
+ const routing = dynamicRoutingContext(db, workspaceId, evidence);
106
+ const supplied = stringRecord(evidence.suppliedRuntimeVariables);
107
+ const original = templatesFromEvidence(evidence, routing);
108
+ const effective = effectiveTemplates(original, supplied);
88
109
  const requiredSources = placeholderSources(original);
89
110
  const required = Object.keys(requiredSources);
90
- const supplied = stringRecord(evidence.suppliedRuntimeVariables);
91
111
  return {
92
112
  original,
93
113
  effective,
@@ -95,94 +115,12 @@ function analysisInputs(evidence: Record<string, unknown>): AnalysisInputs {
95
115
  requiredSources,
96
116
  supplied,
97
117
  order: variableOrder(original, required),
98
- callerRepo: stringValue(evidence.repo),
99
- callerRepoId: numberValue(evidence.repoId),
118
+ callerRepo: routing.callerRepo ?? stringValue(evidence.repo),
119
+ callerRepoId: routing.callerRepoId ?? numberValue(evidence.repoId),
120
+ routing,
100
121
  };
101
122
  }
102
123
 
103
- function candidateTargets(
104
- db: Db,
105
- evidence: Record<string, unknown>,
106
- workspaceId: number | undefined,
107
- ): OperationTarget[] {
108
- const embedded = rowsFromEvidence(evidence.candidates);
109
- if (embedded.length > 0) return embedded;
110
- const operationPath = effectiveSignal(evidence, 'operationPath');
111
- if (!operationPath || extractPlaceholders(operationPath).length > 0) return [];
112
- return queryOperationTargets(db, operationPath, workspaceId);
113
- }
114
-
115
- function rowsFromEvidence(value: unknown): OperationTarget[] {
116
- if (!Array.isArray(value)) return [];
117
- return value.flatMap((item): OperationTarget[] => {
118
- const row = record(item);
119
- const operationId = numberValue(row.operationId);
120
- const repoName = stringValue(row.repoName);
121
- const servicePath = stringValue(row.servicePath);
122
- const operationPath = stringValue(row.operationPath);
123
- const operationName = stringValue(row.operationName) ?? operationPath?.replace(/^\//, '');
124
- if (operationId === undefined || !repoName || !servicePath || !operationPath || !operationName) return [];
125
- return [{
126
- operationId,
127
- repoId: numberValue(row.repoId),
128
- repoName,
129
- packageName: stringValue(row.packageName),
130
- serviceName: stringValue(row.serviceName) ?? '',
131
- qualifiedName: stringValue(row.qualifiedName) ?? '',
132
- servicePath,
133
- operationPath,
134
- operationName,
135
- sourceFile: stringValue(row.sourceFile) ?? '',
136
- sourceLine: numberValue(row.sourceLine) ?? 0,
137
- score: numberValue(row.score) ?? 0,
138
- reasons: stringArray(row.reasons),
139
- }];
140
- });
141
- }
142
-
143
- function queryOperationTargets(
144
- db: Db,
145
- operationPath: string,
146
- workspaceId: number | undefined,
147
- ): OperationTarget[] {
148
- const simple = operationPath.replace(/^\//, '').split('.').at(-1) ?? operationPath;
149
- const rows = db.prepare(
150
- `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,
151
- s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,
152
- o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,
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
155
- WHERE (? IS NULL OR r.workspace_id=?)
156
- AND (o.operation_path IN (?,?) OR o.operation_name=?)
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,
171
- repoId: numberValue(row.repoId),
172
- repoName,
173
- packageName: stringValue(row.packageName),
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,
181
- score: 0.2,
182
- reasons: ['operation_path_match'],
183
- }];
184
- }
185
-
186
124
  function buildCandidates(
187
125
  db: Db,
188
126
  targets: OperationTarget[],
@@ -194,15 +132,49 @@ function buildCandidates(
194
132
  applyDirectSignal(state, inputs, 'operationPath', target.operationPath, 0.25);
195
133
  applyDirectSignal(state, inputs, 'servicePath', target.servicePath, 0.35);
196
134
  const matchingReferences = references.filter((reference) =>
197
- reference.servicePath === target.servicePath);
198
- applyReferenceSignal(state, inputs, matchingReferences, 'alias');
199
- applyReferenceSignal(state, inputs, matchingReferences, 'destination');
135
+ referenceMatchesCandidate(reference, target.servicePath)
136
+ && referenceMatchesSelectedAlias(reference, inputs.routing.selectedBinding));
137
+ const referencesForSignals = fallbackReferencesForCandidate(
138
+ state, matchingReferences, inputs.routing.fallbackUsed,
139
+ );
140
+ applyReferenceSignal(state, inputs, referencesForSignals, 'alias');
141
+ applyReferenceSignal(state, inputs, referencesForSignals, 'destination');
200
142
  if (hasResolvedImplementation(db, target.operationId))
201
143
  addScore(state, 0.1, 'implementation_edge_resolved');
202
144
  return state;
203
145
  });
204
146
  }
205
147
 
148
+ function fallbackReferencesForCandidate(
149
+ state: DynamicTargetCandidate,
150
+ references: DynamicReferenceRow[],
151
+ fallbackUsed: boolean,
152
+ ): DynamicReferenceRow[] {
153
+ if (!fallbackUsed) return references;
154
+ const unique = uniqueFallbackReferences(references);
155
+ if (unique.length <= 1) return unique;
156
+ addReason(state, 'fallback_reference_ambiguous');
157
+ addInferenceBlock(state, 'fallback_reference_ambiguous');
158
+ return [];
159
+ }
160
+
161
+ function uniqueFallbackReferences(
162
+ references: DynamicReferenceRow[],
163
+ ): DynamicReferenceRow[] {
164
+ const seen = new Set<string>();
165
+ return references.filter((reference) => {
166
+ const signature = [
167
+ reference.sourceKind,
168
+ reference.alias,
169
+ reference.destination,
170
+ reference.servicePath,
171
+ ].join('\0');
172
+ if (seen.has(signature)) return false;
173
+ seen.add(signature);
174
+ return true;
175
+ });
176
+ }
177
+
206
178
  function emptyCandidate(
207
179
  target: OperationTarget,
208
180
  inputs: AnalysisInputs,
@@ -251,7 +223,7 @@ function applyDirectSignal(
251
223
  ): void {
252
224
  const effective = inputs.effective[kind];
253
225
  const original = inputs.original[kind];
254
- if (effective && !matchTemplate(effective, concrete)) {
226
+ if (effective && !matchRuntimeTemplate(effective, concrete)) {
255
227
  reject(state, `${signalCode(kind)}_contradicts_runtime_substitution`);
256
228
  return;
257
229
  }
@@ -259,13 +231,23 @@ function applyDirectSignal(
259
231
  const suppliedKeys = extractPlaceholders(original)
260
232
  .filter((key) => inputs.supplied[key] !== undefined);
261
233
  state.explicitSignalStrength += suppliedKeys.length;
262
- const matched = matchTemplate(original, concrete) ?? {};
234
+ const matched = matchRuntimeTemplate(original, concrete) ?? {};
235
+ const fromSelectedBinding = kind === 'servicePath'
236
+ && inputs.routing.selectedBinding !== undefined;
263
237
  for (const [key, value] of Object.entries(matched)) {
264
238
  addDerivation(state, key, value, {
265
- sourceKind: `${signalCode(kind)}_template`,
239
+ sourceKind: fromSelectedBinding
240
+ ? `selected_binding.${signalCode(kind)}_template`
241
+ : `${signalCode(kind)}_template`,
266
242
  value,
267
- rule: 'exact_template_match',
243
+ rule: fromSelectedBinding
244
+ ? 'exact_selected_binding_template_match'
245
+ : 'exact_template_match',
268
246
  template: original,
247
+ sourceRepo: fromSelectedBinding ? inputs.routing.selectedBinding?.repoName : undefined,
248
+ sourceFile: fromSelectedBinding ? inputs.routing.selectedBinding?.sourceFile : undefined,
249
+ sourceLine: fromSelectedBinding ? inputs.routing.selectedBinding?.sourceLine : undefined,
250
+ selection: fromSelectedBinding ? 'selected_binding' : 'call_evidence',
269
251
  });
270
252
  }
271
253
  addScore(state, score, `${signalCode(kind)}_template_match`);
@@ -290,7 +272,7 @@ function applyReferenceSignal(
290
272
  }
291
273
  let matchedSignal = false;
292
274
  for (const { reference, concrete } of values) {
293
- const matched = matchTemplate(original, concrete);
275
+ const matched = matchRuntimeTemplate(original, concrete);
294
276
  if (!matched) continue;
295
277
  matchedSignal = true;
296
278
  for (const [key, value] of Object.entries(matched)) {
@@ -406,6 +388,8 @@ function inferenceDecision(candidates: DynamicTargetCandidate[]): Record<string,
406
388
  const second = viable[1];
407
389
  if (!first || first.missingVariables.length > 0)
408
390
  return { status: 'unresolved', reason: 'missing_required_runtime_variable' };
391
+ if (first.inferenceBlockReasons.length > 0)
392
+ return { status: 'unresolved', reason: first.inferenceBlockReasons[0] };
409
393
  if (first.score < 0.85)
410
394
  return { status: 'unresolved', reason: 'candidate_score_below_inference_threshold' };
411
395
  const scoreGap = second
@@ -432,16 +416,61 @@ function boundedCandidate(
432
416
  candidate: DynamicTargetCandidate,
433
417
  limit: number,
434
418
  ): DynamicTargetCandidate {
435
- const derivationProvenance = Object.fromEntries(
419
+ const provenanceProjections = Object.fromEntries(
436
420
  Object.entries(candidate.derivationProvenance)
437
421
  .sort(([left], [right]) => left.localeCompare(right))
438
- .map(([key, rows]) => [key, rows.slice(0, limit)]),
422
+ .map(([key, rows]) => [key, projectBounded(rows, compareProvenance, limit)]),
439
423
  );
440
- const conflicts = candidate.conflicts.slice(0, limit).map((conflict) => ({
424
+ const derivationProvenance = Object.fromEntries(Object.entries(provenanceProjections)
425
+ .map(([key, projection]) => [key, projection.items]));
426
+ const derivationProvenanceCounts = Object.fromEntries(Object.entries(provenanceProjections)
427
+ .map(([key, projection]) => [key, {
428
+ provenanceCount: projection.totalCount,
429
+ shownProvenanceCount: projection.shownCount,
430
+ omittedProvenanceCount: projection.omittedCount,
431
+ }]));
432
+ const conflicts = projectBounded(candidate.conflicts, compareConflict, limit);
433
+ return {
434
+ ...candidate,
435
+ derivationProvenance,
436
+ derivationProvenanceCounts,
437
+ conflicts: conflicts.items.map(boundedConflict),
438
+ conflictCount: conflicts.totalCount,
439
+ shownConflictCount: conflicts.shownCount,
440
+ omittedConflictCount: conflicts.omittedCount,
441
+ };
442
+ }
443
+
444
+ function compareProvenance(
445
+ left: DynamicVariableProvenance,
446
+ right: DynamicVariableProvenance,
447
+ ): number {
448
+ return left.sourceKind.localeCompare(right.sourceKind)
449
+ || String(left.matchedName ?? '').localeCompare(String(right.matchedName ?? ''))
450
+ || left.value.localeCompare(right.value);
451
+ }
452
+
453
+ function compareConflict(
454
+ left: DynamicTargetCandidate['conflicts'][number],
455
+ right: DynamicTargetCandidate['conflicts'][number],
456
+ ): number {
457
+ return left.key.localeCompare(right.key)
458
+ || left.reason.localeCompare(right.reason)
459
+ || left.values.join('\0').localeCompare(right.values.join('\0'));
460
+ }
461
+
462
+ function boundedConflict(
463
+ conflict: DynamicTargetCandidate['conflicts'][number],
464
+ ): DynamicTargetCandidate['conflicts'][number] & Record<string, unknown> {
465
+ const sources = projectBounded(conflict.sources, (left, right) =>
466
+ left.localeCompare(right));
467
+ return {
441
468
  ...conflict,
442
- sources: conflict.sources.slice(0, limit),
443
- }));
444
- return { ...candidate, derivationProvenance, conflicts };
469
+ sources: sources.items,
470
+ sourceCount: sources.totalCount,
471
+ shownSourceCount: sources.shownCount,
472
+ omittedSourceCount: sources.omittedCount,
473
+ };
445
474
  }
446
475
 
447
476
  function applyModeState(
@@ -462,7 +491,7 @@ function suggestedVarSets(
462
491
  candidates: DynamicTargetCandidate[],
463
492
  order: string[],
464
493
  limit: number,
465
- ): Array<{ variables: Record<string, string>; cli: string }> {
494
+ ): ReturnType<typeof projectBounded<{ variables: Record<string, string>; cli: string }>> {
466
495
  const seen = new Set<string>();
467
496
  const rows: Array<{ variables: Record<string, string>; cli: string }> = [];
468
497
  for (const candidate of candidates) {
@@ -470,9 +499,8 @@ function suggestedVarSets(
470
499
  if (seen.has(candidate.cli)) continue;
471
500
  seen.add(candidate.cli);
472
501
  rows.push({ variables: orderedVariables(candidate.completeVariables, order), cli: candidate.cli });
473
- if (rows.length >= limit) break;
474
502
  }
475
- return rows;
503
+ return projectBounded(rows, (left, right) => left.cli.localeCompare(right.cli), limit);
476
504
  }
477
505
 
478
506
  function hasResolvedImplementation(db: Db, operationId: number): boolean {
@@ -480,17 +508,30 @@ function hasResolvedImplementation(db: Db, operationId: number): boolean {
480
508
  "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
509
  ).get(String(operationId)));
482
510
  }
483
-
484
511
  function templatesFromEvidence(
485
512
  evidence: Record<string, unknown>,
486
- phase: 'original' | 'effective',
513
+ routing: DynamicRoutingContext,
487
514
  ): Templates {
515
+ const selected = routing.selectedBinding;
488
516
  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),
517
+ servicePath: selected?.servicePath ?? substitutionSignal(evidence, 'servicePath', 'original'),
518
+ operationPath: substitutionSignal(evidence, 'operationPath', 'original'),
519
+ alias: selected?.aliasExpr ?? selected?.alias ?? substitutionSignal(evidence,
520
+ evidence.serviceAliasExpr !== undefined ? 'serviceAliasExpr' : 'serviceAlias', 'original'),
521
+ destination: selected?.destination ?? substitutionSignal(evidence, 'destination', 'original'),
522
+ };
523
+ }
524
+ function effectiveTemplates(
525
+ templates: Templates,
526
+ supplied: Record<string, string>,
527
+ ): Templates {
528
+ const operationPath = applyVariables(templates.operationPath, supplied);
529
+ return {
530
+ servicePath: applyVariables(templates.servicePath, supplied),
531
+ operationPath: normalizeODataOperationInvocationPath(operationPath)?.normalizedOperationPath
532
+ ?? operationPath,
533
+ alias: applyVariables(templates.alias, supplied),
534
+ destination: applyVariables(templates.destination, supplied),
494
535
  };
495
536
  }
496
537
 
@@ -503,10 +544,6 @@ function substitutionSignal(
503
544
  return stringValue(substitution[phase]) ?? stringValue(evidence[key]);
504
545
  }
505
546
 
506
- function effectiveSignal(evidence: Record<string, unknown>, key: string): string | undefined {
507
- return substitutionSignal(evidence, key, 'effective');
508
- }
509
-
510
547
  function placeholderSources(templates: Templates): Record<string, string[]> {
511
548
  const sources: Record<string, string[]> = {};
512
549
  for (const [kind, template] of Object.entries(templates)) {
@@ -528,35 +565,20 @@ function variableOrder(templates: Templates, required: string[]): string[] {
528
565
  return [...new Set([...ordered, ...required])];
529
566
  }
530
567
 
531
- function matchTemplate(
532
- template: string | undefined,
533
- concrete: string | undefined,
534
- ): Record<string, string> | undefined {
535
- if (!template || !concrete) return undefined;
536
- const keys = extractPlaceholders(template);
537
- if (keys.length === 0) return template === concrete ? {} : undefined;
538
- const match = new RegExp(`^${templateToPattern(template)}$`).exec(concrete);
539
- if (!match) return undefined;
540
- const values: Record<string, string> = {};
541
- for (let index = 0; index < keys.length; index += 1) {
542
- const key = keys[index];
543
- const value = match[index + 1];
544
- if (!key || value === undefined) return undefined;
545
- if (values[key] !== undefined && values[key] !== value) return undefined;
546
- values[key] = value;
547
- }
548
- return values;
568
+ function referenceMatchesCandidate(
569
+ reference: DynamicReferenceRow,
570
+ servicePath: string,
571
+ ): boolean {
572
+ return matchRuntimeTemplate(reference.servicePath, servicePath) !== undefined;
549
573
  }
550
574
 
551
- function templateToPattern(template: string): string {
552
- let pattern = '';
553
- let lastIndex = 0;
554
- for (const match of template.matchAll(/\$\{([^}]*)\}/g)) {
555
- pattern += escapeRegex(template.slice(lastIndex, match.index));
556
- pattern += '([^/]+?)';
557
- lastIndex = (match.index ?? 0) + match[0].length;
558
- }
559
- return `${pattern}${escapeRegex(template.slice(lastIndex))}`;
575
+ function referenceMatchesSelectedAlias(
576
+ reference: DynamicReferenceRow,
577
+ selected: DynamicRoutingContext['selectedBinding'],
578
+ ): boolean {
579
+ if (reference.selection !== 'selected_binding_require') return true;
580
+ const template = selected?.aliasExpr ?? selected?.alias;
581
+ return matchRuntimeTemplate(template, reference.alias) !== undefined;
560
582
  }
561
583
 
562
584
  function cliFor(variables: Record<string, string>, order: string[]): string {
@@ -608,6 +630,34 @@ function requiredSuppliedVariables(inputs: AnalysisInputs): Record<string, strin
608
630
  inputs.supplied[key] === undefined ? [] : [[key, inputs.supplied[key]]]));
609
631
  }
610
632
 
633
+ function routingEvidence(routing: DynamicRoutingContext): Record<string, unknown> {
634
+ const binding = routing.selectedBinding;
635
+ return {
636
+ outboundCallId: routing.outboundCallId,
637
+ callerRepoId: routing.callerRepoId,
638
+ callerRepo: routing.callerRepo,
639
+ selectedBindingId: routing.selectedBindingId,
640
+ bindingResolutionStatus: routing.bindingResolutionStatus,
641
+ selectedBinding: binding ? {
642
+ bindingId: binding.bindingId,
643
+ alias: binding.alias,
644
+ aliasExpr: binding.aliasExpr,
645
+ destination: binding.destination,
646
+ destinationExpr: binding.destination,
647
+ servicePath: binding.servicePath,
648
+ servicePathExpr: binding.servicePath,
649
+ sourceFile: binding.sourceFile,
650
+ sourceLine: binding.sourceLine,
651
+ helperChain: binding.helperChain,
652
+ } : undefined,
653
+ bindingAlternativeCount: routing.bindingAlternativeCount,
654
+ shownBindingAlternativeCount: routing.shownBindingAlternativeCount,
655
+ omittedBindingAlternativeCount: routing.omittedBindingAlternativeCount,
656
+ bindingAlternatives: routing.bindingAlternatives,
657
+ fallbackUsed: routing.fallbackUsed,
658
+ };
659
+ }
660
+
611
661
  function signalCode(kind: 'servicePath' | 'operationPath'): string {
612
662
  return kind === 'servicePath' ? 'service_path' : 'operation_path';
613
663
  }
@@ -648,7 +698,3 @@ function isConcrete(value: unknown): value is string {
648
698
  return typeof value === 'string' && value.length > 0
649
699
  && extractPlaceholders(value).length === 0;
650
700
  }
651
-
652
- function escapeRegex(value: string): string {
653
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
654
- }