@saptools/service-flow 0.1.23 → 0.1.24

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.24
4
+
5
+ - Add conservative class instance method symbol-call resolution for same-file and relatively imported helper classes, with auditable class-instance evidence.
6
+ - Propagate service-client binding context through resolved local symbol calls for explicit positional and object-literal helper arguments during trace rendering.
7
+ - Harden one-hop higher-order send wrapper literal path propagation for returned async closures and expose wrapper definition evidence.
8
+ - Refine strict doctor categories for no-binding remote actions and split ambiguous versus unresolved implementation diagnostics with capped examples.
9
+
10
+ ## 0.1.23
11
+
12
+ - Retrospective note: preserve same-file late-assignment service binding extraction for connected clients, `cds.connect.to(...)` assignments, identity aliases, and simple transaction aliases before outbound `send(...)` calls.
13
+ - Retrospective note: preserve explicit object destructuring from safe helper-returned client objects so binding rows remain available for downstream remote action linking.
14
+
3
15
  ## 0.1.22
4
16
 
5
17
  - Propagate conservative same-file identity aliases of connected service clients, including typed, `as`, `satisfies`, helper-returned, and transitive aliases, while preserving `.tx()` alias evidence.
@@ -642,28 +642,25 @@ 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
- let found;
645
+ const closure = returnedClosure(fn) ?? fn;
646
+ const sends = [];
646
647
  const visit = (node) => {
647
- if (found) return;
648
- if (node !== fn && (ts4.isFunctionDeclaration(node) || ts4.isArrowFunction(node) || ts4.isFunctionExpression(node))) {
649
- ts4.forEachChild(node, visit);
650
- return;
651
- }
652
648
  if (ts4.isCallExpression(node) && ts4.isPropertyAccessExpression(node.expression) && node.expression.name.text === "send" && ts4.isIdentifier(node.expression.expression)) {
653
649
  const objectArg = node.arguments[0];
654
650
  if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
655
651
  const pathProp = objectArg.properties.find((property) => ts4.isShorthandPropertyAssignment(property) && property.name.text === "path");
656
- if (pathProp) found = { client: node.expression.expression.text, path: pathProp.name.text, method: objectPropertyText(objectArg, "method") };
652
+ if (pathProp) sends.push({ client: node.expression.expression.text, path: pathProp.name.text, method: objectPropertyText(objectArg, "method") });
657
653
  }
658
654
  }
659
655
  ts4.forEachChild(node, visit);
660
656
  };
661
- visit(fn);
662
- if (!found) return;
657
+ visit(closure);
658
+ if (sends.length !== 1) return;
659
+ const found = sends[0];
663
660
  const clientIndex = params.indexOf(found.client);
664
661
  const pathIndex = params.indexOf(found.path);
665
662
  const methodIndex = found.method && params.includes(found.method) ? params.indexOf(found.method) : void 0;
666
- if (clientIndex >= 0 && pathIndex >= 0) specs.set(name, { clientIndex, pathIndex, methodIndex });
663
+ if (clientIndex >= 0 && pathIndex >= 0) specs.set(name, { clientIndex, pathIndex, methodIndex, definitionLine: lineOf3(source.text, fn.getStart(source)) });
667
664
  };
668
665
  for (const stmt of source.statements) {
669
666
  if (ts4.isFunctionDeclaration(stmt) && stmt.name) scanFunction(stmt.name.text, stmt);
@@ -673,6 +670,16 @@ function collectWrapperSpecs(source) {
673
670
  }
674
671
  return specs;
675
672
  }
673
+ function returnedClosure(fn) {
674
+ const body = fn.body;
675
+ if (!body) return void 0;
676
+ if (ts4.isArrowFunction(fn) && !ts4.isBlock(body)) return ts4.isArrowFunction(body) || ts4.isFunctionExpression(body) ? body : void 0;
677
+ if (!ts4.isBlock(body)) return void 0;
678
+ const returns = body.statements.filter(ts4.isReturnStatement);
679
+ if (returns.length !== 1) return void 0;
680
+ const expr = returns[0]?.expression;
681
+ return expr && (ts4.isArrowFunction(expr) || ts4.isFunctionExpression(expr)) ? expr : void 0;
682
+ }
676
683
  function classifyOutboundCallsInSource(source, filePath) {
677
684
  const calls = [];
678
685
  const sourceFile = normalizePath(filePath);
@@ -691,22 +698,24 @@ function classifyOutboundCallsInSource(source, filePath) {
691
698
  const entity = arg ? queryEntityFromAst(arg, initializers) : void 0;
692
699
  const payload = arg?.getText(source) ?? "";
693
700
  add(node, { callType: "local_db_query", queryEntity: entity, payloadSummary: summarizeExpression(payload), confidence: entity ? 0.9 : 0.55, unresolvedReason: entity ? void 0 : queryWarning(payload) });
694
- } else if (ts4.isPropertyAccessExpression(expr) && expr.name.text === "send" && ts4.isIdentifier(expr.expression)) {
701
+ } else if (ts4.isPropertyAccessExpression(expr) && expr.name.text === "send" && (ts4.isIdentifier(expr.expression) || ts4.isPropertyAccessExpression(expr.expression))) {
695
702
  const objectArg = node.arguments[0];
696
703
  if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
697
- const receiver = expr.expression.text;
704
+ const receiver = receiverName(expr.expression);
698
705
  const query = objectPropertyText(objectArg, "query");
699
706
  const op = objectPropertyText(objectArg, "path") ?? objectPropertyText(objectArg, "event");
700
707
  const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
701
708
  add(node, { callType: query ? "remote_query" : "remote_action", serviceVariableName: receiver, method: stripQuotes(objectPropertyText(objectArg, "method") ?? "POST"), operationPathExpr: op && !shorthandPath ? `/${stripQuotes(op).replace(/^\//, "")}` : void 0, queryEntity: query ? extractQueryEntity(query) : void 0, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && shorthandPath ? "dynamic_operation_path_identifier" : void 0 }, { receiver, classifier: "service_client_send_object", operationPathExpression: shorthandPath ? op : void 0, parserWarning: shorthandPath ? "dynamic_operation_path_identifier" : void 0 });
702
709
  }
703
- } else if (ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text)) {
704
- const spec = wrapperSpecs.get(expr.expression.text);
705
- const clientArg = spec ? expr.arguments[spec.clientIndex] : void 0;
706
- const pathArg = spec ? expr.arguments[spec.pathIndex] : void 0;
707
- const methodArg = spec?.methodIndex === void 0 ? void 0 : expr.arguments[spec.methodIndex];
710
+ } else if (ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text) || ts4.isIdentifier(expr) && wrapperSpecs.has(expr.text)) {
711
+ const wrapperName = ts4.isIdentifier(expr) ? expr.text : ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) ? expr.expression.text : "";
712
+ const wrapperArgs = ts4.isIdentifier(expr) ? node.arguments : ts4.isCallExpression(expr) ? expr.arguments : node.arguments;
713
+ const spec = wrapperSpecs.get(wrapperName);
714
+ const clientArg = spec ? wrapperArgs[spec.clientIndex] : void 0;
715
+ const pathArg = spec ? wrapperArgs[spec.pathIndex] : void 0;
716
+ const methodArg = spec?.methodIndex === void 0 ? void 0 : wrapperArgs[spec.methodIndex];
708
717
  if (spec && clientArg && ts4.isIdentifier(clientArg) && isStringLike(pathArg)) {
709
- 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: "local_wrapper_literal_path", wrapperFunction: expr.expression.text, literalCallerArgumentDetected: true });
718
+ 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 });
710
719
  }
711
720
  } else if (ts4.isPropertyAccessExpression(expr) && ["emit", "publish", "on"].includes(expr.name.text)) {
712
721
  const receiver = receiverName(expr.expression);
@@ -2237,6 +2246,65 @@ function runtimeResolution(db, row, evidence, vars) {
2237
2246
  const unresolvedReason = resolution.status === "dynamic" ? `Dynamic target is missing runtime variables: ${resolution.reasons.join(", ")}` : resolution.status === "ambiguous" ? "Ambiguous runtime operation candidates" : "No runtime operation candidate matched substituted service and operation path";
2238
2247
  return { row, evidence: nextEvidence, unresolvedReason };
2239
2248
  }
2249
+ function parseEvidence(value) {
2250
+ try {
2251
+ const parsed = JSON.parse(String(value || "{}"));
2252
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2253
+ } catch {
2254
+ return {};
2255
+ }
2256
+ }
2257
+ function receiverFromEvidence(value) {
2258
+ const evidence = parseEvidence(value);
2259
+ return typeof evidence.receiver === "string" ? evidence.receiver : void 0;
2260
+ }
2261
+ function knownBindingsForCalls(db, calls) {
2262
+ const map = /* @__PURE__ */ new Map();
2263
+ for (const call of calls) {
2264
+ const receiver = receiverFromEvidence(call.evidence_json);
2265
+ const bindingId = Number(call.service_binding_id ?? 0);
2266
+ if (!receiver || !bindingId) continue;
2267
+ 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);
2268
+ if (row) map.set(receiver, { ...row, bindingId, source: "local_service_binding", calleeReceiver: receiver });
2269
+ }
2270
+ return map;
2271
+ }
2272
+ function contextForSymbolCall(db, symbolCall, callerBindings) {
2273
+ const next = /* @__PURE__ */ new Map();
2274
+ if (callerBindings.size === 0) return next;
2275
+ const callEvidence2 = parseEvidence(symbolCall.evidence_json);
2276
+ const callee = db.prepare("SELECT evidence_json evidenceJson FROM symbols WHERE id=?").get(symbolCall.callee_symbol_id);
2277
+ const calleeEvidence = parseEvidence(callee?.evidenceJson);
2278
+ const params = Array.isArray(calleeEvidence.parameters) ? calleeEvidence.parameters.filter((item) => typeof item === "string") : [];
2279
+ const args = Array.isArray(callEvidence2.callArguments) ? callEvidence2.callArguments : [];
2280
+ args.forEach((arg, index) => {
2281
+ const param = params[index];
2282
+ if (!param) return;
2283
+ if (arg.kind === "identifier" && typeof arg.name === "string") {
2284
+ const binding = callerBindings.get(arg.name);
2285
+ if (binding) next.set(param, { ...binding, source: "local_symbol_argument", callerArgument: arg.name, calleeParameter: param, calleeReceiver: param });
2286
+ }
2287
+ if (arg.kind === "object_literal" && Array.isArray(arg.properties)) {
2288
+ for (const prop of arg.properties) {
2289
+ if (typeof prop.property !== "string" || typeof prop.argument !== "string") continue;
2290
+ const binding = callerBindings.get(prop.argument);
2291
+ if (binding) next.set(`${param}.${prop.property}`, { ...binding, source: "local_symbol_object_argument", callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: param, calleeReceiver: `${param}.${prop.property}` });
2292
+ }
2293
+ }
2294
+ });
2295
+ return next;
2296
+ }
2297
+ function contextualRuntimeResolution(db, call, binding, workspaceId) {
2298
+ if (!binding || String(call.call_type) !== "remote_action" || call.operation_path_expr === void 0 || call.operation_path_expr === null) return {};
2299
+ const op = normalizeODataOperationInvocationPathForTrace(String(call.operation_path_expr));
2300
+ const resolution = resolveOperation(db, { servicePath: binding.servicePathExpr, operationPath: op, alias: binding.aliasExpr ?? binding.alias, destination: binding.destinationExpr, hasExplicitOverride: true, isDynamic: false }, workspaceId);
2301
+ 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 };
2302
+ if (!resolution.target) return { evidence, unresolvedReason: resolution.status === "ambiguous" ? "Ambiguous contextual operation candidates" : "No contextual operation candidate matched" };
2303
+ 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 };
2304
+ }
2305
+ function normalizeODataOperationInvocationPathForTrace(raw) {
2306
+ return raw.startsWith("/") ? raw : `/${raw}`;
2307
+ }
2240
2308
  function edgeTarget(row, evidence) {
2241
2309
  const runtimeCandidate = evidence.runtimeResolvedCandidate;
2242
2310
  if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath)
@@ -2269,7 +2337,7 @@ function trace(db, start, options) {
2269
2337
  const maxDepth = positiveDepth(options.depth);
2270
2338
  const edges = [];
2271
2339
  const nodes = /* @__PURE__ */ new Map();
2272
- const queue = scope.selectorMatched ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1 }] : [];
2340
+ const queue = scope.selectorMatched ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: /* @__PURE__ */ new Map() }] : [];
2273
2341
  if (start.servicePath && (start.operation ?? start.operationPath)) {
2274
2342
  const startOperation = normalizeOperation(start.operation ?? start.operationPath);
2275
2343
  const startRows = db.prepare(`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 FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) AND s.service_path=? AND (o.operation_path=? OR o.operation_name=?) ORDER BY r.name,o.id LIMIT 2`).all(scope.repo?.id, scope.repo?.id, start.servicePath, startOperation, startOperation);
@@ -2302,6 +2370,7 @@ function trace(db, start, options) {
2302
2370
  const filtered = calls.filter(
2303
2371
  (c) => (!current.symbolIds || current.symbolIds.has(Number(c.source_symbol_id))) && (!current.files || current.files.has(String(c.source_file))) && includeCall(String(c.call_type), options)
2304
2372
  );
2373
+ const callerBindings = new Map([...current.context ?? /* @__PURE__ */ new Map(), ...knownBindingsForCalls(db, filtered)]);
2305
2374
  if (current.symbolIds && current.symbolIds.size > 0 && current.depth < maxDepth) {
2306
2375
  const symbolRows = db.prepare(`SELECT sc.*,s.repo_id calleeRepoId,s.source_file calleeFile FROM symbol_calls sc LEFT JOIN symbols s ON s.id=sc.callee_symbol_id WHERE sc.caller_symbol_id IN (${[...current.symbolIds].map(() => "?").join(",")}) ORDER BY sc.source_file,sc.source_line`).all(...current.symbolIds);
2307
2376
  for (const symbolCall of symbolRows) {
@@ -2315,7 +2384,7 @@ function trace(db, start, options) {
2315
2384
  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 };
2316
2385
  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 });
2317
2386
  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" });
2318
- else queue.push({ repoId: nextRepoId, files: nextFiles, symbolIds: nextSymbols, depth: current.depth + 1 });
2387
+ else queue.push({ repoId: nextRepoId, files: nextFiles, symbolIds: nextSymbols, depth: current.depth + 1, context: contextForSymbolCall(db, symbolCall, callerBindings) });
2319
2388
  }
2320
2389
  }
2321
2390
  const graph = graphForCalls(
@@ -2332,11 +2401,12 @@ function trace(db, start, options) {
2332
2401
  line: call.source_line,
2333
2402
  callType: call.call_type
2334
2403
  });
2335
- const graphRows = graph.get(Number(call.id)) ?? [];
2404
+ const contextual = contextualRuntimeResolution(db, call, (current.context ?? /* @__PURE__ */ new Map()).get(receiverFromEvidence(call.evidence_json) ?? ""), workspaceIdForCall(db, String(call.id)));
2405
+ const graphRows = contextual.row ? [contextual.row] : graph.get(Number(call.id)) ?? [];
2336
2406
  for (const row of graphRows) {
2337
2407
  if (seenEdges.has(Number(row.id))) continue;
2338
2408
  seenEdges.add(Number(row.id));
2339
- const rawEvidence = JSON.parse(
2409
+ const rawEvidence = contextual.evidence && row.id < 0 ? contextual.evidence : JSON.parse(
2340
2410
  String(row.evidence_json || "{}")
2341
2411
  );
2342
2412
  const effective = runtimeResolution(db, row, rawEvidence, options.vars);
@@ -2433,4 +2503,4 @@ export {
2433
2503
  linkWorkspace,
2434
2504
  trace
2435
2505
  };
2436
- //# sourceMappingURL=chunk-LCXDOSWL.js.map
2506
+ //# sourceMappingURL=chunk-RXJNPONU.js.map