@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,8 +1,6 @@
1
1
  import type { Db } from '../db/connection.js';
2
2
  import { reposByName } from '../db/repositories.js';
3
3
  import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
4
- import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
5
- import { resolveOperation } from '../linker/service-resolver.js';
6
4
  import type { ImplementationHint, TraceEdge, TraceOptions, TraceResult, TraceStart } from '../types.js';
7
5
  import { baseTraceEvidence, edgeTarget, runtimeNoCandidateDiagnostics, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
8
6
  import { dynamicCandidateBranches } from './dynamic-branches.js';
@@ -10,7 +8,22 @@ import {
10
8
  loadTraceDiagnostics,
11
9
  prependTraceDiagnostic,
12
10
  } from './002-trace-diagnostics.js';
13
- import { implementationHintDiagnostic, selectImplementation, type ImplementationSelection } from './implementation-hints.js';
11
+ import { implementationHintDiagnostic } from './implementation-hints.js';
12
+ import {
13
+ contextualImplementationSelection,
14
+ hintedImplementationSelection,
15
+ } from './005-implementation-selection.js';
16
+ import { implementationStartDiagnostic } from './007-implementation-start-diagnostic.js';
17
+ import {
18
+ contextualRuntimeResolution,
19
+ type ContextBinding,
20
+ } from './008-contextual-runtime-state.js';
21
+ import {
22
+ handlerMethodNode,
23
+ withSelectedHandlerProvenance,
24
+ type SelectedHandlerEvidence,
25
+ } from './009-selected-handler-provenance.js';
26
+ import { boundCandidateLikeEvidence } from '../utils/000-bounded-projection.js';
14
27
  import {
15
28
  ambiguousStartDiagnostic,
16
29
  selectorNotFoundDiagnostic,
@@ -53,32 +66,6 @@ interface GraphRow extends Record<string, unknown> {
53
66
  unresolved_reason?: string;
54
67
  status?: string;
55
68
  }
56
- interface ContextBinding {
57
- bindingId?: number;
58
- alias?: string;
59
- aliasExpr?: string;
60
- destinationExpr?: string;
61
- servicePathExpr?: string;
62
- requireServicePath?: string;
63
- requireDestination?: string;
64
- effectiveServicePath?: string;
65
- effectiveDestination?: string;
66
- sourceFile?: string;
67
- sourceLine?: number;
68
- source: string;
69
- callerArgument?: string;
70
- callerProperty?: string;
71
- calleeParameter?: string;
72
- calleeObjectProperty?: string;
73
- calleeLocalDestructuredIdentifier?: string;
74
- parameterPropertyAliasKind?: unknown;
75
- parameterPropertyAliasLine?: unknown;
76
- calleeReceiver: string;
77
- callerSite?: { sourceFile?: string; sourceLine?: number };
78
- calleeSite?: { sourceFile?: string; sourceLine?: number };
79
- resolutionStatus?: 'selected' | 'ambiguous';
80
- bindingCandidates?: Array<Record<string, unknown>>;
81
- }
82
69
  interface ImplementationHintOptions {
83
70
  implementationRepo?: string;
84
71
  implementationHints?: ImplementationHint[];
@@ -110,31 +97,21 @@ function operationStartScope(db: Db, repoId: number | undefined, start: TraceSta
110
97
  const operationId = String(rows[0]?.operationId);
111
98
  const impl = implementationScope(db, operationId);
112
99
  if (impl.edge?.status === 'resolved' && impl.files.size > 0) return { files: impl.files, symbols: impl.symbolId ? new Set([impl.symbolId]) : undefined, repoId: impl.repoId, operationId, diagnostics: [] };
113
- const hinted = implementationMethodIdFromHint(impl.edge, hintOptions);
100
+ const hinted = hintedImplementationSelection(
101
+ db, impl.edge, operationId, hintOptions,
102
+ );
114
103
  if (hinted.methodId) {
115
104
  const hintedScope = handlerScope(db, hinted.methodId);
116
105
  if (hintedScope?.files.size) return { files: hintedScope.files, symbols: hintedScope.symbolId ? new Set([hintedScope.symbolId]) : undefined, repoId: hintedScope.repoId, operationId, diagnostics: [] };
117
106
  }
118
107
  if (impl.edge) {
119
108
  const evidence = parseEvidence(impl.edge.evidence_json);
120
- const hintDiagnostic = implementationHintDiagnostic(hinted, evidence.implementationHintSuggestions);
121
- const diagnostics = [{ severity: 'warning', code: impl.edge.status === 'ambiguous' ? 'trace_start_ambiguous' : 'trace_start_implementation_unresolved', message: `Indexed operation matched but implementation edge is ${String(impl.edge.status ?? 'unresolved')}`, resolutionStage: 'implementation', resolutionStatus: impl.edge.status === 'ambiguous' ? 'ambiguous_implementation' : 'rejected_implementation', implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, implementationAmbiguityReasons: evidence.ambiguityReasons, implementationRejectionReasons: implementationRejectionReasons(evidence), implementationHintSuggestions: evidence.implementationHintSuggestions, candidates: evidence.candidates }];
109
+ const hintDiagnostic = implementationHintDiagnostic(hinted, evidence);
110
+ const diagnostics = [implementationStartDiagnostic(impl.edge, evidence)];
122
111
  return { operationId, diagnostics: hintDiagnostic ? [hintDiagnostic, ...diagnostics] : diagnostics };
123
112
  }
124
113
  return { operationId, diagnostics: [{ severity: 'warning', code: 'trace_start_implementation_unresolved', message: 'Indexed operation matched but no implementation candidate exists', resolutionStage: 'implementation', resolutionStatus: 'operation_without_implementation' }] };
125
114
  }
126
- function implementationRejectionReasons(evidence: Record<string, unknown>): string[] {
127
- const candidates = Array.isArray(evidence.candidates)
128
- ? evidence.candidates.filter((item): item is Record<string, unknown> =>
129
- Boolean(item && typeof item === 'object' && !Array.isArray(item)))
130
- : [];
131
- const reasons = candidates.flatMap((candidate) =>
132
- Array.isArray(candidate.rejectedReasons)
133
- ? candidate.rejectedReasons.filter((reason): reason is string =>
134
- typeof reason === 'string')
135
- : []);
136
- return [...new Set(reasons)].sort();
137
- }
138
115
  function sourceFilesForStart(
139
116
  db: Db,
140
117
  repoId: number | undefined,
@@ -222,11 +199,6 @@ function handlerFilesForOperation(db: Db, operationId: string): Set<string> {
222
199
  function implementationEdge(db: Db, operationId: string): GraphRow | undefined {
223
200
  return db.prepare("SELECT * FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND from_kind='operation' AND from_id=? ORDER BY CASE status WHEN 'resolved' THEN 0 WHEN 'ambiguous' THEN 1 ELSE 2 END,id LIMIT 1").get(operationId) as GraphRow | undefined;
224
201
  }
225
- function handlerMethodNode(db: Db, methodId: string): Record<string, unknown> | undefined {
226
- const row = db.prepare(`SELECT hm.id methodId,hm.method_name methodName,hm.decorator_value decoratorValue,hm.source_line sourceLine,hc.class_name className,hc.source_file sourceFile,r.name repoName,r.id repoId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id JOIN repositories r ON r.id=hc.repo_id WHERE hm.id=?`).get(methodId) as Record<string, unknown> | undefined;
227
- if (!row) return undefined;
228
- return { id: `handler_method:${methodId}`, kind: 'handler_method', label: `${String(row.repoName)}:${String(row.className)}.${String(row.methodName)}`, ...row };
229
- }
230
202
  function implementationScope(db: Db, operationId: string): { repoId?: number; files: Set<string>; symbolId?: number; edge?: GraphRow } {
231
203
  const edge = implementationEdge(db, operationId);
232
204
  if (!edge || edge.status !== 'resolved') return { files: new Set(), edge };
@@ -235,29 +207,6 @@ function implementationScope(db: Db, operationId: string): { repoId?: number; fi
235
207
  return { repoId: row?.repoId, files: new Set(), edge };
236
208
  return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), symbolId: row?.symbolId, edge };
237
209
  }
238
- function implementationMethodIdFromHint(edge: GraphRow | undefined, options: ImplementationHintOptions): ImplementationSelection {
239
- if (!edge || edge.status !== 'ambiguous') return { blocksAutomatic: false, evidence: { status: 'not_applicable' } };
240
- return selectImplementation(parseEvidence(edge.evidence_json), options.implementationHints, options.implementationRepo);
241
- }
242
- function contextImplementationMethodId(edge: GraphRow | undefined, callerRepoId: number | undefined, remoteEvidence: Record<string, unknown>, hintOptions: ImplementationHintOptions): ImplementationSelection {
243
- const hinted = implementationMethodIdFromHint(edge, hintOptions);
244
- if (hinted.methodId) return hinted;
245
- if (hinted.blocksAutomatic) return hinted;
246
- if (!edge || edge.status !== 'ambiguous' || callerRepoId === undefined) return hinted;
247
- const evidence = JSON.parse(String(edge.evidence_json || '{}')) as { candidates?: Array<{ accepted?: boolean; methodId?: number; handlerPackage?: { id?: number; name?: string }; applicationPackage?: { id?: number; name?: string }; reasons?: string[]; score?: number }> };
248
- const scores = (evidence.candidates ?? []).filter((item) => item.accepted).map((item) => {
249
- const reasons: string[] = [];
250
- let score = Number(item.score ?? 0);
251
- if (Number(item.handlerPackage?.id) === callerRepoId) { score += 10; reasons.push('handler_package_matches_caller_repository'); }
252
- if (Number(item.applicationPackage?.id) === callerRepoId) { score += 10; reasons.push('registration_package_matches_caller_repository'); }
253
- if (typeof remoteEvidence.effectiveServicePath === 'string' || typeof remoteEvidence.effectiveDestination === 'string' || typeof remoteEvidence.effectiveAlias === 'string') { score += 1; reasons.push('remote_call_context_available'); }
254
- return { methodId: item.methodId, score, reasons, handlerPackage: item.handlerPackage, applicationPackage: item.applicationPackage };
255
- }).sort((a, b) => b.score - a.score);
256
- if (scores.length === 0) return { blocksAutomatic: false, evidence: { status: 'not_applicable', candidateScores: [] } };
257
- const [first, second] = scores;
258
- if (first && first.methodId !== undefined && first.score > 0 && (!second || first.score > second.score)) return { methodId: String(first.methodId), blocksAutomatic: false, evidence: { status: 'selected', selectedMethodId: first.methodId, candidateScores: scores } };
259
- return { blocksAutomatic: false, evidence: hinted.evidence.reason === 'no_scoped_hint_matched_edge' ? hinted.evidence : { status: 'tied', tieReason: scores.length > 1 ? 'duplicate_helper_implementation_candidates' : 'no_unique_materially_stronger_candidate', candidateScores: scores } };
260
- }
261
210
  function handlerScope(db: Db, methodId: string): { repoId?: number; files: Set<string>; symbolId?: number } | undefined {
262
211
  const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.qualified_name=hc.class_name || '.' || hm.method_name AND s.start_line=hm.source_line WHERE hm.id=?").get(methodId) as { repoId?: number; sourceFile?: string; symbolId?: number } | undefined;
263
212
  if (!row || typeof row.symbolId !== 'number') return undefined;
@@ -317,11 +266,15 @@ function workspaceIdForCall(db: Db, callId: string): number | undefined {
317
266
  function parseEvidence(value: unknown): Record<string, unknown> {
318
267
  try {
319
268
  const parsed = JSON.parse(String(value || '{}')) as unknown;
320
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
269
+ return isEvidenceRecord(parsed) ? boundCandidateLikeEvidence(parsed) : {};
321
270
  } catch {
322
271
  return {};
323
272
  }
324
273
  }
274
+
275
+ function isEvidenceRecord(value: unknown): value is Record<string, unknown> {
276
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
277
+ }
325
278
  function receiverFromEvidence(value: unknown): string | undefined {
326
279
  const evidence = parseEvidence(value);
327
280
  return typeof evidence.receiver === 'string' ? evidence.receiver : undefined;
@@ -458,35 +411,6 @@ function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, calle
458
411
  });
459
412
  return next;
460
413
  }
461
- function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBinding | undefined, workspaceId: number | undefined, persistedRows: GraphRow[] = []): { row?: GraphRow; evidence?: Record<string, unknown>; unresolvedReason?: string } {
462
- if (!binding || String(call.call_type) !== 'remote_action' || call.operation_path_expr === undefined || call.operation_path_expr === null) return {};
463
- if (binding.resolutionStatus === 'ambiguous') {
464
- return {
465
- evidence: {
466
- contextualServiceBindingAttempted: true,
467
- contextualBinding: {
468
- source: binding.source,
469
- status: 'tied',
470
- candidates: binding.bindingCandidates,
471
- },
472
- contextualResolutionStatus: 'ambiguous',
473
- contextualCandidateCount: binding.bindingCandidates?.length ?? 0,
474
- },
475
- unresolvedReason: 'Ambiguous contextual service binding candidates',
476
- };
477
- }
478
- const normalized = normalizeODataOperationInvocationPath(String(call.operation_path_expr));
479
- const op = normalized?.normalizedOperationPath ?? (String(call.operation_path_expr).startsWith('/') ? String(call.operation_path_expr) : `/${String(call.operation_path_expr)}`);
480
- const servicePath = binding.effectiveServicePath ?? binding.servicePathExpr ?? binding.requireServicePath;
481
- const destination = binding.effectiveDestination ?? binding.destinationExpr ?? binding.requireDestination;
482
- const resolution = resolveOperation(db, { servicePath, operationPath: op, alias: binding.aliasExpr ?? binding.alias, destination, hasExplicitOverride: true, isDynamic: false }, workspaceId);
483
- const evidence: Record<string, unknown> = { contextualServiceBindingAttempted: true, contextualBinding: { source: binding.source, callerArgument: binding.callerArgument, callerProperty: binding.callerProperty, calleeParameter: binding.calleeParameter, calleeReceiver: binding.calleeReceiver, callerSite: binding.callerSite, calleeSite: binding.calleeSite, bindingSourceFile: binding.sourceFile, bindingSourceLine: binding.sourceLine, alias: binding.alias, aliasExpr: binding.aliasExpr, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination }, operationPath: op, rawOperationPath: normalized?.rawOperationPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, servicePath, serviceAlias: binding.alias, serviceAliasExpr: binding.aliasExpr, destination, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination, contextualResolutionStatus: resolution.status, contextualCandidateCount: resolution.candidates.length, candidates: resolution.candidates, contextualResolutionReasons: resolution.reasons, resolutionReasons: resolution.reasons };
484
- if (!resolution.target) return { evidence, unresolvedReason: resolution.status === 'ambiguous' ? 'Ambiguous contextual operation candidates' : resolution.status === 'dynamic' ? `Dynamic contextual target is missing runtime variables: ${resolution.reasons.join(', ')}` : 'No contextual operation candidate matched' };
485
- const resolvedEvidence = { ...evidence, contextualServiceBindingSelected: true, targetRepo: resolution.target.repoName, targetServicePath: resolution.target.servicePath, targetOperationPath: resolution.target.operationPath, targetOperation: resolution.target.operationName };
486
- const persistedResolved = persistedRows.find((item) => item.status === 'resolved');
487
- if (persistedResolved) return { row: undefined, evidence: { ...resolvedEvidence, contextualPreservedPersistedResolvedEdge: true }, unresolvedReason: undefined };
488
- return { row: { id: -Number(call.id), edge_type: 'REMOTE_CALL_RESOLVES_TO_OPERATION', from_id: String(call.id), to_kind: 'operation', to_id: String(resolution.target.operationId), confidence: resolution.target.score, evidence_json: JSON.stringify(resolvedEvidence), status: 'resolved' }, evidence: resolvedEvidence, unresolvedReason: undefined };
489
- }
490
414
  export function trace(
491
415
  db: Db,
492
416
  start: TraceStart,
@@ -524,14 +448,32 @@ export function trace(
524
448
  const op = operationNode(db, scope.startOperationId);
525
449
  const impl = implementationScope(db, scope.startOperationId);
526
450
  if (op) nodes.set(String(op.id), op);
527
- const startSelection = implementationMethodIdFromHint(impl.edge, hintOptions);
451
+ const startSelection = hintedImplementationSelection(
452
+ db, impl.edge, scope.startOperationId, hintOptions,
453
+ );
528
454
  if (impl.edge && (impl.edge.status === 'resolved' || startSelection.methodId)) {
529
455
  const selectedMethodId = impl.edge.status === 'resolved' ? impl.edge.to_id : startSelection.methodId;
530
- const implEvidence = { ...parseEvidence(impl.edge.evidence_json), startResolution: { strategy: 'indexed_operation_graph', matchedOperationId: scope.startOperationId, implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, selectedHandlerMethodId: selectedMethodId }, implementationSelection: startSelection.methodId ? startSelection.evidence : undefined };
531
- const handlerNode = selectedMethodId ? handlerMethodNode(db, selectedMethodId) : undefined;
532
- if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
456
+ const implEvidence = {
457
+ ...parseEvidence(impl.edge.evidence_json),
458
+ startResolution: {
459
+ strategy: 'indexed_operation_graph',
460
+ matchedOperationId: scope.startOperationId,
461
+ implementationEdgeId: impl.edge.id,
462
+ implementationStatus: impl.edge.status,
463
+ selectedHandlerMethodId: selectedMethodId,
464
+ },
465
+ implementationSelection: startSelection.methodId
466
+ ? startSelection.evidence : undefined,
467
+ };
468
+ const selected: SelectedHandlerEvidence = selectedMethodId
469
+ ? withSelectedHandlerProvenance(
470
+ implEvidence, selectedMethodId, handlerMethodNode(db, selectedMethodId),
471
+ )
472
+ : { evidence: implEvidence };
473
+ if (selected.diagnostic) prependTraceDiagnostic(diagnostics, selected.diagnostic);
474
+ if (selected.handler) nodes.set(String(selected.handler.id), selected.handler);
533
475
  seenEdges.add(Number(impl.edge.id));
534
- edges.push({ step: 1, type: 'operation_implemented_by_handler', from: op?.label ? String(op.label) : `operation:${scope.startOperationId}`, to: handlerNode?.label ? String(handlerNode.label) : `${impl.edge.to_kind}:${impl.edge.to_id}`, evidence: implEvidence, confidence: Number(impl.edge.confidence ?? 0), unresolvedReason: impl.edge.status === 'resolved' || startSelection.methodId ? undefined : String(impl.edge.unresolved_reason ?? impl.edge.status) });
476
+ edges.push({ step: 1, type: 'operation_implemented_by_handler', from: op?.label ? String(op.label) : `operation:${scope.startOperationId}`, to: selected.handler?.label ? String(selected.handler.label) : `${impl.edge.to_kind}:${impl.edge.to_id}`, evidence: selected.evidence, confidence: Number(impl.edge.confidence ?? 0), unresolvedReason: selected.unresolvedReason ?? (impl.edge.status === 'resolved' || startSelection.methodId ? undefined : String(impl.edge.unresolved_reason ?? impl.edge.status)) });
535
477
  }
536
478
  }
537
479
  const seenScopes = new Set<string>();
@@ -564,7 +506,7 @@ export function trace(
564
506
  const nextKey = `${nextRepoId}:${[...nextSymbols].join(',')}:${[...nextFiles].join(',')}`;
565
507
  const calleeNode = symbolNode(db, Number(symbolCall.callee_symbol_id));
566
508
  if (calleeNode) nodes.set(String(calleeNode.id), calleeNode);
567
- const evidence = { ...(JSON.parse(String(symbolCall.evidence_json || '{}')) as Record<string, unknown>), sourceFile: symbolCall.source_file, sourceLine: symbolCall.source_line, calleeSymbolId: symbolCall.callee_symbol_id, calleeSymbolName: calleeNode?.symbolName, calleeSymbolFile: calleeNode?.sourceFile, resolutionStatus: symbolCall.status };
509
+ const evidence = { ...parseEvidence(symbolCall.evidence_json), sourceFile: symbolCall.source_file, sourceLine: symbolCall.source_line, calleeSymbolId: symbolCall.callee_symbol_id, calleeSymbolName: calleeNode?.symbolName, calleeSymbolFile: calleeNode?.sourceFile, resolutionStatus: symbolCall.status };
568
510
  edges.push({ step: current.depth, type: 'local_symbol_call', from: String(symbolCall.callee_expression), to: calleeNode?.label ? String(calleeNode.label) : `symbol:${String(symbolCall.callee_symbol_id)}`, evidence, confidence: Number(symbolCall.confidence ?? 0.8), unresolvedReason: String(symbolCall.status) === 'resolved' ? undefined : symbolCall.unresolved_reason ? String(symbolCall.unresolved_reason) : undefined });
569
511
  if (seenScopes.has(nextKey)) edges.push({ step: current.depth, type: 'cycle', from: String(symbolCall.callee_expression), to: nextKey, evidence: { cycle: true, symbolCallId: symbolCall.id }, confidence: 1, unresolvedReason: 'Cycle detected; downstream symbol already visited' });
570
512
  else queue.push({ repoId: nextRepoId, files: nextFiles, symbolIds: nextSymbols, depth: current.depth + 1, context: contextForSymbolCall(db, symbolCall, callerBindings) });
@@ -590,15 +532,13 @@ export function trace(
590
532
  for (const row of graphRows) {
591
533
  if (seenEdges.has(Number(row.id))) continue;
592
534
  seenEdges.add(Number(row.id));
593
- const persistedEvidence = JSON.parse(
594
- String(row.evidence_json || '{}'),
595
- ) as Record<string, unknown>;
535
+ const persistedEvidence = parseEvidence(row.evidence_json);
596
536
  const rawEvidence = baseTraceEvidence(row as TraceGraphRow, call, persistedEvidence, contextual.evidence);
597
537
  const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, {
598
538
  vars: options.vars,
599
539
  dynamicMode: options.dynamicMode ?? 'strict',
600
540
  maxDynamicCandidates: options.maxDynamicCandidates,
601
- }, workspaceIdForCall(db, String(call.id)), contextual.unresolvedReason);
541
+ }, workspaceIdForCall(db, String(call.id)), contextual.state);
602
542
  const evidence = effective.evidence;
603
543
  const effectiveRow = effective.row;
604
544
  const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
@@ -618,37 +558,69 @@ export function trace(
618
558
  confidence: Number(effectiveRow.confidence ?? call.confidence),
619
559
  unresolvedReason: effective.unresolvedReason,
620
560
  });
621
- if ((options.dynamicMode ?? 'strict') === 'candidates')
561
+ if ((options.dynamicMode ?? 'strict') === 'candidates'
562
+ && effectiveRow.status !== 'resolved')
622
563
  edges.push(...dynamicCandidateBranches(current.depth, call, evidence));
623
564
  if (effectiveRow.to_kind === 'operation') {
624
565
  const implementation = implementationScope(db, effectiveRow.to_id);
625
- const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId, evidence, hintOptions);
566
+ const contextSelection = contextualImplementationSelection(
567
+ db, implementation.edge, effectiveRow.to_id, current.repoId, evidence,
568
+ hintOptions,
569
+ );
626
570
  const contextMethodId = contextSelection.methodId;
627
- const contextNode = contextMethodId ? handlerMethodNode(db, contextMethodId) : undefined;
571
+ let selectedHandlerAvailable = true;
628
572
  if (implementation.edge) {
629
- const implEvidence = JSON.parse(String(implementation.edge.evidence_json || '{}')) as Record<string, unknown>;
630
- const hintDiagnostic = implementationHintDiagnostic(contextSelection, implEvidence.implementationHintSuggestions);
573
+ const implEvidence = parseEvidence(implementation.edge.evidence_json);
574
+ const hintDiagnostic = implementationHintDiagnostic(contextSelection, implEvidence);
631
575
  if (hintDiagnostic) prependTraceDiagnostic(diagnostics, hintDiagnostic);
632
- const handlerNode = implementation.edge.status === 'resolved' ? handlerMethodNode(db, implementation.edge.to_id) : contextNode;
633
- const implTo = handlerNode?.label ? String(handlerNode.label) : `${implementation.edge.to_kind}:${implementation.edge.to_id}`;
634
- if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
576
+ const selectedMethodId = implementation.edge.status === 'resolved'
577
+ ? implementation.edge.to_id : contextMethodId;
578
+ const selectionEvidence = contextMethodId
579
+ ? {
580
+ ...implEvidence,
581
+ contextualImplementationSelected:
582
+ contextSelection.evidence.strategy !== 'implementation_repo_hint',
583
+ contextualImplementation: contextSelection.evidence,
584
+ implementationSelection: contextSelection.evidence,
585
+ }
586
+ : {
587
+ ...implEvidence,
588
+ contextualImplementation: contextSelection.evidence,
589
+ implementationSelection: contextSelection.evidence,
590
+ };
591
+ const selected: SelectedHandlerEvidence = selectedMethodId
592
+ ? withSelectedHandlerProvenance(
593
+ selectionEvidence,
594
+ selectedMethodId,
595
+ handlerMethodNode(db, selectedMethodId),
596
+ )
597
+ : { evidence: selectionEvidence };
598
+ selectedHandlerAvailable = !selected.unresolvedReason;
599
+ if (selected.diagnostic) prependTraceDiagnostic(diagnostics, selected.diagnostic);
600
+ const implTo = selected.handler?.label
601
+ ? String(selected.handler.label)
602
+ : `${implementation.edge.to_kind}:${implementation.edge.to_id}`;
603
+ if (selected.handler) nodes.set(String(selected.handler.id), selected.handler);
635
604
  edges.push({
636
605
  step: current.depth,
637
606
  type: 'operation_implemented_by_handler',
638
607
  from: to,
639
608
  to: implTo,
640
- evidence: contextMethodId
641
- ? { ...implEvidence, contextualImplementationSelected: contextSelection.evidence.strategy !== 'implementation_repo_hint', contextualImplementation: contextSelection.evidence, implementationSelection: contextSelection.evidence }
642
- : { ...implEvidence, contextualImplementation: contextSelection.evidence, implementationSelection: contextSelection.evidence },
609
+ evidence: selected.evidence,
643
610
  confidence: Number(implementation.edge.confidence ?? 0),
644
- unresolvedReason: implementation.edge.status === 'resolved' || contextMethodId ? undefined : String(implementation.edge.unresolved_reason ?? implementation.edge.status),
611
+ unresolvedReason: selected.unresolvedReason
612
+ ?? (implementation.edge.status === 'resolved' || contextMethodId
613
+ ? undefined
614
+ : String(implementation.edge.unresolved_reason ?? implementation.edge.status)),
645
615
  });
646
616
  }
647
617
  if (current.depth >= maxDepth) continue;
648
618
  const contextScope = contextMethodId ? handlerScope(db, contextMethodId) : undefined;
649
619
  const files = contextScope?.files ?? (implementation.files.size > 0 ? implementation.files : handlerFilesForOperation(db, effectiveRow.to_id));
650
620
  const symbolIds = contextScope?.symbolId ? new Set([contextScope.symbolId]) : implementation.symbolId ? new Set([implementation.symbolId]) : undefined;
651
- if ((implementation.edge?.status === 'resolved' || contextScope) && files.size > 0) {
621
+ if (selectedHandlerAvailable
622
+ && (implementation.edge?.status === 'resolved' || contextScope)
623
+ && files.size > 0) {
652
624
  const targetRepoId = contextScope?.repoId ?? implementation.repoId ?? (db
653
625
  .prepare(
654
626
  'SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?',
@@ -0,0 +1,166 @@
1
+ export const DEFAULT_EVIDENCE_CANDIDATE_LIMIT = 5;
2
+
3
+ export interface BoundedProjection<T> {
4
+ totalCount: number;
5
+ shownCount: number;
6
+ omittedCount: number;
7
+ items: T[];
8
+ }
9
+
10
+ export function projectBounded<T>(
11
+ values: readonly T[],
12
+ compare: (left: T, right: T) => number,
13
+ limit = DEFAULT_EVIDENCE_CANDIDATE_LIMIT,
14
+ ): BoundedProjection<T> {
15
+ const normalizedLimit = positiveLimit(limit);
16
+ const sorted = [...values].sort(compare);
17
+ const items = sorted.slice(0, normalizedLimit);
18
+ return {
19
+ totalCount: sorted.length,
20
+ shownCount: items.length,
21
+ omittedCount: Math.max(0, sorted.length - items.length),
22
+ items,
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Evidence producers establish semantic order before calling the generic
28
+ * recursive bounder. Re-sorting here would make a selected decision appear
29
+ * to be explained by a different candidate.
30
+ */
31
+ export function projectBoundedInOrder<T>(
32
+ values: readonly T[],
33
+ limit = DEFAULT_EVIDENCE_CANDIDATE_LIMIT,
34
+ ): BoundedProjection<T> {
35
+ const normalizedLimit = positiveLimit(limit);
36
+ const items = values.slice(0, normalizedLimit);
37
+ return {
38
+ totalCount: values.length,
39
+ shownCount: items.length,
40
+ omittedCount: Math.max(0, values.length - items.length),
41
+ items,
42
+ };
43
+ }
44
+
45
+ export function positiveLimit(value: number | undefined): number {
46
+ return Number.isFinite(value) && Number(value) > 0
47
+ ? Math.floor(Number(value))
48
+ : DEFAULT_EVIDENCE_CANDIDATE_LIMIT;
49
+ }
50
+
51
+ const candidateLikeCollections = new Set([
52
+ 'candidates',
53
+ 'candidateScores',
54
+ 'candidateFamilies',
55
+ 'candidateEvidence',
56
+ 'candidatePaths',
57
+ 'candidateRawPaths',
58
+ 'candidateNormalizedOperationPaths',
59
+ 'normalizedCandidateOperations',
60
+ 'candidateLiterals',
61
+ 'bindingCandidates',
62
+ 'bindingAlternatives',
63
+ 'implementationHintSuggestions',
64
+ 'selectableImplementationRepositories',
65
+ 'matchedHints',
66
+ 'candidateSuggestions',
67
+ 'dynamicTargetCandidates',
68
+ 'dynamicTargetCandidateSuggestions',
69
+ 'rejectedCandidates',
70
+ 'suggestedVarSets',
71
+ 'copyableExamples',
72
+ 'selectorSuggestions',
73
+ 'serviceSuggestions',
74
+ 'repositories',
75
+ 'examples',
76
+ 'expandedExamples',
77
+ 'registrations',
78
+ ]);
79
+
80
+ /**
81
+ * Parser facts are retained in their tables; graph evidence only carries a
82
+ * deterministic explanation. This prevents nested parser alternatives from
83
+ * bypassing the graph evidence cap while leaving canonical facts queryable.
84
+ */
85
+ export function boundCandidateLikeEvidence(
86
+ evidence: Record<string, unknown>,
87
+ limit = DEFAULT_EVIDENCE_CANDIDATE_LIMIT,
88
+ ): Record<string, unknown> {
89
+ const output: Record<string, unknown> = {};
90
+ for (const [key, value] of Object.entries(evidence)) {
91
+ if (!Array.isArray(value) || !candidateLikeCollections.has(key)) {
92
+ output[key] = boundNestedEvidence(value, limit);
93
+ continue;
94
+ }
95
+ const projection = projectBoundedInOrder(value, limit);
96
+ output[key] = projection.items.map((item) => boundNestedEvidence(item, limit));
97
+ addCollectionCounts(output, evidence, key, projection);
98
+ }
99
+ return output;
100
+ }
101
+
102
+ function boundNestedEvidence(value: unknown, limit: number): unknown {
103
+ if (Array.isArray(value)) return value.map((item) => boundNestedEvidence(item, limit));
104
+ if (!isEvidenceRecord(value)) return value;
105
+ return boundCandidateLikeEvidence(value, limit);
106
+ }
107
+
108
+ function isEvidenceRecord(value: unknown): value is Record<string, unknown> {
109
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
110
+ }
111
+
112
+ function addCollectionCounts(
113
+ output: Record<string, unknown>,
114
+ input: Record<string, unknown>,
115
+ key: string,
116
+ projection: BoundedProjection<unknown>,
117
+ ): void {
118
+ const stem = collectionStem(key);
119
+ const countName = `${stem}Count`;
120
+ const shownName = `shown${upperFirst(stem)}Count`;
121
+ const omittedName = `omitted${upperFirst(stem)}Count`;
122
+ const total = Math.max(numericValue(input[countName]), projection.totalCount);
123
+ output[countName] = total;
124
+ output[shownName] = projection.shownCount;
125
+ output[omittedName] = Math.max(0, total - projection.shownCount);
126
+ }
127
+
128
+ function collectionStem(key: string): string {
129
+ const stems: Record<string, string> = {
130
+ candidates: 'candidate',
131
+ candidateScores: 'candidateScore',
132
+ candidateFamilies: 'candidateFamily',
133
+ candidateEvidence: 'candidateEvidence',
134
+ candidatePaths: 'candidatePath',
135
+ candidateRawPaths: 'candidateRawPath',
136
+ candidateNormalizedOperationPaths: 'candidateNormalizedOperationPath',
137
+ normalizedCandidateOperations: 'normalizedCandidateOperation',
138
+ candidateLiterals: 'candidateLiteral',
139
+ bindingCandidates: 'bindingCandidate',
140
+ bindingAlternatives: 'bindingAlternative',
141
+ implementationHintSuggestions: 'implementationHintSuggestion',
142
+ selectableImplementationRepositories: 'selectableImplementationRepository',
143
+ matchedHints: 'matchedHint',
144
+ candidateSuggestions: 'candidateSuggestion',
145
+ dynamicTargetCandidates: 'dynamicTargetCandidate',
146
+ dynamicTargetCandidateSuggestions: 'dynamicTargetCandidateSuggestion',
147
+ rejectedCandidates: 'rejectedCandidate',
148
+ suggestedVarSets: 'suggestedVarSet',
149
+ copyableExamples: 'copyableExample',
150
+ selectorSuggestions: 'selectorSuggestion',
151
+ serviceSuggestions: 'serviceSuggestion',
152
+ repositories: 'repository',
153
+ examples: 'example',
154
+ expandedExamples: 'expandedExample',
155
+ registrations: 'registration',
156
+ };
157
+ return stems[key] ?? 'candidate';
158
+ }
159
+
160
+ function numericValue(value: unknown): number {
161
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
162
+ }
163
+
164
+ function upperFirst(value: string): string {
165
+ return value ? `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}` : value;
166
+ }