@saptools/service-flow 0.1.34 → 0.1.35

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.35
4
+
5
+ - Hardened OData path precedence so entity key, navigation, and media/property paths with placeholders remain terminal entity evidence instead of dynamic operation candidates.
6
+ - Preserved separate evidence for service-routing placeholders, operation invocation argument placeholders, and entity key placeholders.
7
+ - Render operation-resolved parser entity calls as operation calls in traces while retaining the original parser call type for auditability.
8
+ - Added strict doctor coverage for dynamic remote-entity false positives without indexed operation evidence.
9
+
3
10
  ## 0.1.34
4
11
 
5
12
  - Prefer indexed CDS operation evidence over heuristic remote-entity classification for service-client operation invocations, while keeping true collection, entity, delete, navigation, and media paths terminal.
package/TECHNICAL-NOTE.md CHANGED
@@ -137,3 +137,8 @@ Schema version 6 adds queryable external target metadata columns to `outbound_ca
137
137
  ### 0.1.17 parser ownership policy
138
138
 
139
139
  Outbound call extraction is AST-based and ignores comments, block comments, and string literals. CAP/service `.on(...)` registrations are indexed only when the receiver has CAP/service evidence, and top-level registrations receive `module:<relative-file>#event:<event-name>:<line>` synthetic owners. Generic event emitters such as desktop or window events are ignored by default rather than guessed as CAP async edges. Unsupported source shapes are surfaced through diagnostics and strict doctor ownerless categories instead of guessed graph edges.
140
+
141
+
142
+ ## 0.1.35 OData placeholder semantics
143
+
144
+ Service-flow now records OData placeholders by semantic layer. Service-routing placeholders belong to service bindings and can make an operation edge dynamic until runtime variables are supplied. Operation invocation argument placeholders, such as action/function call arguments, remain operation evidence but are not service-routing variables. Entity key placeholders belong to entity addressing, so key, navigation, and media/property paths remain terminal remote entity/query edges unless indexed CDS operation evidence provides a credible operation match.
@@ -557,15 +557,28 @@ function classifyODataPathIntent(path9, method) {
557
557
  const hasNavigationSegments = segments.length > 1;
558
558
  const entitySegment = entitySegmentFromPath(pathWithoutQuery);
559
559
  const placeholderKeys = [...new Set(extractTemplatePlaceholders(rawPath))];
560
- const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys };
560
+ const firstOpen = firstSegment.indexOf("(");
561
+ const firstClose = firstOpen >= 0 ? matchingClose(firstSegment, firstOpen) : void 0;
562
+ const keyPredicateText = firstOpen >= 0 && firstClose !== void 0 ? firstSegment.slice(firstOpen + 1, firstClose) : "";
563
+ const rawKeyPredicatePlaceholderKeys = [...new Set(extractTemplatePlaceholders(keyPredicateText))];
564
+ const navigationSuffix = hasNavigationSegments ? segments.slice(1).join("/") : void 0;
565
+ const lastSegment = segments.at(-1) ?? "";
566
+ const mediaOrPropertySuffix = hasNavigationSegments ? lastSegment : void 0;
567
+ const invocation = normalizeODataOperationInvocationPath(pathWithoutQuery);
568
+ const operationNameFromInvocation = invocation?.wasInvocation ? invocation.normalizedOperationPath.replace(/^\//, "").split(".").at(-1) : void 0;
569
+ const topLevelName = firstSegment.split("(")[0]?.replace(/^\//, "");
570
+ const topLevelOperationName = operationNameFromInvocation ?? (!hasNavigationSegments && !firstSegment.includes("(") ? topLevelName : void 0);
571
+ const topLevelOperationInvocation = Boolean(invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment));
572
+ const keyPredicatePlaceholderKeys = topLevelOperationInvocation ? [] : rawKeyPredicatePlaceholderKeys;
573
+ const topLevelOperationNameCandidate = Boolean(topLevelOperationName && !hasNavigationSegments && !firstSegment.includes("("));
574
+ const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== void 0, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
561
575
  if (!rawPath || !rawPath.startsWith("/")) return { ...base, kind: "unknown", reason: "path_missing_or_not_absolute" };
562
576
  const upperEntityLike = /^[A-Z][A-Za-z0-9_]*$/.test(entitySegment ?? firstSegment);
563
- const mediaLike = ["content", "$value"].includes((segments.at(-1) ?? "").toLowerCase());
564
- const invocation = normalizeODataOperationInvocationPath(pathWithoutQuery);
577
+ const mediaLike = isMediaOrPropertySuffix(segments.at(-1) ?? "");
565
578
  if (normalizedMethod !== "GET") {
566
579
  if (invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: "operation_invocation", reason: "non_get_balanced_top_level_operation_invocation" };
567
580
  if (mediaLike) return { ...base, kind: "entity_media", reason: "non_get_entity_media_stream_path" };
568
- if (hasNavigationSegments || firstSegment.includes("(")) return { ...base, kind: normalizedMethod === "DELETE" ? "entity_delete" : "entity_mutation", reason: "non_get_entity_path_shape" };
581
+ if (hasNavigationSegments || firstSegment.includes("(")) return { ...base, kind: normalizedMethod === "DELETE" ? "entity_delete" : "entity_mutation", reason: firstSegment.includes("(") ? "non_get_entity_key_or_navigation_path_shape" : "non_get_entity_navigation_path_shape" };
569
582
  if (upperEntityLike) return { ...base, kind: normalizedMethod === "DELETE" ? "entity_delete" : "entity_mutation", reason: "non_get_entity_path_shape" };
570
583
  return { ...base, kind: "operation_invocation", reason: "non_get_lowercase_path_may_be_operation" };
571
584
  }
@@ -589,6 +602,9 @@ function entitySegmentFromPath(path9) {
589
602
  const entity = (open >= 0 ? first.slice(0, open) : first).trim();
590
603
  return entity || void 0;
591
604
  }
605
+ function isMediaOrPropertySuffix(segment) {
606
+ return ["file", "content", "$value", "metadata", "items"].includes(segment.toLowerCase());
607
+ }
592
608
  function looksLikeLowerCamelInvocation(segment) {
593
609
  const open = segment.indexOf("(");
594
610
  if (open <= 0) return false;
@@ -2106,17 +2122,20 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
2106
2122
  const destination = call.destinationExpr ?? call.requireDestination;
2107
2123
  const isDynamic = Boolean(Number(call.isDynamic ?? 0));
2108
2124
  const isRemoteEntityCall = callType.startsWith("remote_entity_");
2109
- const operationLikeRemoteEntity = isRemoteEntityCall && Boolean(op) && !["entity_media", "entity_delete", "entity_key_read", "entity_navigation_query"].includes(intent.kind);
2125
+ const indexedOperationCandidateCount = operationCandidateCount(db, workspaceId, op, intent.topLevelOperationName);
2126
+ const credibleOperationSignal = Boolean(normalized?.wasInvocation) || Boolean(intent.topLevelOperationNameCandidate) && indexedOperationCandidateCount > 0;
2127
+ const strongEntitySignal = ["entity_media", "entity_delete", "entity_key_read", "entity_navigation_query"].includes(intent.kind) || intent.kind === "entity_mutation" && (intent.hasEntityKeyPredicate || intent.hasNavigationSuffix);
2128
+ const operationLikeRemoteEntity = isRemoteEntityCall && Boolean(op) && credibleOperationSignal && (!strongEntitySignal || indexedOperationCandidateCount > 0);
2110
2129
  const isOperationCall = operationLikeRemoteEntity || (callType === "remote_action" || callType === "local_service_call" || callType === "remote_query" && Boolean(op));
2111
2130
  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: [] };
2112
- const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0, normalized, intent);
2131
+ const evidence = { ...callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0, normalized, intent), indexedOperationCandidateCount, parserCallType: callType };
2113
2132
  if (isRemoteEntityCall && (resolution.target || resolution.candidates.length > 0 || resolution.status === "dynamic")) {
2114
2133
  if (resolution.target) {
2115
2134
  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, "REMOTE_CALL_RESOLVES_TO_OPERATION", "resolved", "call", String(call.id), "operation", String(resolution.target.operationId), resolution.target.score, JSON.stringify({ ...evidence, operationEntityPrecedence: "indexed_operation_over_parser_entity" }), 0, generation);
2116
2135
  return { status: "resolved", callType };
2117
2136
  }
2118
2137
  const status2 = resolution.status === "dynamic" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : "unresolved";
2119
- 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, status2 === "dynamic" ? "DYNAMIC_EDGE_CANDIDATE" : "UNRESOLVED_EDGE", status2, "call", String(call.id), "operation_candidate", op ? `Remote action: ${op}` : "Remote action: unknown path", Number(call.confidence ?? 0.2), JSON.stringify({ ...evidence, operationEntityPrecedence: "parser_entity_with_indexed_operation_candidates" }), status2 === "dynamic" ? 1 : 0, unresolvedOperationReason(resolution), generation);
2138
+ 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, status2 === "dynamic" ? "DYNAMIC_EDGE_CANDIDATE" : "UNRESOLVED_EDGE", status2, "call", String(call.id), "operation_candidate", op ? `Remote action: ${op}` : "Remote action: unknown path", Number(call.confidence ?? 0.2), JSON.stringify({ ...evidence, operationEntityPrecedence: resolution.candidates.length > 0 ? "parser_entity_with_indexed_operation_candidates" : "parser_entity_operation_candidate_without_indexed_match" }), status2 === "dynamic" ? 1 : 0, unresolvedOperationReason(resolution), generation);
2120
2139
  return { status: status2, callType };
2121
2140
  }
2122
2141
  if (isRemoteEntityCall) {
@@ -2149,6 +2168,12 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
2149
2168
  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(finalEvidence), graphLevelDynamic ? 1 : 0, unresolvedReason, generation);
2150
2169
  return { status, callType };
2151
2170
  }
2171
+ function operationCandidateCount(db, workspaceId, operationPath, operationName) {
2172
+ if (!operationPath && !operationName) return 0;
2173
+ const normalizedName = operationName ?? operationPath?.replace(/^\//, "").split(".").at(-1);
2174
+ const row = db.prepare(`SELECT COUNT(*) count 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=? AND (o.operation_path=? OR o.operation_path=? OR o.operation_name=?)`).get(workspaceId, operationPath, normalizedName ? `/${normalizedName}` : operationPath, normalizedName);
2175
+ return Number(row?.count ?? 0);
2176
+ }
2152
2177
  function unresolvedOperationReason(resolution) {
2153
2178
  if (resolution.status === "dynamic") return `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ["missing runtime variables"]).join(", ")}`;
2154
2179
  if (resolution.candidates.length === 0) return "No indexed target operation matched";
@@ -2569,6 +2594,11 @@ function handlerScope(db, methodId) {
2569
2594
  if (!row) return void 0;
2570
2595
  return { repoId: row.repoId, files: new Set(row.sourceFile ? [row.sourceFile] : []), symbolId: row.symbolId };
2571
2596
  }
2597
+ function traceEdgeType(call, row) {
2598
+ if (row.to_kind === "operation" && row.edge_type === "REMOTE_CALL_RESOLVES_TO_OPERATION") return "remote_action";
2599
+ if (row.to_kind === "operation" && row.edge_type === "LOCAL_CALL_RESOLVES_TO_OPERATION") return "local_service_call";
2600
+ return String(call.call_type);
2601
+ }
2572
2602
  function includeCall(type, options) {
2573
2603
  if (!options.includeDb && type === "local_db_query") return false;
2574
2604
  if (!options.includeExternal && type === "external_http") return false;
@@ -2862,7 +2892,7 @@ function trace(db, start, options) {
2862
2892
  const to = edgeTarget(effectiveRow, evidence);
2863
2893
  edges.push({
2864
2894
  step: current.depth,
2865
- type: String(call.call_type),
2895
+ type: traceEdgeType(call, effectiveRow),
2866
2896
  from: `${call.repoName}:${call.source_file}:${call.source_line}`,
2867
2897
  to,
2868
2898
  evidence,
@@ -2934,6 +2964,7 @@ export {
2934
2964
  redactText,
2935
2965
  redactValue,
2936
2966
  normalizeODataOperationInvocationPath,
2967
+ classifyODataPathIntent,
2937
2968
  containsSupportedOutboundCall,
2938
2969
  parseOutboundCalls,
2939
2970
  parseServiceBindings,
@@ -2943,4 +2974,4 @@ export {
2943
2974
  linkWorkspace,
2944
2975
  trace
2945
2976
  };
2946
- //# sourceMappingURL=chunk-5SR4SFSU.js.map
2977
+ //# sourceMappingURL=chunk-R47GA5VW.js.map