@saptools/service-flow 0.1.11 → 0.1.13

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.13
4
+
5
+ - Added implementation-context fallback for local `cds.services.*` calls so helper packages can resolve model-package operations only when handler/dependency/registration evidence ties the caller repository to the target operation.
6
+ - Preserved same-repository local service resolution as the strongest path and added explicit evidence/reasons for implementation-context ownership, rejected candidates, and candidate-without-caller-ownership cases.
7
+ - Replaced fragile CAP DB query string extraction with AST traversal for chained `cds.run(SELECT/INSERT/UPDATE/DELETE...)` forms, including multiline `columns(...).where(...)` chains and `this.EntityName` targets.
8
+ - Kept genuinely dynamic DB queries terminal while exposing parser-warning evidence and rendering unknown targets as `Entity: unknown` instead of raw numeric call ids in table/Mermaid output.
9
+ - Further reduced symbol-call noise by filtering built-in collection/string methods, logger calls, global built-ins, third-party package property calls, and unindexed `this.container.method()` calls while retaining indexed local helpers and relative object-literal helper imports.
10
+ - Added doctor aggregate visibility for local service calls resolved by implementation context and calls left unresolved because candidates lack caller ownership.
11
+ - Updated README and technical notes to clarify generated constants remain low-level parser output rather than persisted graph facts.
12
+
13
+ ## 0.1.12
14
+
15
+ - Resolved same-repository local CAP service calls by qualified CDS name, simple service name, and service path, with explicit local transport and alias-chain evidence.
16
+ - Made implementation matching decorator-aware so generated `Func*`/`Action*` constants outrank method-name fallback and contradictory decorators are rejected without making edges ambiguous.
17
+ - Cleared stale unresolved reasons from resolved symbol calls and suppressed false trace unresolved reasons for symbol edges with concrete callee ids.
18
+ - Made local symbol-call collection conservative, added named export-list support, and indexed one-level object-literal helper methods as executable symbols so traces can reach helper database queries.
19
+ - Added first-class symbol nodes and readable symbol labels/locations to JSON, table, and Mermaid trace output.
20
+ - Deduplicated implementation candidates by method identity while retaining multiple registration rows as nested evidence, and kept default doctor output from failing on explainable source-ownership gaps.
21
+ - Documented the generated-constant decision: this patch uses deterministic decorator normalization for linking while `parseGeneratedConstants` remains a low-level parser export rather than a persisted graph fact.
22
+
3
23
  ## 0.1.11
4
24
 
5
25
  - Added repository-owned executable symbols, source-symbol ownership for outbound calls, and local symbol-call facts so traces can follow reachable same-file/imported helpers without including unrelated calls from the same file.
package/README.md CHANGED
@@ -59,7 +59,7 @@ npm install @saptools/service-flow
59
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
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
61
  - Handler registration parsing is AST-based for common `createCombinedHandler({ handler: ... })` forms: direct arrays, arrays assembled with spreads, non-`handlers` array names, aliased class imports, default-imported arrays, named exported arrays, and safe relative re-exports. Class-level rows keep registration file/line and import evidence.
62
- - Implementation edges require both operation compatibility and registration evidence. Same-repository registrations do not need a self-dependency edge; cross-package matches use registration or handler-package dependencies on the model package. Duplicate strong candidates are stored as ambiguous implementation edges.
62
+ - Implementation edges require both operation compatibility and registration evidence. Decorator operation signals are stronger than method-name fallback; common generated names such as `FuncGetConfiguration` and `ActionGetConfiguration` are normalized before comparison, and a contradictory decorator rejects the candidate even when the TypeScript method name collides. Same-repository registrations do not need a self-dependency edge; cross-package matches use registration or handler-package dependencies on the model package. Duplicate strong candidates are stored as ambiguous implementation edges.
63
63
  - Traces render persisted `OPERATION_IMPLEMENTED_BY_HANDLER` hops after static or runtime remote operation resolution, including terminal handler nodes and ambiguous or unresolved implementation evidence when traversal cannot continue.
64
64
  - 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.
65
65
  - 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.
@@ -152,9 +152,11 @@ target handler up to `--depth` instead of showing only calls in the first file.
152
152
 
153
153
  `service-flow trace` starts from the selected handler method symbol, renders outbound calls owned by that symbol, and follows conservative local helper-call facts. Supported helper edges include same-file functions, `this.method()` calls, and exactly mapped relative imports/exports that resolve to an indexed executable symbol. Calls from unrelated functions in the same source file are not included merely because the file path matches.
154
154
 
155
- Local CAP calls through `cds.services.<Service>.<operation>()`, bracket service lookups, and simple aliases are indexed as local operation calls. Entity accessors such as `cds.services.db.entities(...)` are treated as entity metadata access, not operation calls.
155
+ Local CAP calls through `cds.services.<Service>.<operation>()`, bracket service lookups, and simple aliases are indexed as local operation calls. Linking first stays within the same repository and matches the target operation by exact qualified CDS service name, exact simple service name, exact service path, or an unambiguous service-path suffix. If no same-repository service exists, the linker can use implementation-context evidence to resolve model-package operations for helper packages: a resolved/ambiguous implementation candidate, registration package, or dependency/import edge must tie the caller repository to the model operation. Name-only global matches are preserved as unresolved candidate evidence rather than guessed links. Entity accessors such as `cds.services.db.entities(...)` are treated as entity metadata access, not operation calls.
156
+
157
+ Conservative local symbol traversal intentionally excludes decorators, built-ins such as `JSON.parse`, collection methods, third-party APIs, and arbitrary property chains unless the callee can plausibly resolve to an indexed local symbol. Named export lists such as `export { loadTemplate as publicLoadTemplate }` are indexed with the public exported name so relative imports can resolve. One-level object-literal helpers are indexed as symbols named like `cacheHelper.getConfiguration`; nested object literals are not yet expanded beyond the first helper level. `parseGeneratedConstants` remains a public low-level parser export for callers that need it, but generated constants are not persisted as graph facts in this patch; linking uses the deterministic decorator normalizer described above.
156
158
  JSON output includes typed nodes for calls, operations, database entities,
157
- external destinations, and unresolved/dynamic candidates when edges exist.
159
+ external destinations, and unresolved/dynamic candidates when edges exist. Chained CAP DB queries inside `cds.run(...)` are parsed with TypeScript AST evidence for `SELECT`, `INSERT`, `UPDATE`, and `DELETE` forms. When the query target is genuinely dynamic, graph status remains terminal and JSON retains `parserWarning` evidence, while table and Mermaid render the target as `Entity: unknown` rather than a numeric call id.
158
160
 
159
161
  ```bash
160
162
  service-flow trace --workspace /path/to/workspace --repo facade-service --operation doWork
@@ -285,7 +287,7 @@ service-flow clean --workspace /path/to/workspace
285
287
  | Local data access | `cds.run(SELECT...)` and local entity query evidence |
286
288
  | Async channels | Event Mesh-style `emit`, `publish`, and `on` facts |
287
289
  | External calls | Cloud SDK-style HTTP/destination calls and external edge evidence |
288
- | Generated constants | constants used to resolve service paths, operation paths, and thin helper wrappers |
290
+ | Generated constants | low-level `parseGeneratedConstants` parser output for integrations; not persisted as first-class graph facts in this patch |
289
291
 
290
292
  ---
291
293
 
package/TECHNICAL-NOTE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Service Flow 0.1.8 Resolution Notes
1
+ # Service Flow 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.
@@ -6,6 +6,15 @@
6
6
  - Trace cycle safety: trace queues carry repository IDs, visited scope keys are independent of depth, graph edge IDs are emitted once, and revisiting an already-seen downstream operation scope creates a cycle marker instead of recursive expansion.
7
7
  - SQLite reliability: the package uses a persistent SQLite connection per opened database, bound parameters, transactions, WAL, busy timeouts, read-only openings for query commands, and connection-local foreign-key enforcement. Native driver loading failures produce an actionable startup error before output rendering.
8
8
 
9
+ ## 0.1.13 audit follow-up notes
10
+
11
+ - Local CAP service calls keep same-repository service ownership as the strongest resolution path. When the caller repository does not own the CDS service model, the linker searches workspace operations by service identity and operation name/path, then requires caller ownership evidence from implementation edges, ambiguous implementation candidates, registration packages, or resolved dependency/import edges before resolving. Candidate operations without caller evidence are retained with `local_service_candidate_without_caller_ownership` rather than guessed.
12
+ - Trace traversal can use local-call context to choose the caller repository's handler from an otherwise ambiguous global implementation edge. This is scoped to the local call and does not rewrite the global `OPERATION_IMPLEMENTED_BY_HANDLER` ambiguity.
13
+ - CAP DB entity extraction for local `cds.run(...)` calls uses TypeScript AST traversal across chained query expressions, including `SELECT.one.from(Entity).columns(...).where(...)`, `INSERT.into(Entity)`, `UPDATE(Entity)`, and `DELETE.from(Entity)`. Dynamic or unknowable query targets remain terminal graph edges with parser-warning evidence.
14
+ - Human output never labels an unknown DB query target with the raw call id. Table and Mermaid output use `Entity: unknown`; JSON keeps the parser warning and call id evidence for machines.
15
+ - Symbol-call indexing is intentionally conservative for property accesses. Built-in collection/string calls, global built-ins, logger calls, third-party package properties, and unindexed `this.someContainer.method()` calls are filtered unless indexed local helper or relative import evidence makes the edge actionable.
16
+ - Generated constants remain low-level parser output through `parseGeneratedConstants`. They are not persisted as graph facts; implementation linking only uses deterministic decorator normalization for common generated action/function names.
17
+
9
18
  ## 0.1.4 trace-correctness additions
10
19
 
11
20
  - Helper exports are normalized through a public-to-local export map, so `export { helper }` and `export { helper as publicHelper }` both resolve to the local declaration that contains the `cds.connect.to(...)` call.
@@ -555,22 +555,39 @@ function matchingParen(text, open) {
555
555
  }
556
556
  return -1;
557
557
  }
558
- function argumentForCall(expr, marker) {
559
- const idx = expr.indexOf(marker);
560
- if (idx < 0) return void 0;
561
- const open = expr.indexOf("(", idx + marker.length);
562
- if (open < 0) return void 0;
563
- const close = matchingParen(expr, open);
564
- return close > open ? expr.slice(open + 1, close).trim() : void 0;
565
- }
566
- function entityFromArg(arg) {
567
- if (!arg) return void 0;
568
- const first = arg.split(",")[0]?.trim();
569
- if (!first) return void 0;
570
- return stripQuotes(first).replace(/^this\./, "");
558
+ function entityFromExpression(expr) {
559
+ if (!expr) return void 0;
560
+ if (ts4.isIdentifier(expr) || ts4.isStringLiteral(expr) || ts4.isNoSubstitutionTemplateLiteral(expr)) return expr.text;
561
+ if (ts4.isPropertyAccessExpression(expr) && expr.expression.kind === ts4.SyntaxKind.ThisKeyword) return expr.name.text;
562
+ return void 0;
563
+ }
564
+ function expressionName(expr) {
565
+ if (ts4.isIdentifier(expr)) return expr.text;
566
+ if (ts4.isPropertyAccessExpression(expr)) return `${expressionName(expr.expression)}.${expr.name.text}`;
567
+ return expr.getText();
568
+ }
569
+ function queryEntityFromAst(expr) {
570
+ if (ts4.isParenthesizedExpression(expr) || ts4.isAwaitExpression(expr)) return queryEntityFromAst(expr.expression);
571
+ if (ts4.isCallExpression(expr)) {
572
+ const name = expressionName(expr.expression);
573
+ if (name === "cds.run") return queryEntityFromAst(expr.arguments[0]);
574
+ if (name === "SELECT.one.from" || name === "SELECT.from" || name === "INSERT.into" || name === "DELETE.from") return entityFromExpression(expr.arguments[0]);
575
+ if (name === "UPDATE") return entityFromExpression(expr.arguments[0]);
576
+ const receiver = ts4.isPropertyAccessExpression(expr.expression) ? expr.expression.expression : void 0;
577
+ if (receiver) return queryEntityFromAst(receiver);
578
+ }
579
+ return void 0;
571
580
  }
572
581
  function extractQueryEntity(expr) {
573
- return entityFromArg(argumentForCall(expr, "SELECT.one.from")) ?? entityFromArg(argumentForCall(expr, "SELECT.from")) ?? entityFromArg(argumentForCall(expr, "INSERT.into")) ?? entityFromArg(argumentForCall(expr, "UPDATE")) ?? entityFromArg(argumentForCall(expr, "DELETE.from"));
582
+ const source = ts4.createSourceFile("query.ts", `const __query = (${expr});`, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
583
+ let found;
584
+ const visit = (node) => {
585
+ if (found) return;
586
+ if (ts4.isParenthesizedExpression(node)) found = queryEntityFromAst(node.expression);
587
+ ts4.forEachChild(node, visit);
588
+ };
589
+ visit(source);
590
+ return found;
574
591
  }
575
592
  async function parseOutboundCalls(repoPath, filePath) {
576
593
  const text = await fs6.readFile(path7.join(repoPath, filePath), "utf8");
@@ -1074,7 +1091,7 @@ function substituteVariables(template, vars) {
1074
1091
  // src/linker/service-resolver.ts
1075
1092
  function rows(db, operationPath, workspaceId) {
1076
1093
  return db.prepare(
1077
- `SELECT o.id operationId,r.name repoName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine,0 score,'' reasons
1094
+ `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine,0 score,'' reasons
1078
1095
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
1079
1096
  WHERE (? IS NULL OR r.workspace_id=?) AND (o.operation_path=? OR o.operation_name=?) ORDER BY r.name,s.service_path,o.operation_name`
1080
1097
  ).all(
@@ -1098,11 +1115,21 @@ function resolveOperation(db, signals, workspaceId) {
1098
1115
  candidates: [],
1099
1116
  reasons: ["missing_operation_path"]
1100
1117
  };
1101
- const candidates = rows(db, signals.operationPath, workspaceId).filter((c) => matchesLocalRepo(db, c.operationId, signals.repoId)).map((c) => ({
1118
+ const allCandidates = rows(db, signals.operationPath, workspaceId).map((c) => ({
1102
1119
  ...c,
1103
1120
  score: 0.2,
1104
1121
  reasons: ["operation_path_match"]
1105
1122
  }));
1123
+ let candidates = allCandidates.filter((c) => matchesLocalRepo(db, c.operationId, signals.repoId));
1124
+ if (candidates.length === 0 && signals.repoId !== void 0 && signals.serviceName) {
1125
+ candidates = implementationContextCandidates(db, allCandidates, signals.repoId, signals.serviceName);
1126
+ if (candidates.length === 0)
1127
+ return {
1128
+ status: "unresolved",
1129
+ candidates: allCandidates.filter((c) => serviceMatches(c, signals.serviceName)),
1130
+ reasons: allCandidates.some((c) => serviceMatches(c, signals.serviceName)) ? ["local_service_candidate_without_caller_ownership"] : ["no_operation_candidates"]
1131
+ };
1132
+ }
1106
1133
  if (candidates.length === 0)
1107
1134
  return {
1108
1135
  status: "unresolved",
@@ -1110,7 +1137,7 @@ function resolveOperation(db, signals, workspaceId) {
1110
1137
  reasons: ["no_operation_candidates"]
1111
1138
  };
1112
1139
  const hasStrongSignal = Boolean(
1113
- signals.servicePath || signals.alias || signals.destination || signals.hasExplicitOverride
1140
+ signals.servicePath || signals.serviceName || signals.alias || signals.destination || signals.hasExplicitOverride
1114
1141
  );
1115
1142
  for (const c of candidates) {
1116
1143
  if (signals.servicePath && c.servicePath === signals.servicePath) {
@@ -1121,14 +1148,25 @@ function resolveOperation(db, signals, workspaceId) {
1121
1148
  c.score -= 0.1;
1122
1149
  c.reasons.push("service_path_mismatch");
1123
1150
  }
1124
- if (signals.serviceName && (c.servicePath === `/${signals.serviceName}` || c.servicePath.endsWith(`/${signals.serviceName}`))) {
1125
- c.score += 0.7;
1126
- c.reasons.push("exact_local_service_name");
1151
+ if (signals.serviceName) {
1152
+ const simple = signals.serviceName.split(".").at(-1) ?? signals.serviceName;
1153
+ if (c.qualifiedName === signals.serviceName) {
1154
+ c.score += 0.8;
1155
+ c.reasons.push("exact_local_qualified_service_name");
1156
+ } else if (c.serviceName === signals.serviceName || c.serviceName === simple) {
1157
+ c.score += 0.75;
1158
+ c.reasons.push("exact_local_simple_service_name");
1159
+ } else if (c.servicePath === signals.serviceName || c.servicePath === `/${signals.serviceName}` || c.servicePath === `/${simple}`) {
1160
+ c.score += 0.7;
1161
+ c.reasons.push("exact_local_service_path");
1162
+ } else if (c.servicePath.endsWith(`/${simple}`)) {
1163
+ c.score += candidates.filter((candidate) => candidate.servicePath.endsWith(`/${simple}`)).length === 1 ? 0.65 : 0.2;
1164
+ c.reasons.push("suffix_local_service_path");
1165
+ } else c.reasons.push("local_service_name_mismatch");
1127
1166
  }
1128
- if (signals.serviceName && c.servicePath !== `/${signals.serviceName}` && !c.servicePath.endsWith(`/${signals.serviceName}`)) c.reasons.push("local_service_name_mismatch");
1129
1167
  if (signals.hasExplicitOverride) {
1130
1168
  c.score += 0.2;
1131
- c.reasons.push("explicit_dynamic_override");
1169
+ c.reasons.push(signals.repoId !== void 0 ? "explicit_local_service_call" : "explicit_dynamic_override");
1132
1170
  }
1133
1171
  }
1134
1172
  for (const c of candidates) c.score = Math.max(0, Math.min(1, c.score));
@@ -1149,7 +1187,7 @@ function resolveOperation(db, signals, workspaceId) {
1149
1187
  candidates,
1150
1188
  reasons: ["operation_path_only_has_no_strong_target_signal"]
1151
1189
  };
1152
- if (best && best.score >= 0.9 && (best.servicePath === signals.servicePath || Boolean(signals.serviceName && (best.servicePath === `/${signals.serviceName}` || best.servicePath.endsWith(`/${signals.serviceName}`)))) && (best.operationPath === signals.operationPath || best.operationName === signals.operationPath.replace(/^\//, "")) && (!second || best.score - second.score >= 0.25))
1190
+ if (best && best.score >= 0.9 && (best.servicePath === signals.servicePath || Boolean(signals.serviceName && !best.reasons.includes("local_service_name_mismatch"))) && (best.operationPath === signals.operationPath || best.operationName === signals.operationPath.replace(/^\//, "")) && (!second || best.score - second.score >= 0.25))
1153
1191
  return {
1154
1192
  status: "resolved",
1155
1193
  target: best,
@@ -1162,6 +1200,34 @@ function resolveOperation(db, signals, workspaceId) {
1162
1200
  reasons: ["candidate_score_below_resolution_threshold"]
1163
1201
  };
1164
1202
  }
1203
+ function serviceMatches(candidate, serviceName) {
1204
+ if (!serviceName) return false;
1205
+ const simple = serviceName.split(".").at(-1) ?? serviceName;
1206
+ return candidate.qualifiedName === serviceName || candidate.serviceName === serviceName || candidate.serviceName === simple || candidate.servicePath === serviceName || candidate.servicePath === `/${serviceName}` || candidate.servicePath === `/${simple}` || candidate.servicePath.endsWith(`/${simple}`);
1207
+ }
1208
+ function implementationContextCandidates(db, candidates, callerRepoId, serviceName) {
1209
+ const matching = candidates.filter((candidate) => serviceMatches(candidate, serviceName));
1210
+ const owned = matching.map((candidate) => ownershipReason(db, candidate, callerRepoId)).filter((item) => Boolean(item));
1211
+ if (owned.length === 0) return [];
1212
+ const direct = owned.filter((item) => item.reason !== "caller_depends_on_model_package");
1213
+ const chosen = direct.length > 0 ? direct : owned.length === 1 ? owned : [];
1214
+ return chosen.map((item) => ({ ...item.candidate, score: 0.95, reasons: [...item.candidate.reasons, "implementation_context_caller_ownership", item.reason] }));
1215
+ }
1216
+ function ownershipReason(db, candidate, callerRepoId) {
1217
+ const edge = db.prepare("SELECT status,evidence_json,to_id FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND from_kind='operation' AND from_id=? ORDER BY CASE status WHEN 'resolved' THEN 0 WHEN 'ambiguous' THEN 1 ELSE 2 END LIMIT 1").get(String(candidate.operationId));
1218
+ if (edge?.status === "resolved") {
1219
+ const row = db.prepare("SELECT hc.repo_id repoId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id WHERE hm.id=?").get(edge.to_id);
1220
+ if (row?.repoId === callerRepoId) return { candidate, reason: "resolved_implementation_handler_repo_matches_caller" };
1221
+ }
1222
+ if (edge?.evidence_json) {
1223
+ const evidence = JSON.parse(edge.evidence_json);
1224
+ const hit = evidence.candidates?.find((item) => item.accepted && (Number(item.handlerPackage?.id) === callerRepoId || Number(item.applicationPackage?.id) === callerRepoId));
1225
+ if (hit) return { candidate, reason: edge.status === "ambiguous" ? "ambiguous_implementation_candidate_repo_matches_caller" : "registration_package_matches_caller" };
1226
+ }
1227
+ const dep = db.prepare("SELECT 1 FROM graph_edges WHERE edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND status='resolved' AND from_kind='repo' AND from_id=? AND to_id=?").get(String(callerRepoId), String(candidate.repoId));
1228
+ if (dep) return { candidate, reason: "caller_depends_on_model_package" };
1229
+ return void 0;
1230
+ }
1165
1231
  function matchesLocalRepo(db, operationId, repoId) {
1166
1232
  if (repoId === void 0) return true;
1167
1233
  const row = db.prepare("SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?").get(operationId);
@@ -1216,8 +1282,8 @@ function linkWorkspace(db, workspaceId, vars = {}) {
1216
1282
  const generation = nextGraphGeneration(db, workspaceId);
1217
1283
  db.prepare("DELETE FROM graph_edges WHERE workspace_id=?").run(workspaceId);
1218
1284
  const deps = linkHelperPackages(db, workspaceId, generation);
1219
- const callSummary = linkCalls(db, workspaceId, vars, generation);
1220
1285
  const impl = linkImplementations(db, workspaceId, generation);
1286
+ const callSummary = linkCalls(db, workspaceId, vars, generation);
1221
1287
  db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
1222
1288
  return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount };
1223
1289
  });
@@ -1307,7 +1373,7 @@ function rankedImplementationCandidates(db, workspaceId, operation) {
1307
1373
  function deduplicateCandidates(rows2) {
1308
1374
  const merged = /* @__PURE__ */ new Map();
1309
1375
  for (const row of rows2) {
1310
- const key = [row.methodId, row.classId, row.applicationRepoId, row.importSource ?? "", row.registrationKind ?? ""].join(":");
1376
+ const key = [row.methodId, row.classId, row.handlerRepoId].join(":");
1311
1377
  const registration = { file: row.registrationFile, line: row.registrationLine, kind: row.registrationKind, importSource: row.importSource };
1312
1378
  const existing = merged.get(key);
1313
1379
  if (!existing) {
@@ -1335,6 +1401,9 @@ function implementationCandidates(db, workspaceId, operation) {
1335
1401
  const modelRepoGraphId = graphId(operation.modelRepoId);
1336
1402
  return db.prepare(`SELECT DISTINCT
1337
1403
  hm.id methodId,
1404
+ hm.method_name methodName,
1405
+ hm.decorator_value decoratorValue,
1406
+ hm.decorator_raw_expression decoratorRawExpression,
1338
1407
  hc.id classId,
1339
1408
  hc.class_name className,
1340
1409
  hc.source_file sourceFile,
@@ -1361,7 +1430,7 @@ function implementationCandidates(db, workspaceId, operation) {
1361
1430
  CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
1362
1431
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id AND localService.service_path=?) THEN 1 ELSE 0 END localServicePathMatch,
1363
1432
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
1364
- CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON (localClass.id=localReg.handler_class_id OR localClass.class_name=localReg.class_name) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
1433
+ CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON (localClass.id=localReg.handler_class_id OR localClass.class_name=localReg.class_name) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
1365
1434
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END appDependsOnModel,
1366
1435
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(handlerRepo.id AS TEXT)) THEN 1 ELSE 0 END appDependsOnHandler,
1367
1436
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END handlerDependsOnModel
@@ -1371,7 +1440,7 @@ function implementationCandidates(db, workspaceId, operation) {
1371
1440
  JOIN handler_registrations hr ON (hr.handler_class_id=hc.id OR (hr.class_name=hc.class_name AND (hr.repo_id=hc.repo_id OR hr.import_source IS NOT NULL)))
1372
1441
  JOIN repositories appRepo ON appRepo.id=hr.repo_id
1373
1442
  WHERE appRepo.workspace_id=?
1374
- AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=?)`).all(
1443
+ AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
1375
1444
  operation.modelRepoId,
1376
1445
  operation.modelRepo,
1377
1446
  operation.modelPackage,
@@ -1385,12 +1454,14 @@ function implementationCandidates(db, workspaceId, operation) {
1385
1454
  normalizedOperation(String(operation.operationPath ?? "")),
1386
1455
  operation.operationName,
1387
1456
  operation.operationName,
1457
+ `%${upperFirst(normalizedOperation(String(operation.operationPath ?? operation.operationName ?? "")))}%`,
1388
1458
  modelRepoGraphId,
1389
1459
  modelRepoGraphId,
1390
1460
  workspaceId,
1391
1461
  normalizedOperation(String(operation.operationPath ?? "")),
1392
1462
  operation.operationName,
1393
- operation.operationName
1463
+ operation.operationName,
1464
+ `%${upperFirst(normalizedOperation(String(operation.operationPath ?? operation.operationName ?? "")))}%`
1394
1465
  );
1395
1466
  }
1396
1467
  function scoreImplementationCandidate(row, operation) {
@@ -1408,7 +1479,10 @@ function scoreImplementationCandidate(row, operation) {
1408
1479
  const importSource = typeof row.importSource === "string" && row.importSource.length > 0;
1409
1480
  const sameRepoRegistration = flag(row.sameRepoRegistration);
1410
1481
  const modelOriented = row.modelKind === "cap-db-model" || !applicationHasLocalRegistrationForOperation;
1411
- const methodMatches = true;
1482
+ const methodSignal = implementationMethodSignal(row, operation);
1483
+ const methodMatches = methodSignal.matches;
1484
+ acceptedReasons.push(...methodSignal.acceptedReasons);
1485
+ rejectedReasons.push(...methodSignal.rejectedReasons);
1412
1486
  const registeredAndLinked = sameRepoRegistration && importSource;
1413
1487
  const helperOwned = modelOriented && methodMatches && registeredAndLinked && sameRepoRegistration && !applicationHasLocalServices && !modelIsApplicationRepo && !modelIsHandlerRepo && !localServicePathMatch && !appDependsOnModel && !appDependsOnHandler && !handlerDependsOnModel;
1414
1488
  if (modelIsApplicationRepo) {
@@ -1449,7 +1523,7 @@ function scoreImplementationCandidate(row, operation) {
1449
1523
  const hasCrossPackage = appDependsOnModel && (modelIsHandlerRepo || appDependsOnHandler || !importSource);
1450
1524
  const contradicted = applicationHasLocalServices && !localServicePathMatch && !appDependsOnModel && !hasOwnership;
1451
1525
  if (!hasOwnership && !localServicePathMatch && !hasCrossPackage && !helperOwned) rejectedReasons.push("missing direct ownership, exact local service path, or validated cross-package dependency evidence");
1452
- const accepted = !contradicted && (hasOwnership || localServicePathMatch || hasCrossPackage || handlerDependsOnModel || helperOwned);
1526
+ const accepted = methodMatches && !methodSignal.contradicted && !contradicted && (hasOwnership || localServicePathMatch || hasCrossPackage || handlerDependsOnModel || helperOwned);
1453
1527
  if (!accepted && rejectedReasons.length === 0) rejectedReasons.push("candidate did not meet implementation ownership policy");
1454
1528
  return { ...row, methodId: Number(row.methodId), score, accepted, acceptedReasons, rejectedReasons };
1455
1529
  }
@@ -1485,6 +1559,29 @@ function candidateEvidence(candidate, rank) {
1485
1559
  }
1486
1560
  };
1487
1561
  }
1562
+ function implementationMethodSignal(row, operation) {
1563
+ const op = normalizedOperation(String(operation.operationPath ?? operation.operationName ?? ""));
1564
+ const decorator = normalizeDecoratorOperation(typeof row.decoratorValue === "string" ? row.decoratorValue : void 0, typeof row.decoratorRawExpression === "string" ? row.decoratorRawExpression : void 0);
1565
+ if (decorator && decorator === op) return { matches: true, contradicted: false, acceptedReasons: ["decorator targets operation"], rejectedReasons: [] };
1566
+ if (decorator && decorator !== op) return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ["method_name_matches_but_decorator_targets_different_operation"] };
1567
+ if (String(row.methodName ?? "") === op) return { matches: true, contradicted: false, acceptedReasons: ["method name fallback matched operation"], rejectedReasons: [] };
1568
+ return { matches: false, contradicted: false, acceptedReasons: [], rejectedReasons: ["method name does not match operation"] };
1569
+ }
1570
+ function normalizeDecoratorOperation(value, raw) {
1571
+ const candidate = value ?? raw?.split(".").filter(Boolean).at(-2);
1572
+ if (!candidate) return void 0;
1573
+ const cleaned = candidate.replace(/^['"`]|['"`]$/g, "");
1574
+ for (const prefix of ["Func", "Action"]) {
1575
+ if (cleaned.startsWith(prefix) && cleaned.length > prefix.length) return lowerFirst(cleaned.slice(prefix.length));
1576
+ }
1577
+ return normalizedOperation(cleaned);
1578
+ }
1579
+ function upperFirst(value) {
1580
+ return value ? `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}` : value;
1581
+ }
1582
+ function lowerFirst(value) {
1583
+ return value ? `${value[0]?.toLowerCase() ?? ""}${value.slice(1)}` : value;
1584
+ }
1488
1585
  function flag(value) {
1489
1586
  return Boolean(Number(value ?? 0));
1490
1587
  }
@@ -1591,6 +1688,17 @@ function implementationScope(db, operationId) {
1591
1688
  const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name WHERE hm.id=?").get(edge.to_id);
1592
1689
  return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), symbolId: row?.symbolId, edge };
1593
1690
  }
1691
+ function contextImplementationMethodId(edge, callerRepoId) {
1692
+ if (!edge || edge.status !== "ambiguous" || callerRepoId === void 0) return void 0;
1693
+ const evidence = JSON.parse(String(edge.evidence_json || "{}"));
1694
+ const candidate = evidence.candidates?.find((item) => item.accepted && (Number(item.handlerPackage?.id) === callerRepoId || Number(item.applicationPackage?.id) === callerRepoId));
1695
+ return candidate?.methodId === void 0 ? void 0 : String(candidate.methodId);
1696
+ }
1697
+ function handlerScope(db, methodId) {
1698
+ const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name WHERE hm.id=?").get(methodId);
1699
+ if (!row) return void 0;
1700
+ return { repoId: row.repoId, files: new Set(row.sourceFile ? [row.sourceFile] : []), symbolId: row.symbolId };
1701
+ }
1594
1702
  function includeCall(type, options) {
1595
1703
  if (!options.includeDb && type === "local_db_query") return false;
1596
1704
  if (!options.includeExternal && type === "external_http") return false;
@@ -1634,6 +1742,12 @@ function evidenceWithRuntimeVariables(evidence, vars) {
1634
1742
  if (missing.length > 0) next.missingRuntimeVariables = [...new Set(missing)];
1635
1743
  return next;
1636
1744
  }
1745
+ function symbolNode(db, symbolId) {
1746
+ const row = db.prepare(`SELECT s.id symbolId,s.name symbolName,s.qualified_name qualifiedName,s.source_file sourceFile,s.start_line startLine,s.end_line endLine,r.name repoName,r.id repoId FROM symbols s JOIN repositories r ON r.id=s.repo_id WHERE s.id=?`).get(symbolId);
1747
+ if (!row) return void 0;
1748
+ const fileName = String(row.sourceFile ?? "").split("/").at(-1) ?? String(row.sourceFile ?? "");
1749
+ return { id: `symbol:${symbolId}`, kind: "symbol", label: `${fileName}:${String(row.qualifiedName ?? row.symbolName)}`, ...row };
1750
+ }
1637
1751
  function operationNode(db, operationId) {
1638
1752
  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);
1639
1753
  if (!row) return void 0;
@@ -1668,6 +1782,7 @@ function edgeTarget(row, evidence) {
1668
1782
  const operationPath = typeof evidence.operationPath === "string" ? evidence.operationPath : void 0;
1669
1783
  const targetOperation = typeof evidence.targetOperation === "string" ? evidence.targetOperation : void 0;
1670
1784
  const targetRepo = typeof evidence.targetRepo === "string" ? evidence.targetRepo : "";
1785
+ if (row.edge_type === "HANDLER_RUNS_DB_QUERY") return /^\d+$/.test(row.to_id) ? "Entity: unknown" : `Entity: ${row.to_id}`;
1671
1786
  return servicePath && operationPath ? `${servicePath}${operationPath}` : targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
1672
1787
  }
1673
1788
  function trace(db, start, options) {
@@ -1728,7 +1843,10 @@ function trace(db, start, options) {
1728
1843
  const nextFiles = /* @__PURE__ */ new Set([String(symbolCall.calleeFile)]);
1729
1844
  const nextRepoId = Number(symbolCall.calleeRepoId);
1730
1845
  const nextKey = `${nextRepoId}:${[...nextSymbols].join(",")}:${[...nextFiles].join(",")}`;
1731
- edges.push({ step: current.depth, type: "local_symbol_call", from: String(symbolCall.callee_expression), to: `symbol:${String(symbolCall.callee_symbol_id)}`, evidence: JSON.parse(String(symbolCall.evidence_json || "{}")), confidence: Number(symbolCall.confidence ?? 0.8), unresolvedReason: symbolCall.unresolved_reason ? String(symbolCall.unresolved_reason) : void 0 });
1846
+ const calleeNode = symbolNode(db, Number(symbolCall.callee_symbol_id));
1847
+ if (calleeNode) nodes.set(String(calleeNode.id), calleeNode);
1848
+ const evidence = { ...JSON.parse(String(symbolCall.evidence_json || "{}")), sourceFile: symbolCall.source_file, sourceLine: symbolCall.source_line, calleeSymbolId: symbolCall.callee_symbol_id, calleeSymbolName: calleeNode?.symbolName, calleeSymbolFile: calleeNode?.sourceFile, resolutionStatus: symbolCall.status };
1849
+ edges.push({ step: current.depth, type: "local_symbol_call", from: String(symbolCall.callee_expression), to: calleeNode?.label ? String(calleeNode.label) : `symbol:${String(symbolCall.callee_symbol_id)}`, evidence, confidence: Number(symbolCall.confidence ?? 0.8), unresolvedReason: String(symbolCall.status) === "resolved" ? void 0 : symbolCall.unresolved_reason ? String(symbolCall.unresolved_reason) : void 0 });
1732
1850
  if (seenScopes.has(nextKey)) edges.push({ step: current.depth, type: "cycle", from: String(symbolCall.callee_expression), to: nextKey, evidence: { cycle: true, symbolCallId: symbolCall.id }, confidence: 1, unresolvedReason: "Cycle detected; downstream symbol already visited" });
1733
1851
  else queue.push({ repoId: nextRepoId, files: nextFiles, symbolIds: nextSymbols, depth: current.depth + 1 });
1734
1852
  }
@@ -1776,9 +1894,11 @@ function trace(db, start, options) {
1776
1894
  });
1777
1895
  if (effectiveRow.to_kind === "operation") {
1778
1896
  const implementation = implementationScope(db, effectiveRow.to_id);
1897
+ const contextMethodId = contextImplementationMethodId(implementation.edge, current.repoId);
1898
+ const contextNode = contextMethodId ? handlerMethodNode(db, contextMethodId) : void 0;
1779
1899
  if (implementation.edge) {
1780
1900
  const implEvidence = JSON.parse(String(implementation.edge.evidence_json || "{}"));
1781
- const handlerNode = implementation.edge.status === "resolved" ? handlerMethodNode(db, implementation.edge.to_id) : void 0;
1901
+ const handlerNode = implementation.edge.status === "resolved" ? handlerMethodNode(db, implementation.edge.to_id) : contextNode;
1782
1902
  const implTo = handlerNode?.label ? String(handlerNode.label) : `${implementation.edge.to_kind}:${implementation.edge.to_id}`;
1783
1903
  if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
1784
1904
  edges.push({
@@ -1792,10 +1912,11 @@ function trace(db, start, options) {
1792
1912
  });
1793
1913
  }
1794
1914
  if (current.depth >= maxDepth) continue;
1795
- const files = implementation.files.size > 0 ? implementation.files : handlerFilesForOperation(db, effectiveRow.to_id);
1796
- const symbolIds = implementation.symbolId ? /* @__PURE__ */ new Set([implementation.symbolId]) : void 0;
1797
- if (implementation.edge?.status === "resolved" && files.size > 0) {
1798
- const targetRepoId = implementation.repoId ?? db.prepare(
1915
+ const contextScope = contextMethodId ? handlerScope(db, contextMethodId) : void 0;
1916
+ const files = contextScope?.files ?? (implementation.files.size > 0 ? implementation.files : handlerFilesForOperation(db, effectiveRow.to_id));
1917
+ const symbolIds = contextScope?.symbolId ? /* @__PURE__ */ new Set([contextScope.symbolId]) : implementation.symbolId ? /* @__PURE__ */ new Set([implementation.symbolId]) : void 0;
1918
+ if ((implementation.edge?.status === "resolved" || contextScope) && files.size > 0) {
1919
+ const targetRepoId = contextScope?.repoId ?? implementation.repoId ?? db.prepare(
1799
1920
  "SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?"
1800
1921
  ).get(effectiveRow.to_id)?.repoId;
1801
1922
  const nextKey = `${targetRepoId ?? "*"}:${[...symbolIds ?? /* @__PURE__ */ new Set(["*"])].sort().join(",")}:${[...files].sort().join(",")}`;
@@ -1842,4 +1963,4 @@ export {
1842
1963
  linkWorkspace,
1843
1964
  trace
1844
1965
  };
1845
- //# sourceMappingURL=chunk-GG4XJGES.js.map
1966
+ //# sourceMappingURL=chunk-Q7W7TE3W.js.map