@saptools/service-flow 0.1.28 → 0.1.29

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.29
4
+
5
+ - Classify GET OData entity/query paths with query strings, filter functions, key predicates, navigation reads, and query placeholders as terminal remote query/entity edges when no strong indexed CDS operation candidate resolves them.
6
+ - Preserve raw path, entity segment, query-string presence, query placeholders, method, and classifier reason in link evidence without creating dynamic operation edges from query parameters.
7
+ - Keep balanced top-level OData action/function invocation normalization for real operation imports while avoiding truncation at parentheses inside query strings.
8
+ - Document the conservative entity-query versus operation-invocation distinction for neutral CAP service-client calls.
9
+
3
10
  ## 0.1.28
4
11
 
5
12
  - Propagate contextual service-client bindings through one-level object-parameter destructuring aliases, including renamed and assignment destructuring in neutral CAP helpers.
package/README.md CHANGED
@@ -419,7 +419,7 @@ Run `service-flow index` after source, CDS, package metadata, or helper-package
419
419
  <summary><b>Why is an expected call unresolved?</b></summary>
420
420
 
421
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.
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. GET OData entity/query reads such as `/Books?$filter=contains(title,'A')`, `/Books(ID='1000')`, and navigation queries are terminal remote query/entity edges unless strong indexed CDS operation evidence resolves them; placeholders inside query strings are preserved as query evidence rather than dynamic operation selectors.
423
423
 
424
424
  </details>
425
425
 
package/TECHNICAL-NOTE.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Service Flow Resolution Notes
2
2
 
3
+ ## 0.1.29 OData entity-query intent notes
4
+
5
+ - Service-client `GET` paths with OData collection queries, filter/search functions in the query string, entity key predicates, navigation reads, or query-string placeholders are classified conservatively as terminal remote entity/query reads when no strong indexed CDS operation candidate resolves the path. Examples include `/Books?$filter=contains(title,'A')`, `/Books(ID='1000')`, and `/Authors('A1')/books?$select=ID`.
6
+ - Query-string placeholders are recorded as query evidence and do not make the operation target dynamic. Runtime variables in service paths, destinations, aliases, and true operation paths still keep the existing dynamic-edge behavior.
7
+ - OData invocation normalization no longer truncates paths at parentheses that appear inside query strings. Balanced top-level operation imports such as `/calculateScore(input='A')` continue to normalize and resolve against indexed CDS functions/actions when service evidence is strong.
8
+
3
9
  ## 0.1.22 alias, wrapper, contextual trace, and strict doctor notes
4
10
 
5
11
  - 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.
@@ -521,6 +521,96 @@ function summarizeExpression(text) {
521
521
  return redactText(text).slice(0, 240);
522
522
  }
523
523
 
524
+ // src/linker/odata-path-normalizer.ts
525
+ function normalizeODataOperationInvocationPath(path9) {
526
+ if (path9 === void 0) return void 0;
527
+ const raw = path9.trim();
528
+ if (!raw) return void 0;
529
+ const open = raw.indexOf("(");
530
+ if (open < 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
531
+ const query = raw.indexOf("?");
532
+ if (query >= 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
533
+ if (!raw.startsWith("/") || raw.slice(1, open).includes("/")) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
534
+ const close = matchingClose(raw, open);
535
+ if (close === void 0 || raw.slice(close + 1).trim().length > 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
536
+ const operationSegment = raw.slice(0, open).trim();
537
+ if (operationSegment.length <= 1) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
538
+ return { rawOperationPath: raw, normalizedOperationPath: operationSegment, wasInvocation: true };
539
+ }
540
+ function classifyODataPathIntent(path9, method) {
541
+ const rawPath = (path9 ?? "").trim();
542
+ const normalizedMethod = (method ?? "GET").trim().toUpperCase() || "GET";
543
+ const queryIndex = rawPath.indexOf("?");
544
+ const pathWithoutQuery = queryIndex >= 0 ? rawPath.slice(0, queryIndex) : rawPath;
545
+ const queryString = queryIndex >= 0 ? rawPath.slice(queryIndex + 1) : void 0;
546
+ const segments = pathWithoutQuery.replace(/^\//, "").split("/").filter(Boolean);
547
+ const firstSegment = segments[0] ?? "";
548
+ const hasNavigationSegments = segments.length > 1;
549
+ const entitySegment = entitySegmentFromPath(pathWithoutQuery);
550
+ const placeholderKeys = [...new Set(extractTemplatePlaceholders(rawPath))];
551
+ const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys };
552
+ if (!rawPath || !rawPath.startsWith("/")) return { ...base, kind: "unknown", reason: "path_missing_or_not_absolute" };
553
+ if (normalizedMethod !== "GET") return { ...base, kind: "operation_invocation", reason: "non_get_service_send_defaults_to_operation" };
554
+ if (queryIndex >= 0) {
555
+ if (hasNavigationSegments) return { ...base, kind: "entity_navigation_query", reason: "get_path_has_navigation_and_query_string" };
556
+ if (looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: "unknown", reason: "get_invocation_with_query_string_requires_indexed_operation_evidence" };
557
+ return { ...base, kind: "entity_query", reason: "get_collection_path_has_query_string" };
558
+ }
559
+ if (hasNavigationSegments) return { ...base, kind: "entity_navigation_query", reason: "get_path_has_navigation_segments" };
560
+ if (firstSegment.includes("(")) {
561
+ return looksLikeLowerCamelInvocation(firstSegment) ? { ...base, kind: "operation_invocation", reason: "get_single_lower_camel_segment_has_top_level_invocation" } : { ...base, kind: "entity_key_read", reason: "get_entity_segment_has_key_predicate" };
562
+ }
563
+ return { ...base, kind: "unknown", reason: "get_path_has_no_query_key_or_navigation_signal" };
564
+ }
565
+ function entitySegmentFromPath(path9) {
566
+ const first = path9.replace(/^\//, "").split("/")[0]?.trim();
567
+ if (!first) return void 0;
568
+ const open = first.indexOf("(");
569
+ const entity = (open >= 0 ? first.slice(0, open) : first).trim();
570
+ return entity || void 0;
571
+ }
572
+ function looksLikeLowerCamelInvocation(segment) {
573
+ const open = segment.indexOf("(");
574
+ if (open <= 0) return false;
575
+ const name = segment.slice(0, open).split(".").at(-1) ?? segment.slice(0, open);
576
+ return /^[a-z][A-Za-z0-9_]*$/.test(name);
577
+ }
578
+ function extractTemplatePlaceholders(text) {
579
+ return [...text.matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? "").trim()).filter(Boolean);
580
+ }
581
+ function matchingClose(text, openIndex) {
582
+ let depth = 0;
583
+ let quote;
584
+ for (let index = openIndex; index < text.length; index += 1) {
585
+ const char = text[index];
586
+ const prev = text[index - 1];
587
+ if (quote) {
588
+ if (prev === "\\") continue;
589
+ if (quote === "single" && char === "'" || quote === "double" && char === '"' || quote === "template" && char === "`") quote = void 0;
590
+ continue;
591
+ }
592
+ if (char === "'") {
593
+ quote = "single";
594
+ continue;
595
+ }
596
+ if (char === '"') {
597
+ quote = "double";
598
+ continue;
599
+ }
600
+ if (char === "`") {
601
+ quote = "template";
602
+ continue;
603
+ }
604
+ if (char === "(") depth += 1;
605
+ if (char === ")") {
606
+ depth -= 1;
607
+ if (depth === 0) return index;
608
+ if (depth < 0) return void 0;
609
+ }
610
+ }
611
+ return void 0;
612
+ }
613
+
524
614
  // src/parsers/outbound-call-parser.ts
525
615
  import fs6 from "fs/promises";
526
616
  import path7 from "path";
@@ -708,9 +798,13 @@ function classifyOutboundCallsInSource(source, filePath) {
708
798
  if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
709
799
  const receiver = receiverName(expr.expression);
710
800
  const query = objectPropertyText(objectArg, "query");
801
+ const method = stripQuotes(objectPropertyText(objectArg, "method") ?? "POST");
711
802
  const op = objectPropertyText(objectArg, "path") ?? objectPropertyText(objectArg, "event");
712
803
  const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
713
- 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 });
804
+ const operationPathExpr = op && !shorthandPath ? `/${stripQuotes(op).replace(/^\//, "")}` : void 0;
805
+ const intent = classifyODataPathIntent(operationPathExpr, method);
806
+ const isODataQueryRead = method.toUpperCase() === "GET" && ["entity_query", "entity_key_read", "entity_navigation_query"].includes(intent.kind);
807
+ add(node, { callType: query || isODataQueryRead ? "remote_query" : "remote_action", serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : 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, odataPathIntent: operationPathExpr ? intent : void 0, parserWarning: shorthandPath ? "dynamic_operation_path_identifier" : void 0 });
714
808
  }
715
809
  } else if (ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text) || ts4.isIdentifier(expr) && wrapperSpecs.has(expr.text)) {
716
810
  const wrapperName = ts4.isIdentifier(expr) ? expr.text : ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) ? expr.expression.text : "";
@@ -1433,53 +1527,6 @@ function substituteVariables(template, vars) {
1433
1527
  };
1434
1528
  }
1435
1529
 
1436
- // src/linker/odata-path-normalizer.ts
1437
- function normalizeODataOperationInvocationPath(path9) {
1438
- if (path9 === void 0) return void 0;
1439
- const raw = path9.trim();
1440
- if (!raw) return void 0;
1441
- const open = raw.indexOf("(");
1442
- if (open < 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1443
- if (!raw.startsWith("/") || raw.slice(1, open).includes("/")) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1444
- const close = matchingClose(raw, open);
1445
- if (close === void 0 || raw.slice(close + 1).trim().length > 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1446
- const operationSegment = raw.slice(0, open).trim();
1447
- if (operationSegment.length <= 1) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1448
- return { rawOperationPath: raw, normalizedOperationPath: operationSegment, wasInvocation: true };
1449
- }
1450
- function matchingClose(text, openIndex) {
1451
- let depth = 0;
1452
- let quote;
1453
- for (let index = openIndex; index < text.length; index += 1) {
1454
- const char = text[index];
1455
- const prev = text[index - 1];
1456
- if (quote) {
1457
- if (prev === "\\") continue;
1458
- if (quote === "single" && char === "'" || quote === "double" && char === '"' || quote === "template" && char === "`") quote = void 0;
1459
- continue;
1460
- }
1461
- if (char === "'") {
1462
- quote = "single";
1463
- continue;
1464
- }
1465
- if (char === '"') {
1466
- quote = "double";
1467
- continue;
1468
- }
1469
- if (char === "`") {
1470
- quote = "template";
1471
- continue;
1472
- }
1473
- if (char === "(") depth += 1;
1474
- if (char === ")") {
1475
- depth -= 1;
1476
- if (depth === 0) return index;
1477
- if (depth < 0) return void 0;
1478
- }
1479
- }
1480
- return void 0;
1481
- }
1482
-
1483
1530
  // src/linker/remote-query-target.ts
1484
1531
  function buildRemoteQueryTarget(input) {
1485
1532
  const entity = typeof input.queryEntity === "string" && input.queryEntity.trim() ? input.queryEntity.trim() : void 0;
@@ -1751,15 +1798,18 @@ function linkCalls(db, workspaceId, vars, generation) {
1751
1798
  function insertCallEdge(db, workspaceId, call, vars, generation) {
1752
1799
  const callType = String(call.call_type);
1753
1800
  const rawOp = applyVariables(String(call.operation_path_expr ?? ""), vars);
1754
- const normalized = normalizeODataOperationInvocationPath(rawOp);
1755
- const op = normalized?.normalizedOperationPath ?? rawOp;
1801
+ const intent = classifyODataPathIntent(rawOp, call.method);
1802
+ const isEntityQueryIntent = ["entity_query", "entity_key_read", "entity_navigation_query"].includes(intent.kind);
1803
+ const resolutionRawOp = callType === "remote_query" && isEntityQueryIntent ? intent.pathWithoutQuery : rawOp;
1804
+ const normalized = normalizeODataOperationInvocationPath(resolutionRawOp);
1805
+ const op = normalized?.normalizedOperationPath ?? resolutionRawOp;
1756
1806
  const servicePath = applyVariables(call.servicePathExpr ?? call.requireServicePath, vars);
1757
1807
  const destination = call.destinationExpr ?? call.requireDestination;
1758
1808
  const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1759
1809
  const isOperationCall = callType === "remote_action" || callType === "local_service_call" || callType === "remote_query" && Boolean(op);
1760
1810
  const resolution = isOperationCall ? resolveOperation(db, { servicePath, operationPath: op, serviceName: call.local_service_name, repoId: callType === "local_service_call" ? Number(call.repo_id) : void 0, alias: applyVariables(call.aliasExpr ?? call.alias, vars), destination: destination ? applyVariables(destination, vars) : void 0, isDynamic, hasExplicitOverride: Object.keys(vars).length > 0 || callType === "local_service_call" }, workspaceId) : { status: "unresolved", candidates: [], reasons: [] };
1761
- const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0, normalized);
1762
- if (callType === "remote_query" && !op) {
1811
+ const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0, normalized, intent);
1812
+ if (callType === "remote_query" && (isEntityQueryIntent || !op) && !resolution.target) {
1763
1813
  const target = buildRemoteQueryTarget({ queryEntity: call.query_entity, servicePath, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination: destination ? applyVariables(destination, vars) : void 0, isDynamic, parserWarning: evidence.parserWarning });
1764
1814
  db.prepare("INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)").run(workspaceId, "HANDLER_RUNS_REMOTE_QUERY", "terminal", "call", String(call.id), target.toKind, target.toId, Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, ...target.evidence }), 0, generation);
1765
1815
  return { status: "terminal", callType };
@@ -1801,9 +1851,9 @@ function objectJson(value) {
1801
1851
  const parsed = parseJson(value);
1802
1852
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
1803
1853
  }
1804
- function callEvidence(call, resolution, servicePath, op, destination, normalized) {
1854
+ function callEvidence(call, resolution, servicePath, op, destination, normalized, odataPathIntent) {
1805
1855
  const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
1806
- return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, rawOperationPath: normalized?.wasInvocation ? normalized.rawOperationPath : void 0, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : void 0, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: parseJson(call.alias_chain_json), transport: call.call_type === "local_service_call" ? "local" : void 0, targetRepo: resolution.target?.repoName, targetServicePath: resolution.target?.servicePath, targetOperationPath: resolution.target?.operationPath, targetOperation: resolution.target?.operationName, helperChain: parseJson(call.helperChainJson), candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, bindingHasDynamicExpression: bindingHasDynamicExpression || void 0, outboundEvidence: objectJson(call.evidence_json), analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
1856
+ return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, rawOperationPath: normalized?.wasInvocation ? normalized.rawOperationPath : odataPathIntent?.rawPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : void 0, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: parseJson(call.alias_chain_json), transport: call.call_type === "local_service_call" ? "local" : void 0, targetRepo: resolution.target?.repoName, targetServicePath: resolution.target?.servicePath, targetOperationPath: resolution.target?.operationPath, targetOperation: resolution.target?.operationName, helperChain: parseJson(call.helperChainJson), candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, odataPathIntent, queryStringPresent: odataPathIntent?.hasQueryString || void 0, queryPlaceholderKeys: odataPathIntent?.placeholderKeys.length ? odataPathIntent.placeholderKeys : void 0, bindingHasDynamicExpression: bindingHasDynamicExpression || void 0, outboundEvidence: objectJson(call.evidence_json), analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
1807
1857
  }
1808
1858
  function linkImplementations(db, workspaceId, generation) {
1809
1859
  const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=?`).all(workspaceId);
@@ -2554,14 +2604,14 @@ export {
2554
2604
  parseHandlerRegistrations,
2555
2605
  redactText,
2556
2606
  redactValue,
2607
+ normalizeODataOperationInvocationPath,
2557
2608
  containsSupportedOutboundCall,
2558
2609
  parseOutboundCalls,
2559
2610
  parseServiceBindings,
2560
2611
  applyVariables,
2561
2612
  extractPlaceholders,
2562
2613
  substituteVariables,
2563
- normalizeODataOperationInvocationPath,
2564
2614
  linkWorkspace,
2565
2615
  trace
2566
2616
  };
2567
- //# sourceMappingURL=chunk-SWPGRM7Z.js.map
2617
+ //# sourceMappingURL=chunk-GOE6ZK6I.js.map