@saptools/service-flow 0.1.6 → 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,15 @@
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
+
3
13
  ## 0.1.6
4
14
 
5
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.
package/README.md CHANGED
@@ -47,12 +47,12 @@ npm install @saptools/service-flow
47
47
  ```
48
48
 
49
49
  > [!NOTE]
50
- > Requires **Node.js ≥ 24.0.0** for the bundled `node:sqlite` runtime. Version 0.1.6 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
54
 
55
- ### Correctness notes for 0.1.6
55
+ ### Correctness notes for 0.1.7
56
56
 
57
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
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]`.
@@ -436,3 +436,20 @@ MIT
436
436
  ---
437
437
 
438
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.6 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.
@@ -21,7 +21,7 @@
21
21
  - Repository-level fingerprints include source paths/hashes, package dependencies, and analyzer schema version. Unchanged repositories are skipped unless `--force` is used.
22
22
 
23
23
 
24
- ## 0.1.6 correctness additions
24
+ ## 0.1.7 correctness additions
25
25
 
26
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
27
  - Operation candidate scores are clamped into `[0, 1]` before graph or trace rendering.
@@ -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",
@@ -992,149 +1001,120 @@ function normalizeName(value) {
992
1001
  }
993
1002
  function candidatesForDependency(repos, dep, sourceId) {
994
1003
  const exact = repos.filter((repo) => repo.id !== sourceId && repo.package_name === dep);
995
- if (exact.length > 0) return exact;
1004
+ if (exact.length > 0) return { candidates: exact, strategy: "exact_package_name" };
996
1005
  const normalized = normalizeName(dep);
997
- return repos.filter((repo) => repo.id !== sourceId && normalizeName(repo.name) === normalized);
1006
+ return { candidates: repos.filter((repo) => repo.id !== sourceId && normalizeName(repo.name) === normalized), strategy: "normalized_directory" };
998
1007
  }
999
- function linkHelperPackages(db, workspaceId) {
1000
- const repos = db.prepare(
1001
- "SELECT id,name,package_name,dependencies_json FROM repositories WHERE workspace_id=?"
1002
- ).all(workspaceId);
1003
- let count = 0;
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 };
1004
1011
  for (const repo of repos) {
1005
1012
  const deps = JSON.parse(repo.dependencies_json);
1006
1013
  for (const dep of Object.keys(deps)) {
1007
- const candidates = candidatesForDependency(repos, dep, repo.id);
1008
- if (candidates.length === 0) continue;
1009
- const status = candidates.length === 1 ? "resolved" : "ambiguous";
1010
- const helper = candidates.length === 1 ? candidates[0] : void 0;
1011
- db.prepare(
1012
- "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(?,?,?,?,?,?,?,?,?,?,?)"
1013
- ).run(
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(
1014
1019
  workspaceId,
1015
1020
  "REPO_IMPORTS_HELPER_PACKAGE",
1016
1021
  status,
1017
1022
  "repo",
1018
1023
  String(repo.id),
1019
1024
  helper ? "repo" : "repo_candidates",
1020
- helper ? String(helper.id) : candidates.map((candidate) => candidate.id).join(","),
1025
+ helper ? String(helper.id) : result.candidates.map((candidate) => candidate.id).join(","),
1021
1026
  helper ? 1 : 0.5,
1022
- JSON.stringify({
1023
- dependency: dep,
1024
- candidates: candidates.map((candidate) => ({ id: candidate.id, name: candidate.name, packageName: candidate.package_name })),
1025
- match: helper?.package_name === dep ? "package_name" : "normalized_directory"
1026
- }),
1027
+ JSON.stringify({ dependency: dep, candidates: result.candidates.map((candidate) => ({ id: candidate.id, name: candidate.name, packageName: candidate.package_name })), match: result.strategy }),
1027
1028
  0,
1028
- helper ? null : "Ambiguous dependency package candidates"
1029
+ helper ? null : "Ambiguous dependency package candidates",
1030
+ generation
1029
1031
  );
1030
- count += 1;
1032
+ summary.edgeCount += 1;
1033
+ if (helper) summary.resolvedCount += 1;
1034
+ else summary.ambiguousCount += 1;
1031
1035
  }
1032
1036
  }
1033
- return count;
1037
+ return summary;
1034
1038
  }
1035
1039
 
1036
1040
  // src/linker/cross-repo-linker.ts
1037
1041
  function linkWorkspace(db, workspaceId, vars = {}) {
1038
1042
  return db.transaction(() => {
1043
+ const generation = nextGraphGeneration(db, workspaceId);
1039
1044
  db.prepare("DELETE FROM graph_edges WHERE workspace_id=?").run(workspaceId);
1040
- let edges = linkHelperPackages(db, workspaceId);
1041
- let unresolved = 0;
1042
- let resolvedCount = 0;
1043
- let ambiguousCount = 0;
1044
- let dynamicCount = 0;
1045
- let terminalCount = 0;
1046
- const calls = db.prepare(
1047
- `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=?`
1048
- ).all(workspaceId);
1049
- for (const call of calls) {
1050
- const callType = String(call.call_type);
1051
- const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1052
- const servicePath = applyVariables(
1053
- call.servicePathExpr ?? call.requireServicePath,
1054
- vars
1055
- );
1056
- const destination = call.destinationExpr ?? call.requireDestination;
1057
- const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1058
- const resolution = callType.startsWith("remote") ? resolveOperation(
1059
- db,
1060
- {
1061
- servicePath,
1062
- operationPath: op,
1063
- alias: applyVariables(call.aliasExpr ?? call.alias, vars),
1064
- destination: applyVariables(destination, vars),
1065
- isDynamic,
1066
- hasExplicitOverride: Object.keys(vars).length > 0
1067
- },
1068
- workspaceId
1069
- ) : { status: "unresolved", candidates: [], reasons: [] };
1070
- const target = resolution.target;
1071
- const evidence = {
1072
- sourceFile: call.source_file,
1073
- sourceLine: call.source_line,
1074
- file: call.source_file,
1075
- line: call.source_line,
1076
- repo: call.repoName,
1077
- serviceAlias: call.alias,
1078
- serviceAliasExpr: call.aliasExpr,
1079
- destination: applyVariables(destination, vars),
1080
- servicePath,
1081
- operationPath: op,
1082
- targetRepo: target?.repoName,
1083
- targetOperation: target?.operationName,
1084
- helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0,
1085
- candidates: resolution.candidates,
1086
- candidateCount: resolution.candidates.length,
1087
- resolutionStatus: resolution.status,
1088
- resolutionReasons: resolution.reasons
1089
- };
1090
- if (target) {
1091
- db.prepare(
1092
- "INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic) VALUES(?,?,?,?,?,?,?,?,?,?)"
1093
- ).run(
1094
- workspaceId,
1095
- "REMOTE_CALL_RESOLVES_TO_OPERATION",
1096
- "resolved",
1097
- "call",
1098
- String(call.id),
1099
- "operation",
1100
- String(target.operationId),
1101
- target.score,
1102
- JSON.stringify(evidence),
1103
- isDynamic ? 1 : 0
1104
- );
1105
- edges += 1;
1106
- resolvedCount += 1;
1107
- } else {
1108
- 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";
1109
- const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1110
- const unresolvedReason = status === "terminal" ? null : String(
1111
- 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")
1112
- );
1113
- db.prepare(
1114
- "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(?,?,?,?,?,?,?,?,?,?,?)"
1115
- ).run(
1116
- workspaceId,
1117
- edgeType,
1118
- status,
1119
- "call",
1120
- String(call.id),
1121
- callType.startsWith("async_") ? "event" : "external",
1122
- String(call.event_name_expr ?? call.query_entity ?? op ?? call.id),
1123
- Number(call.confidence ?? 0.2),
1124
- JSON.stringify(evidence),
1125
- isDynamic || resolution.status === "dynamic" ? 1 : 0,
1126
- unresolvedReason
1127
- );
1128
- edges += 1;
1129
- unresolved += status === "unresolved" ? 1 : 0;
1130
- ambiguousCount += status === "ambiguous" ? 1 : 0;
1131
- dynamicCount += status === "dynamic" ? 1 : 0;
1132
- terminalCount += status === "terminal" ? 1 : 0;
1133
- }
1134
- }
1135
- 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 };
1136
1050
  });
1137
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
+ }
1138
1118
 
1139
1119
  // src/trace/trace-engine.ts
1140
1120
  function normalizeOperation(value) {
@@ -1182,8 +1162,10 @@ function startScope(db, start) {
1182
1162
  if (start.repo && !repo) return { repo, selectorMatched: false };
1183
1163
  const sourceFiles = sourceFilesForStart(db, repo?.id, start);
1184
1164
  const hasSelector = Boolean(
1185
- start.handler ?? start.operation ?? start.operationPath
1165
+ start.handler ?? start.operation ?? start.operationPath ?? start.servicePath
1186
1166
  );
1167
+ if (start.servicePath && !start.operation && !start.operationPath && !start.handler)
1168
+ return { repo, selectorMatched: false };
1187
1169
  return {
1188
1170
  repo,
1189
1171
  sourceFiles,
@@ -1204,6 +1186,12 @@ function handlerFilesForOperation(db, operationId) {
1204
1186
  ).all(op.repoId, operation, operation, op.operationName);
1205
1187
  return new Set(rows2.map((row) => row.sourceFile).filter(Boolean));
1206
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
+ }
1207
1195
  function includeCall(type, options) {
1208
1196
  if (!options.includeDb && type === "local_db_query") return false;
1209
1197
  if (!options.includeExternal && type === "external_http") return false;
@@ -1288,11 +1276,14 @@ function trace(db, start, options) {
1288
1276
  const diagnostics = db.prepare(
1289
1277
  "SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics WHERE (? IS NULL OR repo_id=?)"
1290
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.` });
1291
1282
  if (!scope.selectorMatched)
1292
1283
  diagnostics.unshift({
1293
1284
  severity: "warning",
1294
1285
  code: "trace_start_not_found",
1295
- 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"
1296
1287
  });
1297
1288
  const maxDepth = positiveDepth(options.depth);
1298
1289
  const edges = [];
@@ -1354,9 +1345,10 @@ function trace(db, start, options) {
1354
1345
  unresolvedReason: effective.unresolvedReason
1355
1346
  });
1356
1347
  if (effectiveRow.to_kind === "operation" && current.depth < maxDepth) {
1357
- 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);
1358
1350
  if (files.size > 0) {
1359
- const targetRepoId = db.prepare(
1351
+ const targetRepoId = implementation.repoId ?? db.prepare(
1360
1352
  "SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?"
1361
1353
  ).get(effectiveRow.to_id)?.repoId;
1362
1354
  const nextKey = `${targetRepoId ?? "*"}:${[...files].sort().join(",")}`;
@@ -1402,4 +1394,4 @@ export {
1402
1394
  linkWorkspace,
1403
1395
  trace
1404
1396
  };
1405
- //# sourceMappingURL=chunk-6C5HZ6IR.js.map
1397
+ //# sourceMappingURL=chunk-YFT57U54.js.map