@saptools/service-flow 0.1.19 → 0.1.20

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.20
4
+
5
+ - Preserve service-binding evidence when helpers return connected clients inside object properties and callers destructure or create simple transaction aliases.
6
+ - Track full template-expression placeholder keys such as `${domainInfo.serviceName}`, `${domainInfo.shortName?.toLowerCase()}`, and `${items[0].service}` for runtime `--var` substitution without evaluating JavaScript.
7
+ - Render unresolved remote actions with unknown or dynamic paths as semantic targets instead of numeric call ids, with parser evidence for shorthand `path` identifiers.
8
+ - Expand strict doctor aggregates for normalized OData invocation ambiguity and remote-action unresolved target quality.
9
+
3
10
  ## 0.1.19
4
11
 
5
12
  - Normalize balanced top-level OData action/function invocation paths before remote operation resolution, including namespace-qualified invocation lookup, while preserving raw and normalized evidence.
package/README.md CHANGED
@@ -54,7 +54,7 @@ npm install @saptools/service-flow
54
54
 
55
55
  ### Correctness notes
56
56
 
57
- - Runtime `--var` values are considered only for dynamic, ambiguous, or unresolved **remote** graph edges whose alias, destination, service path, or operation path expressions contain supplied placeholders. Local database, external HTTP, event, and already resolved static edges keep their persisted status, target, reason, and confidence. Partial substitutions remain dynamic and report the missing placeholder names.
57
+ - Runtime `--var` values are considered only for dynamic, ambiguous, or unresolved **remote** graph edges whose alias, destination, service path, or operation path expressions contain supplied placeholders. Placeholder keys are the full trimmed expression inside `${...}`, so keys such as `domainInfo.serviceName`, `domainInfo.shortName?.toLowerCase()`, and `items[0].service` can be supplied literally without JavaScript evaluation. Local database, external HTTP, event, and already resolved static edges keep their persisted status, target, reason, and confidence. Partial substitutions remain dynamic and report the missing placeholder names.
58
58
  - `trace` and `graph` both accept repeatable `--var key=value` options. Effective substitutions are rendered in trace evidence without mutating the persisted graph. Confidence values are bounded to `[0, 1]`.
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.
@@ -310,7 +310,7 @@ service-flow trace --workspace /path/to/workspace --repo facade-service --operat
310
310
 
311
311
  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.
312
312
 
313
- Service-binding evidence keeps these fields distinct: service alias, alias expression, destination expression, service-path expression, operation-path expression, and runtime placeholders. 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.
313
+ 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.
314
314
 
315
315
  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.
316
316
 
@@ -411,7 +411,7 @@ Run `service-flow index` after source, CDS, package metadata, or helper-package
411
411
  <summary><b>Why is an expected call unresolved?</b></summary>
412
412
 
413
413
  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`, and custom wrappers may need new parser support.
414
- 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.
414
+ 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.
415
415
 
416
416
  </details>
417
417
 
package/TECHNICAL-NOTE.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Service Flow Resolution Notes
2
2
 
3
+ ## 0.1.20 helper object clients, expression placeholders, and remote-action target notes
4
+
5
+ - Helper-return binding analysis now follows concrete `cds.connect.to(...)` clients returned through object shorthand or explicit properties. Callers that destructure those properties, rename them, or assign a simple `.tx()` transaction alias keep helper-chain evidence including caller variable, returned property, helper source, destination expression, service-path expression, and placeholders. Arbitrary object returns are ignored.
6
+ - Template placeholders use the full trimmed expression inside `${...}` as the runtime key. Examples include `domain`, `domainInfo.serviceName`, `domainInfo.shortName?.toLowerCase()`, and `items[0].service`. Runtime `--var` substitution matches these keys literally and does not execute or partially evaluate JavaScript. Missing expression keys keep edges dynamic.
7
+ - Remote action calls that do not expose a static path now use semantic unresolved targets such as `Remote action: unknown path` or `Remote action: dynamic path`; shorthand `path` properties retain `operationPathExpression` and `dynamic_operation_path_identifier` parser evidence.
8
+ - Strict doctor reports normalized OData invocation totals by resolved/dynamic/ambiguous/unresolved status and remote-action target quality, including numeric unresolved targets and semantic unknown/dynamic target counts.
9
+
3
10
  ## 0.1.19 remote invocation and query notes
4
11
 
5
12
  - Remote action/function paths are scanned for a balanced top-level OData invocation suffix. Single-segment operation imports like `/readConfig(...)` normalize to `/readConfig`; namespace-qualified operation imports keep the qualified request segment for evidence and can resolve against the indexed simple CDS operation when service signals are strong. Navigation/property paths like `/Orders(id='123')/items` are left unchanged. Graph evidence keeps both `rawOperationPath` and `normalizedOperationPath` when normalization occurs.
@@ -590,8 +590,14 @@ function literalText(expr) {
590
590
  return void 0;
591
591
  }
592
592
  function objectPropertyText(object, key) {
593
- const prop = object.properties.find((property) => ts4.isPropertyAssignment(property) && nameOfProperty(property.name) === key);
594
- return prop ? prop.initializer.getText() : void 0;
593
+ const prop = object.properties.find(
594
+ (property) => ts4.isPropertyAssignment(property) && nameOfProperty(property.name) === key || ts4.isShorthandPropertyAssignment(property) && property.name.text === key
595
+ );
596
+ if (!prop) return void 0;
597
+ return ts4.isShorthandPropertyAssignment(prop) ? prop.name.text : prop.initializer.getText();
598
+ }
599
+ function objectPropertyIsShorthand(object, key) {
600
+ return object.properties.some((property) => ts4.isShorthandPropertyAssignment(property) && property.name.text === key);
595
601
  }
596
602
  function nameOfProperty(name) {
597
603
  if (ts4.isIdentifier(name) || ts4.isStringLiteral(name) || ts4.isNumericLiteral(name)) return name.text;
@@ -655,7 +661,8 @@ function classifyOutboundCallsInSource(source, filePath) {
655
661
  const receiver = expr.expression.text;
656
662
  const query = objectPropertyText(objectArg, "query");
657
663
  const op = objectPropertyText(objectArg, "path") ?? objectPropertyText(objectArg, "event");
658
- add(node, { callType: query ? "remote_query" : "remote_action", serviceVariableName: receiver, method: stripQuotes(objectPropertyText(objectArg, "method") ?? "POST"), operationPathExpr: op ? `/${stripQuotes(op).replace(/^\//, "")}` : void 0, queryEntity: query ? extractQueryEntity(query) : void 0, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4 }, { receiver, classifier: "service_client_send_object" });
664
+ const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
665
+ 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 });
659
666
  }
660
667
  } else if (ts4.isPropertyAccessExpression(expr) && ["emit", "publish", "on"].includes(expr.name.text)) {
661
668
  const receiver = receiverName(expr.expression);
@@ -750,7 +757,7 @@ function stringValue(node) {
750
757
  return node.getText();
751
758
  }
752
759
  function placeholders(value) {
753
- return [...(value ?? "").matchAll(/\$\{\s*(\w+)\s*\}/g)].map((m) => m[1] ?? "").filter(Boolean);
760
+ return [...(value ?? "").matchAll(/\$\{([^}]*)\}/g)].map((m) => (m[1] ?? "").trim()).filter(Boolean);
754
761
  }
755
762
  function connectFactFromCall(call) {
756
763
  const expr = call.expression;
@@ -889,6 +896,33 @@ async function importsFor(repoPath, filePath, sf) {
889
896
  }
890
897
  return imports;
891
898
  }
899
+ function collectReturnedObjectBindings(fn) {
900
+ const bindings = /* @__PURE__ */ new Map();
901
+ const returns = /* @__PURE__ */ new Map();
902
+ function visit(node) {
903
+ if (ts5.isVariableDeclaration(node) && ts5.isIdentifier(node.name) && node.initializer) {
904
+ const fact = findConnectInExpression(node.initializer);
905
+ if (fact) bindings.set(node.name.text, fact);
906
+ }
907
+ if (ts5.isReturnStatement(node) && node.expression && ts5.isObjectLiteralExpression(node.expression)) {
908
+ for (const prop of node.expression.properties) {
909
+ if (ts5.isShorthandPropertyAssignment(prop)) returns.set(prop.name.text, prop.name.text);
910
+ if (ts5.isPropertyAssignment(prop) && ts5.isIdentifier(prop.initializer)) {
911
+ const propertyName = ts5.isIdentifier(prop.name) || ts5.isStringLiteralLike(prop.name) ? prop.name.text : void 0;
912
+ if (propertyName) returns.set(propertyName, prop.initializer.text);
913
+ }
914
+ }
915
+ }
916
+ ts5.forEachChild(node, visit);
917
+ }
918
+ visit(fn);
919
+ const out = /* @__PURE__ */ new Map();
920
+ for (const [propertyName, variableName] of returns) {
921
+ const fact = bindings.get(variableName);
922
+ if (fact) out.set(propertyName, fact);
923
+ }
924
+ return out;
925
+ }
892
926
  function exportedLocalNames(sf) {
893
927
  const exports = /* @__PURE__ */ new Map();
894
928
  for (const stmt of sf.statements) {
@@ -924,6 +958,8 @@ async function helperBindings(repoPath, filePath) {
924
958
  if (!fact) ts5.forEachChild(node, visit);
925
959
  });
926
960
  if (fact) factsByLocal.set(stmt.name.text, { ...fact, sourceLine: lineOf4(sf, stmt) });
961
+ for (const [returnedProperty, objectFact] of collectReturnedObjectBindings(stmt))
962
+ factsByLocal.set(`${stmt.name.text}#${returnedProperty}`, { ...objectFact, returnedProperty, sourceLine: lineOf4(sf, stmt) });
927
963
  }
928
964
  if (ts5.isVariableStatement(stmt))
929
965
  for (const decl of stmt.declarationList.declarations) {
@@ -946,6 +982,14 @@ async function helperBindings(repoPath, filePath) {
946
982
  sourceLine: fact.sourceLine
947
983
  });
948
984
  }
985
+ for (const [key, fact] of factsByLocal) {
986
+ const [localName, returnedProperty] = key.split("#");
987
+ if (!returnedProperty) continue;
988
+ for (const [exportedName, exportedLocal] of exportedLocals) {
989
+ if (exportedLocal !== localName) continue;
990
+ out.push({ ...fact, exportedName, returnedProperty, sourceFile: normalizePath(filePath), sourceLine: fact.sourceLine });
991
+ }
992
+ }
949
993
  return out;
950
994
  }
951
995
  async function parseServiceBindings(repoPath, filePath) {
@@ -956,6 +1000,14 @@ async function parseServiceBindings(repoPath, filePath) {
956
1000
  const imports = await importsFor(repoPath, filePath, sf);
957
1001
  const helperCache = /* @__PURE__ */ new Map();
958
1002
  const classHelpers = collectClassHelpers(sourceFileAst);
1003
+ const localObjectHelpers = /* @__PURE__ */ new Map();
1004
+ for (const stmt of sourceFileAst.statements) {
1005
+ if (!ts5.isFunctionDeclaration(stmt) || !stmt.name) continue;
1006
+ const rows2 = [];
1007
+ for (const [returnedProperty, fact] of collectReturnedObjectBindings(stmt))
1008
+ rows2.push({ ...fact, exportedName: stmt.name.text, returnedProperty, sourceFile: normalizePath(filePath), sourceLine: lineOf4(sourceFileAst, stmt) });
1009
+ if (rows2.length > 0) localObjectHelpers.set(stmt.name.text, rows2);
1010
+ }
959
1011
  async function importedHelper(localName) {
960
1012
  const imp = imports.find((i) => i.localName === localName && i.sourceFile);
961
1013
  if (!imp?.sourceFile) return void 0;
@@ -1005,6 +1057,37 @@ async function parseServiceBindings(repoPath, filePath) {
1005
1057
  });
1006
1058
  }
1007
1059
  }
1060
+ async function helperForCall(call) {
1061
+ if (!ts5.isIdentifier(call.expression)) return void 0;
1062
+ const local = localObjectHelpers.get(call.expression.text)?.[0];
1063
+ if (local) return { helper: local };
1064
+ const resolved = await importedHelper(call.expression.text);
1065
+ return resolved ? { helper: resolved.helper, imp: resolved.imp } : void 0;
1066
+ }
1067
+ async function recordDestructuredHelper(decl) {
1068
+ if (!ts5.isObjectBindingPattern(decl.name) || !decl.initializer) return;
1069
+ const call = unwrapCall(decl.initializer);
1070
+ if (!call) return;
1071
+ const resolved = await helperForCall(call);
1072
+ if (!resolved) return;
1073
+ for (const el of decl.name.elements) {
1074
+ if (!ts5.isIdentifier(el.name)) continue;
1075
+ const propertyName = el.propertyName && ts5.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
1076
+ if (resolved.helper.returnedProperty !== propertyName) continue;
1077
+ out.push({
1078
+ variableName: el.name.text,
1079
+ alias: resolved.helper.alias,
1080
+ aliasExpr: resolved.helper.aliasExpr,
1081
+ destinationExpr: resolved.helper.destinationExpr,
1082
+ servicePathExpr: resolved.helper.servicePathExpr,
1083
+ isDynamic: resolved.helper.isDynamic,
1084
+ placeholders: resolved.helper.placeholders,
1085
+ sourceFile: normalizePath(filePath),
1086
+ sourceLine: lineOf4(sourceFileAst, decl),
1087
+ helperChain: [{ callerVariable: el.name.text, helperFunction: call.expression.getText(sourceFileAst), returnedProperty: propertyName, importSource: resolved.imp?.sourceFile, helperSourceFile: resolved.helper.sourceFile, helperSourceLine: resolved.helper.sourceLine }]
1088
+ });
1089
+ }
1090
+ }
1008
1091
  function recordDestructuredClassHelper(decl) {
1009
1092
  if (!ts5.isObjectBindingPattern(decl.name) || !decl.initializer) return;
1010
1093
  const call = unwrapCall(decl.initializer);
@@ -1044,8 +1127,14 @@ async function parseServiceBindings(repoPath, filePath) {
1044
1127
  }
1045
1128
  collectDeclarations(sourceFileAst);
1046
1129
  for (const decl of declarations) {
1130
+ await recordDestructuredHelper(decl);
1047
1131
  recordDestructuredClassHelper(decl);
1048
1132
  await recordVariable(decl);
1133
+ 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)) {
1134
+ const sourceName = decl.initializer.expression.expression.text;
1135
+ const existing = out.find((row) => row.variableName === sourceName && row.sourceFile === normalizePath(filePath));
1136
+ if (existing) out.push({ ...existing, variableName: decl.name.text, sourceLine: lineOf4(sourceFileAst, decl), helperChain: [...existing.helperChain ?? [], { callerVariable: decl.name.text, aliasOf: sourceName, aliasKind: "transaction" }] });
1137
+ }
1049
1138
  }
1050
1139
  return out;
1051
1140
  }
@@ -1106,22 +1195,22 @@ function collectClassHelpers(sf) {
1106
1195
  }
1107
1196
 
1108
1197
  // src/linker/dynamic-edge-resolver.ts
1109
- var PLACEHOLDER = /\$\{\s*(\w+)\s*\}/g;
1198
+ var PLACEHOLDER = /\$\{([^}]*)\}/g;
1110
1199
  function applyVariables(template, vars) {
1111
1200
  return substituteVariables(template, vars).effective;
1112
1201
  }
1113
1202
  function extractPlaceholders(template) {
1114
- return [...(template ?? "").matchAll(PLACEHOLDER)].map((m) => m[1] ?? "").filter(Boolean);
1203
+ return [...(template ?? "").matchAll(PLACEHOLDER)].map((m) => (m[1] ?? "").trim()).filter(Boolean);
1115
1204
  }
1116
1205
  function substituteVariables(template, vars) {
1117
1206
  if (!template) return { placeholders: [], missing: [], supplied: [], changed: false };
1118
1207
  const placeholders2 = [...new Set(extractPlaceholders(template))];
1119
1208
  const supplied = placeholders2.filter((key) => Object.hasOwn(vars, key));
1120
1209
  const missing = placeholders2.filter((key) => !Object.hasOwn(vars, key));
1121
- const effective = template.replace(
1122
- PLACEHOLDER,
1123
- (_m, key) => Object.hasOwn(vars, key) ? vars[key] ?? "" : `\${${key}}`
1124
- );
1210
+ const effective = template.replace(PLACEHOLDER, (_m, key) => {
1211
+ const trimmed = key.trim();
1212
+ return Object.hasOwn(vars, trimmed) ? vars[trimmed] ?? "" : `\${${trimmed}}`;
1213
+ });
1125
1214
  return {
1126
1215
  original: template,
1127
1216
  effective,
@@ -1230,7 +1319,7 @@ function operationMatches(candidate, operationPath) {
1230
1319
  return candidate.operationPath === names.path || candidate.operationPath === names.simplePath || candidate.operationName === names.name || candidate.operationName === names.simpleName;
1231
1320
  }
1232
1321
  function resolveOperation(db, signals, workspaceId) {
1233
- const missing = [signals.servicePath, signals.alias, signals.destination, signals.operationPath].flatMap((value) => [...(value ?? "").matchAll(/\$\{\s*(\w+)\s*\}/g)].map((match) => match[1] ?? "")).filter(Boolean);
1322
+ const missing = [signals.servicePath, signals.alias, signals.destination, signals.operationPath].flatMap((value) => [...(value ?? "").matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? "").trim())).filter(Boolean);
1234
1323
  if (missing.length > 0)
1235
1324
  return {
1236
1325
  status: "dynamic",
@@ -1475,7 +1564,7 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
1475
1564
  const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1476
1565
  const unresolvedReason = status === "terminal" ? null : String(call.unresolved_reason ?? (resolution.status === "ambiguous" ? "Ambiguous operation candidates require a strong service signal" : resolution.status === "dynamic" ? `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ["missing runtime variables"]).join(", ")}` : "No indexed target operation matched"));
1477
1566
  const targetKind = callType === "local_db_query" ? "db_entity" : callType.startsWith("async_") ? "event" : "external";
1478
- const targetId = callType === "local_db_query" ? String(call.query_entity ?? "unknown") : String(call.event_name_expr ?? op ?? call.id);
1567
+ const targetId = callType === "local_db_query" ? String(call.query_entity ?? "unknown") : callType === "remote_action" ? op ? `Remote action: ${op}` : call.unresolved_reason === "dynamic_operation_path_identifier" ? "Remote action: dynamic path" : "Remote action: unknown path" : String(call.event_name_expr ?? op ?? call.id);
1479
1568
  const graphLevelDynamic = edgeType === "DYNAMIC_EDGE_CANDIDATE" && resolution.status === "dynamic";
1480
1569
  db.prepare("INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)").run(workspaceId, edgeType, status, "call", String(call.id), targetKind, targetId, Number(call.confidence ?? 0.2), JSON.stringify(evidence), graphLevelDynamic ? 1 : 0, unresolvedReason, generation);
1481
1570
  return { status, callType };
@@ -2132,4 +2221,4 @@ export {
2132
2221
  linkWorkspace,
2133
2222
  trace
2134
2223
  };
2135
- //# sourceMappingURL=chunk-YG66SHAR.js.map
2224
+ //# sourceMappingURL=chunk-EKLZJJW7.js.map