@saptools/service-flow 0.1.21 → 0.1.22

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.22
4
+
5
+ - Propagate conservative same-file identity aliases of connected service clients, including typed, `as`, `satisfies`, helper-returned, and transitive aliases, while preserving `.tx()` alias evidence.
6
+ - Link outbound `send(...)` calls through identity aliases to the closest same-file binding row so destination, service-path, placeholder, and helper-chain evidence remains available.
7
+ - Add narrow same-file wrapper literal-path propagation for wrappers that pass a client parameter directly to `send({ path })`; dynamic wrapper paths remain semantic dynamic targets with parser warnings.
8
+ - Improve trace-time contextual implementation selection evidence and suppress stale ambiguous unresolved reasons when a unique contextual handler is selected.
9
+ - Expand `doctor --strict` with alias-binding, no-binding remote-action, contextual implementation, and wrapper path propagation quality aggregates.
10
+
3
11
  ## 0.1.21
4
12
 
5
13
  - Propagate helper-returned connected clients from function declarations, arrow-function variables, function-expression variables, named export lists, and aliased exports into caller destructuring and transaction aliases.
package/README.md CHANGED
@@ -318,7 +318,7 @@ service-flow trace --workspace /path/to/workspace --repo facade-service --operat
318
318
 
319
319
  When a concrete target exists after variable substitution, the trace shows both the parameterized evidence and the resolved match. When it does not, `service-flow` keeps the edge as a dynamic candidate or unresolved edge so the missing link remains visible.
320
320
 
321
- Service-binding evidence keeps these fields distinct: service alias, alias expression, destination expression, service-path expression, operation-path expression, and runtime placeholders. Helpers that return concrete connected clients inside object properties are followed through destructuring and simple transaction aliases while preserving helper-chain evidence. This is important for common CAP helpers such as `cds.connect.to(`remote_${code}`, { credentials: { destination: `remote_${code}`, path: `/${entityType}ProcessService` } })`, where the alias is not the service path.
321
+ Service-binding evidence keeps these fields distinct: service alias, alias expression, destination expression, service-path expression, operation-path expression, and runtime placeholders. Helpers that return concrete connected clients inside object properties are followed through destructuring, simple identity aliases, transitive same-file aliases, and transaction aliases while preserving helper-chain evidence. This is important for common CAP helpers such as `cds.connect.to(`remote_${code}`, { credentials: { destination: `remote_${code}`, path: `/${entityType}ProcessService` } })`, where the alias is not the service path.
322
322
 
323
323
  By default, production traces should be built from production source files. Keep generated credentials and local state out of git, and use explicit fixture/test workspaces when validating test-only mocked service clients so they do not pollute production graph interpretation.
324
324
 
@@ -418,8 +418,8 @@ Run `service-flow index` after source, CDS, package metadata, or helper-package
418
418
  <details>
419
419
  <summary><b>Why is an expected call unresolved?</b></summary>
420
420
 
421
- Check `service-flow doctor`, then inspect the facts with `service-flow list services`, `service-flow list operations`, and `service-flow list calls`. Dynamic destinations may need `--var key=value`; the key is the full trimmed expression inside `${...}` and is matched literally without JavaScript evaluation. Operation-path-only ambiguous remote actions usually mean the call had no service binding id; inspect `list calls`, `inspect operation`, and `doctor --strict` to determine whether helper-return propagation or wrapper support is missing. Contextual implementation selection only continues into a handler when static evidence such as caller repository, resolved service path, destination/alias expression, dependency edges, registration package, and local service ownership makes exactly one candidate stronger; ties remain ambiguous with reasons.
422
- Default doctor output is intended to focus on actionable indexing or trace-impacting issues; use `--strict` when you need exhaustive model-shape diagnostics for entity-only or extension-heavy CDS models. Strict mode also reports normalized OData invocation ambiguity and remote-action target quality, including whether unresolved unknown/dynamic paths are semantic instead of numeric call ids.
421
+ Check `service-flow doctor`, then inspect the facts with `service-flow list services`, `service-flow list operations`, and `service-flow list calls`. Dynamic destinations may need `--var key=value`; the key is the full trimmed expression inside `${...}` and is matched literally without JavaScript evaluation. Operation-path-only ambiguous remote actions usually mean the call had no service binding id; same-file identity aliases are propagated, while property/call-expression aliases and ambiguous wrapper flows remain conservative; inspect `list calls`, `inspect operation`, and `doctor --strict` to determine whether helper-return propagation or wrapper support is missing. Contextual implementation selection only continues into a handler when static evidence such as caller repository, resolved service path, destination/alias expression, dependency edges, registration package, and local service ownership makes exactly one candidate stronger; ties remain ambiguous with reasons.
422
+ Default doctor output is intended to focus on actionable indexing or trace-impacting issues; use `--strict` when you need exhaustive model-shape diagnostics for entity-only or extension-heavy CDS models. Strict mode also reports normalized OData invocation ambiguity, remote-action target quality, likely missed identity aliases, no-binding remote actions, contextual implementation stops, and wrapper dynamic-path candidates, including whether unresolved unknown/dynamic paths are semantic instead of numeric call ids.
423
423
 
424
424
  </details>
425
425
 
package/TECHNICAL-NOTE.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Service Flow Resolution Notes
2
2
 
3
+ ## 0.1.22 alias, wrapper, contextual trace, and strict doctor notes
4
+
5
+ - Same-file service-client identity aliases now create their own service binding rows when the right-hand side is a known connected client variable. The parser accepts direct identifier aliases plus typed, `as`, and `satisfies` forms, supports source-order transitive aliases, and records `aliasKind: identity` helper-chain evidence. It still does not infer aliases from property reads, indexed access, function calls, object metadata, or cross-scope guesses.
6
+ - Outbound calls through identity aliases can attach to the closest alias binding row in the same source file, preserving alias, destination, service path, placeholder, and helper-chain evidence for link and trace.
7
+ - Wrapper path propagation is intentionally narrow: same-file wrappers can resolve literal caller paths only when a wrapper client parameter is passed directly to `.send(...)` and a wrapper path parameter is passed as `send({ path })`. Dynamic caller paths remain dynamic with parser-warning evidence.
8
+ - Contextual implementation selection now records selected/tied score evidence. When trace selects a unique contextual handler, the implementation hop sets `contextualImplementationSelected` and no longer renders the original ambiguous reason as a failure.
9
+ - Strict doctor adds compact aggregates for likely missed identity aliases, remote actions with operation paths but no binding id, contextual implementation stops, and wrapper dynamic-path candidates.
10
+
3
11
  ## 0.1.21 helper-return propagation and contextual trace notes
4
12
 
5
13
  - Helper-return binding analysis uses the same returned-object scanner for `function` declarations, `async function` declarations, arrow-function variables, async arrow-function variables, and function-expression variables. Named export lists and aliases are resolved, so `export { connectCatalog as createCatalogClient }` can be destructured by callers without losing evidence.
@@ -638,11 +638,47 @@ function isSupportedEventReceiver(receiver, rootReceiver, serviceVariables) {
638
638
  if (/^(srv|service|serviceClient|messaging|messageClient|eventClient)$/.test(candidate)) return true;
639
639
  return false;
640
640
  }
641
+ function collectWrapperSpecs(source) {
642
+ const specs = /* @__PURE__ */ new Map();
643
+ const scanFunction = (name, fn) => {
644
+ const params = fn.parameters.map((param) => ts4.isIdentifier(param.name) ? param.name.text : void 0);
645
+ let found;
646
+ 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
+ if (ts4.isCallExpression(node) && ts4.isPropertyAccessExpression(node.expression) && node.expression.name.text === "send" && ts4.isIdentifier(node.expression.expression)) {
653
+ const objectArg = node.arguments[0];
654
+ if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
655
+ 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") };
657
+ }
658
+ }
659
+ ts4.forEachChild(node, visit);
660
+ };
661
+ visit(fn);
662
+ if (!found) return;
663
+ const clientIndex = params.indexOf(found.client);
664
+ const pathIndex = params.indexOf(found.path);
665
+ 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 });
667
+ };
668
+ for (const stmt of source.statements) {
669
+ if (ts4.isFunctionDeclaration(stmt) && stmt.name) scanFunction(stmt.name.text, stmt);
670
+ if (ts4.isVariableStatement(stmt)) {
671
+ for (const decl of stmt.declarationList.declarations) if (ts4.isIdentifier(decl.name) && decl.initializer && (ts4.isArrowFunction(decl.initializer) || ts4.isFunctionExpression(decl.initializer))) scanFunction(decl.name.text, decl.initializer);
672
+ }
673
+ }
674
+ return specs;
675
+ }
641
676
  function classifyOutboundCallsInSource(source, filePath) {
642
677
  const calls = [];
643
678
  const sourceFile = normalizePath(filePath);
644
679
  const initializers = variableInitializers(source);
645
680
  const serviceVariables = collectServiceVariables(source);
681
+ const wrapperSpecs = collectWrapperSpecs(source);
646
682
  const add = (node, fact, extra) => {
647
683
  calls.push({ node, fact: { ...fact, sourceFile, sourceLine: lineOf3(source.text, node.getStart(source)), confidence: fact.confidence ?? 0.8, evidence: parserEvidence(source, node, extra) } });
648
684
  };
@@ -664,6 +700,14 @@ function classifyOutboundCallsInSource(source, filePath) {
664
700
  const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
665
701
  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 });
666
702
  }
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];
708
+ 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 });
710
+ }
667
711
  } else if (ts4.isPropertyAccessExpression(expr) && ["emit", "publish", "on"].includes(expr.name.text)) {
668
712
  const receiver = receiverName(expr.expression);
669
713
  const rootReceiver = rootReceiverName(expr.expression);
@@ -815,9 +859,15 @@ function connectFactFromCall(call) {
815
859
  }
816
860
  function unwrapCall(expr) {
817
861
  if (ts5.isAwaitExpression(expr)) return unwrapCall(expr.expression);
862
+ if (ts5.isAsExpression(expr) || ts5.isSatisfiesExpression(expr)) return unwrapCall(expr.expression);
818
863
  if (ts5.isCallExpression(expr)) return expr;
819
864
  return void 0;
820
865
  }
866
+ function unwrapIdentityExpression(expr) {
867
+ if (ts5.isAwaitExpression(expr)) return unwrapIdentityExpression(expr.expression);
868
+ if (ts5.isAsExpression(expr) || ts5.isSatisfiesExpression(expr)) return unwrapIdentityExpression(expr.expression);
869
+ return expr;
870
+ }
821
871
  function findConnectInExpression(expr) {
822
872
  const direct = unwrapCall(expr);
823
873
  if (direct) {
@@ -1075,6 +1125,35 @@ async function parseServiceBindings(repoPath, filePath) {
1075
1125
  async function importedHelper(localName) {
1076
1126
  return (await importedHelpers(localName)).find((row) => !row.helper.returnedProperty);
1077
1127
  }
1128
+ function bindingForVariable(variableName) {
1129
+ const sourceFile = normalizePath(filePath);
1130
+ return [...out].reverse().find((row) => row.variableName === variableName && row.sourceFile === sourceFile);
1131
+ }
1132
+ function cloneAliasBinding(decl, sourceName, aliasKind) {
1133
+ if (!ts5.isIdentifier(decl.name)) return;
1134
+ const existing = bindingForVariable(sourceName);
1135
+ if (!existing) return;
1136
+ out.push({
1137
+ ...existing,
1138
+ variableName: decl.name.text,
1139
+ sourceLine: lineOf4(sourceFileAst, decl),
1140
+ helperChain: [
1141
+ ...existing.helperChain ?? [],
1142
+ {
1143
+ callerVariable: decl.name.text,
1144
+ aliasOf: sourceName,
1145
+ aliasKind,
1146
+ scopeRule: "same-file-source-order"
1147
+ }
1148
+ ]
1149
+ });
1150
+ }
1151
+ function recordIdentityAlias(decl) {
1152
+ if (!ts5.isIdentifier(decl.name) || !decl.initializer) return;
1153
+ const unwrapped = unwrapIdentityExpression(decl.initializer);
1154
+ if (!ts5.isIdentifier(unwrapped)) return;
1155
+ cloneAliasBinding(decl, unwrapped.text, "identity");
1156
+ }
1078
1157
  async function recordVariable(decl) {
1079
1158
  if (!ts5.isIdentifier(decl.name) || !decl.initializer) return;
1080
1159
  const call = unwrapCall(decl.initializer);
@@ -1187,10 +1266,9 @@ async function parseServiceBindings(repoPath, filePath) {
1187
1266
  await recordDestructuredHelper(decl);
1188
1267
  recordDestructuredClassHelper(decl);
1189
1268
  await recordVariable(decl);
1269
+ recordIdentityAlias(decl);
1190
1270
  if (ts5.isIdentifier(decl.name) && decl.initializer && ts5.isCallExpression(decl.initializer) && ts5.isPropertyAccessExpression(decl.initializer.expression) && decl.initializer.expression.name.text === "tx" && ts5.isIdentifier(decl.initializer.expression.expression)) {
1191
- const sourceName = decl.initializer.expression.expression.text;
1192
- const existing = out.find((row) => row.variableName === sourceName && row.sourceFile === normalizePath(filePath));
1193
- if (existing) out.push({ ...existing, variableName: decl.name.text, sourceLine: lineOf4(sourceFileAst, decl), helperChain: [...existing.helperChain ?? [], { callerVariable: decl.name.text, aliasOf: sourceName, aliasKind: "transaction" }] });
1271
+ cloneAliasBinding(decl, decl.initializer.expression.expression.text, "transaction");
1194
1272
  }
1195
1273
  }
1196
1274
  return out;
@@ -1998,10 +2076,25 @@ function implementationScope(db, operationId) {
1998
2076
  return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), symbolId: row?.symbolId, edge };
1999
2077
  }
2000
2078
  function contextImplementationMethodId(edge, callerRepoId) {
2001
- if (!edge || edge.status !== "ambiguous" || callerRepoId === void 0) return void 0;
2079
+ if (!edge || edge.status !== "ambiguous" || callerRepoId === void 0) return { evidence: { status: "not_applicable" } };
2002
2080
  const evidence = JSON.parse(String(edge.evidence_json || "{}"));
2003
- const candidate = evidence.candidates?.find((item) => item.accepted && (Number(item.handlerPackage?.id) === callerRepoId || Number(item.applicationPackage?.id) === callerRepoId));
2004
- return candidate?.methodId === void 0 ? void 0 : String(candidate.methodId);
2081
+ const scores = (evidence.candidates ?? []).filter((item) => item.accepted).map((item) => {
2082
+ const reasons = [];
2083
+ let score = Number(item.score ?? 0);
2084
+ if (Number(item.handlerPackage?.id) === callerRepoId) {
2085
+ score += 10;
2086
+ reasons.push("handler_package_matches_caller_repository");
2087
+ }
2088
+ if (Number(item.applicationPackage?.id) === callerRepoId) {
2089
+ score += 10;
2090
+ reasons.push("registration_package_matches_caller_repository");
2091
+ }
2092
+ return { methodId: item.methodId, score, reasons, handlerPackage: item.handlerPackage, applicationPackage: item.applicationPackage };
2093
+ }).sort((a, b) => b.score - a.score);
2094
+ if (scores.length === 0) return { evidence: { status: "not_applicable", candidateScores: [] } };
2095
+ const [first, second] = scores;
2096
+ 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 } };
2097
+ return { evidence: { status: "tied", tieReason: "no_unique_materially_stronger_candidate", candidateScores: scores } };
2005
2098
  }
2006
2099
  function handlerScope(db, methodId) {
2007
2100
  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);
@@ -2207,7 +2300,8 @@ function trace(db, start, options) {
2207
2300
  });
2208
2301
  if (effectiveRow.to_kind === "operation") {
2209
2302
  const implementation = implementationScope(db, effectiveRow.to_id);
2210
- const contextMethodId = contextImplementationMethodId(implementation.edge, current.repoId);
2303
+ const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId);
2304
+ const contextMethodId = contextSelection.methodId;
2211
2305
  const contextNode = contextMethodId ? handlerMethodNode(db, contextMethodId) : void 0;
2212
2306
  if (implementation.edge) {
2213
2307
  const implEvidence = JSON.parse(String(implementation.edge.evidence_json || "{}"));
@@ -2219,9 +2313,9 @@ function trace(db, start, options) {
2219
2313
  type: "operation_implemented_by_handler",
2220
2314
  from: to,
2221
2315
  to: implTo,
2222
- evidence: implEvidence,
2316
+ evidence: contextMethodId ? { ...implEvidence, contextualImplementationSelected: true, contextualImplementation: contextSelection.evidence } : { ...implEvidence, contextualImplementation: contextSelection.evidence },
2223
2317
  confidence: Number(implementation.edge.confidence ?? 0),
2224
- unresolvedReason: implementation.edge.status === "resolved" ? void 0 : String(implementation.edge.unresolved_reason ?? implementation.edge.status)
2318
+ unresolvedReason: implementation.edge.status === "resolved" || contextMethodId ? void 0 : String(implementation.edge.unresolved_reason ?? implementation.edge.status)
2225
2319
  });
2226
2320
  }
2227
2321
  if (current.depth >= maxDepth) continue;
@@ -2278,4 +2372,4 @@ export {
2278
2372
  linkWorkspace,
2279
2373
  trace
2280
2374
  };
2281
- //# sourceMappingURL=chunk-TSMDIKML.js.map
2375
+ //# sourceMappingURL=chunk-JIY37C44.js.map