@saptools/service-flow 0.1.31 → 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,20 @@
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
+
11
+ ## 0.1.32
12
+
13
+ - Hardened operation-first trace starts to fail closed on ambiguous, rejected, or non-executable implementation evidence.
14
+ - Made decorator normalization explicit and conservative for unsupported expressions.
15
+ - Populated queryable external HTTP target metadata with sanitized labels and kept CAP candidates distinct from HTTP endpoints.
16
+ - Removed accidental fresh-schema external-target columns from symbols and documented migration/re-index expectations.
17
+
3
18
  ## 0.1.31
4
19
 
5
20
  - Resolve operation and path trace selectors from indexed CDS operations and persisted implementation edges before conservative handler fallback, including generated `Action<Name>` and `Func<Name>` decorator constants whose method names differ from public operation names.
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
@@ -490,3 +494,14 @@ The `graph` command accepts repeatable `--var key=value` options, matching `trac
490
494
  ### 0.1.17 parser ownership policy
491
495
 
492
496
  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.
497
+
498
+
499
+ ## 0.1.32 hardening notes
500
+
501
+ Operation trace starts resolve indexed CDS operations before handler fallback. Ambiguous operation matches, ambiguous/rejected implementation edges, and resolved edges without an executable handler scope now fail closed with structured `resolutionStage` and `resolutionStatus` diagnostics and no speculative traversal. Handler fallback is only considered when no indexed CDS operation matches and the fallback is unique under the existing registration and ownership policy.
502
+
503
+ Decorator signals are interpreted as resolved, absent, unsupported, or malformed. Only resolved string/template values, generated `Action<Name>.name`/`Func<Name>.name` constants, qualified generated constants ending in `.name`, and narrow safe `String(identifier)` wrappers can contradict method-name fallback. Unsupported expressions are preserved as evidence but are not treated as proof that a handler targets another operation.
504
+
505
+ External HTTP facts persist queryable target kind, stable id, safe label, and dynamic flag on `outbound_calls`; graph evidence uses the same sanitized model. URL user info, fragments, query values, headers, cookies, credentials, tokens, API keys, and request/response bodies are not persisted. Legacy workspaces may relink from old evidence when possible, but full semantic enrichment requires re-index plus relink when old parser facts did not contain safe target metadata.
506
+
507
+ Graph taxonomy now keeps CAP operation candidates separate from external HTTP targets. Dynamic, ambiguous, and unresolved CAP calls use operation-candidate semantics, external destinations/endpoints are produced only by `external_http` calls, and terminal local transport-client methods use transport-method semantics rather than external HTTP edges. `doctor --strict` reports external target quality and taxonomy consistency.
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) {
@@ -720,6 +727,57 @@ function topLevelQueryIndex(text) {
720
727
  import fs6 from "fs/promises";
721
728
  import path7 from "path";
722
729
  import ts4 from "typescript";
730
+
731
+ // src/linker/external-http-target.ts
732
+ import { createHash } from "crypto";
733
+ var sensitiveKeys = /* @__PURE__ */ new Set(["token", "access_token", "id_token", "api_key", "apikey", "key", "password", "passwd", "pwd", "secret", "client_secret", "authorization", "cookie", "signature"]);
734
+ function hash(value) {
735
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
736
+ }
737
+ function methodPrefix(method) {
738
+ return typeof method === "string" && method.length > 0 ? `${method.toUpperCase()} ` : "";
739
+ }
740
+ function redactUrl(value) {
741
+ try {
742
+ const url = new URL(value, value.startsWith("/") ? "https://relative.invalid" : void 0);
743
+ url.username = "";
744
+ url.password = "";
745
+ for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, sensitiveKeys.has(key.toLowerCase()) ? "<redacted>" : "<redacted>");
746
+ const path9 = `${url.pathname}${url.search ? url.search : ""}`;
747
+ return value.startsWith("/") ? path9 : `${url.origin}${path9}`;
748
+ } catch {
749
+ return value.replace(/([?&][^=;&]*(?:token|key|password|secret|cookie|authorization)[^=;&]*=)[^&]*/gi, "$1<redacted>");
750
+ }
751
+ }
752
+ function externalHttpTarget(call) {
753
+ const evidence = typeof call.evidence_json === "string" ? safeParse(call.evidence_json) : {};
754
+ const target = evidence.externalTarget && typeof evidence.externalTarget === "object" && !Array.isArray(evidence.externalTarget) ? evidence.externalTarget : {};
755
+ const method = typeof call.method === "string" ? call.method : typeof target.method === "string" ? target.method : void 0;
756
+ const kind = typeof target.kind === "string" ? target.kind : "unknown";
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
+ }
763
+ if (kind === "destination" && expression) return { kind, toKind: "external_destination", toId: `destination:${expression}`, label: `External destination: ${expression}`, method, dynamic: false, expression };
764
+ if (kind === "static_url" && expression) {
765
+ const redacted = redactUrl(expression);
766
+ return { kind, toKind: "external_endpoint", toId: `endpoint:${hash(`${method ?? ""}:${redacted}`)}`, label: `External endpoint: ${methodPrefix(method)}${redacted}`, method, dynamic: false, expression: redacted };
767
+ }
768
+ if (kind === "url_expression" && expression) return { kind, toKind: "external_endpoint", toId: `dynamic:${hash(expression)}`, label: `External endpoint: ${methodPrefix(method)}dynamic URL`, method, dynamic: true, expression: `expr:${hash(expression)}` };
769
+ return { kind: "unknown", toKind: "external_endpoint", toId: "unknown", label: "External endpoint: unknown", method, dynamic: false };
770
+ }
771
+ function safeParse(value) {
772
+ try {
773
+ const parsed = JSON.parse(value);
774
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
775
+ } catch {
776
+ return {};
777
+ }
778
+ }
779
+
780
+ // src/parsers/outbound-call-parser.ts
723
781
  function lineOf3(text, idx) {
724
782
  return text.slice(0, idx).split("\n").length;
725
783
  }
@@ -804,6 +862,24 @@ function staticExpressionText(expr, initializers) {
804
862
  if (ts4.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
805
863
  return void 0;
806
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
+ }
807
883
  function propertyInitializer(object, key) {
808
884
  for (const property of object.properties) {
809
885
  if (ts4.isPropertyAssignment(property) && nameOfProperty(property.name) === key) return property.initializer;
@@ -824,7 +900,10 @@ function urlTargetFromExpression(expr, initializers) {
824
900
  function destinationTargetFromExpression(expr, initializers) {
825
901
  const text = staticExpressionText(expr, initializers);
826
902
  if (text) return { kind: "destination", expression: text, dynamic: false };
827
- 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 };
828
907
  return void 0;
829
908
  }
830
909
  function externalHttpEvidence(node, source, initializers) {
@@ -972,8 +1051,10 @@ function classifyOutboundCallsInSource(source, filePath) {
972
1051
  const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
973
1052
  const operationPathExpr = op && !shorthandPath ? `/${stripQuotes(op).replace(/^\//, "")}` : void 0;
974
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];
975
1056
  const isODataQueryRead = method.toUpperCase() === "GET" && ["entity_query", "entity_key_read", "entity_navigation_query"].includes(intent.kind);
976
- 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 });
977
1058
  }
978
1059
  } else if (ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text) || ts4.isIdentifier(expr) && wrapperSpecs.has(expr.text)) {
979
1060
  const wrapperName = ts4.isIdentifier(expr) ? expr.text : ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) ? expr.expression.text : "";
@@ -996,7 +1077,11 @@ function classifyOutboundCallsInSource(source, filePath) {
996
1077
  }
997
1078
  } else {
998
1079
  const external = externalHttpEvidence(node, source, initializers);
999
- if (external) add(node, { callType: "external_http", method: external.method, payloadSummary: summarizeExpression(node.arguments.map((arg) => arg.getText(source)).join(", ")), confidence: 0.7, unresolvedReason: "External HTTP destination is outside indexed CAP services" }, { classifier: external.classifier, externalTarget: { ...external.externalTarget, method: external.method, parserClassifier: external.classifier, sourceCallShape: external.sourceCallShape }, sourceCallShape: external.sourceCallShape });
1080
+ if (external) {
1081
+ const evidenceTarget = { ...external.externalTarget, method: external.method, parserClassifier: external.classifier, sourceCallShape: external.sourceCallShape };
1082
+ const safeTarget = externalHttpTarget({ method: external.method, evidence_json: JSON.stringify({ externalTarget: evidenceTarget }) });
1083
+ add(node, { callType: "external_http", method: external.method, payloadSummary: void 0, confidence: 0.7, unresolvedReason: "External HTTP destination is outside indexed CAP services", externalTarget: { kind: safeTarget.kind, stableId: safeTarget.toId, label: safeTarget.label, dynamic: safeTarget.dynamic } }, { classifier: external.classifier, externalTarget: safeTarget, sourceCallShape: external.sourceCallShape });
1084
+ }
1000
1085
  }
1001
1086
  }
1002
1087
  ts4.forEachChild(node, visit);
@@ -1450,16 +1535,16 @@ async function parseServiceBindings(repoPath, filePath) {
1450
1535
  helperChain: aliasKind === "assignment" ? [{ callerVariable: targetName, assignedFrom: call.expression.getText(sourceFileAst), aliasKind, scopeRule: "same-file-source-order" }] : void 0
1451
1536
  });
1452
1537
  else if (ts5.isIdentifier(call.expression)) {
1453
- const resolved = await importedHelper(call.expression.text);
1454
- if (resolved)
1538
+ const resolved2 = await importedHelper(call.expression.text);
1539
+ if (resolved2)
1455
1540
  out.push({
1456
1541
  variableName: targetName,
1457
- alias: resolved.helper.alias,
1458
- aliasExpr: resolved.helper.aliasExpr,
1459
- destinationExpr: resolved.helper.destinationExpr,
1460
- servicePathExpr: resolved.helper.servicePathExpr,
1461
- isDynamic: resolved.helper.isDynamic,
1462
- placeholders: resolved.helper.placeholders,
1542
+ alias: resolved2.helper.alias,
1543
+ aliasExpr: resolved2.helper.aliasExpr,
1544
+ destinationExpr: resolved2.helper.destinationExpr,
1545
+ servicePathExpr: resolved2.helper.servicePathExpr,
1546
+ isDynamic: resolved2.helper.isDynamic,
1547
+ placeholders: resolved2.helper.placeholders,
1463
1548
  sourceFile: normalizePath(filePath),
1464
1549
  sourceLine: lineOf4(sourceFileAst, node),
1465
1550
  helperChain: [
@@ -1467,10 +1552,10 @@ async function parseServiceBindings(repoPath, filePath) {
1467
1552
  callerVariable: targetName,
1468
1553
  ...aliasKind === "assignment" ? { assignedFrom: call.expression.text, aliasKind, scopeRule: "same-file-source-order" } : {},
1469
1554
  importedHelper: call.expression.text,
1470
- importSource: resolved.imp.sourceFile,
1471
- exportedSymbol: resolved.imp.exportedName,
1472
- helperSourceFile: resolved.helper.sourceFile,
1473
- helperSourceLine: resolved.helper.sourceLine
1555
+ importSource: resolved2.imp.sourceFile,
1556
+ exportedSymbol: resolved2.imp.exportedName,
1557
+ helperSourceFile: resolved2.helper.sourceFile,
1558
+ helperSourceLine: resolved2.helper.sourceLine
1474
1559
  }
1475
1560
  ]
1476
1561
  });
@@ -1497,18 +1582,18 @@ async function parseServiceBindings(repoPath, filePath) {
1497
1582
  const propertyName = el.propertyName && ts5.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
1498
1583
  const matches = helpers.filter((row) => row.helper.returnedProperty === propertyName);
1499
1584
  if (matches.length !== 1) continue;
1500
- const resolved = matches[0];
1585
+ const resolved2 = matches[0];
1501
1586
  out.push({
1502
1587
  variableName: el.name.text,
1503
- alias: resolved.helper.alias,
1504
- aliasExpr: resolved.helper.aliasExpr,
1505
- destinationExpr: resolved.helper.destinationExpr,
1506
- servicePathExpr: resolved.helper.servicePathExpr,
1507
- isDynamic: resolved.helper.isDynamic,
1508
- placeholders: resolved.helper.placeholders,
1588
+ alias: resolved2.helper.alias,
1589
+ aliasExpr: resolved2.helper.aliasExpr,
1590
+ destinationExpr: resolved2.helper.destinationExpr,
1591
+ servicePathExpr: resolved2.helper.servicePathExpr,
1592
+ isDynamic: resolved2.helper.isDynamic,
1593
+ placeholders: resolved2.helper.placeholders,
1509
1594
  sourceFile: normalizePath(filePath),
1510
1595
  sourceLine: lineOf4(sourceFileAst, decl),
1511
- helperChain: [{ callerVariable: el.name.text, helperFunction: call.expression.getText(sourceFileAst), returnedProperty: propertyName, importSource: resolved.imp?.sourceFile, exportedSymbol: resolved.imp?.exportedName, helperSourceFile: resolved.helper.sourceFile, helperSourceLine: resolved.helper.sourceLine }]
1596
+ helperChain: [{ callerVariable: el.name.text, helperFunction: call.expression.getText(sourceFileAst), returnedProperty: propertyName, importSource: resolved2.imp?.sourceFile, exportedSymbol: resolved2.imp?.exportedName, helperSourceFile: resolved2.helper.sourceFile, helperSourceLine: resolved2.helper.sourceLine }]
1512
1597
  });
1513
1598
  }
1514
1599
  }
@@ -1530,18 +1615,18 @@ async function parseServiceBindings(repoPath, filePath) {
1530
1615
  if (!propertyName || !targetName) continue;
1531
1616
  const matches = helpers.filter((row) => row.helper.returnedProperty === propertyName);
1532
1617
  if (matches.length !== 1) continue;
1533
- const resolved = matches[0];
1618
+ const resolved2 = matches[0];
1534
1619
  out.push({
1535
1620
  variableName: targetName,
1536
- alias: resolved.helper.alias,
1537
- aliasExpr: resolved.helper.aliasExpr,
1538
- destinationExpr: resolved.helper.destinationExpr,
1539
- servicePathExpr: resolved.helper.servicePathExpr,
1540
- isDynamic: resolved.helper.isDynamic,
1541
- placeholders: resolved.helper.placeholders,
1621
+ alias: resolved2.helper.alias,
1622
+ aliasExpr: resolved2.helper.aliasExpr,
1623
+ destinationExpr: resolved2.helper.destinationExpr,
1624
+ servicePathExpr: resolved2.helper.servicePathExpr,
1625
+ isDynamic: resolved2.helper.isDynamic,
1626
+ placeholders: resolved2.helper.placeholders,
1542
1627
  sourceFile: normalizePath(filePath),
1543
1628
  sourceLine: lineOf4(sourceFileAst, node),
1544
- helperChain: [{ callerVariable: targetName, assignedFrom: call.expression.getText(sourceFileAst), aliasKind: "assignment", scopeRule: "same-file-source-order", returnedProperty: propertyName, importSource: resolved.imp?.sourceFile, exportedSymbol: resolved.imp?.exportedName, helperSourceFile: resolved.helper.sourceFile, helperSourceLine: resolved.helper.sourceLine }]
1629
+ helperChain: [{ callerVariable: targetName, assignedFrom: call.expression.getText(sourceFileAst), aliasKind: "assignment", scopeRule: "same-file-source-order", returnedProperty: propertyName, importSource: resolved2.imp?.sourceFile, exportedSymbol: resolved2.imp?.exportedName, helperSourceFile: resolved2.helper.sourceFile, helperSourceLine: resolved2.helper.sourceLine }]
1545
1630
  });
1546
1631
  }
1547
1632
  }
@@ -1942,62 +2027,28 @@ function generatedFromConstantName(value) {
1942
2027
  }
1943
2028
  return void 0;
1944
2029
  }
1945
- function normalizeDecoratorOperation(value, raw) {
1946
- if (value) {
1947
- const literal = clean(value);
1948
- const generated = generatedFromConstantName(literal);
1949
- return generated ?? normalizedOperationName(literal);
1950
- }
1951
- if (!raw) return void 0;
2030
+ function resolved(value, raw) {
2031
+ const literal = clean(value);
2032
+ const generated = generatedFromConstantName(literal);
2033
+ return { status: "resolved", operationName: generated ?? normalizedOperationName(literal), raw };
2034
+ }
2035
+ function normalizeDecoratorOperationSignal(value, raw, candidateOperation) {
2036
+ if (value) return resolved(value, raw);
2037
+ if (!raw || raw.trim().length === 0) return { status: "none", raw };
1952
2038
  const expression = raw.trim();
1953
2039
  const nameMatch = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/.exec(expression);
1954
- if (nameMatch?.[1]) return generatedFromConstantName(nameMatch[1]);
1955
- const tail = expression.split(".").filter(Boolean).at(-1);
1956
- return tail ? normalizedOperationName(clean(tail)) : void 0;
1957
- }
1958
-
1959
- // src/linker/external-http-target.ts
1960
- import { createHash } from "crypto";
1961
- var sensitiveKeys = /* @__PURE__ */ new Set(["token", "access_token", "id_token", "api_key", "apikey", "key", "password", "passwd", "pwd", "secret", "client_secret", "authorization", "cookie", "signature"]);
1962
- function hash(value) {
1963
- return createHash("sha256").update(value).digest("hex").slice(0, 12);
1964
- }
1965
- function methodPrefix(method) {
1966
- return typeof method === "string" && method.length > 0 ? `${method.toUpperCase()} ` : "";
1967
- }
1968
- function redactUrl(value) {
1969
- try {
1970
- const url = new URL(value, value.startsWith("/") ? "https://relative.invalid" : void 0);
1971
- url.username = "";
1972
- url.password = "";
1973
- for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, sensitiveKeys.has(key.toLowerCase()) ? "<redacted>" : "<redacted>");
1974
- const path9 = `${url.pathname}${url.search ? url.search : ""}`;
1975
- return value.startsWith("/") ? path9 : `${url.origin}${path9}`;
1976
- } catch {
1977
- return value.replace(/([?&][^=;&]*(?:token|key|password|secret|cookie|authorization)[^=;&]*=)[^&]*/gi, "$1<redacted>");
1978
- }
1979
- }
1980
- function externalHttpTarget(call) {
1981
- const evidence = typeof call.evidence_json === "string" ? safeParse(call.evidence_json) : {};
1982
- const target = evidence.externalTarget && typeof evidence.externalTarget === "object" && !Array.isArray(evidence.externalTarget) ? evidence.externalTarget : {};
1983
- const method = typeof call.method === "string" ? call.method : typeof target.method === "string" ? target.method : void 0;
1984
- const kind = typeof target.kind === "string" ? target.kind : "unknown";
1985
- const expression = typeof target.expression === "string" ? target.expression : void 0;
1986
- if (kind === "destination" && expression) return { kind, toKind: "external_destination", toId: `destination:${expression}`, label: `External destination: ${expression}`, method, dynamic: false, expression };
1987
- if (kind === "static_url" && expression) {
1988
- const redacted = redactUrl(expression);
1989
- return { kind, toKind: "external_endpoint", toId: `endpoint:${hash(`${method ?? ""}:${redacted}`)}`, label: `External endpoint: ${methodPrefix(method)}${redacted}`, method, dynamic: false, expression: redacted };
1990
- }
1991
- if (kind === "url_expression" && expression) return { kind, toKind: "external_endpoint", toId: `dynamic:${hash(expression)}`, label: `External endpoint: ${methodPrefix(method)}dynamic URL`, method, dynamic: true, expression: `expr:${hash(expression)}` };
1992
- return { kind: "unknown", toKind: "external_endpoint", toId: "unknown", label: "External endpoint: unknown", method, dynamic: false };
1993
- }
1994
- function safeParse(value) {
1995
- try {
1996
- const parsed = JSON.parse(value);
1997
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1998
- } catch {
1999
- return {};
2000
- }
2040
+ if (nameMatch?.[1]) return resolved(nameMatch[1], expression);
2041
+ const stringMatch = /^String\(([A-Za-z_$][\w$]*)\)$/.exec(expression);
2042
+ if (stringMatch?.[1]) {
2043
+ const identifier = stringMatch[1];
2044
+ const generated = generatedFromConstantName(identifier);
2045
+ const normalizedCandidate = candidateOperation ? normalizedOperationName(candidateOperation) : void 0;
2046
+ if (generated) return { status: "resolved", operationName: generated, raw: expression };
2047
+ if (normalizedCandidate && identifier === normalizedCandidate) return { status: "resolved", operationName: identifier, raw: expression };
2048
+ return { status: "unsupported", raw: expression, reason: "string_wrapper_identifier_not_resolved" };
2049
+ }
2050
+ if (/^[`'"]/.test(expression) && !/[`'"]$/.test(expression)) return { status: "malformed", raw: expression, reason: "unterminated_literal" };
2051
+ return { status: "unsupported", raw: expression, reason: "unsupported_decorator_expression" };
2001
2052
  }
2002
2053
 
2003
2054
  // src/linker/cross-repo-linker.ts
@@ -2050,16 +2101,23 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
2050
2101
  const servicePath = applyVariables(call.servicePathExpr ?? call.requireServicePath, vars);
2051
2102
  const destination = call.destinationExpr ?? call.requireDestination;
2052
2103
  const isDynamic = Boolean(Number(call.isDynamic ?? 0));
2053
- 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));
2054
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: [] };
2055
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
+ }
2056
2114
  if (callType === "remote_query" && (isEntityQueryIntent || !op) && !resolution.target) {
2057
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 });
2058
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);
2059
2117
  return { status: "terminal", callType };
2060
2118
  }
2061
2119
  if (callType === "local_service_call" && call.unresolved_reason === "transport_client_method" && !resolution.target && resolution.candidates.length === 0) {
2062
- 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);
2120
+ 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_TRANSPORT_METHOD", "terminal", "call", String(call.id), "transport_method", String(op || "transport_client_method"), Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, classification: "transport_client_method" }), 0, generation);
2063
2121
  return { status: "terminal", callType };
2064
2122
  }
2065
2123
  if (resolution.target) {
@@ -2070,7 +2128,7 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
2070
2128
  const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
2071
2129
  const unresolvedReason = status === "terminal" ? null : String(call.unresolved_reason ?? unresolvedOperationReason(resolution));
2072
2130
  const externalTarget = callType === "external_http" ? externalHttpTarget(call) : void 0;
2073
- const targetKind = callType === "local_db_query" ? "db_entity" : callType.startsWith("async_") ? "event" : externalTarget?.toKind ?? "external_endpoint";
2131
+ const targetKind = callType === "local_db_query" ? "db_entity" : callType.startsWith("async_") ? "event" : callType === "external_http" ? externalTarget?.toKind ?? "external_endpoint" : "operation_candidate";
2074
2132
  const targetId = callType === "local_db_query" ? String(call.query_entity ?? "unknown") : callType === "remote_action" ? op ? `Remote action: ${op}` : call.unresolved_reason === "dynamic_operation_path_identifier" ? "Remote action: dynamic path" : "Remote action: unknown path" : callType === "external_http" ? String(externalTarget?.toId ?? "unknown") : String(call.event_name_expr ?? op ?? "unknown");
2075
2133
  const graphLevelDynamic = edgeType === "DYNAMIC_EDGE_CANDIDATE" && resolution.status === "dynamic";
2076
2134
  const finalEvidence = externalTarget ? { ...evidence, externalTarget } : evidence;
@@ -2329,9 +2387,9 @@ function candidateEvidence(candidate, rank) {
2329
2387
  }
2330
2388
  function implementationMethodSignal(row, operation) {
2331
2389
  const op = normalizedOperationName(String(operation.operationPath ?? operation.operationName ?? ""));
2332
- const decorator = normalizeDecoratorOperation(typeof row.decoratorValue === "string" ? row.decoratorValue : void 0, typeof row.decoratorRawExpression === "string" ? row.decoratorRawExpression : void 0);
2333
- if (decorator && decorator === op) return { matches: true, contradicted: false, acceptedReasons: ["decorator targets operation"], rejectedReasons: [] };
2334
- if (decorator && decorator !== op) return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ["method_name_matches_but_decorator_targets_different_operation"] };
2390
+ const decorator = normalizeDecoratorOperationSignal(typeof row.decoratorValue === "string" ? row.decoratorValue : void 0, typeof row.decoratorRawExpression === "string" ? row.decoratorRawExpression : void 0, op);
2391
+ if (decorator.status === "resolved" && decorator.operationName === op) return { matches: true, contradicted: false, acceptedReasons: ["decorator targets operation"], rejectedReasons: [] };
2392
+ if (decorator.status === "resolved" && decorator.operationName !== op) return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ["method_name_matches_but_decorator_targets_different_operation"] };
2335
2393
  if (String(row.methodName ?? "") === op) return { matches: true, contradicted: false, acceptedReasons: ["method name fallback matched operation"], rejectedReasons: [] };
2336
2394
  return { matches: false, contradicted: false, acceptedReasons: [], rejectedReasons: ["method name does not match operation"] };
2337
2395
  }
@@ -2363,17 +2421,17 @@ function operationStartScope(db, repoId, start) {
2363
2421
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
2364
2422
  WHERE (? IS NULL OR r.id=?) AND (? IS NULL OR s.service_path=?) AND (o.operation_name=? OR o.operation_path=? OR o.operation_path=?)
2365
2423
  ORDER BY r.name,s.service_path,o.operation_name,o.id`).all(repoId, repoId, start.servicePath, start.servicePath, requested, requested, requested.startsWith("/") ? requested : `/${requested}`);
2366
- if (rows2.length === 0) return { diagnostics: [{ severity: "warning", code: "trace_start_not_found", message: "No indexed operation matched the requested trace selector", normalizedSelectorValue: requested }] };
2424
+ if (rows2.length === 0) return void 0;
2367
2425
  const repoCount = new Set(rows2.map((row) => String(row.repoName))).size;
2368
2426
  const serviceCount = new Set(rows2.map((row) => `${String(row.repoName)}:${String(row.servicePath)}`)).size;
2369
- if (!repoId && repoCount > 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple repositories; add --repo to disambiguate", normalizedSelectorValue: requested, candidates: rows2 }] };
2370
- if (!start.servicePath && serviceCount > 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple services; add --service to disambiguate", normalizedSelectorValue: requested, candidates: rows2 }] };
2371
- if (rows2.length !== 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple indexed operations", normalizedSelectorValue: requested, candidates: rows2 }] };
2427
+ if (!repoId && repoCount > 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple repositories; add --repo to disambiguate", normalizedSelectorValue: requested, resolutionStage: "operation", resolutionStatus: "ambiguous_operation", candidates: rows2 }] };
2428
+ if (!start.servicePath && serviceCount > 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple services; add --service to disambiguate", normalizedSelectorValue: requested, resolutionStage: "operation", resolutionStatus: "ambiguous_operation", candidates: rows2 }] };
2429
+ if (rows2.length !== 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple indexed operations", normalizedSelectorValue: requested, resolutionStage: "operation", resolutionStatus: "ambiguous_operation", candidates: rows2 }] };
2372
2430
  const operationId = String(rows2[0]?.operationId);
2373
2431
  const impl = implementationScope(db, operationId);
2374
2432
  if (impl.edge?.status === "resolved" && impl.files.size > 0) return { files: impl.files, symbols: impl.symbolId ? /* @__PURE__ */ new Set([impl.symbolId]) : void 0, operationId, diagnostics: [] };
2375
- if (impl.edge) return { operationId, diagnostics: [{ severity: "warning", code: impl.edge.status === "ambiguous" ? "trace_start_ambiguous" : "trace_start_not_found", message: `Indexed operation matched but implementation edge is ${String(impl.edge.status ?? "unresolved")}`, implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, candidates: parseEvidence(impl.edge.evidence_json).candidates }] };
2376
- return { operationId, diagnostics: [{ severity: "warning", code: "trace_start_not_found", message: "Indexed operation matched but no implementation candidate exists" }] };
2433
+ if (impl.edge) return { operationId, diagnostics: [{ severity: "warning", code: impl.edge.status === "ambiguous" ? "trace_start_ambiguous" : "trace_start_implementation_unresolved", message: `Indexed operation matched but implementation edge is ${String(impl.edge.status ?? "unresolved")}`, resolutionStage: "implementation", resolutionStatus: impl.edge.status === "ambiguous" ? "ambiguous_implementation" : "rejected_implementation", implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, candidates: parseEvidence(impl.edge.evidence_json).candidates }] };
2434
+ return { operationId, diagnostics: [{ severity: "warning", code: "trace_start_implementation_unresolved", message: "Indexed operation matched but no implementation candidate exists", resolutionStage: "implementation", resolutionStatus: "operation_without_implementation" }] };
2377
2435
  }
2378
2436
  function sourceFilesForStart(db, repoId, start) {
2379
2437
  const handler = start.handler;
@@ -2421,7 +2479,8 @@ function startScope(db, start) {
2421
2479
  ).get(start.repo, start.repo) : void 0;
2422
2480
  if (start.repo && !repo) return { repo, selectorMatched: false };
2423
2481
  const operationScope = operationStartScope(db, repo?.id, start);
2424
- const sourceScope = operationScope?.files ? operationScope : sourceFilesForStart(db, repo?.id, start);
2482
+ const terminalOperationScope = operationScope && !operationScope.files && (operationScope.diagnostics ?? []).some((d) => d.resolutionStage === "operation" || d.resolutionStage === "implementation");
2483
+ const sourceScope = operationScope?.files || terminalOperationScope ? operationScope : sourceFilesForStart(db, repo?.id, start);
2425
2484
  const sourceFiles = sourceScope?.files;
2426
2485
  const hasSelector = Boolean(
2427
2486
  start.handler ?? start.operation ?? start.operationPath ?? start.servicePath
@@ -2432,7 +2491,7 @@ function startScope(db, start) {
2432
2491
  repo,
2433
2492
  sourceFiles,
2434
2493
  symbolIds: sourceScope?.symbols,
2435
- selectorMatched: !hasSelector || sourceFiles !== void 0,
2494
+ selectorMatched: !terminalOperationScope && (!hasSelector || sourceFiles !== void 0),
2436
2495
  startOperationId: operationScope?.operationId,
2437
2496
  startDiagnostics: operationScope?.diagnostics
2438
2497
  };
@@ -2656,7 +2715,7 @@ function contextForSymbolCall(db, symbolCall, callerBindings) {
2656
2715
  });
2657
2716
  return next;
2658
2717
  }
2659
- function contextualRuntimeResolution(db, call, binding, workspaceId) {
2718
+ function contextualRuntimeResolution(db, call, binding, workspaceId, persistedRows = []) {
2660
2719
  if (!binding || String(call.call_type) !== "remote_action" || call.operation_path_expr === void 0 || call.operation_path_expr === null) return {};
2661
2720
  const normalized = normalizeODataOperationInvocationPath(String(call.operation_path_expr));
2662
2721
  const op = normalized?.normalizedOperationPath ?? (String(call.operation_path_expr).startsWith("/") ? String(call.operation_path_expr) : `/${String(call.operation_path_expr)}`);
@@ -2666,6 +2725,8 @@ function contextualRuntimeResolution(db, call, binding, workspaceId) {
2666
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 };
2667
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" };
2668
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 };
2669
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 };
2670
2731
  }
2671
2732
  function edgeTarget(row, evidence) {
@@ -2707,11 +2768,11 @@ function trace(db, start, options) {
2707
2768
  const nodes = /* @__PURE__ */ new Map();
2708
2769
  const seenEdges = /* @__PURE__ */ new Set();
2709
2770
  const queue = scope.selectorMatched ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: /* @__PURE__ */ new Map() }] : [];
2710
- if (scope.startOperationId) {
2771
+ if (scope.startOperationId && scope.selectorMatched) {
2711
2772
  const op = operationNode(db, scope.startOperationId);
2712
2773
  const impl = implementationScope(db, scope.startOperationId);
2713
2774
  if (op) nodes.set(String(op.id), op);
2714
- if (impl.edge) {
2775
+ if (impl.edge && impl.edge.status === "resolved") {
2715
2776
  const implEvidence = { ...parseEvidence(impl.edge.evidence_json), startResolution: { strategy: "indexed_operation_graph", matchedOperationId: scope.startOperationId, implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, selectedHandlerMethodId: impl.edge.status === "resolved" ? impl.edge.to_id : void 0 } };
2716
2777
  const handlerNode = impl.edge.status === "resolved" ? handlerMethodNode(db, impl.edge.to_id) : void 0;
2717
2778
  if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
@@ -2764,15 +2825,16 @@ function trace(db, start, options) {
2764
2825
  line: call.source_line,
2765
2826
  callType: call.call_type
2766
2827
  });
2767
- const contextual = contextualRuntimeResolution(db, call, callerBindings.get(receiverFromEvidence(call.evidence_json) ?? ""), workspaceIdForCall(db, String(call.id)));
2768
- 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;
2769
2831
  for (const row of graphRows) {
2770
2832
  if (seenEdges.has(Number(row.id))) continue;
2771
2833
  seenEdges.add(Number(row.id));
2772
2834
  const persistedEvidence = JSON.parse(
2773
2835
  String(row.evidence_json || "{}")
2774
2836
  );
2775
- 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) };
2776
2838
  const effective = runtimeResolution(db, row, rawEvidence, options.vars);
2777
2839
  const evidence = effective.evidence;
2778
2840
  const effectiveRow = effective.row;
@@ -2867,4 +2929,4 @@ export {
2867
2929
  linkWorkspace,
2868
2930
  trace
2869
2931
  };
2870
- //# sourceMappingURL=chunk-AM2JZFQ6.js.map
2932
+ //# sourceMappingURL=chunk-CWJYVIG2.js.map