@saptools/service-flow 0.1.18 → 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,19 @@
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
+
10
+ ## 0.1.19
11
+
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.
13
+ - Model remote service-client queries as terminal remote-query edges with stable semantic targets instead of unresolved operation calls.
14
+ - Add strict doctor aggregates for remote-query target quality and OData invocation resolution quality.
15
+ - Document remote query terminal semantics and normalized invocation resolution behavior.
16
+
3
17
  ## 0.1.18
4
18
 
5
19
  - Applied conservative CAP receiver eligibility to `.emit()` and `.publish()` so generic realtime, socket, DOM, and EventEmitter-style calls no longer become CAP async event facts without explicit CAP messaging/service evidence.
package/README.md CHANGED
@@ -18,14 +18,6 @@ Index independent Git repositories, persist CAP/CDS facts in SQLite, resolve cro
18
18
 
19
19
  ---
20
20
 
21
- ## 0.1.18 quality update
22
-
23
- `service-flow` 0.1.18 hardens auditability after the 0.1.17 audit. Event `.emit()` and `.publish()` parsing now requires conservative CAP receiver evidence, local CAP service calls persist structured parser evidence, call-derived graph and JSON trace evidence include nested outbound parser evidence, terminal DB/external/event edges are not marked graph-dynamic, and `doctor --strict` reports outbound evidence, graph evidence propagation, event receiver classification, and dynamic flag consistency.
24
-
25
- ## 0.1.17 quality update
26
-
27
- `service-flow` 0.1.17 hardens outbound extraction after the 0.1.16 audit. Outbound calls are classified from the TypeScript AST, so comments and strings are ignored; CAP/service lifecycle registrations that remain indexed receive synthetic event-registration owners; generic event emitters and response `.send()` calls are not treated as CAP service-flow edges; and `doctor --strict` reports actionable ownerless categories. The previous 0.1.16 source ownership and proxy-evidence improvements remain in place. It indexes class property arrow/function members, creates conservative synthetic callback symbols only for top-level framework callbacks containing supported outbound calls, prefers explicit outbound source-symbol names when provided, hardens proxy-member resolution away from ambiguous global name-only matches, and splits link output into remote/local/unresolved/ambiguous/dynamic/terminal operation-call buckets.
28
-
29
21
  ## ✨ Features
30
22
 
31
23
  - 🧭 **Cross-repository CAP tracing** — starts from a repo, service, operation path, operation name, or handler and follows the indexed flow across workspace boundaries
@@ -62,7 +54,7 @@ npm install @saptools/service-flow
62
54
 
63
55
  ### Correctness notes
64
56
 
65
- - 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.
66
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]`.
67
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.
68
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.
@@ -318,7 +310,7 @@ service-flow trace --workspace /path/to/workspace --repo facade-service --operat
318
310
 
319
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.
320
312
 
321
- 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.
322
314
 
323
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.
324
316
 
@@ -419,7 +411,7 @@ Run `service-flow index` after source, CDS, package metadata, or helper-package
419
411
  <summary><b>Why is an expected call unresolved?</b></summary>
420
412
 
421
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.
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.
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.
423
415
 
424
416
  </details>
425
417
 
package/TECHNICAL-NOTE.md CHANGED
@@ -1,5 +1,18 @@
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
+
10
+ ## 0.1.19 remote invocation and query notes
11
+
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.
13
+ - Remote `send({ query })` calls without explicit operation-path evidence now become `HANDLER_RUNS_REMOTE_QUERY` terminal edges. Static entities produce `Remote entity: ...` targets; dynamic or unknown entities produce `Remote query: unknown` with parser-warning evidence.
14
+ - Strict doctor includes remote-query target-quality and OData invocation-resolution aggregates to catch numeric query targets and unresolved normalized invocation paths.
15
+
3
16
  ## 0.1.18 auditability notes
4
17
 
5
18
  - CAP async event parsing treats `.emit()`, `.publish()`, and `.on()` consistently: a row is indexed only when the direct or chained root receiver has explicit CAP service or messaging evidence such as `cds` or a variable initialized from `cds.connect.to(...)`. Generic realtime/socket/EventEmitter receivers are ignored for CAP graph purposes by default.
@@ -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,
@@ -1132,21 +1221,105 @@ function substituteVariables(template, vars) {
1132
1221
  };
1133
1222
  }
1134
1223
 
1224
+ // src/linker/odata-path-normalizer.ts
1225
+ function normalizeODataOperationInvocationPath(path9) {
1226
+ if (path9 === void 0) return void 0;
1227
+ const raw = path9.trim();
1228
+ if (!raw) return void 0;
1229
+ const open = raw.indexOf("(");
1230
+ if (open < 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1231
+ if (!raw.startsWith("/") || raw.slice(1, open).includes("/")) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1232
+ const close = matchingClose(raw, open);
1233
+ if (close === void 0 || raw.slice(close + 1).trim().length > 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1234
+ const operationSegment = raw.slice(0, open).trim();
1235
+ if (operationSegment.length <= 1) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1236
+ return { rawOperationPath: raw, normalizedOperationPath: operationSegment, wasInvocation: true };
1237
+ }
1238
+ function matchingClose(text, openIndex) {
1239
+ let depth = 0;
1240
+ let quote;
1241
+ for (let index = openIndex; index < text.length; index += 1) {
1242
+ const char = text[index];
1243
+ const prev = text[index - 1];
1244
+ if (quote) {
1245
+ if (prev === "\\") continue;
1246
+ if (quote === "single" && char === "'" || quote === "double" && char === '"' || quote === "template" && char === "`") quote = void 0;
1247
+ continue;
1248
+ }
1249
+ if (char === "'") {
1250
+ quote = "single";
1251
+ continue;
1252
+ }
1253
+ if (char === '"') {
1254
+ quote = "double";
1255
+ continue;
1256
+ }
1257
+ if (char === "`") {
1258
+ quote = "template";
1259
+ continue;
1260
+ }
1261
+ if (char === "(") depth += 1;
1262
+ if (char === ")") {
1263
+ depth -= 1;
1264
+ if (depth === 0) return index;
1265
+ if (depth < 0) return void 0;
1266
+ }
1267
+ }
1268
+ return void 0;
1269
+ }
1270
+
1271
+ // src/linker/remote-query-target.ts
1272
+ function buildRemoteQueryTarget(input) {
1273
+ const entity = typeof input.queryEntity === "string" && input.queryEntity.trim() ? input.queryEntity.trim() : void 0;
1274
+ const servicePath = input.servicePath?.trim();
1275
+ const prefix = servicePath ? `${servicePath}:` : "";
1276
+ const label = entity ? `Remote entity: ${prefix}${entity}` : "Remote query: unknown";
1277
+ return {
1278
+ toKind: entity ? "remote_entity" : "remote_query",
1279
+ toId: entity ? `${prefix}${entity}` : "unknown",
1280
+ label,
1281
+ evidence: {
1282
+ remoteQueryTarget: label,
1283
+ queryEntity: entity,
1284
+ queryTargetKind: entity ? "remote_entity" : "remote_query_unknown",
1285
+ queryEntityDynamic: entity ? void 0 : Boolean(input.isDynamic) || void 0,
1286
+ serviceAlias: input.serviceAlias,
1287
+ serviceAliasExpr: input.serviceAliasExpr,
1288
+ destination: input.destination,
1289
+ servicePath,
1290
+ parserWarning: entity ? input.parserWarning : input.parserWarning ?? { code: "query_entity_unknown", message: "Remote query entity is dynamic or unavailable" }
1291
+ }
1292
+ };
1293
+ }
1294
+
1135
1295
  // src/linker/service-resolver.ts
1136
1296
  function rows(db, operationPath, workspaceId) {
1297
+ const names = operationLookupNames(operationPath);
1137
1298
  return db.prepare(
1138
1299
  `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine,0 score,'' reasons
1139
1300
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
1140
- WHERE (? IS NULL OR r.workspace_id=?) AND (o.operation_path=? OR o.operation_name=?) ORDER BY r.name,s.service_path,o.operation_name`
1301
+ WHERE (? IS NULL OR r.workspace_id=?) AND (o.operation_path IN (?,?) OR o.operation_name IN (?,?)) ORDER BY r.name,s.service_path,o.operation_name`
1141
1302
  ).all(
1142
1303
  workspaceId,
1143
1304
  workspaceId,
1144
- operationPath,
1145
- operationPath.replace(/^\//, "")
1305
+ names.path,
1306
+ names.simplePath,
1307
+ names.name,
1308
+ names.simpleName
1146
1309
  );
1147
1310
  }
1311
+ function operationLookupNames(operationPath) {
1312
+ const name = operationPath.replace(/^\//, "");
1313
+ const simpleName = name.split(".").at(-1) ?? name;
1314
+ return { path: operationPath, simplePath: `/${simpleName}`, name, simpleName };
1315
+ }
1316
+ function operationMatches(candidate, operationPath) {
1317
+ if (!operationPath) return false;
1318
+ const names = operationLookupNames(operationPath);
1319
+ return candidate.operationPath === names.path || candidate.operationPath === names.simplePath || candidate.operationName === names.name || candidate.operationName === names.simpleName;
1320
+ }
1148
1321
  function resolveOperation(db, signals, workspaceId) {
1149
- 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);
1150
1323
  if (missing.length > 0)
1151
1324
  return {
1152
1325
  status: "dynamic",
@@ -1212,7 +1385,7 @@ function resolveOperation(db, signals, workspaceId) {
1212
1385
  c.score += 0.2;
1213
1386
  c.reasons.push(signals.repoId !== void 0 ? "explicit_local_service_call" : "explicit_dynamic_override");
1214
1387
  }
1215
- if (signals.repoId !== void 0 && candidates.length === 1 && signals.serviceName && c.reasons.includes("local_service_name_mismatch") && (c.operationPath === signals.operationPath || c.operationName === signals.operationPath.replace(/^\//, ""))) {
1388
+ if (signals.repoId !== void 0 && candidates.length === 1 && signals.serviceName && c.reasons.includes("local_service_name_mismatch") && operationMatches(c, signals.operationPath)) {
1216
1389
  c.score = Math.max(c.score, 0.9);
1217
1390
  c.reasons.push("same_repo_unique_operation_path_with_lookup_mismatch");
1218
1391
  }
@@ -1235,7 +1408,7 @@ function resolveOperation(db, signals, workspaceId) {
1235
1408
  candidates,
1236
1409
  reasons: ["operation_path_only_has_no_strong_target_signal"]
1237
1410
  };
1238
- if (best && best.score >= 0.9 && (best.servicePath === signals.servicePath || Boolean(signals.serviceName && (!best.reasons.includes("local_service_name_mismatch") || best.reasons.includes("same_repo_unique_operation_path_with_lookup_mismatch")))) && (best.operationPath === signals.operationPath || best.operationName === signals.operationPath.replace(/^\//, "")) && (!second || best.score - second.score >= 0.25))
1411
+ if (best && best.score >= 0.9 && (best.servicePath === signals.servicePath || Boolean(signals.serviceName && (!best.reasons.includes("local_service_name_mismatch") || best.reasons.includes("same_repo_unique_operation_path_with_lookup_mismatch")))) && operationMatches(best, signals.operationPath) && (!second || best.score - second.score >= 0.25))
1239
1412
  return {
1240
1413
  status: "resolved",
1241
1414
  target: best,
@@ -1365,13 +1538,20 @@ function linkCalls(db, workspaceId, vars, generation) {
1365
1538
  }
1366
1539
  function insertCallEdge(db, workspaceId, call, vars, generation) {
1367
1540
  const callType = String(call.call_type);
1368
- const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1541
+ const rawOp = applyVariables(String(call.operation_path_expr ?? ""), vars);
1542
+ const normalized = normalizeODataOperationInvocationPath(rawOp);
1543
+ const op = normalized?.normalizedOperationPath ?? rawOp;
1369
1544
  const servicePath = applyVariables(call.servicePathExpr ?? call.requireServicePath, vars);
1370
1545
  const destination = call.destinationExpr ?? call.requireDestination;
1371
1546
  const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1372
- const isOperationCall = callType.startsWith("remote") || callType === "local_service_call";
1547
+ const isOperationCall = callType === "remote_action" || callType === "local_service_call" || callType === "remote_query" && Boolean(op);
1373
1548
  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: [] };
1374
- const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0);
1549
+ const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0, normalized);
1550
+ if (callType === "remote_query" && !op) {
1551
+ 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 });
1552
+ 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);
1553
+ return { status: "terminal", callType };
1554
+ }
1375
1555
  if (callType === "local_service_call" && call.unresolved_reason === "transport_client_method" && !resolution.target && resolution.candidates.length === 0) {
1376
1556
  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_CALLS_EXTERNAL_HTTP", "terminal", "call", String(call.id), "external", String(op || "transport_client_method"), Number(call.confidence ?? 0.5), JSON.stringify({ ...evidence, classification: "transport_client_method" }), 0, generation);
1377
1557
  return { status: "terminal", callType };
@@ -1384,7 +1564,7 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
1384
1564
  const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1385
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"));
1386
1566
  const targetKind = callType === "local_db_query" ? "db_entity" : callType.startsWith("async_") ? "event" : "external";
1387
- 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);
1388
1568
  const graphLevelDynamic = edgeType === "DYNAMIC_EDGE_CANDIDATE" && resolution.status === "dynamic";
1389
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);
1390
1570
  return { status, callType };
@@ -1401,9 +1581,9 @@ function objectJson(value) {
1401
1581
  const parsed = parseJson(value);
1402
1582
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
1403
1583
  }
1404
- function callEvidence(call, resolution, servicePath, op, destination) {
1584
+ function callEvidence(call, resolution, servicePath, op, destination, normalized) {
1405
1585
  const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
1406
- 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, 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, 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 };
1586
+ 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 };
1407
1587
  }
1408
1588
  function linkImplementations(db, workspaceId, generation) {
1409
1589
  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);
@@ -1833,7 +2013,7 @@ function runtimeResolution(db, row, evidence, vars) {
1833
2013
  return { row, evidence, unresolvedReason: row.unresolved_reason };
1834
2014
  const nextEvidence = evidenceWithRuntimeVariables(evidence, vars);
1835
2015
  const servicePath = typeof nextEvidence.servicePath === "string" ? nextEvidence.servicePath : void 0;
1836
- const operationPath = typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
2016
+ const operationPath = typeof nextEvidence.normalizedOperationPath === "string" ? nextEvidence.normalizedOperationPath : typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
1837
2017
  const alias = typeof nextEvidence.serviceAliasExpr === "string" ? nextEvidence.serviceAliasExpr : typeof nextEvidence.serviceAlias === "string" ? nextEvidence.serviceAlias : void 0;
1838
2018
  const destination = typeof nextEvidence.destination === "string" ? nextEvidence.destination : void 0;
1839
2019
  const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceIdForCall(db, row.from_id));
@@ -1850,11 +2030,15 @@ function edgeTarget(row, evidence) {
1850
2030
  const runtimeCandidate = evidence.runtimeResolvedCandidate;
1851
2031
  if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath)
1852
2032
  return `${runtimeCandidate.servicePath}${runtimeCandidate.operationPath}`;
2033
+ const targetServicePath = typeof evidence.targetServicePath === "string" ? evidence.targetServicePath : void 0;
2034
+ const targetOperationPath = typeof evidence.targetOperationPath === "string" ? evidence.targetOperationPath : void 0;
2035
+ if (targetServicePath && targetOperationPath) return `${targetServicePath}${targetOperationPath}`;
1853
2036
  const servicePath = typeof evidence.servicePath === "string" ? evidence.servicePath : void 0;
1854
2037
  const operationPath = typeof evidence.operationPath === "string" ? evidence.operationPath : void 0;
1855
2038
  const targetOperation = typeof evidence.targetOperation === "string" ? evidence.targetOperation : void 0;
1856
2039
  const targetRepo = typeof evidence.targetRepo === "string" ? evidence.targetRepo : "";
1857
2040
  if (row.edge_type === "HANDLER_RUNS_DB_QUERY") return `Entity: ${row.to_id || "unknown"}`;
2041
+ if (row.edge_type === "HANDLER_RUNS_REMOTE_QUERY") return typeof evidence.remoteQueryTarget === "string" ? evidence.remoteQueryTarget : `Remote query: ${row.to_id || "unknown"}`;
1858
2042
  return servicePath && operationPath ? `${servicePath}${operationPath}` : targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
1859
2043
  }
1860
2044
  function trace(db, start, options) {
@@ -2033,7 +2217,8 @@ export {
2033
2217
  applyVariables,
2034
2218
  extractPlaceholders,
2035
2219
  substituteVariables,
2220
+ normalizeODataOperationInvocationPath,
2036
2221
  linkWorkspace,
2037
2222
  trace
2038
2223
  };
2039
- //# sourceMappingURL=chunk-DUGM7FRA.js.map
2224
+ //# sourceMappingURL=chunk-EKLZJJW7.js.map