@saptools/service-flow 0.1.18 → 0.1.19

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.19
4
+
5
+ - Normalize balanced top-level OData action/function invocation paths before remote operation resolution, including namespace-qualified invocation lookup, while preserving raw and normalized evidence.
6
+ - Model remote service-client queries as terminal remote-query edges with stable semantic targets instead of unresolved operation calls.
7
+ - Add strict doctor aggregates for remote-query target quality and OData invocation resolution quality.
8
+ - Document remote query terminal semantics and normalized invocation resolution behavior.
9
+
3
10
  ## 0.1.18
4
11
 
5
12
  - 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
package/TECHNICAL-NOTE.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Service Flow Resolution Notes
2
2
 
3
+ ## 0.1.19 remote invocation and query notes
4
+
5
+ - 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.
6
+ - 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.
7
+ - Strict doctor includes remote-query target-quality and OData invocation-resolution aggregates to catch numeric query targets and unresolved normalized invocation paths.
8
+
3
9
  ## 0.1.18 auditability notes
4
10
 
5
11
  - 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.
@@ -1132,19 +1132,103 @@ function substituteVariables(template, vars) {
1132
1132
  };
1133
1133
  }
1134
1134
 
1135
+ // src/linker/odata-path-normalizer.ts
1136
+ function normalizeODataOperationInvocationPath(path9) {
1137
+ if (path9 === void 0) return void 0;
1138
+ const raw = path9.trim();
1139
+ if (!raw) return void 0;
1140
+ const open = raw.indexOf("(");
1141
+ if (open < 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1142
+ if (!raw.startsWith("/") || raw.slice(1, open).includes("/")) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1143
+ const close = matchingClose(raw, open);
1144
+ if (close === void 0 || raw.slice(close + 1).trim().length > 0) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1145
+ const operationSegment = raw.slice(0, open).trim();
1146
+ if (operationSegment.length <= 1) return { rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false };
1147
+ return { rawOperationPath: raw, normalizedOperationPath: operationSegment, wasInvocation: true };
1148
+ }
1149
+ function matchingClose(text, openIndex) {
1150
+ let depth = 0;
1151
+ let quote;
1152
+ for (let index = openIndex; index < text.length; index += 1) {
1153
+ const char = text[index];
1154
+ const prev = text[index - 1];
1155
+ if (quote) {
1156
+ if (prev === "\\") continue;
1157
+ if (quote === "single" && char === "'" || quote === "double" && char === '"' || quote === "template" && char === "`") quote = void 0;
1158
+ continue;
1159
+ }
1160
+ if (char === "'") {
1161
+ quote = "single";
1162
+ continue;
1163
+ }
1164
+ if (char === '"') {
1165
+ quote = "double";
1166
+ continue;
1167
+ }
1168
+ if (char === "`") {
1169
+ quote = "template";
1170
+ continue;
1171
+ }
1172
+ if (char === "(") depth += 1;
1173
+ if (char === ")") {
1174
+ depth -= 1;
1175
+ if (depth === 0) return index;
1176
+ if (depth < 0) return void 0;
1177
+ }
1178
+ }
1179
+ return void 0;
1180
+ }
1181
+
1182
+ // src/linker/remote-query-target.ts
1183
+ function buildRemoteQueryTarget(input) {
1184
+ const entity = typeof input.queryEntity === "string" && input.queryEntity.trim() ? input.queryEntity.trim() : void 0;
1185
+ const servicePath = input.servicePath?.trim();
1186
+ const prefix = servicePath ? `${servicePath}:` : "";
1187
+ const label = entity ? `Remote entity: ${prefix}${entity}` : "Remote query: unknown";
1188
+ return {
1189
+ toKind: entity ? "remote_entity" : "remote_query",
1190
+ toId: entity ? `${prefix}${entity}` : "unknown",
1191
+ label,
1192
+ evidence: {
1193
+ remoteQueryTarget: label,
1194
+ queryEntity: entity,
1195
+ queryTargetKind: entity ? "remote_entity" : "remote_query_unknown",
1196
+ queryEntityDynamic: entity ? void 0 : Boolean(input.isDynamic) || void 0,
1197
+ serviceAlias: input.serviceAlias,
1198
+ serviceAliasExpr: input.serviceAliasExpr,
1199
+ destination: input.destination,
1200
+ servicePath,
1201
+ parserWarning: entity ? input.parserWarning : input.parserWarning ?? { code: "query_entity_unknown", message: "Remote query entity is dynamic or unavailable" }
1202
+ }
1203
+ };
1204
+ }
1205
+
1135
1206
  // src/linker/service-resolver.ts
1136
1207
  function rows(db, operationPath, workspaceId) {
1208
+ const names = operationLookupNames(operationPath);
1137
1209
  return db.prepare(
1138
1210
  `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
1211
  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`
1212
+ 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
1213
  ).all(
1142
1214
  workspaceId,
1143
1215
  workspaceId,
1144
- operationPath,
1145
- operationPath.replace(/^\//, "")
1216
+ names.path,
1217
+ names.simplePath,
1218
+ names.name,
1219
+ names.simpleName
1146
1220
  );
1147
1221
  }
1222
+ function operationLookupNames(operationPath) {
1223
+ const name = operationPath.replace(/^\//, "");
1224
+ const simpleName = name.split(".").at(-1) ?? name;
1225
+ return { path: operationPath, simplePath: `/${simpleName}`, name, simpleName };
1226
+ }
1227
+ function operationMatches(candidate, operationPath) {
1228
+ if (!operationPath) return false;
1229
+ const names = operationLookupNames(operationPath);
1230
+ return candidate.operationPath === names.path || candidate.operationPath === names.simplePath || candidate.operationName === names.name || candidate.operationName === names.simpleName;
1231
+ }
1148
1232
  function resolveOperation(db, signals, workspaceId) {
1149
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);
1150
1234
  if (missing.length > 0)
@@ -1212,7 +1296,7 @@ function resolveOperation(db, signals, workspaceId) {
1212
1296
  c.score += 0.2;
1213
1297
  c.reasons.push(signals.repoId !== void 0 ? "explicit_local_service_call" : "explicit_dynamic_override");
1214
1298
  }
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(/^\//, ""))) {
1299
+ if (signals.repoId !== void 0 && candidates.length === 1 && signals.serviceName && c.reasons.includes("local_service_name_mismatch") && operationMatches(c, signals.operationPath)) {
1216
1300
  c.score = Math.max(c.score, 0.9);
1217
1301
  c.reasons.push("same_repo_unique_operation_path_with_lookup_mismatch");
1218
1302
  }
@@ -1235,7 +1319,7 @@ function resolveOperation(db, signals, workspaceId) {
1235
1319
  candidates,
1236
1320
  reasons: ["operation_path_only_has_no_strong_target_signal"]
1237
1321
  };
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))
1322
+ 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
1323
  return {
1240
1324
  status: "resolved",
1241
1325
  target: best,
@@ -1365,13 +1449,20 @@ function linkCalls(db, workspaceId, vars, generation) {
1365
1449
  }
1366
1450
  function insertCallEdge(db, workspaceId, call, vars, generation) {
1367
1451
  const callType = String(call.call_type);
1368
- const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1452
+ const rawOp = applyVariables(String(call.operation_path_expr ?? ""), vars);
1453
+ const normalized = normalizeODataOperationInvocationPath(rawOp);
1454
+ const op = normalized?.normalizedOperationPath ?? rawOp;
1369
1455
  const servicePath = applyVariables(call.servicePathExpr ?? call.requireServicePath, vars);
1370
1456
  const destination = call.destinationExpr ?? call.requireDestination;
1371
1457
  const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1372
- const isOperationCall = callType.startsWith("remote") || callType === "local_service_call";
1458
+ const isOperationCall = callType === "remote_action" || callType === "local_service_call" || callType === "remote_query" && Boolean(op);
1373
1459
  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);
1460
+ const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0, normalized);
1461
+ if (callType === "remote_query" && !op) {
1462
+ 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 });
1463
+ 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);
1464
+ return { status: "terminal", callType };
1465
+ }
1375
1466
  if (callType === "local_service_call" && call.unresolved_reason === "transport_client_method" && !resolution.target && resolution.candidates.length === 0) {
1376
1467
  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
1468
  return { status: "terminal", callType };
@@ -1401,9 +1492,9 @@ function objectJson(value) {
1401
1492
  const parsed = parseJson(value);
1402
1493
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
1403
1494
  }
1404
- function callEvidence(call, resolution, servicePath, op, destination) {
1495
+ function callEvidence(call, resolution, servicePath, op, destination, normalized) {
1405
1496
  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 };
1497
+ 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
1498
  }
1408
1499
  function linkImplementations(db, workspaceId, generation) {
1409
1500
  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 +1924,7 @@ function runtimeResolution(db, row, evidence, vars) {
1833
1924
  return { row, evidence, unresolvedReason: row.unresolved_reason };
1834
1925
  const nextEvidence = evidenceWithRuntimeVariables(evidence, vars);
1835
1926
  const servicePath = typeof nextEvidence.servicePath === "string" ? nextEvidence.servicePath : void 0;
1836
- const operationPath = typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
1927
+ const operationPath = typeof nextEvidence.normalizedOperationPath === "string" ? nextEvidence.normalizedOperationPath : typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
1837
1928
  const alias = typeof nextEvidence.serviceAliasExpr === "string" ? nextEvidence.serviceAliasExpr : typeof nextEvidence.serviceAlias === "string" ? nextEvidence.serviceAlias : void 0;
1838
1929
  const destination = typeof nextEvidence.destination === "string" ? nextEvidence.destination : void 0;
1839
1930
  const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceIdForCall(db, row.from_id));
@@ -1850,11 +1941,15 @@ function edgeTarget(row, evidence) {
1850
1941
  const runtimeCandidate = evidence.runtimeResolvedCandidate;
1851
1942
  if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath)
1852
1943
  return `${runtimeCandidate.servicePath}${runtimeCandidate.operationPath}`;
1944
+ const targetServicePath = typeof evidence.targetServicePath === "string" ? evidence.targetServicePath : void 0;
1945
+ const targetOperationPath = typeof evidence.targetOperationPath === "string" ? evidence.targetOperationPath : void 0;
1946
+ if (targetServicePath && targetOperationPath) return `${targetServicePath}${targetOperationPath}`;
1853
1947
  const servicePath = typeof evidence.servicePath === "string" ? evidence.servicePath : void 0;
1854
1948
  const operationPath = typeof evidence.operationPath === "string" ? evidence.operationPath : void 0;
1855
1949
  const targetOperation = typeof evidence.targetOperation === "string" ? evidence.targetOperation : void 0;
1856
1950
  const targetRepo = typeof evidence.targetRepo === "string" ? evidence.targetRepo : "";
1857
1951
  if (row.edge_type === "HANDLER_RUNS_DB_QUERY") return `Entity: ${row.to_id || "unknown"}`;
1952
+ if (row.edge_type === "HANDLER_RUNS_REMOTE_QUERY") return typeof evidence.remoteQueryTarget === "string" ? evidence.remoteQueryTarget : `Remote query: ${row.to_id || "unknown"}`;
1858
1953
  return servicePath && operationPath ? `${servicePath}${operationPath}` : targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
1859
1954
  }
1860
1955
  function trace(db, start, options) {
@@ -2033,7 +2128,8 @@ export {
2033
2128
  applyVariables,
2034
2129
  extractPlaceholders,
2035
2130
  substituteVariables,
2131
+ normalizeODataOperationInvocationPath,
2036
2132
  linkWorkspace,
2037
2133
  trace
2038
2134
  };
2039
- //# sourceMappingURL=chunk-DUGM7FRA.js.map
2135
+ //# sourceMappingURL=chunk-YG66SHAR.js.map