@saptools/service-flow 0.1.17 → 0.1.19

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.19
4
+
5
+ - Normalize balanced top-level OData action/function invocation paths before remote operation resolution, including namespace-qualified invocation lookup, while preserving raw and normalized evidence.
6
+ - Model remote service-client queries as terminal remote-query edges with stable semantic targets instead of unresolved operation calls.
7
+ - Add strict doctor aggregates for remote-query target quality and OData invocation resolution quality.
8
+ - Document remote query terminal semantics and normalized invocation resolution behavior.
9
+
10
+ ## 0.1.18
11
+
12
+ - Applied conservative CAP receiver eligibility to `.emit()` and `.publish()` so generic realtime, socket, DOM, and EventEmitter-style calls no longer become CAP async event facts without explicit CAP messaging/service evidence.
13
+ - Added structured parser evidence for local CAP service calls, including source offsets, service lookup/name, operation, and alias chain.
14
+ - Propagated outbound parser evidence into call-derived graph and JSON trace evidence under `outboundEvidence` for remote, local, DB, external, and async edges.
15
+ - Clarified graph-level dynamic flags so terminal DB, external, and event edges stay static while dynamic binding details remain in edge evidence.
16
+ - Expanded `doctor --strict` with outbound evidence, graph evidence propagation, event receiver classification, and dynamic terminal-edge consistency aggregates.
17
+
3
18
  ## 0.1.17
4
19
 
5
20
  - Replaced remaining raw-text outbound call detection with TypeScript AST classification so comments and strings do not create outbound facts or graph edges.
package/README.md CHANGED
@@ -18,10 +18,6 @@ Index independent Git repositories, persist CAP/CDS facts in SQLite, resolve cro
18
18
 
19
19
  ---
20
20
 
21
- ## 0.1.17 quality update
22
-
23
- `service-flow` 0.1.17 hardens outbound extraction after the 0.1.16 audit. Outbound calls are classified from the TypeScript AST, so comments and strings are ignored; CAP/service lifecycle registrations that remain indexed receive synthetic event-registration owners; generic event emitters and response `.send()` calls are not treated as CAP service-flow edges; and `doctor --strict` reports actionable ownerless categories. The previous 0.1.16 source ownership and proxy-evidence improvements remain in place. It indexes class property arrow/function members, creates conservative synthetic callback symbols only for top-level framework callbacks containing supported outbound calls, prefers explicit outbound source-symbol names when provided, hardens proxy-member resolution away from ambiguous global name-only matches, and splits link output into remote/local/unresolved/ambiguous/dynamic/terminal operation-call buckets.
24
-
25
21
  ## ✨ Features
26
22
 
27
23
  - 🧭 **Cross-repository CAP tracing** — starts from a repo, service, operation path, operation name, or handler and follows the indexed flow across workspace boundaries
package/TECHNICAL-NOTE.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Service Flow Resolution Notes
2
2
 
3
+ ## 0.1.19 remote invocation and query notes
4
+
5
+ - Remote action/function paths are scanned for a balanced top-level OData invocation suffix. Single-segment operation imports like `/readConfig(...)` normalize to `/readConfig`; namespace-qualified operation imports keep the qualified request segment for evidence and can resolve against the indexed simple CDS operation when service signals are strong. Navigation/property paths like `/Orders(id='123')/items` are left unchanged. Graph evidence keeps both `rawOperationPath` and `normalizedOperationPath` when normalization occurs.
6
+ - Remote `send({ query })` calls without explicit operation-path evidence now become `HANDLER_RUNS_REMOTE_QUERY` terminal edges. Static entities produce `Remote entity: ...` targets; dynamic or unknown entities produce `Remote query: unknown` with parser-warning evidence.
7
+ - Strict doctor includes remote-query target-quality and OData invocation-resolution aggregates to catch numeric query targets and unresolved normalized invocation paths.
8
+
9
+ ## 0.1.18 auditability notes
10
+
11
+ - CAP async event parsing treats `.emit()`, `.publish()`, and `.on()` consistently: a row is indexed only when the direct or chained root receiver has explicit CAP service or messaging evidence such as `cds` or a variable initialized from `cds.connect.to(...)`. Generic realtime/socket/EventEmitter receivers are ignored for CAP graph purposes by default.
12
+ - Local CAP service calls now carry TypeScript AST evidence with classifier, source offsets, service lookup/name, operation, and alias chain.
13
+ - Call-derived graph evidence nests persisted outbound parser evidence as `outboundEvidence`, allowing JSON trace output to explain parser classification without colliding with linker fields.
14
+ - `graph_edges.is_dynamic` means the edge itself requires runtime operation-target resolution. Terminal database, external HTTP, and async event edges keep `is_dynamic=0`; dynamic binding provenance remains in `evidence_json.bindingHasDynamicExpression`.
15
+ - Strict doctor adds aggregate checks for outbound evidence JSON quality, graph evidence propagation, event receiver classification, and dynamic terminal-edge consistency.
16
+
3
17
  - 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
18
  - 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
19
  - 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.
@@ -602,7 +602,7 @@ function collectServiceVariables(source) {
602
602
  const visit = (node) => {
603
603
  if (ts4.isVariableDeclaration(node) && ts4.isIdentifier(node.name) && node.initializer) {
604
604
  const text = node.initializer.getText(source);
605
- if (/cds\.connect\.(to|messaging)\s*\(/.test(text) || /create.*(Client|Remote|Service|Messaging)/.test(text)) vars.add(node.name.text);
605
+ if (/cds\.connect\.(to|messaging)\s*\(/.test(text)) vars.add(node.name.text);
606
606
  }
607
607
  ts4.forEachChild(node, visit);
608
608
  };
@@ -611,14 +611,25 @@ function collectServiceVariables(source) {
611
611
  }
612
612
  function receiverName(expr) {
613
613
  if (ts4.isIdentifier(expr)) return expr.text;
614
- if (ts4.isPropertyAccessExpression(expr)) return expr.getText();
614
+ if (ts4.isPropertyAccessExpression(expr)) return expr.getText(sourceOf(expr));
615
615
  return void 0;
616
616
  }
617
- function isSupportedEventReceiver(receiver, serviceVariables) {
618
- if (!receiver) return false;
619
- if (receiver === "cds") return true;
620
- if (serviceVariables.has(receiver)) return true;
621
- if (/^(srv|service|serviceClient|messaging|messageClient|eventClient|.*Client)$/.test(receiver)) return true;
617
+ function sourceOf(node) {
618
+ return node.getSourceFile();
619
+ }
620
+ function rootReceiverName(expr) {
621
+ if (ts4.isIdentifier(expr)) return expr.text;
622
+ if (ts4.isPropertyAccessExpression(expr)) return rootReceiverName(expr.expression);
623
+ if (ts4.isCallExpression(expr)) return rootReceiverName(expr.expression);
624
+ return void 0;
625
+ }
626
+ function isSupportedEventReceiver(receiver, rootReceiver, serviceVariables) {
627
+ const candidate = rootReceiver ?? receiver;
628
+ if (!candidate) return false;
629
+ if (candidate === "cds") return true;
630
+ if (serviceVariables.has(candidate)) return true;
631
+ if (receiver && serviceVariables.has(receiver)) return true;
632
+ if (/^(srv|service|serviceClient|messaging|messageClient|eventClient)$/.test(candidate)) return true;
622
633
  return false;
623
634
  }
624
635
  function classifyOutboundCallsInSource(source, filePath) {
@@ -648,9 +659,10 @@ function classifyOutboundCallsInSource(source, filePath) {
648
659
  }
649
660
  } else if (ts4.isPropertyAccessExpression(expr) && ["emit", "publish", "on"].includes(expr.name.text)) {
650
661
  const receiver = receiverName(expr.expression);
651
- if (expr.name.text !== "on" || isSupportedEventReceiver(receiver, serviceVariables)) {
662
+ const rootReceiver = rootReceiverName(expr.expression);
663
+ if (isSupportedEventReceiver(receiver, rootReceiver, serviceVariables)) {
652
664
  const eventName = literalText(node.arguments[0]);
653
- if (eventName) add(node, { callType: expr.name.text === "on" ? "async_subscribe" : "async_emit", serviceVariableName: receiver, eventNameExpr: eventName }, { receiver, classifier: expr.name.text === "on" ? "cap_service_event_subscription" : "cap_service_event_emit" });
665
+ if (eventName) add(node, { callType: expr.name.text === "on" ? "async_subscribe" : "async_emit", serviceVariableName: rootReceiver ?? receiver, eventNameExpr: eventName }, { receiver, rootReceiver, classifier: expr.name.text === "on" ? "cap_service_event_subscription" : "cap_service_event_emit", receiverClassification: "cap_evidence" });
654
666
  }
655
667
  } else if (exprText === "axios" || exprText === "executeHttpRequest" || exprText === "useOrFetchDestination") {
656
668
  add(node, { callType: "external_http", payloadSummary: summarizeExpression(node.arguments.map((arg) => arg.getText(source)).join(", ")), confidence: 0.7, unresolvedReason: "External HTTP destination is outside indexed CAP services" });
@@ -693,7 +705,14 @@ function parseLocalServiceCalls(text, filePath) {
693
705
  sourceFile: normalizePath(filePath),
694
706
  sourceLine: lineOf3(text, node.getStart(source)),
695
707
  confidence: 0.9,
696
- unresolvedReason: ["send", "emit", "publish", "on"].includes(parsed.operation) ? "transport_client_method" : void 0
708
+ unresolvedReason: ["send", "emit", "publish", "on"].includes(parsed.operation) ? "transport_client_method" : void 0,
709
+ evidence: parserEvidence(source, node, {
710
+ classifier: "local_cap_service_call",
711
+ localServiceLookup: parsed.lookup,
712
+ localServiceName: parsed.service,
713
+ operation: parsed.operation,
714
+ aliasChain: parsed.chain
715
+ })
697
716
  });
698
717
  }
699
718
  ts4.forEachChild(node, visit);
@@ -1113,19 +1132,103 @@ function substituteVariables(template, vars) {
1113
1132
  };
1114
1133
  }
1115
1134
 
1135
+ // src/linker/odata-path-normalizer.ts
1136
+ function normalizeODataOperationInvocationPath(path9) {
1137
+ if (path9 === void 0) return void 0;
1138
+ const raw = path9.trim();
1139
+ if (!raw) return void 0;
1140
+ const open = raw.indexOf("(");
1141
+ if (open < 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1142
+ if (!raw.startsWith("/") || raw.slice(1, open).includes("/")) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1143
+ const close = matchingClose(raw, open);
1144
+ if (close === void 0 || raw.slice(close + 1).trim().length > 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1145
+ const operationSegment = raw.slice(0, open).trim();
1146
+ if (operationSegment.length <= 1) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1147
+ return { rawOperationPath: raw, normalizedOperationPath: operationSegment, wasInvocation: true };
1148
+ }
1149
+ function matchingClose(text, openIndex) {
1150
+ let depth = 0;
1151
+ let quote;
1152
+ for (let index = openIndex; index < text.length; index += 1) {
1153
+ const char = text[index];
1154
+ const prev = text[index - 1];
1155
+ if (quote) {
1156
+ if (prev === "\\") continue;
1157
+ if (quote === "single" && char === "'" || quote === "double" && char === '"' || quote === "template" && char === "`") quote = void 0;
1158
+ continue;
1159
+ }
1160
+ if (char === "'") {
1161
+ quote = "single";
1162
+ continue;
1163
+ }
1164
+ if (char === '"') {
1165
+ quote = "double";
1166
+ continue;
1167
+ }
1168
+ if (char === "`") {
1169
+ quote = "template";
1170
+ continue;
1171
+ }
1172
+ if (char === "(") depth += 1;
1173
+ if (char === ")") {
1174
+ depth -= 1;
1175
+ if (depth === 0) return index;
1176
+ if (depth < 0) return void 0;
1177
+ }
1178
+ }
1179
+ return void 0;
1180
+ }
1181
+
1182
+ // src/linker/remote-query-target.ts
1183
+ function buildRemoteQueryTarget(input) {
1184
+ const entity = typeof input.queryEntity === "string" && input.queryEntity.trim() ? input.queryEntity.trim() : void 0;
1185
+ const servicePath = input.servicePath?.trim();
1186
+ const prefix = servicePath ? `${servicePath}:` : "";
1187
+ const label = entity ? `Remote entity: ${prefix}${entity}` : "Remote query: unknown";
1188
+ return {
1189
+ toKind: entity ? "remote_entity" : "remote_query",
1190
+ toId: entity ? `${prefix}${entity}` : "unknown",
1191
+ label,
1192
+ evidence: {
1193
+ remoteQueryTarget: label,
1194
+ queryEntity: entity,
1195
+ queryTargetKind: entity ? "remote_entity" : "remote_query_unknown",
1196
+ queryEntityDynamic: entity ? void 0 : Boolean(input.isDynamic) || void 0,
1197
+ serviceAlias: input.serviceAlias,
1198
+ serviceAliasExpr: input.serviceAliasExpr,
1199
+ destination: input.destination,
1200
+ servicePath,
1201
+ parserWarning: entity ? input.parserWarning : input.parserWarning ?? { code: "query_entity_unknown", message: "Remote query entity is dynamic or unavailable" }
1202
+ }
1203
+ };
1204
+ }
1205
+
1116
1206
  // src/linker/service-resolver.ts
1117
1207
  function rows(db, operationPath, workspaceId) {
1208
+ const names = operationLookupNames(operationPath);
1118
1209
  return db.prepare(
1119
1210
  `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine,0 score,'' reasons
1120
1211
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
1121
- WHERE (? IS NULL OR r.workspace_id=?) AND (o.operation_path=? OR o.operation_name=?) ORDER BY r.name,s.service_path,o.operation_name`
1212
+ WHERE (? IS NULL OR r.workspace_id=?) AND (o.operation_path IN (?,?) OR o.operation_name IN (?,?)) ORDER BY r.name,s.service_path,o.operation_name`
1122
1213
  ).all(
1123
1214
  workspaceId,
1124
1215
  workspaceId,
1125
- operationPath,
1126
- operationPath.replace(/^\//, "")
1216
+ names.path,
1217
+ names.simplePath,
1218
+ names.name,
1219
+ names.simpleName
1127
1220
  );
1128
1221
  }
1222
+ function operationLookupNames(operationPath) {
1223
+ const name = operationPath.replace(/^\//, "");
1224
+ const simpleName = name.split(".").at(-1) ?? name;
1225
+ return { path: operationPath, simplePath: `/${simpleName}`, name, simpleName };
1226
+ }
1227
+ function operationMatches(candidate, operationPath) {
1228
+ if (!operationPath) return false;
1229
+ const names = operationLookupNames(operationPath);
1230
+ return candidate.operationPath === names.path || candidate.operationPath === names.simplePath || candidate.operationName === names.name || candidate.operationName === names.simpleName;
1231
+ }
1129
1232
  function resolveOperation(db, signals, workspaceId) {
1130
1233
  const missing = [signals.servicePath, signals.alias, signals.destination, signals.operationPath].flatMap((value) => [...(value ?? "").matchAll(/\$\{\s*(\w+)\s*\}/g)].map((match) => match[1] ?? "")).filter(Boolean);
1131
1234
  if (missing.length > 0)
@@ -1193,7 +1296,7 @@ function resolveOperation(db, signals, workspaceId) {
1193
1296
  c.score += 0.2;
1194
1297
  c.reasons.push(signals.repoId !== void 0 ? "explicit_local_service_call" : "explicit_dynamic_override");
1195
1298
  }
1196
- if (signals.repoId !== void 0 && candidates.length === 1 && signals.serviceName && c.reasons.includes("local_service_name_mismatch") && (c.operationPath === signals.operationPath || c.operationName === signals.operationPath.replace(/^\//, ""))) {
1299
+ if (signals.repoId !== void 0 && candidates.length === 1 && signals.serviceName && c.reasons.includes("local_service_name_mismatch") && operationMatches(c, signals.operationPath)) {
1197
1300
  c.score = Math.max(c.score, 0.9);
1198
1301
  c.reasons.push("same_repo_unique_operation_path_with_lookup_mismatch");
1199
1302
  }
@@ -1216,7 +1319,7 @@ function resolveOperation(db, signals, workspaceId) {
1216
1319
  candidates,
1217
1320
  reasons: ["operation_path_only_has_no_strong_target_signal"]
1218
1321
  };
1219
- if (best && best.score >= 0.9 && (best.servicePath === signals.servicePath || Boolean(signals.serviceName && (!best.reasons.includes("local_service_name_mismatch") || best.reasons.includes("same_repo_unique_operation_path_with_lookup_mismatch")))) && (best.operationPath === signals.operationPath || best.operationName === signals.operationPath.replace(/^\//, "")) && (!second || best.score - second.score >= 0.25))
1322
+ if (best && best.score >= 0.9 && (best.servicePath === signals.servicePath || Boolean(signals.serviceName && (!best.reasons.includes("local_service_name_mismatch") || best.reasons.includes("same_repo_unique_operation_path_with_lookup_mismatch")))) && operationMatches(best, signals.operationPath) && (!second || best.score - second.score >= 0.25))
1220
1323
  return {
1221
1324
  status: "resolved",
1222
1325
  target: best,
@@ -1346,19 +1449,26 @@ function linkCalls(db, workspaceId, vars, generation) {
1346
1449
  }
1347
1450
  function insertCallEdge(db, workspaceId, call, vars, generation) {
1348
1451
  const callType = String(call.call_type);
1349
- const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1452
+ const rawOp = applyVariables(String(call.operation_path_expr ?? ""), vars);
1453
+ const normalized = normalizeODataOperationInvocationPath(rawOp);
1454
+ const op = normalized?.normalizedOperationPath ?? rawOp;
1350
1455
  const servicePath = applyVariables(call.servicePathExpr ?? call.requireServicePath, vars);
1351
1456
  const destination = call.destinationExpr ?? call.requireDestination;
1352
1457
  const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1353
- const isOperationCall = callType.startsWith("remote") || callType === "local_service_call";
1458
+ const isOperationCall = callType === "remote_action" || callType === "local_service_call" || callType === "remote_query" && Boolean(op);
1354
1459
  const resolution = isOperationCall ? resolveOperation(db, { servicePath, operationPath: op, serviceName: call.local_service_name, repoId: callType === "local_service_call" ? Number(call.repo_id) : void 0, alias: applyVariables(call.aliasExpr ?? call.alias, vars), destination: destination ? applyVariables(destination, vars) : void 0, isDynamic, hasExplicitOverride: Object.keys(vars).length > 0 || callType === "local_service_call" }, workspaceId) : { status: "unresolved", candidates: [], reasons: [] };
1355
- const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0);
1460
+ const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0, normalized);
1461
+ if (callType === "remote_query" && !op) {
1462
+ const target = buildRemoteQueryTarget({ queryEntity: call.query_entity, servicePath, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination: destination ? applyVariables(destination, vars) : void 0, isDynamic, parserWarning: evidence.parserWarning });
1463
+ db.prepare("INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)").run(workspaceId, "HANDLER_RUNS_REMOTE_QUERY", "terminal", "call", String(call.id), target.toKind, target.toId, Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, ...target.evidence }), 0, generation);
1464
+ return { status: "terminal", callType };
1465
+ }
1356
1466
  if (callType === "local_service_call" && call.unresolved_reason === "transport_client_method" && !resolution.target && resolution.candidates.length === 0) {
1357
1467
  db.prepare("INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)").run(workspaceId, "HANDLER_CALLS_EXTERNAL_HTTP", "terminal", "call", String(call.id), "external", String(op || "transport_client_method"), Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, classification: "transport_client_method" }), 0, generation);
1358
1468
  return { status: "terminal", callType };
1359
1469
  }
1360
1470
  if (resolution.target) {
1361
- db.prepare("INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)").run(workspaceId, callType === "local_service_call" ? "LOCAL_CALL_RESOLVES_TO_OPERATION" : "REMOTE_CALL_RESOLVES_TO_OPERATION", "resolved", "call", String(call.id), "operation", String(resolution.target.operationId), resolution.target.score, JSON.stringify(evidence), isDynamic ? 1 : 0, generation);
1471
+ db.prepare("INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)").run(workspaceId, callType === "local_service_call" ? "LOCAL_CALL_RESOLVES_TO_OPERATION" : "REMOTE_CALL_RESOLVES_TO_OPERATION", "resolved", "call", String(call.id), "operation", String(resolution.target.operationId), resolution.target.score, JSON.stringify(evidence), 0, generation);
1362
1472
  return { status: "resolved", callType };
1363
1473
  }
1364
1474
  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";
@@ -1366,11 +1476,25 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
1366
1476
  const unresolvedReason = status === "terminal" ? null : String(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"));
1367
1477
  const targetKind = callType === "local_db_query" ? "db_entity" : callType.startsWith("async_") ? "event" : "external";
1368
1478
  const targetId = callType === "local_db_query" ? String(call.query_entity ?? "unknown") : String(call.event_name_expr ?? op ?? call.id);
1369
- db.prepare("INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)").run(workspaceId, edgeType, status, "call", String(call.id), targetKind, targetId, Number(call.confidence ?? 0.2), JSON.stringify(evidence), isDynamic || resolution.status === "dynamic" ? 1 : 0, unresolvedReason, generation);
1479
+ const graphLevelDynamic = edgeType === "DYNAMIC_EDGE_CANDIDATE" && resolution.status === "dynamic";
1480
+ db.prepare("INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)").run(workspaceId, edgeType, status, "call", String(call.id), targetKind, targetId, Number(call.confidence ?? 0.2), JSON.stringify(evidence), graphLevelDynamic ? 1 : 0, unresolvedReason, generation);
1370
1481
  return { status, callType };
1371
1482
  }
1372
- function callEvidence(call, resolution, servicePath, op, destination) {
1373
- return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: call.alias_chain_json ? JSON.parse(String(call.alias_chain_json)) : void 0, transport: call.call_type === "local_service_call" ? "local" : void 0, targetRepo: resolution.target?.repoName, targetOperation: resolution.target?.operationName, helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0, candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
1483
+ function parseJson(value) {
1484
+ if (!value) return void 0;
1485
+ try {
1486
+ return JSON.parse(String(value));
1487
+ } catch {
1488
+ return void 0;
1489
+ }
1490
+ }
1491
+ function objectJson(value) {
1492
+ const parsed = parseJson(value);
1493
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
1494
+ }
1495
+ function callEvidence(call, resolution, servicePath, op, destination, normalized) {
1496
+ const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
1497
+ return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, rawOperationPath: normalized?.wasInvocation ? normalized.rawOperationPath : void 0, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : void 0, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: parseJson(call.alias_chain_json), transport: call.call_type === "local_service_call" ? "local" : void 0, targetRepo: resolution.target?.repoName, targetServicePath: resolution.target?.servicePath, targetOperationPath: resolution.target?.operationPath, targetOperation: resolution.target?.operationName, helperChain: parseJson(call.helperChainJson), candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, bindingHasDynamicExpression: bindingHasDynamicExpression || void 0, outboundEvidence: objectJson(call.evidence_json), analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
1374
1498
  }
1375
1499
  function linkImplementations(db, workspaceId, generation) {
1376
1500
  const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=?`).all(workspaceId);
@@ -1800,7 +1924,7 @@ function runtimeResolution(db, row, evidence, vars) {
1800
1924
  return { row, evidence, unresolvedReason: row.unresolved_reason };
1801
1925
  const nextEvidence = evidenceWithRuntimeVariables(evidence, vars);
1802
1926
  const servicePath = typeof nextEvidence.servicePath === "string" ? nextEvidence.servicePath : void 0;
1803
- const operationPath = typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
1927
+ const operationPath = typeof nextEvidence.normalizedOperationPath === "string" ? nextEvidence.normalizedOperationPath : typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
1804
1928
  const alias = typeof nextEvidence.serviceAliasExpr === "string" ? nextEvidence.serviceAliasExpr : typeof nextEvidence.serviceAlias === "string" ? nextEvidence.serviceAlias : void 0;
1805
1929
  const destination = typeof nextEvidence.destination === "string" ? nextEvidence.destination : void 0;
1806
1930
  const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceIdForCall(db, row.from_id));
@@ -1817,11 +1941,15 @@ function edgeTarget(row, evidence) {
1817
1941
  const runtimeCandidate = evidence.runtimeResolvedCandidate;
1818
1942
  if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath)
1819
1943
  return `${runtimeCandidate.servicePath}${runtimeCandidate.operationPath}`;
1944
+ const targetServicePath = typeof evidence.targetServicePath === "string" ? evidence.targetServicePath : void 0;
1945
+ const targetOperationPath = typeof evidence.targetOperationPath === "string" ? evidence.targetOperationPath : void 0;
1946
+ if (targetServicePath && targetOperationPath) return `${targetServicePath}${targetOperationPath}`;
1820
1947
  const servicePath = typeof evidence.servicePath === "string" ? evidence.servicePath : void 0;
1821
1948
  const operationPath = typeof evidence.operationPath === "string" ? evidence.operationPath : void 0;
1822
1949
  const targetOperation = typeof evidence.targetOperation === "string" ? evidence.targetOperation : void 0;
1823
1950
  const targetRepo = typeof evidence.targetRepo === "string" ? evidence.targetRepo : "";
1824
1951
  if (row.edge_type === "HANDLER_RUNS_DB_QUERY") return `Entity: ${row.to_id || "unknown"}`;
1952
+ if (row.edge_type === "HANDLER_RUNS_REMOTE_QUERY") return typeof evidence.remoteQueryTarget === "string" ? evidence.remoteQueryTarget : `Remote query: ${row.to_id || "unknown"}`;
1825
1953
  return servicePath && operationPath ? `${servicePath}${operationPath}` : targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
1826
1954
  }
1827
1955
  function trace(db, start, options) {
@@ -2000,7 +2128,8 @@ export {
2000
2128
  applyVariables,
2001
2129
  extractPlaceholders,
2002
2130
  substituteVariables,
2131
+ normalizeODataOperationInvocationPath,
2003
2132
  linkWorkspace,
2004
2133
  trace
2005
2134
  };
2006
- //# sourceMappingURL=chunk-HBR27SPD.js.map
2135
+ //# sourceMappingURL=chunk-YG66SHAR.js.map