@saptools/service-flow 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5
4
+
5
+ - Runtime `--var` substitution now re-runs operation resolution in memory for trace and graph output, clears stale unresolved reasons on exact matches, and traverses into the matched downstream handler without mutating the persisted graph.
6
+ - Operation nodes now use target CDS provenance while call-site evidence remains on edges.
7
+ - Graph edges now carry explicit status values for resolved, terminal, dynamic, ambiguous, and unresolved cases; normal DB, external HTTP, and event terminals no longer receive remote-resolution failure text.
8
+ - Replaced per-statement SQLite shelling with a persistent `node:sqlite` connection, bound parameters, transactions, read-only query openings, WAL, busy timeouts, and connection-local foreign-key enforcement.
9
+ - Added schema user-version migration support for edge status and repository fingerprints.
10
+ - Repository indexing now skips unchanged repositories unless `--force` is supplied and reports indexed/skipped counts.
11
+ - `service-flow --version` now matches package metadata for the 0.1.5 release.
12
+
3
13
  ## 0.1.4
4
14
 
5
15
  - Resolved helper-returned service bindings exported through named export lists, including aliased exports, while preserving helper-chain evidence.
package/README.md CHANGED
@@ -24,7 +24,7 @@ Index independent Git repositories, persist CAP/CDS facts in SQLite, resolve cro
24
24
  - 🧩 **Static CAP/CDS indexing** — extracts services, actions, functions, events, handler classes, decorator metadata, handler registrations, generated constants, and package-level `cds.requires`
25
25
  - 🔗 **Service-to-service linking** — resolves `cds.connect.to(...)`, `remote.send(...)`, `cds.services.*` style calls, helper package imports, dynamic candidates, and unresolved evidence into graph edges
26
26
  - 🗄️ **SQLite-backed workspace cache** — stores deterministic facts under `.service-flow/service-flow.db` so large workspaces can be queried repeatedly without reparsing everything
27
- - 🧠 **Dynamic edge support** — preserves parameterized destinations and service paths such as `svc_${objectCode}_process`, then lets traces apply runtime `--var key=value` values
27
+ - 🧠 **Dynamic edge support** — preserves parameterized destinations and service paths such as `svc_${objectCode}_process`, then lets trace and graph commands apply runtime `--var key=value` values that can turn dynamic candidates into effective traversable operation edges
28
28
  - 📊 **Multiple output modes** — renders human-readable tables, JSON for automation, and Mermaid diagrams for architecture docs
29
29
  - 🩺 **Diagnostics-first workflow** — records parse/index issues and exposes them through `service-flow doctor` instead of hiding partial analysis
30
30
  - 🧩 **CAP helper-aware binding evidence** — follows imported helpers exported directly or through named export lists and separates alias, destination, and service-path expressions for dynamic `cds.connect.to(alias, options)` calls
@@ -47,7 +47,7 @@ npm install @saptools/service-flow
47
47
  ```
48
48
 
49
49
  > [!NOTE]
50
- > Requires **Node.js ≥ 20**. The analyzer is static: it reads files and package metadata, but it does not start CAP services, connect to SAP BTP, or execute application code.
50
+ > Requires **Node.js ≥ 20**. Version 0.1.5 uses a persistent SQLite driver (`node:sqlite` in supported Node builds) for bound parameters, transactions, WAL, busy timeouts, and read-only query commands. The analyzer is static: it reads files and package metadata, but it does not start CAP services, connect to SAP BTP, or execute application code.
51
51
 
52
52
  ---
53
53
 
@@ -73,7 +73,7 @@ service-flow graph --workspace /path/to/workspace --service /FacadeService --pat
73
73
  service-flow doctor --workspace /path/to/workspace
74
74
  ```
75
75
 
76
- After `init`, the workspace configuration and SQLite database live below the selected workspace by default. Run `index` whenever source changes, then run `link` to rebuild the graph edges used by `trace` and `graph`.
76
+ After `init`, the workspace configuration and SQLite database live below the selected workspace by default. Run `index` whenever source changes; unchanged repositories are skipped unless `--force` is supplied. Then run `link` to rebuild the graph edges used by `trace` and `graph`.
77
77
 
78
78
  ---
79
79
 
package/TECHNICAL-NOTE.md CHANGED
@@ -1,10 +1,10 @@
1
- # Service Flow 0.1.4 Resolution Notes
1
+ # Service Flow 0.1.5 Resolution Notes
2
2
 
3
3
  - Imported helper bindings: TypeScript imports are resolved for relative modules. When a caller assigns `const client = await connectToService()`, the analyzer follows the imported symbol to an exported helper that returns `cds.connect.to(...)` and persists caller-variable evidence plus the helper source/export chain.
4
4
  - Candidate ranking: operation-path matches start as weak candidates. A resolved operation edge requires a strong signal such as exact service path, CDS alias/destination context, or explicit dynamic variable overrides. Otherwise candidates are preserved in edge evidence as ambiguous or unresolved.
5
5
  - Edge states: `REMOTE_CALL_RESOLVES_TO_OPERATION` is used only above the resolution threshold; `DYNAMIC_EDGE_CANDIDATE` preserves runtime-dependent service paths/destinations; `UNRESOLVED_EDGE` carries candidate counts and reasons when static evidence is insufficient.
6
6
  - Trace cycle safety: trace queues carry repository IDs, visited scope keys are independent of depth, graph edge IDs are emitted once, and revisiting an already-seen downstream operation scope creates a cycle marker instead of recursive expansion.
7
- - SQLite reliability: the package uses WAL, foreign keys, busy timeouts, and retries around the SQLite adapter. Statement failures throw before output rendering so commands do not report successful JSON/table/Mermaid results after an internal database failure.
7
+ - SQLite reliability: the package uses a persistent SQLite connection per opened database, bound parameters, transactions, WAL, busy timeouts, read-only openings for query commands, and connection-local foreign-key enforcement. Native driver loading failures produce an actionable startup error before output rendering.
8
8
 
9
9
  ## 0.1.4 trace-correctness additions
10
10
 
@@ -12,3 +12,10 @@
12
12
  - Two-argument CAP connections keep alias expressions distinct from `credentials.destination` and `credentials.path` / `credentials.servicePath`; dynamic placeholders from all three fields are retained for later `--var` substitution.
13
13
  - Repository discovery validates `.git` markers using `HEAD`, `config`, or gitfile `gitdir:` content and keeps scanning children so outer workspaces can contain many nested repositories.
14
14
  - Fresh SQLite stores now declare core parent/child foreign keys with cascading cleanup for repository-owned facts.
15
+
16
+ ## 0.1.5 hardening additions
17
+
18
+ - Runtime variables are applied to alias, destination, service path, and operation path evidence. Trace/graph then perform an effective in-memory resolution requiring both operation-path compatibility and an exact service-path signal.
19
+ - Edge evidence and target operation provenance are separate: call file/line and helper chains stay on the edge, while operation nodes are loaded from CDS operation/service/repository rows.
20
+ - The linker stores explicit edge status categories and summary counts. Valid DB, event, and external terminals keep `unresolved_reason` null.
21
+ - Repository-level fingerprints include source paths/hashes, package dependencies, and analyzer schema version. Unchanged repositories are skipped unless `--force` is used.
@@ -895,6 +895,13 @@ function rows(db, operationPath, workspaceId) {
895
895
  );
896
896
  }
897
897
  function resolveOperation(db, signals, workspaceId) {
898
+ const missing = [signals.servicePath, signals.alias, signals.destination, signals.operationPath].flatMap((value) => [...(value ?? "").matchAll(/\$\{\s*(\w+)\s*\}/g)].map((match) => match[1] ?? "")).filter(Boolean);
899
+ if (missing.length > 0)
900
+ return {
901
+ status: "dynamic",
902
+ candidates: signals.operationPath ? rows(db, signals.operationPath, workspaceId) : [],
903
+ reasons: [...new Set(missing)].map((name) => `missing_variable:${name}`)
904
+ };
898
905
  if (!signals.operationPath)
899
906
  return {
900
907
  status: "unresolved",
@@ -946,7 +953,7 @@ function resolveOperation(db, signals, workspaceId) {
946
953
  candidates,
947
954
  reasons: ["operation_path_only_has_no_strong_target_signal"]
948
955
  };
949
- if (best && best.score >= 0.9 && (!second || best.score - second.score >= 0.25))
956
+ if (best && best.score >= 0.9 && best.servicePath === signals.servicePath && (best.operationPath === signals.operationPath || best.operationName === signals.operationPath.replace(/^\//, "")) && (!second || best.score - second.score >= 0.25))
950
957
  return {
951
958
  status: "resolved",
952
959
  target: best,
@@ -993,91 +1000,105 @@ function linkHelperPackages(db, workspaceId) {
993
1000
 
994
1001
  // src/linker/cross-repo-linker.ts
995
1002
  function linkWorkspace(db, workspaceId, vars = {}) {
996
- db.prepare("DELETE FROM graph_edges WHERE workspace_id=?").run(workspaceId);
997
- let edges = linkHelperPackages(db, workspaceId);
998
- let unresolved = 0;
999
- const calls = db.prepare(
1000
- `SELECT c.*,r.name repoName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`
1001
- ).all(workspaceId);
1002
- for (const call of calls) {
1003
- const callType = String(call.call_type);
1004
- const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1005
- const servicePath = applyVariables(
1006
- call.servicePathExpr ?? call.requireServicePath,
1007
- vars
1008
- );
1009
- const destination = call.destinationExpr ?? call.requireDestination;
1010
- const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1011
- const resolution = callType.startsWith("remote") ? resolveOperation(
1012
- db,
1013
- {
1003
+ return db.transaction(() => {
1004
+ db.prepare("DELETE FROM graph_edges WHERE workspace_id=?").run(workspaceId);
1005
+ let edges = linkHelperPackages(db, workspaceId);
1006
+ let unresolved = 0;
1007
+ let resolvedCount = 0;
1008
+ let ambiguousCount = 0;
1009
+ let dynamicCount = 0;
1010
+ let terminalCount = 0;
1011
+ const calls = db.prepare(
1012
+ `SELECT c.*,r.name repoName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`
1013
+ ).all(workspaceId);
1014
+ for (const call of calls) {
1015
+ const callType = String(call.call_type);
1016
+ const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1017
+ const servicePath = applyVariables(
1018
+ call.servicePathExpr ?? call.requireServicePath,
1019
+ vars
1020
+ );
1021
+ const destination = call.destinationExpr ?? call.requireDestination;
1022
+ const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1023
+ const resolution = callType.startsWith("remote") ? resolveOperation(
1024
+ db,
1025
+ {
1026
+ servicePath,
1027
+ operationPath: op,
1028
+ alias: applyVariables(call.aliasExpr ?? call.alias, vars),
1029
+ destination: applyVariables(destination, vars),
1030
+ isDynamic,
1031
+ hasExplicitOverride: Object.keys(vars).length > 0
1032
+ },
1033
+ workspaceId
1034
+ ) : { status: "unresolved", candidates: [], reasons: [] };
1035
+ const target = resolution.target;
1036
+ const evidence = {
1037
+ sourceFile: call.source_file,
1038
+ sourceLine: call.source_line,
1039
+ file: call.source_file,
1040
+ line: call.source_line,
1041
+ repo: call.repoName,
1042
+ serviceAlias: call.alias,
1043
+ serviceAliasExpr: call.aliasExpr,
1044
+ destination: applyVariables(destination, vars),
1014
1045
  servicePath,
1015
1046
  operationPath: op,
1016
- alias: call.alias,
1017
- destination,
1018
- isDynamic,
1019
- hasExplicitOverride: Object.keys(vars).length > 0
1020
- },
1021
- workspaceId
1022
- ) : { status: "unresolved", candidates: [], reasons: [] };
1023
- const target = resolution.target;
1024
- const evidence = {
1025
- sourceFile: call.source_file,
1026
- sourceLine: call.source_line,
1027
- file: call.source_file,
1028
- line: call.source_line,
1029
- repo: call.repoName,
1030
- serviceAlias: call.alias,
1031
- serviceAliasExpr: call.aliasExpr,
1032
- destination,
1033
- servicePath,
1034
- operationPath: op,
1035
- targetRepo: target?.repoName,
1036
- targetOperation: target?.operationName,
1037
- helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0,
1038
- candidates: resolution.candidates,
1039
- candidateCount: resolution.candidates.length,
1040
- resolutionStatus: resolution.status,
1041
- resolutionReasons: resolution.reasons
1042
- };
1043
- if (target) {
1044
- db.prepare(
1045
- "INSERT INTO graph_edges(workspace_id,edge_type,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic) VALUES(?,?,?,?,?,?,?,?,?)"
1046
- ).run(
1047
- workspaceId,
1048
- "REMOTE_CALL_RESOLVES_TO_OPERATION",
1049
- "call",
1050
- String(call.id),
1051
- "operation",
1052
- String(target.operationId),
1053
- target.score,
1054
- JSON.stringify(evidence),
1055
- isDynamic ? 1 : 0
1056
- );
1057
- edges += 1;
1058
- } else {
1059
- const edgeType = callType === "local_db_query" ? "HANDLER_RUNS_DB_QUERY" : callType === "external_http" ? "HANDLER_CALLS_EXTERNAL_HTTP" : callType === "async_emit" ? "HANDLER_EMITS_EVENT" : callType === "async_subscribe" ? "EVENT_CONSUMED_BY_HANDLER" : resolution.status === "dynamic" ? "DYNAMIC_EDGE_CANDIDATE" : "UNRESOLVED_EDGE";
1060
- db.prepare(
1061
- "INSERT INTO graph_edges(workspace_id,edge_type,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason) VALUES(?,?,?,?,?,?,?,?,?,?)"
1062
- ).run(
1063
- workspaceId,
1064
- edgeType,
1065
- "call",
1066
- String(call.id),
1067
- callType.startsWith("async_") ? "event" : "external",
1068
- String(call.event_name_expr ?? call.query_entity ?? op ?? call.id),
1069
- Number(call.confidence ?? 0.2),
1070
- JSON.stringify(evidence),
1071
- isDynamic || resolution.status === "dynamic" ? 1 : 0,
1072
- String(
1073
- call.unresolved_reason ?? (resolution.status === "ambiguous" ? "Ambiguous operation candidates require a strong service signal" : resolution.status === "dynamic" ? "Dynamic target requires runtime variable overrides" : "No indexed target operation matched")
1074
- )
1075
- );
1076
- edges += 1;
1077
- unresolved += edgeType === "UNRESOLVED_EDGE" ? 1 : 0;
1047
+ targetRepo: target?.repoName,
1048
+ targetOperation: target?.operationName,
1049
+ helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0,
1050
+ candidates: resolution.candidates,
1051
+ candidateCount: resolution.candidates.length,
1052
+ resolutionStatus: resolution.status,
1053
+ resolutionReasons: resolution.reasons
1054
+ };
1055
+ if (target) {
1056
+ db.prepare(
1057
+ "INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic) VALUES(?,?,?,?,?,?,?,?,?,?)"
1058
+ ).run(
1059
+ workspaceId,
1060
+ "REMOTE_CALL_RESOLVES_TO_OPERATION",
1061
+ "resolved",
1062
+ "call",
1063
+ String(call.id),
1064
+ "operation",
1065
+ String(target.operationId),
1066
+ target.score,
1067
+ JSON.stringify(evidence),
1068
+ isDynamic ? 1 : 0
1069
+ );
1070
+ edges += 1;
1071
+ resolvedCount += 1;
1072
+ } else {
1073
+ const edgeType = callType === "local_db_query" ? "HANDLER_RUNS_DB_QUERY" : callType === "external_http" ? "HANDLER_CALLS_EXTERNAL_HTTP" : callType === "async_emit" ? "HANDLER_EMITS_EVENT" : callType === "async_subscribe" ? "EVENT_CONSUMED_BY_HANDLER" : resolution.status === "dynamic" ? "DYNAMIC_EDGE_CANDIDATE" : "UNRESOLVED_EDGE";
1074
+ const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1075
+ const unresolvedReason = status === "terminal" ? null : String(
1076
+ call.unresolved_reason ?? (resolution.status === "ambiguous" ? "Ambiguous operation candidates require a strong service signal" : resolution.status === "dynamic" ? `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ["missing runtime variables"]).join(", ")}` : "No indexed target operation matched")
1077
+ );
1078
+ db.prepare(
1079
+ "INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)"
1080
+ ).run(
1081
+ workspaceId,
1082
+ edgeType,
1083
+ status,
1084
+ "call",
1085
+ String(call.id),
1086
+ callType.startsWith("async_") ? "event" : "external",
1087
+ String(call.event_name_expr ?? call.query_entity ?? op ?? call.id),
1088
+ Number(call.confidence ?? 0.2),
1089
+ JSON.stringify(evidence),
1090
+ isDynamic || resolution.status === "dynamic" ? 1 : 0,
1091
+ unresolvedReason
1092
+ );
1093
+ edges += 1;
1094
+ unresolved += status === "unresolved" ? 1 : 0;
1095
+ ambiguousCount += status === "ambiguous" ? 1 : 0;
1096
+ dynamicCount += status === "dynamic" ? 1 : 0;
1097
+ terminalCount += status === "terminal" ? 1 : 0;
1098
+ }
1078
1099
  }
1079
- }
1080
- return { edgeCount: edges, unresolvedCount: unresolved };
1100
+ return { edgeCount: edges, unresolvedCount: unresolved, resolvedCount, ambiguousCount, dynamicCount, terminalCount };
1101
+ });
1081
1102
  }
1082
1103
 
1083
1104
  // src/trace/trace-engine.ts
@@ -1096,7 +1117,8 @@ function sourceFilesForStart(db, repoId, start) {
1096
1117
  `SELECT DISTINCT hc.source_file sourceFile
1097
1118
  FROM handler_classes hc LEFT JOIN handler_methods hm ON hm.handler_class_id=hc.id
1098
1119
  WHERE (? IS NULL OR hc.repo_id=?) AND (? IS NULL OR hc.class_name=? OR hm.method_name=?)
1099
- AND (? IS NULL OR hm.decorator_value=? OR hm.method_name=?)`
1120
+ AND (? IS NULL OR hm.decorator_value=? OR hm.method_name=?)
1121
+ AND (? IS NULL OR EXISTS (SELECT 1 FROM cds_services s JOIN cds_operations o ON o.service_id=s.id WHERE s.repo_id=hc.repo_id AND s.service_path=? AND (? IS NULL OR o.operation_path=? OR o.operation_name=? OR hm.decorator_value=? OR hm.method_name=?)))`
1100
1122
  ).all(
1101
1123
  repoId,
1102
1124
  repoId,
@@ -1105,8 +1127,16 @@ function sourceFilesForStart(db, repoId, start) {
1105
1127
  handler,
1106
1128
  operation,
1107
1129
  operation,
1130
+ operation,
1131
+ start.servicePath,
1132
+ start.servicePath,
1133
+ operation,
1134
+ operation,
1135
+ operation,
1136
+ operation,
1108
1137
  operation
1109
1138
  );
1139
+ if (start.servicePath && rows2.length === 0) return void 0;
1110
1140
  if (rows2.length === 0) return void 0;
1111
1141
  return new Set(rows2.map((row) => row.sourceFile).filter(Boolean));
1112
1142
  }
@@ -1150,7 +1180,7 @@ function graphForCalls(db, callIds) {
1150
1180
  if (callIds.length === 0) return map;
1151
1181
  const rows2 = db.prepare(
1152
1182
  `SELECT * FROM graph_edges WHERE from_kind='call' AND from_id IN (${callIds.map(() => "?").join(",")}) ORDER BY id`
1153
- ).all(...callIds);
1183
+ ).all(...callIds.map((id) => String(id)));
1154
1184
  for (const row of rows2) {
1155
1185
  const id = Number(row.from_id);
1156
1186
  map.set(id, [...map.get(id) ?? [], row]);
@@ -1182,6 +1212,30 @@ function evidenceWithRuntimeVariables(evidence, vars) {
1182
1212
  } : void 0
1183
1213
  };
1184
1214
  }
1215
+ function operationNode(db, operationId) {
1216
+ const row = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_type operationType,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.id serviceId,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,r.id repoId,r.name repoName FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operationId);
1217
+ if (!row) return void 0;
1218
+ return { id: `operation:${operationId}`, kind: "operation", label: `${String(row.repoName)}:${String(row.servicePath)}${String(row.operationPath)}`, ...row };
1219
+ }
1220
+ function workspaceIdForCall(db, callId) {
1221
+ return db.prepare("SELECT r.workspace_id workspaceId FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE c.id=?").get(callId)?.workspaceId;
1222
+ }
1223
+ function runtimeResolution(db, row, evidence, vars) {
1224
+ if (!vars || Object.keys(vars).length === 0) return { row, evidence, unresolvedReason: row.unresolved_reason };
1225
+ const nextEvidence = evidenceWithRuntimeVariables(evidence, vars);
1226
+ const servicePath = typeof nextEvidence.servicePath === "string" ? nextEvidence.servicePath : void 0;
1227
+ const operationPath = typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
1228
+ const alias = typeof nextEvidence.serviceAliasExpr === "string" ? applyVariables(nextEvidence.serviceAliasExpr, vars) : typeof nextEvidence.serviceAlias === "string" ? applyVariables(nextEvidence.serviceAlias, vars) : void 0;
1229
+ const destination = typeof nextEvidence.destination === "string" ? applyVariables(nextEvidence.destination, vars) : void 0;
1230
+ const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: Boolean(row.status === "dynamic" || nextEvidence.resolutionStatus === "dynamic") }, workspaceIdForCall(db, row.from_id));
1231
+ nextEvidence.runtimeResolutionStatus = resolution.status;
1232
+ nextEvidence.runtimeResolutionReasons = resolution.reasons;
1233
+ if (resolution.target) {
1234
+ nextEvidence.runtimeResolvedCandidate = resolution.target;
1235
+ return { row: { ...row, to_kind: "operation", to_id: String(resolution.target.operationId), unresolved_reason: void 0, confidence: resolution.target.score }, evidence: nextEvidence, target: resolution.target };
1236
+ }
1237
+ return { row, evidence: nextEvidence, unresolvedReason: resolution.status === "dynamic" ? `Dynamic target is missing runtime variables: ${resolution.reasons.join(", ")}` : resolution.status === "ambiguous" ? "Ambiguous runtime operation candidates" : "No runtime operation candidate matched substituted service and operation path" };
1238
+ }
1185
1239
  function edgeTarget(row, evidence) {
1186
1240
  const runtimeCandidate = evidence.runtimeResolvedCandidate;
1187
1241
  if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath)
@@ -1242,33 +1296,32 @@ function trace(db, start, options) {
1242
1296
  const rawEvidence = JSON.parse(
1243
1297
  String(row.evidence_json || "{}")
1244
1298
  );
1245
- const evidence = evidenceWithRuntimeVariables(
1246
- rawEvidence,
1247
- options.vars
1248
- );
1249
- const targetNode = `${row.to_kind}:${row.to_id}`;
1250
- nodes.set(targetNode, {
1299
+ const effective = runtimeResolution(db, row, rawEvidence, options.vars);
1300
+ const evidence = effective.evidence;
1301
+ const effectiveRow = effective.row;
1302
+ const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
1303
+ const opNode = effectiveRow.to_kind === "operation" ? operationNode(db, effectiveRow.to_id) : void 0;
1304
+ nodes.set(targetNode, opNode ?? {
1251
1305
  id: targetNode,
1252
- kind: row.to_kind,
1253
- label: row.to_id,
1254
- ...evidence
1306
+ kind: effectiveRow.to_kind,
1307
+ label: effectiveRow.to_id
1255
1308
  });
1256
- const to = edgeTarget(row, evidence);
1309
+ const to = edgeTarget(effectiveRow, evidence);
1257
1310
  edges.push({
1258
1311
  step: current.depth,
1259
1312
  type: String(call.call_type),
1260
1313
  from: `${call.repoName}:${call.source_file}`,
1261
1314
  to,
1262
1315
  evidence,
1263
- confidence: Number(row.confidence ?? call.confidence),
1264
- unresolvedReason: row.unresolved_reason
1316
+ confidence: Number(effectiveRow.confidence ?? call.confidence),
1317
+ unresolvedReason: effective.unresolvedReason
1265
1318
  });
1266
- if (row.to_kind === "operation" && current.depth < maxDepth) {
1267
- const files = handlerFilesForOperation(db, row.to_id);
1319
+ if (effectiveRow.to_kind === "operation" && current.depth < maxDepth) {
1320
+ const files = handlerFilesForOperation(db, effectiveRow.to_id);
1268
1321
  if (files.size > 0) {
1269
1322
  const targetRepoId = db.prepare(
1270
1323
  "SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?"
1271
- ).get(row.to_id)?.repoId;
1324
+ ).get(effectiveRow.to_id)?.repoId;
1272
1325
  const nextKey = `${targetRepoId ?? "*"}:${[...files].sort().join(",")}`;
1273
1326
  if (seenScopes.has(nextKey))
1274
1327
  edges.push({
@@ -1310,4 +1363,4 @@ export {
1310
1363
  linkWorkspace,
1311
1364
  trace
1312
1365
  };
1313
- //# sourceMappingURL=chunk-JY6GBGZT.js.map
1366
+ //# sourceMappingURL=chunk-I33ASGX3.js.map