@saptools/service-flow 0.1.26 → 0.1.28

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.28
4
+
5
+ - Propagate contextual service-client bindings through one-level object-parameter destructuring aliases, including renamed and assignment destructuring in neutral CAP helpers.
6
+ - Prefer caller-site higher-order wrapper remote-action evidence for literal wrapper paths while keeping dynamic wrapper path diagnostics on the caller edge.
7
+ - Refine contextual implementation selection evidence for duplicate helper candidates and report structured duplicate-candidate ties when selection is unsafe.
8
+ - Fix strict doctor contextual opportunity metrics so aggregate totals, capped examples, and actionable severity remain internally consistent.
9
+
10
+ ## 0.1.27
11
+
12
+ - Enrich trace-time contextual service-client bindings from package-level CAP `cds.requires` aliases so helper-internal sends resolve through require-derived service paths and destinations.
13
+ - Preserve contextual binding attempt evidence for unresolved helper sends, including effective service path, destination, candidate counts, and resolution reasons.
14
+ - Refine strict doctor diagnostics for contextual helper sends to distinguish resolved contextual opportunities, missing `cds.requires` rows, require-backed unresolved sends, remaining runtime variables, and workspaces with no contextual opportunity.
15
+ - Expand neutral CAP coverage for positional helper arguments, destructured object helper parameters, renamed properties, late assignments, table output, Mermaid output, and require-derived target operations.
16
+
3
17
  ## 0.1.26
4
18
 
5
19
  - Resolve helper-internal remote sends contextually in trace output when service clients are passed through positional arguments or one-level destructured object parameters.
@@ -642,7 +642,8 @@ function collectWrapperSpecs(source) {
642
642
  const specs = /* @__PURE__ */ new Map();
643
643
  const scanFunction = (name, fn) => {
644
644
  const params = fn.parameters.map((param) => ts4.isIdentifier(param.name) ? param.name.text : void 0);
645
- const closure = returnedClosure(fn) ?? fn;
645
+ const closure = returnedClosure(fn);
646
+ if (!closure) return;
646
647
  const sends = [];
647
648
  const visit = (node) => {
648
649
  if (ts4.isCallExpression(node) && ts4.isPropertyAccessExpression(node.expression) && node.expression.name.text === "send" && ts4.isIdentifier(node.expression.expression)) {
@@ -660,7 +661,7 @@ function collectWrapperSpecs(source) {
660
661
  const clientIndex = params.indexOf(found.client);
661
662
  const pathIndex = params.indexOf(found.path);
662
663
  const methodIndex = found.method && params.includes(found.method) ? params.indexOf(found.method) : void 0;
663
- if (clientIndex >= 0 && pathIndex >= 0) specs.set(name, { clientIndex, pathIndex, methodIndex, definitionLine: lineOf3(source.text, fn.getStart(source)) });
664
+ if (clientIndex >= 0 && pathIndex >= 0) specs.set(name, { clientIndex, pathIndex, methodIndex, definitionLine: lineOf3(source.text, fn.getStart(source)), internalStart: closure.getStart(source), internalEnd: closure.getEnd() });
664
665
  };
665
666
  for (const stmt of source.statements) {
666
667
  if (ts4.isFunctionDeclaration(stmt) && stmt.name) scanFunction(stmt.name.text, stmt);
@@ -686,11 +687,15 @@ function classifyOutboundCallsInSource(source, filePath) {
686
687
  const initializers = variableInitializers(source);
687
688
  const serviceVariables = collectServiceVariables(source);
688
689
  const wrapperSpecs = collectWrapperSpecs(source);
690
+ const wrapperInternalRanges = [...wrapperSpecs.values()].map((spec) => ({ start: spec.internalStart, end: spec.internalEnd }));
689
691
  const add = (node, fact, extra) => {
690
692
  calls.push({ node, fact: { ...fact, sourceFile, sourceLine: lineOf3(source.text, node.getStart(source)), confidence: fact.confidence ?? 0.8, evidence: parserEvidence(source, node, extra) } });
691
693
  };
692
694
  const visit = (node) => {
693
695
  if (ts4.isCallExpression(node)) {
696
+ if (wrapperInternalRanges.some((range) => node.getStart(source) >= range.start && node.getEnd() <= range.end)) {
697
+ return;
698
+ }
694
699
  const expr = node.expression;
695
700
  const exprText = expr.getText(source);
696
701
  if (exprText === "cds.run") {
@@ -716,6 +721,8 @@ function classifyOutboundCallsInSource(source, filePath) {
716
721
  const methodArg = spec?.methodIndex === void 0 ? void 0 : wrapperArgs[spec.methodIndex];
717
722
  if (spec && clientArg && ts4.isIdentifier(clientArg) && isStringLike(pathArg)) {
718
723
  add(node, { callType: "remote_action", serviceVariableName: clientArg.text, method: stripQuotes(methodArg?.getText(source) ?? "POST"), operationPathExpr: `/${pathArg.text.replace(/^\//, "")}`, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver: clientArg.text, classifier: "higher_order_wrapper_literal_path", wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, literalCallerArgumentDetected: true });
724
+ } else if (spec && clientArg && ts4.isIdentifier(clientArg)) {
725
+ add(node, { callType: "remote_action", serviceVariableName: clientArg.text, method: stripQuotes(methodArg?.getText(source) ?? "POST"), payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason: "dynamic_operation_path_identifier" }, { receiver: clientArg.text, classifier: "higher_order_wrapper_dynamic_path", wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, parserWarning: "dynamic_operation_path_identifier" });
719
726
  }
720
727
  } else if (ts4.isPropertyAccessExpression(expr) && ["emit", "publish", "on"].includes(expr.name.text)) {
721
728
  const receiver = receiverName(expr.expression);
@@ -2153,7 +2160,7 @@ function implementationScope(db, operationId) {
2153
2160
  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);
2154
2161
  return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), symbolId: row?.symbolId, edge };
2155
2162
  }
2156
- function contextImplementationMethodId(edge, callerRepoId) {
2163
+ function contextImplementationMethodId(edge, callerRepoId, remoteEvidence = {}) {
2157
2164
  if (!edge || edge.status !== "ambiguous" || callerRepoId === void 0) return { evidence: { status: "not_applicable" } };
2158
2165
  const evidence = JSON.parse(String(edge.evidence_json || "{}"));
2159
2166
  const scores = (evidence.candidates ?? []).filter((item) => item.accepted).map((item) => {
@@ -2167,12 +2174,16 @@ function contextImplementationMethodId(edge, callerRepoId) {
2167
2174
  score += 10;
2168
2175
  reasons.push("registration_package_matches_caller_repository");
2169
2176
  }
2177
+ if (typeof remoteEvidence.effectiveServicePath === "string" || typeof remoteEvidence.effectiveDestination === "string" || typeof remoteEvidence.effectiveAlias === "string") {
2178
+ score += 1;
2179
+ reasons.push("remote_call_context_available");
2180
+ }
2170
2181
  return { methodId: item.methodId, score, reasons, handlerPackage: item.handlerPackage, applicationPackage: item.applicationPackage };
2171
2182
  }).sort((a, b) => b.score - a.score);
2172
2183
  if (scores.length === 0) return { evidence: { status: "not_applicable", candidateScores: [] } };
2173
2184
  const [first, second] = scores;
2174
2185
  if (first && first.methodId !== void 0 && first.score > 0 && (!second || first.score > second.score)) return { methodId: String(first.methodId), evidence: { status: "selected", selectedMethodId: first.methodId, candidateScores: scores } };
2175
- return { evidence: { status: "tied", tieReason: "no_unique_materially_stronger_candidate", candidateScores: scores } };
2186
+ return { evidence: { status: "tied", tieReason: scores.length > 1 ? "duplicate_helper_implementation_candidates" : "no_unique_materially_stronger_candidate", candidateScores: scores } };
2176
2187
  }
2177
2188
  function handlerScope(db, methodId) {
2178
2189
  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);
@@ -2266,21 +2277,33 @@ function receiverFromEvidence(value) {
2266
2277
  const evidence = parseEvidence(value);
2267
2278
  return typeof evidence.receiver === "string" ? evidence.receiver : void 0;
2268
2279
  }
2280
+ function hasDynamicPlaceholder(value) {
2281
+ return extractPlaceholders(value).length > 0;
2282
+ }
2283
+ function enrichBinding(row) {
2284
+ const effectiveServicePath = row.servicePathExpr && !hasDynamicPlaceholder(row.servicePathExpr) ? row.servicePathExpr : !row.servicePathExpr ? row.requireServicePath : void 0;
2285
+ const effectiveDestination = row.destinationExpr && !hasDynamicPlaceholder(row.destinationExpr) ? row.destinationExpr : !row.destinationExpr ? row.requireDestination : void 0;
2286
+ return { ...row, effectiveServicePath, effectiveDestination };
2287
+ }
2269
2288
  function knownBindingsForCalls(db, calls) {
2270
2289
  const map = /* @__PURE__ */ new Map();
2271
2290
  for (const call of calls) {
2272
2291
  const receiver = receiverFromEvidence(call.evidence_json);
2273
2292
  const bindingId = Number(call.service_binding_id ?? 0);
2274
2293
  if (!receiver || !bindingId) continue;
2275
- const row = db.prepare("SELECT id,alias,alias_expr aliasExpr,destination_expr destinationExpr,service_path_expr servicePathExpr,source_file sourceFile,source_line sourceLine FROM service_bindings WHERE id=?").get(bindingId);
2276
- if (row) map.set(receiver, { ...row, bindingId, source: "local_service_binding", calleeReceiver: receiver });
2294
+ const row = db.prepare(`SELECT b.id,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.source_file sourceFile,b.source_line sourceLine,req.service_path requireServicePath,req.destination requireDestination
2295
+ FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
2296
+ WHERE b.id=?`).get(bindingId);
2297
+ if (row) map.set(receiver, enrichBinding({ ...row, bindingId, source: "local_service_binding", calleeReceiver: receiver }));
2277
2298
  }
2278
2299
  return map;
2279
2300
  }
2280
2301
  function knownBindingsForScope(db, repoId, symbolIds, files) {
2281
2302
  const map = /* @__PURE__ */ new Map();
2282
2303
  if (repoId === void 0) return map;
2283
- const rows2 = db.prepare("SELECT id,variable_name variableName,alias,alias_expr aliasExpr,destination_expr destinationExpr,service_path_expr servicePathExpr,source_file sourceFile,source_line sourceLine FROM service_bindings WHERE repo_id=?").all(repoId);
2304
+ const rows2 = db.prepare(`SELECT b.id,b.variable_name variableName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.source_file sourceFile,b.source_line sourceLine,req.service_path requireServicePath,req.destination requireDestination
2305
+ FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
2306
+ WHERE b.repo_id=?`).all(repoId);
2284
2307
  for (const row of rows2) {
2285
2308
  if (!row.variableName) continue;
2286
2309
  if (files && !files.has(String(row.sourceFile))) continue;
@@ -2288,7 +2311,7 @@ function knownBindingsForScope(db, repoId, symbolIds, files) {
2288
2311
  const owner = db.prepare("SELECT id FROM symbols WHERE id IN (" + [...symbolIds].map(() => "?").join(",") + ") AND source_file=? AND start_line<=? AND end_line>=? LIMIT 1").get(...symbolIds, row.sourceFile, row.sourceLine, row.sourceLine);
2289
2312
  if (!owner) continue;
2290
2313
  }
2291
- map.set(row.variableName, { ...row, bindingId: Number(row.id), source: "local_service_binding", calleeReceiver: row.variableName });
2314
+ map.set(row.variableName, enrichBinding({ ...row, bindingId: Number(row.id), source: "local_service_binding", calleeReceiver: row.variableName }));
2292
2315
  }
2293
2316
  return map;
2294
2317
  }
@@ -2300,6 +2323,7 @@ function contextForSymbolCall(db, symbolCall, callerBindings) {
2300
2323
  const calleeEvidence = parseEvidence(callee?.evidenceJson);
2301
2324
  const params = Array.isArray(calleeEvidence.parameters) ? calleeEvidence.parameters.filter((item) => typeof item === "string") : [];
2302
2325
  const parameterBindings = Array.isArray(calleeEvidence.parameterBindings) ? calleeEvidence.parameterBindings.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item))) : [];
2326
+ const parameterPropertyAliases = Array.isArray(calleeEvidence.parameterPropertyAliases) ? calleeEvidence.parameterPropertyAliases.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item))) : [];
2303
2327
  const args = Array.isArray(callEvidence2.callArguments) ? callEvidence2.callArguments : [];
2304
2328
  args.forEach((arg, index) => {
2305
2329
  const paramBinding = parameterBindings.find((binding) => binding.index === index);
@@ -2315,7 +2339,12 @@ function contextForSymbolCall(db, symbolCall, callerBindings) {
2315
2339
  if (!binding) continue;
2316
2340
  const destructured = paramBinding?.kind === "object_pattern" && Array.isArray(paramBinding.properties) ? paramBinding.properties.find((item) => item.property === prop.property && typeof item.local === "string") : void 0;
2317
2341
  if (destructured && typeof destructured.local === "string") next.set(destructured.local, { ...binding, source: "local_symbol_destructured_object_argument", callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: String(index), calleeReceiver: destructured.local });
2318
- else if (param) next.set(`${param}.${prop.property}`, { ...binding, source: "local_symbol_object_argument", callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: param, calleeReceiver: `${param}.${prop.property}` });
2342
+ else if (param) {
2343
+ next.set(`${param}.${prop.property}`, { ...binding, source: "local_symbol_object_argument", callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: param, calleeReceiver: `${param}.${prop.property}` });
2344
+ for (const alias of parameterPropertyAliases) {
2345
+ if (alias.parameter === param && alias.property === prop.property && typeof alias.local === "string") next.set(alias.local, { ...binding, source: "local_symbol_object_parameter_destructure", callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: param, calleeObjectProperty: `${param}.${prop.property}`, calleeReceiver: alias.local, calleeLocalDestructuredIdentifier: alias.local, parameterPropertyAliasKind: alias.kind, parameterPropertyAliasLine: alias.line });
2346
+ }
2347
+ }
2319
2348
  }
2320
2349
  }
2321
2350
  });
@@ -2324,10 +2353,13 @@ function contextForSymbolCall(db, symbolCall, callerBindings) {
2324
2353
  function contextualRuntimeResolution(db, call, binding, workspaceId) {
2325
2354
  if (!binding || String(call.call_type) !== "remote_action" || call.operation_path_expr === void 0 || call.operation_path_expr === null) return {};
2326
2355
  const op = normalizeODataOperationInvocationPathForTrace(String(call.operation_path_expr));
2327
- const resolution = resolveOperation(db, { servicePath: binding.servicePathExpr, operationPath: op, alias: binding.aliasExpr ?? binding.alias, destination: binding.destinationExpr, hasExplicitOverride: true, isDynamic: false }, workspaceId);
2328
- const evidence = { contextualServiceBindingSelected: true, contextualBinding: { source: binding.source, callerArgument: binding.callerArgument, callerProperty: binding.callerProperty, calleeParameter: binding.calleeParameter, calleeReceiver: binding.calleeReceiver, bindingSourceFile: binding.sourceFile, bindingSourceLine: binding.sourceLine }, operationPath: op, servicePath: binding.servicePathExpr, serviceAlias: binding.alias, serviceAliasExpr: binding.aliasExpr, destination: binding.destinationExpr, contextualResolutionStatus: resolution.status, candidates: resolution.candidates, resolutionReasons: resolution.reasons };
2329
- if (!resolution.target) return { evidence, unresolvedReason: resolution.status === "ambiguous" ? "Ambiguous contextual operation candidates" : "No contextual operation candidate matched" };
2330
- return { row: { id: -Number(call.id), edge_type: "REMOTE_CALL_RESOLVES_TO_OPERATION", from_id: String(call.id), to_kind: "operation", to_id: String(resolution.target.operationId), confidence: resolution.target.score, evidence_json: JSON.stringify(evidence), status: "resolved" }, evidence: { ...evidence, targetRepo: resolution.target.repoName, targetServicePath: resolution.target.servicePath, targetOperationPath: resolution.target.operationPath, targetOperation: resolution.target.operationName }, unresolvedReason: void 0 };
2356
+ const servicePath = binding.effectiveServicePath ?? binding.servicePathExpr ?? binding.requireServicePath;
2357
+ const destination = binding.effectiveDestination ?? binding.destinationExpr ?? binding.requireDestination;
2358
+ const resolution = resolveOperation(db, { servicePath, operationPath: op, alias: binding.aliasExpr ?? binding.alias, destination, hasExplicitOverride: true, isDynamic: false }, workspaceId);
2359
+ const evidence = { contextualServiceBindingAttempted: true, contextualBinding: { source: binding.source, callerArgument: binding.callerArgument, callerProperty: binding.callerProperty, calleeParameter: binding.calleeParameter, calleeReceiver: binding.calleeReceiver, bindingSourceFile: binding.sourceFile, bindingSourceLine: binding.sourceLine, alias: binding.alias, aliasExpr: binding.aliasExpr, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination }, operationPath: op, servicePath, serviceAlias: binding.alias, serviceAliasExpr: binding.aliasExpr, destination, requireServicePath: binding.requireServicePath, requireDestination: binding.requireDestination, effectiveServicePath: binding.effectiveServicePath, effectiveDestination: binding.effectiveDestination, contextualResolutionStatus: resolution.status, contextualCandidateCount: resolution.candidates.length, candidates: resolution.candidates, contextualResolutionReasons: resolution.reasons, resolutionReasons: resolution.reasons };
2360
+ if (!resolution.target) return { evidence, unresolvedReason: resolution.status === "ambiguous" ? "Ambiguous contextual operation candidates" : resolution.status === "dynamic" ? `Dynamic contextual target is missing runtime variables: ${resolution.reasons.join(", ")}` : "No contextual operation candidate matched" };
2361
+ const resolvedEvidence = { ...evidence, contextualServiceBindingSelected: true, targetRepo: resolution.target.repoName, targetServicePath: resolution.target.servicePath, targetOperationPath: resolution.target.operationPath, targetOperation: resolution.target.operationName };
2362
+ return { row: { id: -Number(call.id), edge_type: "REMOTE_CALL_RESOLVES_TO_OPERATION", from_id: String(call.id), to_kind: "operation", to_id: String(resolution.target.operationId), confidence: resolution.target.score, evidence_json: JSON.stringify(resolvedEvidence), status: "resolved" }, evidence: resolvedEvidence, unresolvedReason: void 0 };
2331
2363
  }
2332
2364
  function normalizeODataOperationInvocationPathForTrace(raw) {
2333
2365
  return raw.startsWith("/") ? raw : `/${raw}`;
@@ -2434,9 +2466,10 @@ function trace(db, start, options) {
2434
2466
  for (const row of graphRows) {
2435
2467
  if (seenEdges.has(Number(row.id))) continue;
2436
2468
  seenEdges.add(Number(row.id));
2437
- const rawEvidence = contextual.evidence && row.id < 0 ? contextual.evidence : JSON.parse(
2469
+ const persistedEvidence = JSON.parse(
2438
2470
  String(row.evidence_json || "{}")
2439
2471
  );
2472
+ const rawEvidence = contextual.evidence ? { ...persistedEvidence, ...contextual.evidence } : persistedEvidence;
2440
2473
  const effective = runtimeResolution(db, row, rawEvidence, options.vars);
2441
2474
  const evidence = effective.evidence;
2442
2475
  const effectiveRow = effective.row;
@@ -2459,7 +2492,7 @@ function trace(db, start, options) {
2459
2492
  });
2460
2493
  if (effectiveRow.to_kind === "operation") {
2461
2494
  const implementation = implementationScope(db, effectiveRow.to_id);
2462
- const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId);
2495
+ const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId, evidence);
2463
2496
  const contextMethodId = contextSelection.methodId;
2464
2497
  const contextNode = contextMethodId ? handlerMethodNode(db, contextMethodId) : void 0;
2465
2498
  if (implementation.edge) {
@@ -2531,4 +2564,4 @@ export {
2531
2564
  linkWorkspace,
2532
2565
  trace
2533
2566
  };
2534
- //# sourceMappingURL=chunk-Q3J2QDNX.js.map
2567
+ //# sourceMappingURL=chunk-SWPGRM7Z.js.map