@saptools/service-flow 0.1.5 → 0.1.7

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7
4
+
5
+ - Added atomic last-good repository publication so failed source reads preserve the previous complete snapshot and fingerprint.
6
+ - Added explicit graph freshness metadata and stale diagnostics after successful fact changes until relink.
7
+ - Persisted handler registration class/import evidence and linked registered cross-package implementation handlers through application dependency evidence.
8
+ - Made service-only trace selectors return a typed narrowing diagnostic instead of workspace-wide traversal.
9
+ - Expanded link summaries with dependency and implementation categories whose totals reconcile with persisted graph edges.
10
+ - Preserved terminal parser warning evidence separately from routing status.
11
+
12
+
13
+ ## 0.1.6
14
+
15
+ - Scoped runtime variable resolution to eligible dynamic, ambiguous, or unresolved remote edges with matching placeholders, preserving terminal and static resolved edge status, target, reason, and confidence.
16
+ - Clamped operation-resolution confidence to the `[0, 1]` range and retained original runtime expressions alongside effective substitutions and missing-variable evidence.
17
+ - Resolved helper/package dependency graph edges primarily by indexed package name, persisted ambiguous dependency candidates with evidence, and marked inserted helper edges with explicit statuses.
18
+ - Expanded repository fingerprints to include normalized package metadata, full `cds.requires`, scripts, package file content, and the analyzer version so metadata-only changes invalidate stale facts.
19
+ - Replaced the hard-coded CLI version with package metadata as the release source of truth and documented selector, graph variable, SQLite, fingerprint, freshness, and parser-warning semantics.
20
+
3
21
  ## 0.1.5
4
22
 
5
23
  - Runtime `--var` substitution now re-runs operation resolution in memory for trace and graph output, clears stale unresolved reasons on exact matches, and traverses into the matched downstream handler without mutating the persisted graph.
package/README.md CHANGED
@@ -47,10 +47,21 @@ npm install @saptools/service-flow
47
47
  ```
48
48
 
49
49
  > [!NOTE]
50
- > Requires **Node.js ≥ 20**. Version 0.1.5 uses a persistent SQLite driver (`node:sqlite` in supported Node builds) for bound parameters, transactions, WAL, busy timeouts, and read-only query commands. The analyzer is static: it reads files and package metadata, but it does not start CAP services, connect to SAP BTP, or execute application code.
50
+ > Requires **Node.js ≥ 24.0.0** for the bundled `node:sqlite` runtime. Version 0.1.7 uses a persistent SQLite driver (`node:sqlite` in supported Node builds) for bound parameters, transactions, WAL, busy timeouts, and read-only query commands. The analyzer is static: it reads files and package metadata, but it does not start CAP services, connect to SAP BTP, or execute application code.
51
51
 
52
52
  ---
53
53
 
54
+
55
+ ### Correctness notes for 0.1.7
56
+
57
+ - Runtime `--var` values are considered only for dynamic, ambiguous, or unresolved **remote** graph edges whose alias, destination, service path, or operation path expressions contain supplied placeholders. Local database, external HTTP, event, and already resolved static edges keep their persisted status, target, reason, and confidence. Partial substitutions remain dynamic and report the missing placeholder names.
58
+ - `trace` and `graph` both accept repeatable `--var key=value` options. Effective substitutions are rendered in trace evidence without mutating the persisted graph. Confidence values are bounded to `[0, 1]`.
59
+ - Repository selectors on list, trace, graph, and inspect commands narrow scope. Unknown selectors return empty machine-readable diagnostics instead of falling back to the whole workspace.
60
+ - Helper-package dependency edges prefer exact indexed package names. Duplicate package-name candidates are persisted as ambiguous evidence rather than silently selecting one repository.
61
+ - Repository fingerprints include source content, package name/version, dependencies and devDependencies, scripts, normalized `cds.requires` (including nested credentials), package file content, and the analyzer version. Metadata-only changes therefore trigger reindexing.
62
+ - Index publication is designed around the last-good snapshot: failed parse or persistence attempts are recorded as diagnostics and must not be mixed with older graph facts. After indexing changes, relink before relying on graph/trace output; doctor reports stale or inconsistent stores where detectable.
63
+ - 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.
64
+
54
65
  ## 🚀 Quick Start
55
66
 
56
67
  ```bash
@@ -425,3 +436,20 @@ MIT
425
436
  ---
426
437
 
427
438
  Made with ❤️ to make your work life easier!
439
+
440
+
441
+ ### Service-only trace policy
442
+
443
+ `service-flow trace --service <path>` is intentionally not a broad workspace traversal. Provide `--operation`, `--path`, or `--handler`; otherwise the command returns a typed `trace_start_not_found` diagnostic and no edges.
444
+
445
+ ### Graph freshness and last-good snapshots
446
+
447
+ Repository facts are parsed before publication and committed atomically. Failed indexing attempts record diagnostics and retain the last complete published snapshot and successful fingerprint. Successful fact publication marks the workspace graph stale until `service-flow link` rebuilds dependency, remote-call, and implementation edges for the current fact generation.
448
+
449
+ ### Cross-package implementation evidence
450
+
451
+ Handler registrations persist parsed class names and import sources. Linking resolves implementation edges only through registered application evidence plus model and handler package dependency edges; a decorator-name match alone is not enough.
452
+
453
+ ### Graph variables
454
+
455
+ The `graph` command accepts repeatable `--var key=value` options, matching `trace`, for runtime substitution previews in JSON or Mermaid output.
package/TECHNICAL-NOTE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Service Flow 0.1.5 Resolution Notes
1
+ # Service Flow 0.1.7 Resolution Notes
2
2
 
3
3
  - Imported helper bindings: TypeScript imports are resolved for relative modules. When a caller assigns `const client = await connectToService()`, the analyzer follows the imported symbol to an exported helper that returns `cds.connect.to(...)` and persists caller-variable evidence plus the helper source/export chain.
4
4
  - Candidate ranking: operation-path matches start as weak candidates. A resolved operation edge requires a strong signal such as exact service path, CDS alias/destination context, or explicit dynamic variable overrides. Otherwise candidates are preserved in edge evidence as ambiguous or unresolved.
@@ -19,3 +19,13 @@
19
19
  - Edge evidence and target operation provenance are separate: call file/line and helper chains stay on the edge, while operation nodes are loaded from CDS operation/service/repository rows.
20
20
  - The linker stores explicit edge status categories and summary counts. Valid DB, event, and external terminals keep `unresolved_reason` null.
21
21
  - Repository-level fingerprints include source paths/hashes, package dependencies, and analyzer schema version. Unchanged repositories are skipped unless `--force` is used.
22
+
23
+
24
+ ## 0.1.7 correctness additions
25
+
26
+ - Runtime resolution now has an explicit eligibility gate: only remote dynamic/ambiguous/unresolved graph edges with affected placeholders are re-resolved in memory. Terminal and resolved static edges are copied through unchanged, and substitutions keep original expressions, effective values, supplied variables, and missing variables separate.
27
+ - Operation candidate scores are clamped into `[0, 1]` before graph or trace rendering.
28
+ - Helper package linking uses exact `repositories.package_name` matches before normalized directory-name fallback. Ambiguous package names are represented as ambiguous graph edges with all candidates in evidence.
29
+ - Fingerprints hash normalized package facts and package bytes in addition to source file paths/content and analyzer version.
30
+ - The CLI version imports package metadata, so package metadata, `service-flow --version`, changelog, and analyzer/fingerprint version share one release source.
31
+ - Supported runtime is Node.js 24+ with `node:sqlite` validation; older runtimes should fail with a compatibility message instead of a late `DatabaseSync` error.
@@ -336,6 +336,14 @@ function lineOf2(text, idx) {
336
336
  async function parseHandlerRegistrations(repoPath, filePath) {
337
337
  const text = await fs5.readFile(path6.join(repoPath, filePath), "utf8");
338
338
  const out = [];
339
+ const imports = /* @__PURE__ */ new Map();
340
+ for (const m of text.matchAll(/import\s+\{?\s*([A-Za-z0-9_,\s]+)\s*\}?\s+from\s+['"]([^'"]+)['"]/g)) {
341
+ const source = m[2];
342
+ for (const name of (m[1] ?? "").split(",")) {
343
+ const symbol = name.trim().split(/\s+as\s+/).pop()?.trim();
344
+ if (symbol) imports.set(symbol, source);
345
+ }
346
+ }
339
347
  for (const m of text.matchAll(
340
348
  /createCombinedHandler\s*\(|srv\.prepend\s*\(|cds\.serve\s*\(/g
341
349
  ))
@@ -351,6 +359,7 @@ async function parseHandlerRegistrations(repoPath, filePath) {
351
359
  for (const c of (m[1] ?? "").matchAll(/\b(\w+Handler)\b/g))
352
360
  out.push({
353
361
  className: c[1],
362
+ importSource: imports.get(c[1]),
354
363
  registrationFile: normalizePath(filePath),
355
364
  registrationLine: lineOf2(text, (m.index ?? 0) + (c.index ?? 0)),
356
365
  registrationKind: "handler-array",
@@ -873,12 +882,30 @@ function collectClassHelpers(sf) {
873
882
  }
874
883
 
875
884
  // src/linker/dynamic-edge-resolver.ts
885
+ var PLACEHOLDER = /\$\{\s*(\w+)\s*\}/g;
876
886
  function applyVariables(template, vars) {
877
- if (!template) return void 0;
878
- return template.replace(
879
- /\$\{\s*(\w+)\s*\}/g,
880
- (_m, key) => vars[key] ?? `\${${key}}`
887
+ return substituteVariables(template, vars).effective;
888
+ }
889
+ function extractPlaceholders(template) {
890
+ return [...(template ?? "").matchAll(PLACEHOLDER)].map((m) => m[1] ?? "").filter(Boolean);
891
+ }
892
+ function substituteVariables(template, vars) {
893
+ if (!template) return { placeholders: [], missing: [], supplied: [], changed: false };
894
+ const placeholders2 = [...new Set(extractPlaceholders(template))];
895
+ const supplied = placeholders2.filter((key) => Object.hasOwn(vars, key));
896
+ const missing = placeholders2.filter((key) => !Object.hasOwn(vars, key));
897
+ const effective = template.replace(
898
+ PLACEHOLDER,
899
+ (_m, key) => Object.hasOwn(vars, key) ? vars[key] ?? "" : `\${${key}}`
881
900
  );
901
+ return {
902
+ original: template,
903
+ effective,
904
+ placeholders: placeholders2,
905
+ missing,
906
+ supplied,
907
+ changed: effective !== template
908
+ };
882
909
  }
883
910
 
884
911
  // src/linker/service-resolver.ts
@@ -936,6 +963,7 @@ function resolveOperation(db, signals, workspaceId) {
936
963
  c.reasons.push("explicit_dynamic_override");
937
964
  }
938
965
  }
966
+ for (const c of candidates) c.score = Math.max(0, Math.min(1, c.score));
939
967
  candidates.sort(
940
968
  (a, b) => b.score - a.score || a.repoName.localeCompare(b.repoName)
941
969
  );
@@ -968,138 +996,125 @@ function resolveOperation(db, signals, workspaceId) {
968
996
  }
969
997
 
970
998
  // src/linker/helper-package-linker.ts
971
- function linkHelperPackages(db, workspaceId) {
972
- const repos = db.prepare(
973
- "SELECT id,name,dependencies_json FROM repositories WHERE workspace_id=?"
974
- ).all(workspaceId);
975
- let count = 0;
999
+ function normalizeName(value) {
1000
+ return value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "");
1001
+ }
1002
+ function candidatesForDependency(repos, dep, sourceId) {
1003
+ const exact = repos.filter((repo) => repo.id !== sourceId && repo.package_name === dep);
1004
+ if (exact.length > 0) return { candidates: exact, strategy: "exact_package_name" };
1005
+ const normalized = normalizeName(dep);
1006
+ return { candidates: repos.filter((repo) => repo.id !== sourceId && normalizeName(repo.name) === normalized), strategy: "normalized_directory" };
1007
+ }
1008
+ function linkHelperPackages(db, workspaceId, generation) {
1009
+ const repos = db.prepare("SELECT id,name,package_name,dependencies_json FROM repositories WHERE workspace_id=?").all(workspaceId);
1010
+ const summary = { edgeCount: 0, resolvedCount: 0, ambiguousCount: 0 };
976
1011
  for (const repo of repos) {
977
1012
  const deps = JSON.parse(repo.dependencies_json);
978
1013
  for (const dep of Object.keys(deps)) {
979
- const helper = repos.find((r) => r.name === dep || dep.endsWith(r.name));
980
- if (helper) {
981
- db.prepare(
982
- "INSERT INTO graph_edges(workspace_id,edge_type,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic) VALUES(?,?,?,?,?,?,?,?,?)"
983
- ).run(
984
- workspaceId,
985
- "REPO_IMPORTS_HELPER_PACKAGE",
986
- "repo",
987
- String(repo.id),
988
- "repo",
989
- String(helper.id),
990
- 0.9,
991
- JSON.stringify({ dependency: dep }),
992
- 0
993
- );
994
- count += 1;
995
- }
1014
+ const result = candidatesForDependency(repos, dep, repo.id);
1015
+ if (result.candidates.length === 0) continue;
1016
+ const status = result.candidates.length === 1 ? "resolved" : "ambiguous";
1017
+ const helper = status === "resolved" ? result.candidates[0] : void 0;
1018
+ 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(
1019
+ workspaceId,
1020
+ "REPO_IMPORTS_HELPER_PACKAGE",
1021
+ status,
1022
+ "repo",
1023
+ String(repo.id),
1024
+ helper ? "repo" : "repo_candidates",
1025
+ helper ? String(helper.id) : result.candidates.map((candidate) => candidate.id).join(","),
1026
+ helper ? 1 : 0.5,
1027
+ JSON.stringify({ dependency: dep, candidates: result.candidates.map((candidate) => ({ id: candidate.id, name: candidate.name, packageName: candidate.package_name })), match: result.strategy }),
1028
+ 0,
1029
+ helper ? null : "Ambiguous dependency package candidates",
1030
+ generation
1031
+ );
1032
+ summary.edgeCount += 1;
1033
+ if (helper) summary.resolvedCount += 1;
1034
+ else summary.ambiguousCount += 1;
996
1035
  }
997
1036
  }
998
- return count;
1037
+ return summary;
999
1038
  }
1000
1039
 
1001
1040
  // src/linker/cross-repo-linker.ts
1002
1041
  function linkWorkspace(db, workspaceId, vars = {}) {
1003
1042
  return db.transaction(() => {
1043
+ const generation = nextGraphGeneration(db, workspaceId);
1004
1044
  db.prepare("DELETE FROM graph_edges WHERE workspace_id=?").run(workspaceId);
1005
- let edges = linkHelperPackages(db, workspaceId);
1006
- let unresolved = 0;
1007
- let resolvedCount = 0;
1008
- let ambiguousCount = 0;
1009
- let dynamicCount = 0;
1010
- let terminalCount = 0;
1011
- const calls = db.prepare(
1012
- `SELECT c.*,r.name repoName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`
1013
- ).all(workspaceId);
1014
- for (const call of calls) {
1015
- const callType = String(call.call_type);
1016
- const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1017
- const servicePath = applyVariables(
1018
- call.servicePathExpr ?? call.requireServicePath,
1019
- vars
1020
- );
1021
- const destination = call.destinationExpr ?? call.requireDestination;
1022
- const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1023
- const resolution = callType.startsWith("remote") ? resolveOperation(
1024
- db,
1025
- {
1026
- servicePath,
1027
- operationPath: op,
1028
- alias: applyVariables(call.aliasExpr ?? call.alias, vars),
1029
- destination: applyVariables(destination, vars),
1030
- isDynamic,
1031
- hasExplicitOverride: Object.keys(vars).length > 0
1032
- },
1033
- workspaceId
1034
- ) : { status: "unresolved", candidates: [], reasons: [] };
1035
- const target = resolution.target;
1036
- const evidence = {
1037
- sourceFile: call.source_file,
1038
- sourceLine: call.source_line,
1039
- file: call.source_file,
1040
- line: call.source_line,
1041
- repo: call.repoName,
1042
- serviceAlias: call.alias,
1043
- serviceAliasExpr: call.aliasExpr,
1044
- destination: applyVariables(destination, vars),
1045
- servicePath,
1046
- operationPath: op,
1047
- targetRepo: target?.repoName,
1048
- targetOperation: target?.operationName,
1049
- helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0,
1050
- candidates: resolution.candidates,
1051
- candidateCount: resolution.candidates.length,
1052
- resolutionStatus: resolution.status,
1053
- resolutionReasons: resolution.reasons
1054
- };
1055
- if (target) {
1056
- db.prepare(
1057
- "INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic) VALUES(?,?,?,?,?,?,?,?,?,?)"
1058
- ).run(
1059
- workspaceId,
1060
- "REMOTE_CALL_RESOLVES_TO_OPERATION",
1061
- "resolved",
1062
- "call",
1063
- String(call.id),
1064
- "operation",
1065
- String(target.operationId),
1066
- target.score,
1067
- JSON.stringify(evidence),
1068
- isDynamic ? 1 : 0
1069
- );
1070
- edges += 1;
1071
- resolvedCount += 1;
1072
- } else {
1073
- const edgeType = callType === "local_db_query" ? "HANDLER_RUNS_DB_QUERY" : callType === "external_http" ? "HANDLER_CALLS_EXTERNAL_HTTP" : callType === "async_emit" ? "HANDLER_EMITS_EVENT" : callType === "async_subscribe" ? "EVENT_CONSUMED_BY_HANDLER" : resolution.status === "dynamic" ? "DYNAMIC_EDGE_CANDIDATE" : "UNRESOLVED_EDGE";
1074
- const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1075
- const unresolvedReason = status === "terminal" ? null : String(
1076
- call.unresolved_reason ?? (resolution.status === "ambiguous" ? "Ambiguous operation candidates require a strong service signal" : resolution.status === "dynamic" ? `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ["missing runtime variables"]).join(", ")}` : "No indexed target operation matched")
1077
- );
1078
- db.prepare(
1079
- "INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)"
1080
- ).run(
1081
- workspaceId,
1082
- edgeType,
1083
- status,
1084
- "call",
1085
- String(call.id),
1086
- callType.startsWith("async_") ? "event" : "external",
1087
- String(call.event_name_expr ?? call.query_entity ?? op ?? call.id),
1088
- Number(call.confidence ?? 0.2),
1089
- JSON.stringify(evidence),
1090
- isDynamic || resolution.status === "dynamic" ? 1 : 0,
1091
- unresolvedReason
1092
- );
1093
- edges += 1;
1094
- unresolved += status === "unresolved" ? 1 : 0;
1095
- ambiguousCount += status === "ambiguous" ? 1 : 0;
1096
- dynamicCount += status === "dynamic" ? 1 : 0;
1097
- terminalCount += status === "terminal" ? 1 : 0;
1098
- }
1099
- }
1100
- return { edgeCount: edges, unresolvedCount: unresolved, resolvedCount, ambiguousCount, dynamicCount, terminalCount };
1045
+ const deps = linkHelperPackages(db, workspaceId, generation);
1046
+ const callSummary = linkCalls(db, workspaceId, vars, generation);
1047
+ const impl = linkImplementations(db, workspaceId, generation);
1048
+ db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
1049
+ return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount };
1101
1050
  });
1102
1051
  }
1052
+ function nextGraphGeneration(db, workspaceId) {
1053
+ const row = db.prepare("SELECT COALESCE(MAX(graph_generation),0) generation FROM repositories WHERE workspace_id=?").get(workspaceId);
1054
+ return Number(row?.generation ?? 0) + 1;
1055
+ }
1056
+ function linkCalls(db, workspaceId, vars, generation) {
1057
+ let edgeCount = 0;
1058
+ let unresolvedCount = 0;
1059
+ let resolvedCount = 0;
1060
+ let ambiguousCount = 0;
1061
+ let dynamicCount = 0;
1062
+ let terminalCount = 0;
1063
+ const calls = db.prepare(`SELECT c.*,r.name repoName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`).all(workspaceId);
1064
+ for (const call of calls) {
1065
+ const result = insertCallEdge(db, workspaceId, call, vars, generation);
1066
+ edgeCount += 1;
1067
+ resolvedCount += result.status === "resolved" ? 1 : 0;
1068
+ unresolvedCount += result.status === "unresolved" ? 1 : 0;
1069
+ ambiguousCount += result.status === "ambiguous" ? 1 : 0;
1070
+ dynamicCount += result.status === "dynamic" ? 1 : 0;
1071
+ terminalCount += result.status === "terminal" ? 1 : 0;
1072
+ }
1073
+ return { edgeCount, unresolvedCount, resolvedCount, ambiguousCount, dynamicCount, terminalCount };
1074
+ }
1075
+ function insertCallEdge(db, workspaceId, call, vars, generation) {
1076
+ const callType = String(call.call_type);
1077
+ const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1078
+ const servicePath = applyVariables(call.servicePathExpr ?? call.requireServicePath, vars);
1079
+ const destination = call.destinationExpr ?? call.requireDestination;
1080
+ const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1081
+ const resolution = callType.startsWith("remote") ? resolveOperation(db, { servicePath, operationPath: op, alias: applyVariables(call.aliasExpr ?? call.alias, vars), destination: destination ? applyVariables(destination, vars) : void 0, isDynamic, hasExplicitOverride: Object.keys(vars).length > 0 }, workspaceId) : { status: "unresolved", candidates: [], reasons: [] };
1082
+ const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0);
1083
+ if (resolution.target) {
1084
+ 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), isDynamic ? 1 : 0, generation);
1085
+ return { status: "resolved" };
1086
+ }
1087
+ const edgeType = callType === "local_db_query" ? "HANDLER_RUNS_DB_QUERY" : callType === "external_http" ? "HANDLER_CALLS_EXTERNAL_HTTP" : callType === "async_emit" ? "HANDLER_EMITS_EVENT" : callType === "async_subscribe" ? "EVENT_CONSUMED_BY_HANDLER" : resolution.status === "dynamic" ? "DYNAMIC_EDGE_CANDIDATE" : "UNRESOLVED_EDGE";
1088
+ const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1089
+ const unresolvedReason = status === "terminal" ? null : String(call.unresolved_reason ?? (resolution.status === "ambiguous" ? "Ambiguous operation candidates require a strong service signal" : resolution.status === "dynamic" ? `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ["missing runtime variables"]).join(", ")}` : "No indexed target operation matched"));
1090
+ 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), callType.startsWith("async_") ? "event" : "external", String(call.event_name_expr ?? call.query_entity ?? op ?? call.id), Number(call.confidence ?? 0.2), JSON.stringify(evidence), isDynamic || resolution.status === "dynamic" ? 1 : 0, unresolvedReason, generation);
1091
+ return { status };
1092
+ }
1093
+ function callEvidence(call, resolution, servicePath, op, destination) {
1094
+ return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, targetRepo: resolution.target?.repoName, targetOperation: resolution.target?.operationName, helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0, candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
1095
+ }
1096
+ function linkImplementations(db, workspaceId, generation) {
1097
+ const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,s.service_path servicePath,s.repo_id modelRepoId,r.package_name modelPackage FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=?`).all(workspaceId);
1098
+ let edgeCount = 0;
1099
+ let resolvedCount = 0;
1100
+ let ambiguousCount = 0;
1101
+ for (const operation of operations) {
1102
+ const rows2 = implementationCandidates(db, workspaceId, operation);
1103
+ if (rows2.length === 0) continue;
1104
+ const unique = rows2.length === 1 ? rows2[0] : void 0;
1105
+ 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, "OPERATION_IMPLEMENTED_BY_HANDLER", unique ? "resolved" : "ambiguous", "operation", String(operation.operationId), unique ? "handler_method" : "handler_method_candidates", unique ? String(unique.methodId) : rows2.map((row) => row.methodId).join(","), unique ? 0.95 : 0.5, JSON.stringify({ servicePath: operation.servicePath, operationPath: operation.operationPath, operationName: operation.operationName, candidates: rows2, evidence: "registered_application_dependency" }), 0, unique ? null : "Ambiguous registered handler implementation candidates", generation);
1106
+ edgeCount += 1;
1107
+ if (unique) resolvedCount += 1;
1108
+ else ambiguousCount += 1;
1109
+ }
1110
+ return { edgeCount, resolvedCount, ambiguousCount };
1111
+ }
1112
+ function implementationCandidates(db, workspaceId, operation) {
1113
+ return db.prepare(`SELECT DISTINCT hm.id methodId,hc.id classId,hc.class_name className,hc.source_file sourceFile,hc.source_line sourceLine,hr.repo_id applicationRepoId,handlerRepo.name handlerRepo,appRepo.name applicationRepo FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id JOIN handler_registrations hr ON hr.class_name=hc.class_name JOIN repositories appRepo ON appRepo.id=hr.repo_id JOIN graph_edges modelDep ON modelDep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND modelDep.status='resolved' AND modelDep.from_kind='repo' AND modelDep.from_id=CAST(appRepo.id AS TEXT) AND modelDep.to_id=CAST(? AS TEXT) JOIN graph_edges handlerDep ON handlerDep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND handlerDep.status='resolved' AND handlerDep.from_kind='repo' AND handlerDep.from_id=CAST(appRepo.id AS TEXT) AND handlerDep.to_id=CAST(handlerRepo.id AS TEXT) WHERE appRepo.workspace_id=? AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=?)`).all(operation.modelRepoId, workspaceId, normalizedOperation(String(operation.operationPath ?? "")), operation.operationName, operation.operationName);
1114
+ }
1115
+ function normalizedOperation(value) {
1116
+ return value.startsWith("/") ? value.slice(1) : value;
1117
+ }
1103
1118
 
1104
1119
  // src/trace/trace-engine.ts
1105
1120
  function normalizeOperation(value) {
@@ -1147,8 +1162,10 @@ function startScope(db, start) {
1147
1162
  if (start.repo && !repo) return { repo, selectorMatched: false };
1148
1163
  const sourceFiles = sourceFilesForStart(db, repo?.id, start);
1149
1164
  const hasSelector = Boolean(
1150
- start.handler ?? start.operation ?? start.operationPath
1165
+ start.handler ?? start.operation ?? start.operationPath ?? start.servicePath
1151
1166
  );
1167
+ if (start.servicePath && !start.operation && !start.operationPath && !start.handler)
1168
+ return { repo, selectorMatched: false };
1152
1169
  return {
1153
1170
  repo,
1154
1171
  sourceFiles,
@@ -1169,6 +1186,12 @@ function handlerFilesForOperation(db, operationId) {
1169
1186
  ).all(op.repoId, operation, operation, op.operationName);
1170
1187
  return new Set(rows2.map((row) => row.sourceFile).filter(Boolean));
1171
1188
  }
1189
+ function implementationScope(db, operationId) {
1190
+ const edge = db.prepare("SELECT * FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND status='resolved' AND from_kind='operation' AND from_id=? ORDER BY id LIMIT 1").get(operationId);
1191
+ if (!edge) return { files: /* @__PURE__ */ new Set() };
1192
+ const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id WHERE hm.id=?").get(edge.to_id);
1193
+ return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []) };
1194
+ }
1172
1195
  function includeCall(type, options) {
1173
1196
  if (!options.includeDb && type === "local_db_query") return false;
1174
1197
  if (!options.includeExternal && type === "external_http") return false;
@@ -1187,30 +1210,30 @@ function graphForCalls(db, callIds) {
1187
1210
  }
1188
1211
  return map;
1189
1212
  }
1190
- function candidatesFromEvidence(evidence) {
1191
- return Array.isArray(evidence.candidates) ? evidence.candidates.filter(
1192
- (candidate) => typeof candidate === "object" && candidate !== null
1193
- ) : [];
1213
+ function hasRuntimeVariable(value, vars) {
1214
+ return typeof value === "string" && extractPlaceholders(value).some((key) => Object.hasOwn(vars, key));
1215
+ }
1216
+ function isRemoteRuntimeCandidate(row, evidence, vars) {
1217
+ if (!vars || Object.keys(vars).length === 0) return false;
1218
+ if (!["dynamic", "ambiguous", "unresolved"].includes(String(row.status ?? ""))) return false;
1219
+ if (!["DYNAMIC_EDGE_CANDIDATE", "UNRESOLVED_EDGE", "REMOTE_CALL_RESOLVES_TO_OPERATION"].includes(row.edge_type)) return false;
1220
+ if (row.status === "resolved") return false;
1221
+ return ["servicePath", "operationPath", "serviceAliasExpr", "serviceAlias", "destination"].some((key) => hasRuntimeVariable(evidence[key], vars));
1194
1222
  }
1195
1223
  function evidenceWithRuntimeVariables(evidence, vars) {
1196
1224
  if (!vars || Object.keys(vars).length === 0) return evidence;
1197
- const servicePath = typeof evidence.servicePath === "string" ? applyVariables(evidence.servicePath, vars) : void 0;
1198
- const operationPath = typeof evidence.operationPath === "string" ? applyVariables(evidence.operationPath, vars) : void 0;
1199
- const candidates = candidatesFromEvidence(evidence);
1200
- const matched = servicePath ? candidates.find((candidate) => candidate.servicePath === servicePath) : void 0;
1201
- return {
1202
- ...evidence,
1203
- servicePath: servicePath ?? evidence.servicePath,
1204
- operationPath: operationPath ?? evidence.operationPath,
1205
- runtimeVariablesApplied: true,
1206
- runtimeResolvedCandidate: matched ? {
1207
- repoName: matched.repoName,
1208
- servicePath: matched.servicePath,
1209
- operationPath: matched.operationPath,
1210
- operationName: matched.operationName,
1211
- score: matched.score
1212
- } : void 0
1213
- };
1225
+ const substitutions = {};
1226
+ for (const key of ["servicePath", "operationPath", "serviceAliasExpr", "serviceAlias", "destination"]) {
1227
+ const substitution = substituteVariables(typeof evidence[key] === "string" ? String(evidence[key]) : void 0, vars);
1228
+ if (substitution.placeholders.length > 0) substitutions[key] = substitution;
1229
+ }
1230
+ const next = { ...evidence, runtimeVariablesApplied: true, runtimeSubstitutions: substitutions };
1231
+ for (const [key, value] of Object.entries(substitutions)) {
1232
+ if (value.effective) next[key] = value.effective;
1233
+ }
1234
+ const missing = Object.values(substitutions).flatMap((value) => value.missing);
1235
+ if (missing.length > 0) next.missingRuntimeVariables = [...new Set(missing)];
1236
+ return next;
1214
1237
  }
1215
1238
  function operationNode(db, operationId) {
1216
1239
  const row = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_type operationType,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.id serviceId,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,r.id repoId,r.name repoName FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operationId);
@@ -1221,20 +1244,22 @@ function workspaceIdForCall(db, callId) {
1221
1244
  return db.prepare("SELECT r.workspace_id workspaceId FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE c.id=?").get(callId)?.workspaceId;
1222
1245
  }
1223
1246
  function runtimeResolution(db, row, evidence, vars) {
1224
- if (!vars || Object.keys(vars).length === 0) return { row, evidence, unresolvedReason: row.unresolved_reason };
1247
+ if (!isRemoteRuntimeCandidate(row, evidence, vars))
1248
+ return { row, evidence, unresolvedReason: row.unresolved_reason };
1225
1249
  const nextEvidence = evidenceWithRuntimeVariables(evidence, vars);
1226
1250
  const servicePath = typeof nextEvidence.servicePath === "string" ? nextEvidence.servicePath : void 0;
1227
1251
  const operationPath = typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
1228
- const alias = typeof nextEvidence.serviceAliasExpr === "string" ? applyVariables(nextEvidence.serviceAliasExpr, vars) : typeof nextEvidence.serviceAlias === "string" ? applyVariables(nextEvidence.serviceAlias, vars) : void 0;
1229
- const destination = typeof nextEvidence.destination === "string" ? applyVariables(nextEvidence.destination, vars) : void 0;
1230
- const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: Boolean(row.status === "dynamic" || nextEvidence.resolutionStatus === "dynamic") }, workspaceIdForCall(db, row.from_id));
1252
+ const alias = typeof nextEvidence.serviceAliasExpr === "string" ? nextEvidence.serviceAliasExpr : typeof nextEvidence.serviceAlias === "string" ? nextEvidence.serviceAlias : void 0;
1253
+ const destination = typeof nextEvidence.destination === "string" ? nextEvidence.destination : void 0;
1254
+ const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceIdForCall(db, row.from_id));
1231
1255
  nextEvidence.runtimeResolutionStatus = resolution.status;
1232
1256
  nextEvidence.runtimeResolutionReasons = resolution.reasons;
1233
1257
  if (resolution.target) {
1234
1258
  nextEvidence.runtimeResolvedCandidate = resolution.target;
1235
- return { row: { ...row, to_kind: "operation", to_id: String(resolution.target.operationId), unresolved_reason: void 0, confidence: resolution.target.score }, evidence: nextEvidence, target: resolution.target };
1259
+ return { row: { ...row, to_kind: "operation", to_id: String(resolution.target.operationId), unresolved_reason: void 0, confidence: Math.max(0, Math.min(1, resolution.target.score)) }, evidence: nextEvidence, target: resolution.target };
1236
1260
  }
1237
- return { row, evidence: nextEvidence, unresolvedReason: resolution.status === "dynamic" ? `Dynamic target is missing runtime variables: ${resolution.reasons.join(", ")}` : resolution.status === "ambiguous" ? "Ambiguous runtime operation candidates" : "No runtime operation candidate matched substituted service and operation path" };
1261
+ const unresolvedReason = resolution.status === "dynamic" ? `Dynamic target is missing runtime variables: ${resolution.reasons.join(", ")}` : resolution.status === "ambiguous" ? "Ambiguous runtime operation candidates" : "No runtime operation candidate matched substituted service and operation path";
1262
+ return { row, evidence: nextEvidence, unresolvedReason };
1238
1263
  }
1239
1264
  function edgeTarget(row, evidence) {
1240
1265
  const runtimeCandidate = evidence.runtimeResolvedCandidate;
@@ -1251,11 +1276,14 @@ function trace(db, start, options) {
1251
1276
  const diagnostics = db.prepare(
1252
1277
  "SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics WHERE (? IS NULL OR repo_id=?)"
1253
1278
  ).all(scope.repo?.id, scope.repo?.id);
1279
+ const stale = db.prepare("SELECT name,graph_stale_reason reason FROM repositories WHERE graph_stale_reason IS NOT NULL AND (? IS NULL OR id=?)").all(scope.repo?.id, scope.repo?.id);
1280
+ for (const row of stale)
1281
+ diagnostics.unshift({ severity: "warning", code: "graph_stale", message: `Graph is stale for ${row.name ?? "repository"}: ${row.reason ?? "facts_changed"}. Run service-flow link.` });
1254
1282
  if (!scope.selectorMatched)
1255
1283
  diagnostics.unshift({
1256
1284
  severity: "warning",
1257
1285
  code: "trace_start_not_found",
1258
- message: "No handler source matched the requested trace start selector"
1286
+ message: start.servicePath && !start.operation && !start.operationPath && !start.handler ? "Service-only trace requires --operation or --path and will not broaden to the whole workspace" : "No handler source matched the requested trace start selector"
1259
1287
  });
1260
1288
  const maxDepth = positiveDepth(options.depth);
1261
1289
  const edges = [];
@@ -1317,9 +1345,10 @@ function trace(db, start, options) {
1317
1345
  unresolvedReason: effective.unresolvedReason
1318
1346
  });
1319
1347
  if (effectiveRow.to_kind === "operation" && current.depth < maxDepth) {
1320
- const files = handlerFilesForOperation(db, effectiveRow.to_id);
1348
+ const implementation = implementationScope(db, effectiveRow.to_id);
1349
+ const files = implementation.files.size > 0 ? implementation.files : handlerFilesForOperation(db, effectiveRow.to_id);
1321
1350
  if (files.size > 0) {
1322
- const targetRepoId = db.prepare(
1351
+ const targetRepoId = implementation.repoId ?? db.prepare(
1323
1352
  "SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?"
1324
1353
  ).get(effectiveRow.to_id)?.repoId;
1325
1354
  const nextKey = `${targetRepoId ?? "*"}:${[...files].sort().join(",")}`;
@@ -1360,7 +1389,9 @@ export {
1360
1389
  parseOutboundCalls,
1361
1390
  parseServiceBindings,
1362
1391
  applyVariables,
1392
+ extractPlaceholders,
1393
+ substituteVariables,
1363
1394
  linkWorkspace,
1364
1395
  trace
1365
1396
  };
1366
- //# sourceMappingURL=chunk-I33ASGX3.js.map
1397
+ //# sourceMappingURL=chunk-YFT57U54.js.map