@saptools/service-flow 0.1.32 → 0.1.33

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.33
4
+
5
+ - Preserved persisted graph decisions and call-site evidence during trace and graph rendering while keeping contextual runtime resolution as enrichment.
6
+ - Classified OData entity reads, mutations, deletes, navigation, media streams, and uppercase entity candidates as terminal remote entity edges instead of unresolved operation candidates.
7
+ - Kept dynamic external HTTP destinations dynamic with stable synthetic ids, neutral labels, bounded safe candidates, and sanitized URL evidence.
8
+ - Bumped the SQLite schema capability to version 7 and added strict doctor diagnostics for legacy schema drift and reindex-required external metadata.
9
+ - Standardized terminal trace-start diagnostics so non-traversable starts return zero graph nodes and edges by default.
10
+
3
11
  ## 0.1.32
4
12
 
5
13
  - Hardened operation-first trace starts to fail closed on ambiguous, rejected, or non-executable implementation evidence.
package/README.md CHANGED
@@ -68,6 +68,10 @@ npm install @saptools/service-flow
68
68
  - Doctor treats a `running` index run as abandoned only after 60 minutes and includes the run id/start time. Active short-lived concurrent runs are not default warnings.
69
69
  - Fresh databases include foreign keys for key graph, run, and diagnostic tables. Migrated legacy stores that still lack that metadata are reported by doctor with `legacy_schema_weaker_foreign_keys`; rebuild into a fresh database if strict structural parity is required.
70
70
  - Parser warnings describe analysis completeness, while routing status describes graph behavior. A terminal DB edge can remain terminal while still exposing parser warning evidence about an unknown entity.
71
+ - Persisted graph rows take precedence during `trace` and `graph`: resolved call edges keep their `graph_edges.id`, `outbound_calls.id`, call-site file/line, outbound parser evidence, linker status/reason, and selected target evidence. Contextual runtime resolution can enrich evidence but does not replace an already resolved persisted target. Terminal start diagnostics such as ambiguous operations or rejected implementations return zero nodes and zero edges by default; candidates remain in structured diagnostics for automation.
72
+ - OData entity paths are conservative terminal remote entity edges. Reads, mutations, deletes, navigation paths, media-stream paths such as `/Documents(ID)/content`, and uppercase unknown entity-set candidates do not inflate unresolved operation counts. Lowercase action/function-style paths remain eligible for indexed operation resolution.
73
+ - External HTTP destinations are static only when a safe literal or local const literal proves the value. Identifier, property-read, function-call, and arbitrary destination expressions are dynamic with stable `destination:dynamic:<hash>` ids and neutral labels; conditional literal branches expose only safe candidate names.
74
+ - Schema version 7 uses an explicit reindex-required upgrade policy for legacy schema drift. `doctor --strict` reports `schema_legacy_columns_present`, `external_target_columns_missing_data`, and `reindex_required_after_upgrade` with remediation when relink alone cannot make an upgraded database equivalent to a fresh index.
71
75
 
72
76
 
73
77
  ## 🚀 Quick Start
package/TECHNICAL-NOTE.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Service Flow Resolution Notes
2
2
 
3
3
 
4
+ ## 0.1.33 trace, entity, destination, and upgrade notes
5
+
6
+ - Trace and graph rendering use persisted resolved graph rows as authoritative base edges. Runtime/contextual resolution may add evidence such as substitutions or binding propagation, but persisted graph edge ids, outbound call ids, call-site file/line, parser evidence, linker status, selected target id, and target evidence remain present in effective edges.
7
+ - The trace-start machine contract is fail-closed: terminal start diagnostics produce zero graph nodes and zero graph edges by default. Candidate operations, implementation candidates, rejected edges, and selected ids are diagnostics-only evidence.
8
+ - Service-client OData entity paths are separated from action/function invocations. Entity reads, mutations, deletes, navigation, media stream calls, and uppercase entity-set candidates become terminal remote entity graph edges; lowercase operation-looking paths still go through operation resolution when indexed evidence exists.
9
+ - External HTTP destination extraction uses conservative static evaluation. Literals and safe local const literals are static; all other expressions stay dynamic unless a conditional has all-static branches, in which case only the candidate literals and sanitized expression shape are persisted.
10
+ - Schema version 7 follows an explicit reindex-required upgrade policy. Legacy external-target columns on `symbols` or missing queryable external metadata are strict doctor warnings with rebuild/reindex remediation instead of silent relink-only drift.
11
+
4
12
  ## 0.1.31 selector and external target notes
5
13
 
6
14
  Operation trace selectors are resolved from CDS operation facts before handler-source fallback. The start resolver scopes by repository and service path, requires disambiguation when multiple repositories or services match, and only queues traversal from a resolved implementation edge. Generated operation decorator constants are normalized in `operation-decorator-normalizer` so linker, trace fallback, diagnostics, and tests share one conservative implementation.
@@ -559,16 +559,23 @@ function classifyODataPathIntent(path9, method) {
559
559
  const placeholderKeys = [...new Set(extractTemplatePlaceholders(rawPath))];
560
560
  const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys };
561
561
  if (!rawPath || !rawPath.startsWith("/")) return { ...base, kind: "unknown", reason: "path_missing_or_not_absolute" };
562
- if (normalizedMethod !== "GET") return { ...base, kind: "operation_invocation", reason: "non_get_service_send_defaults_to_operation" };
562
+ const upperEntityLike = /^[A-Z][A-Za-z0-9_]*$/.test(entitySegment ?? firstSegment);
563
+ const mediaLike = ["content", "$value"].includes((segments.at(-1) ?? "").toLowerCase());
564
+ if (normalizedMethod !== "GET") {
565
+ if (mediaLike) return { ...base, kind: "entity_media", reason: "non_get_entity_media_stream_path" };
566
+ if (upperEntityLike || firstSegment.includes("(") || hasNavigationSegments) return { ...base, kind: normalizedMethod === "DELETE" ? "entity_delete" : "entity_mutation", reason: "non_get_entity_path_shape" };
567
+ return { ...base, kind: "operation_invocation", reason: "non_get_lowercase_path_may_be_operation" };
568
+ }
563
569
  if (queryIndex >= 0) {
564
570
  if (hasNavigationSegments) return { ...base, kind: "entity_navigation_query", reason: "get_path_has_navigation_and_query_string" };
565
571
  if (looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: "unknown", reason: "get_invocation_with_query_string_requires_indexed_operation_evidence" };
566
572
  return { ...base, kind: "entity_query", reason: "get_collection_path_has_query_string" };
567
573
  }
568
- if (hasNavigationSegments) return { ...base, kind: "entity_navigation_query", reason: "get_path_has_navigation_segments" };
574
+ if (hasNavigationSegments) return mediaLike ? { ...base, kind: "entity_media", reason: "get_entity_media_stream_path" } : { ...base, kind: "entity_navigation_query", reason: "get_path_has_navigation_segments" };
569
575
  if (firstSegment.includes("(")) {
570
576
  return looksLikeLowerCamelInvocation(firstSegment) ? { ...base, kind: "operation_invocation", reason: "get_single_lower_camel_segment_has_top_level_invocation" } : { ...base, kind: "entity_key_read", reason: "get_entity_segment_has_key_predicate" };
571
577
  }
578
+ if (/^[A-Z][A-Za-z0-9_]*$/.test(firstSegment)) return { ...base, kind: "entity_candidate", reason: "uppercase_collection_segment_without_indexed_entity_evidence" };
572
579
  return { ...base, kind: "unknown", reason: "get_path_has_no_query_key_or_navigation_signal" };
573
580
  }
574
581
  function entitySegmentFromPath(path9) {
@@ -748,6 +755,11 @@ function externalHttpTarget(call) {
748
755
  const method = typeof call.method === "string" ? call.method : typeof target.method === "string" ? target.method : void 0;
749
756
  const kind = typeof target.kind === "string" ? target.kind : "unknown";
750
757
  const expression = typeof target.expression === "string" ? target.expression : void 0;
758
+ if (kind === "destination" && target.dynamic === true) {
759
+ const shape = typeof target.expressionShape === "string" ? target.expressionShape : "expression";
760
+ const candidates = Array.isArray(target.candidateLiterals) ? target.candidateLiterals.filter((item) => typeof item === "string") : [];
761
+ return { kind, toKind: "external_destination", toId: `destination:dynamic:${hash(`${shape}:${candidates.join("|")}`)}`, label: "External destination: dynamic destination", method, dynamic: true, expression: candidates.length ? `candidates:${candidates.join("|")}` : `shape:${shape}` };
762
+ }
751
763
  if (kind === "destination" && expression) return { kind, toKind: "external_destination", toId: `destination:${expression}`, label: `External destination: ${expression}`, method, dynamic: false, expression };
752
764
  if (kind === "static_url" && expression) {
753
765
  const redacted = redactUrl(expression);
@@ -850,6 +862,24 @@ function staticExpressionText(expr, initializers) {
850
862
  if (ts4.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
851
863
  return void 0;
852
864
  }
865
+ function destinationExpressionShape(expr) {
866
+ if (!expr) return void 0;
867
+ if (ts4.isIdentifier(expr)) return "identifier";
868
+ if (ts4.isPropertyAccessExpression(expr) || ts4.isElementAccessExpression(expr)) return "property_read";
869
+ if (ts4.isCallExpression(expr)) return "function_call";
870
+ if (ts4.isConditionalExpression(expr)) return "conditional";
871
+ if (ts4.isBinaryExpression(expr)) return "binary_expression";
872
+ if (ts4.isTemplateExpression(expr)) return "template_expression";
873
+ return ts4.SyntaxKind[expr.kind] ?? "expression";
874
+ }
875
+ function staticConditionalCandidates(expr, initializers) {
876
+ const resolved2 = expr && ts4.isIdentifier(expr) && initializers.has(expr.text) ? initializers.get(expr.text) : expr;
877
+ if (!resolved2 || !ts4.isConditionalExpression(resolved2)) return void 0;
878
+ const left = staticExpressionText(resolved2.whenTrue, initializers);
879
+ const right = staticExpressionText(resolved2.whenFalse, initializers);
880
+ if (!left || !right) return void 0;
881
+ return [.../* @__PURE__ */ new Set([left, right])];
882
+ }
853
883
  function propertyInitializer(object, key) {
854
884
  for (const property of object.properties) {
855
885
  if (ts4.isPropertyAssignment(property) && nameOfProperty(property.name) === key) return property.initializer;
@@ -870,7 +900,10 @@ function urlTargetFromExpression(expr, initializers) {
870
900
  function destinationTargetFromExpression(expr, initializers) {
871
901
  const text = staticExpressionText(expr, initializers);
872
902
  if (text) return { kind: "destination", expression: text, dynamic: false };
873
- if (expr && ts4.isIdentifier(expr)) return { kind: "destination", expression: expr.text, dynamic: true };
903
+ const candidates = staticConditionalCandidates(expr, initializers);
904
+ if (candidates) return { kind: "destination", dynamic: true, expressionShape: "conditional", candidateLiterals: candidates };
905
+ const shape = destinationExpressionShape(expr);
906
+ if (shape) return { kind: "destination", dynamic: true, expressionShape: shape };
874
907
  return void 0;
875
908
  }
876
909
  function externalHttpEvidence(node, source, initializers) {
@@ -1018,8 +1051,10 @@ function classifyOutboundCallsInSource(source, filePath) {
1018
1051
  const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
1019
1052
  const operationPathExpr = op && !shorthandPath ? `/${stripQuotes(op).replace(/^\//, "")}` : void 0;
1020
1053
  const intent = classifyODataPathIntent(operationPathExpr, method);
1054
+ const entityCallTypes = { entity_mutation: "remote_entity_mutation", entity_delete: "remote_entity_delete", entity_media: "remote_entity_media", entity_candidate: "remote_entity_candidate" };
1055
+ const entityCallType = entityCallTypes[intent.kind];
1021
1056
  const isODataQueryRead = method.toUpperCase() === "GET" && ["entity_query", "entity_key_read", "entity_navigation_query"].includes(intent.kind);
1022
- add(node, { callType: query || isODataQueryRead ? "remote_query" : "remote_action", serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : void 0, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && shorthandPath ? "dynamic_operation_path_identifier" : void 0 }, { receiver, classifier: "service_client_send_object", operationPathExpression: shorthandPath ? op : void 0, odataPathIntent: operationPathExpr ? intent : void 0, parserWarning: shorthandPath ? "dynamic_operation_path_identifier" : void 0 });
1057
+ add(node, { callType: query ? "remote_query" : entityCallType ?? (isODataQueryRead ? "remote_query" : "remote_action"), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : void 0, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && shorthandPath ? "dynamic_operation_path_identifier" : void 0 }, { receiver, classifier: "service_client_send_object", operationPathExpression: shorthandPath ? op : void 0, odataPathIntent: operationPathExpr ? intent : void 0, parserWarning: shorthandPath ? "dynamic_operation_path_identifier" : void 0 });
1023
1058
  }
1024
1059
  } else if (ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text) || ts4.isIdentifier(expr) && wrapperSpecs.has(expr.text)) {
1025
1060
  const wrapperName = ts4.isIdentifier(expr) ? expr.text : ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) ? expr.expression.text : "";
@@ -2066,9 +2101,16 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
2066
2101
  const servicePath = applyVariables(call.servicePathExpr ?? call.requireServicePath, vars);
2067
2102
  const destination = call.destinationExpr ?? call.requireDestination;
2068
2103
  const isDynamic = Boolean(Number(call.isDynamic ?? 0));
2069
- const isOperationCall = callType === "remote_action" || callType === "local_service_call" || callType === "remote_query" && Boolean(op);
2104
+ const isRemoteEntityCall = callType.startsWith("remote_entity_");
2105
+ const isOperationCall = !isRemoteEntityCall && (callType === "remote_action" || callType === "local_service_call" || callType === "remote_query" && Boolean(op));
2070
2106
  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: [] };
2071
2107
  const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0, normalized, intent);
2108
+ if (isRemoteEntityCall) {
2109
+ const target = buildRemoteQueryTarget({ queryEntity: intent.entitySegment ?? call.query_entity, servicePath, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination: destination ? applyVariables(destination, vars) : void 0, isDynamic, parserWarning: evidence.parserWarning });
2110
+ const entityKind = callType.replace("remote_entity_", "remote_entity_");
2111
+ 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_ACCESSES_REMOTE_ENTITY", "terminal", "call", String(call.id), target.toKind, target.toId, Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, ...target.evidence, remoteEntityAccess: entityKind }), 0, generation);
2112
+ return { status: "terminal", callType };
2113
+ }
2072
2114
  if (callType === "remote_query" && (isEntityQueryIntent || !op) && !resolution.target) {
2073
2115
  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 });
2074
2116
  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);
@@ -2673,7 +2715,7 @@ function contextForSymbolCall(db, symbolCall, callerBindings) {
2673
2715
  });
2674
2716
  return next;
2675
2717
  }
2676
- function contextualRuntimeResolution(db, call, binding, workspaceId) {
2718
+ function contextualRuntimeResolution(db, call, binding, workspaceId, persistedRows = []) {
2677
2719
  if (!binding || String(call.call_type) !== "remote_action" || call.operation_path_expr === void 0 || call.operation_path_expr === null) return {};
2678
2720
  const normalized = normalizeODataOperationInvocationPath(String(call.operation_path_expr));
2679
2721
  const op = normalized?.normalizedOperationPath ?? (String(call.operation_path_expr).startsWith("/") ? String(call.operation_path_expr) : `/${String(call.operation_path_expr)}`);
@@ -2683,6 +2725,8 @@ function contextualRuntimeResolution(db, call, binding, workspaceId) {
2683
2725
  const evidence = { contextualServiceBindingAttempted: true, contextualBinding: { source: binding.source, callerArgument: binding.callerArgument, callerProperty: binding.callerProperty, calleeParameter: binding.calleeParameter, calleeReceiver: binding.calleeReceiver, 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 : void 0, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : void 0, 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 };
2684
2726
  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" };
2685
2727
  const resolvedEvidence = { ...evidence, contextualServiceBindingSelected: true, targetRepo: resolution.target.repoName, targetServicePath: resolution.target.servicePath, targetOperationPath: resolution.target.operationPath, targetOperation: resolution.target.operationName };
2728
+ const persistedResolved = persistedRows.find((item) => item.status === "resolved");
2729
+ if (persistedResolved) return { row: void 0, evidence: { ...resolvedEvidence, contextualPreservedPersistedResolvedEdge: true }, unresolvedReason: void 0 };
2686
2730
  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: void 0 };
2687
2731
  }
2688
2732
  function edgeTarget(row, evidence) {
@@ -2724,7 +2768,7 @@ function trace(db, start, options) {
2724
2768
  const nodes = /* @__PURE__ */ new Map();
2725
2769
  const seenEdges = /* @__PURE__ */ new Set();
2726
2770
  const queue = scope.selectorMatched ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: /* @__PURE__ */ new Map() }] : [];
2727
- if (scope.startOperationId) {
2771
+ if (scope.startOperationId && scope.selectorMatched) {
2728
2772
  const op = operationNode(db, scope.startOperationId);
2729
2773
  const impl = implementationScope(db, scope.startOperationId);
2730
2774
  if (op) nodes.set(String(op.id), op);
@@ -2781,15 +2825,16 @@ function trace(db, start, options) {
2781
2825
  line: call.source_line,
2782
2826
  callType: call.call_type
2783
2827
  });
2784
- const contextual = contextualRuntimeResolution(db, call, callerBindings.get(receiverFromEvidence(call.evidence_json) ?? ""), workspaceIdForCall(db, String(call.id)));
2785
- const graphRows = contextual.row ? [contextual.row] : graph.get(Number(call.id)) ?? [];
2828
+ const persistedRowsForCall = graph.get(Number(call.id)) ?? [];
2829
+ const contextual = contextualRuntimeResolution(db, call, callerBindings.get(receiverFromEvidence(call.evidence_json) ?? ""), workspaceIdForCall(db, String(call.id)), persistedRowsForCall);
2830
+ const graphRows = contextual.row ? [contextual.row] : persistedRowsForCall;
2786
2831
  for (const row of graphRows) {
2787
2832
  if (seenEdges.has(Number(row.id))) continue;
2788
2833
  seenEdges.add(Number(row.id));
2789
2834
  const persistedEvidence = JSON.parse(
2790
2835
  String(row.evidence_json || "{}")
2791
2836
  );
2792
- const rawEvidence = contextual.evidence ? { ...persistedEvidence, ...contextual.evidence } : persistedEvidence;
2837
+ const rawEvidence = { ...persistedEvidence, ...contextual.evidence ?? {}, graphEdgeId: row.id, persistedGraphEdgeId: row.id > 0 ? row.id : void 0, outboundCallId: call.id, callSite: { sourceFile: call.source_file, sourceLine: call.source_line }, sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, linker: { status: row.status, confidence: row.confidence, reason: row.unresolved_reason, edgeType: row.edge_type }, persistedTarget: { kind: row.to_kind, id: row.to_id }, contextualResolutionParticipated: Boolean(contextual.evidence?.contextualServiceBindingAttempted) };
2793
2838
  const effective = runtimeResolution(db, row, rawEvidence, options.vars);
2794
2839
  const evidence = effective.evidence;
2795
2840
  const effectiveRow = effective.row;
@@ -2884,4 +2929,4 @@ export {
2884
2929
  linkWorkspace,
2885
2930
  trace
2886
2931
  };
2887
- //# sourceMappingURL=chunk-VT5FVMOA.js.map
2932
+ //# sourceMappingURL=chunk-CWJYVIG2.js.map