@saptools/service-flow 0.1.13 → 0.1.14

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.14
4
+
5
+ - Made local symbol-call indexing opt-in: CAP DSL, request helpers, package namespace/CommonJS calls, global runtime APIs, and service-client transport helpers are filtered unless indexed local or relative-import evidence makes the edge actionable.
6
+ - Expanded local DB query entity extraction for `SELECT.one(Entity)`, `UPSERT.into(Entity)`, `UPDATE.entity(Entity)`, static element access such as `this.model['Books']`, and clearer dynamic-query warning reasons.
7
+ - Persisted unknown DB query graph targets as semantic `db_entity:unknown` terminal nodes with source `callId` and parser-warning evidence, so fresh relinks no longer store numeric call ids as DB targets.
8
+ - Classified unresolved local service-client `.send`, `.emit`, `.publish`, and `.on` calls as terminal transport/client calls when the model does not declare a matching operation, while preserving real declared operations.
9
+ - Tightened doctor default local-service warnings and added compact `doctor --strict` symbol-call and DB-query quality aggregates with capped top unresolved examples.
10
+ - Added neutral regression fixtures for symbol-call noise, DB query forms, and local service-client methods.
11
+
3
12
  ## 0.1.13
4
13
 
5
14
  - Added implementation-context fallback for local `cds.services.*` calls so helper packages can resolve model-package operations only when handler/dependency/registration evidence ties the caller repository to the target operation.
package/README.md CHANGED
@@ -69,6 +69,7 @@ npm install @saptools/service-flow
69
69
  - Fresh databases include foreign keys for key graph, run, and diagnostic tables. Migrated legacy stores that still lack that metadata are reported by doctor with `legacy_schema_weaker_foreign_keys`; rebuild into a fresh database if strict structural parity is required.
70
70
  - Parser warnings describe analysis completeness, while routing status describes graph behavior. A terminal DB edge can remain terminal while still exposing parser warning evidence about an unknown entity.
71
71
 
72
+
72
73
  ## 🚀 Quick Start
73
74
 
74
75
  ```bash
package/TECHNICAL-NOTE.md CHANGED
@@ -6,13 +6,14 @@
6
6
  - Trace cycle safety: trace queues carry repository IDs, visited scope keys are independent of depth, graph edge IDs are emitted once, and revisiting an already-seen downstream operation scope creates a cycle marker instead of recursive expansion.
7
7
  - SQLite reliability: the package uses a persistent SQLite connection per opened database, bound parameters, transactions, WAL, busy timeouts, read-only openings for query commands, and connection-local foreign-key enforcement. Native driver loading failures produce an actionable startup error before output rendering.
8
8
 
9
- ## 0.1.13 audit follow-up notes
9
+ ## 0.1.14 audit follow-up notes
10
10
 
11
11
  - Local CAP service calls keep same-repository service ownership as the strongest resolution path. When the caller repository does not own the CDS service model, the linker searches workspace operations by service identity and operation name/path, then requires caller ownership evidence from implementation edges, ambiguous implementation candidates, registration packages, or resolved dependency/import edges before resolving. Candidate operations without caller evidence are retained with `local_service_candidate_without_caller_ownership` rather than guessed.
12
12
  - Trace traversal can use local-call context to choose the caller repository's handler from an otherwise ambiguous global implementation edge. This is scoped to the local call and does not rewrite the global `OPERATION_IMPLEMENTED_BY_HANDLER` ambiguity.
13
- - CAP DB entity extraction for local `cds.run(...)` calls uses TypeScript AST traversal across chained query expressions, including `SELECT.one.from(Entity).columns(...).where(...)`, `INSERT.into(Entity)`, `UPDATE(Entity)`, and `DELETE.from(Entity)`. Dynamic or unknowable query targets remain terminal graph edges with parser-warning evidence.
14
- - Human output never labels an unknown DB query target with the raw call id. Table and Mermaid output use `Entity: unknown`; JSON keeps the parser warning and call id evidence for machines.
15
- - Symbol-call indexing is intentionally conservative for property accesses. Built-in collection/string calls, global built-ins, logger calls, third-party package properties, and unindexed `this.someContainer.method()` calls are filtered unless indexed local helper or relative import evidence makes the edge actionable.
13
+ - CAP DB entity extraction for local `cds.run(...)` calls uses TypeScript AST traversal across chained query expressions, including `SELECT.from(Entity)`, `SELECT.one.from(Entity)`, `SELECT.one(Entity)`, `INSERT.into(Entity)`, `UPSERT.into(Entity)`, `UPDATE(Entity)`, `UPDATE.entity(Entity)`, `DELETE.from(Entity)`, and static element access such as `this.model['Books']`. Dynamic or unknowable query targets remain terminal graph edges with parser-warning evidence such as `dynamic_entity_expression`.
14
+ - Fresh relinks persist unknown DB query targets semantically as `to_kind = db_entity` and `to_id = unknown`; no schema migration is required for this row-level graph change. JSON, table, and Mermaid output use `Entity: unknown`, while evidence keeps the source `callId` and parser warning for machines.
15
+ - Symbol-call indexing is opt-in. Same-file symbols, indexed `this.method()` calls, exact relative import/export matches, and exported object-literal helper methods are kept; CAP DSL, request helpers, package namespace/CommonJS calls, global runtime APIs, and generic transport helpers are filtered unless local indexed evidence makes the edge actionable.
16
+ - `doctor --strict` reports compact parser-quality aggregates for symbol calls and local DB query known/unknown ratios; default doctor stays focused on actionable warnings.
16
17
  - Generated constants remain low-level parser output through `parseGeneratedConstants`. They are not persisted as graph facts; implementation linking only uses deterministic decorator normalization for common generated action/function names.
17
18
 
18
19
  ## 0.1.4 trace-correctness additions
@@ -559,6 +559,7 @@ function entityFromExpression(expr) {
559
559
  if (!expr) return void 0;
560
560
  if (ts4.isIdentifier(expr) || ts4.isStringLiteral(expr) || ts4.isNoSubstitutionTemplateLiteral(expr)) return expr.text;
561
561
  if (ts4.isPropertyAccessExpression(expr) && expr.expression.kind === ts4.SyntaxKind.ThisKeyword) return expr.name.text;
562
+ if (ts4.isElementAccessExpression(expr) && expr.argumentExpression && (ts4.isStringLiteral(expr.argumentExpression) || ts4.isNoSubstitutionTemplateLiteral(expr.argumentExpression))) return expr.argumentExpression.text;
562
563
  return void 0;
563
564
  }
564
565
  function expressionName(expr) {
@@ -566,29 +567,45 @@ function expressionName(expr) {
566
567
  if (ts4.isPropertyAccessExpression(expr)) return `${expressionName(expr.expression)}.${expr.name.text}`;
567
568
  return expr.getText();
568
569
  }
569
- function queryEntityFromAst(expr) {
570
- if (ts4.isParenthesizedExpression(expr) || ts4.isAwaitExpression(expr)) return queryEntityFromAst(expr.expression);
570
+ function variableInitializers(source) {
571
+ const initializers = /* @__PURE__ */ new Map();
572
+ const visit = (node) => {
573
+ if (ts4.isVariableDeclaration(node) && ts4.isIdentifier(node.name) && node.initializer && (node.parent.flags & ts4.NodeFlags.Const) !== 0) initializers.set(node.name.text, node.initializer);
574
+ ts4.forEachChild(node, visit);
575
+ };
576
+ visit(source);
577
+ return initializers;
578
+ }
579
+ function queryEntityFromAst(expr, initializers = /* @__PURE__ */ new Map()) {
580
+ if (ts4.isParenthesizedExpression(expr) || ts4.isAwaitExpression(expr)) return queryEntityFromAst(expr.expression, initializers);
581
+ if (ts4.isIdentifier(expr) && initializers.has(expr.text)) return queryEntityFromAst(initializers.get(expr.text), initializers);
571
582
  if (ts4.isCallExpression(expr)) {
572
583
  const name = expressionName(expr.expression);
573
- if (name === "cds.run") return queryEntityFromAst(expr.arguments[0]);
574
- if (name === "SELECT.one.from" || name === "SELECT.from" || name === "INSERT.into" || name === "DELETE.from") return entityFromExpression(expr.arguments[0]);
584
+ if (name === "cds.run") return queryEntityFromAst(expr.arguments[0], initializers);
585
+ if (["SELECT.one.from", "SELECT.from", "SELECT.one", "INSERT.into", "UPSERT.into", "DELETE.from", "UPDATE.entity"].includes(name)) return entityFromExpression(expr.arguments[0]);
575
586
  if (name === "UPDATE") return entityFromExpression(expr.arguments[0]);
576
587
  const receiver = ts4.isPropertyAccessExpression(expr.expression) ? expr.expression.expression : void 0;
577
- if (receiver) return queryEntityFromAst(receiver);
588
+ if (receiver) return queryEntityFromAst(receiver, initializers);
578
589
  }
579
590
  return void 0;
580
591
  }
581
592
  function extractQueryEntity(expr) {
582
593
  const source = ts4.createSourceFile("query.ts", `const __query = (${expr});`, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
594
+ const initializers = variableInitializers(source);
583
595
  let found;
584
596
  const visit = (node) => {
585
597
  if (found) return;
586
- if (ts4.isParenthesizedExpression(node)) found = queryEntityFromAst(node.expression);
598
+ if (ts4.isParenthesizedExpression(node)) found = queryEntityFromAst(node.expression, initializers);
587
599
  ts4.forEachChild(node, visit);
588
600
  };
589
601
  visit(source);
590
602
  return found;
591
603
  }
604
+ function queryWarning(expr) {
605
+ if (/^\s*[`'"]/.test(expr)) return "raw_sql_or_cql_expression";
606
+ if (/^\s*\w+\s*$/.test(expr)) return "query_variable_without_static_initializer";
607
+ return "dynamic_entity_expression";
608
+ }
592
609
  async function parseOutboundCalls(repoPath, filePath) {
593
610
  const text = await fs6.readFile(path7.join(repoPath, filePath), "utf8");
594
611
  const out = [];
@@ -620,7 +637,7 @@ async function parseOutboundCalls(repoPath, filePath) {
620
637
  sourceFile: normalizePath(filePath),
621
638
  sourceLine: lineOf3(text, m.index ?? 0),
622
639
  confidence: entity ? 0.9 : 0.55,
623
- unresolvedReason: entity ? void 0 : "Could not resolve CAP query target entity from nested expression"
640
+ unresolvedReason: entity ? void 0 : queryWarning(expr)
624
641
  });
625
642
  }
626
643
  for (const m of text.matchAll(
@@ -668,7 +685,8 @@ function parseLocalServiceCalls(text, filePath) {
668
685
  aliasChain: parsed.chain,
669
686
  sourceFile: normalizePath(filePath),
670
687
  sourceLine: lineOf3(text, node.getStart(source)),
671
- confidence: 0.9
688
+ confidence: 0.9,
689
+ unresolvedReason: ["send", "emit", "publish", "on"].includes(parsed.operation) ? "transport_client_method" : void 0
672
690
  });
673
691
  }
674
692
  ts4.forEachChild(node, visit);
@@ -1320,6 +1338,10 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
1320
1338
  const isOperationCall = callType.startsWith("remote") || callType === "local_service_call";
1321
1339
  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: [] };
1322
1340
  const evidence = callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : void 0);
1341
+ if (callType === "local_service_call" && call.unresolved_reason === "transport_client_method" && !resolution.target && resolution.candidates.length === 0) {
1342
+ 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);
1343
+ return { status: "terminal" };
1344
+ }
1323
1345
  if (resolution.target) {
1324
1346
  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, callType === "local_service_call" ? "LOCAL_CALL_RESOLVES_TO_OPERATION" : "REMOTE_CALL_RESOLVES_TO_OPERATION", "resolved", "call", String(call.id), "operation", String(resolution.target.operationId), resolution.target.score, JSON.stringify(evidence), isDynamic ? 1 : 0, generation);
1325
1347
  return { status: "resolved" };
@@ -1327,11 +1349,13 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
1327
1349
  const edgeType = callType === "local_db_query" ? "HANDLER_RUNS_DB_QUERY" : callType === "external_http" ? "HANDLER_CALLS_EXTERNAL_HTTP" : callType === "async_emit" ? "HANDLER_EMITS_EVENT" : callType === "async_subscribe" ? "EVENT_CONSUMED_BY_HANDLER" : resolution.status === "dynamic" ? "DYNAMIC_EDGE_CANDIDATE" : "UNRESOLVED_EDGE";
1328
1350
  const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1329
1351
  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"));
1330
- 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), callType.startsWith("async_") ? "event" : "external", String(call.event_name_expr ?? call.query_entity ?? op ?? call.id), Number(call.confidence ?? 0.2), JSON.stringify(evidence), isDynamic || resolution.status === "dynamic" ? 1 : 0, unresolvedReason, generation);
1352
+ const targetKind = callType === "local_db_query" ? "db_entity" : callType.startsWith("async_") ? "event" : "external";
1353
+ const targetId = callType === "local_db_query" ? String(call.query_entity ?? "unknown") : String(call.event_name_expr ?? op ?? call.id);
1354
+ 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), isDynamic || resolution.status === "dynamic" ? 1 : 0, unresolvedReason, generation);
1331
1355
  return { status };
1332
1356
  }
1333
1357
  function callEvidence(call, resolution, servicePath, op, destination) {
1334
- return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: call.alias_chain_json ? JSON.parse(String(call.alias_chain_json)) : void 0, transport: call.call_type === "local_service_call" ? "local" : void 0, targetRepo: resolution.target?.repoName, targetOperation: resolution.target?.operationName, helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0, candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
1358
+ 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: call.alias_chain_json ? JSON.parse(String(call.alias_chain_json)) : void 0, transport: call.call_type === "local_service_call" ? "local" : void 0, targetRepo: resolution.target?.repoName, targetOperation: resolution.target?.operationName, helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0, candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
1335
1359
  }
1336
1360
  function linkImplementations(db, workspaceId, generation) {
1337
1361
  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);
@@ -1782,7 +1806,7 @@ function edgeTarget(row, evidence) {
1782
1806
  const operationPath = typeof evidence.operationPath === "string" ? evidence.operationPath : void 0;
1783
1807
  const targetOperation = typeof evidence.targetOperation === "string" ? evidence.targetOperation : void 0;
1784
1808
  const targetRepo = typeof evidence.targetRepo === "string" ? evidence.targetRepo : "";
1785
- if (row.edge_type === "HANDLER_RUNS_DB_QUERY") return /^\d+$/.test(row.to_id) ? "Entity: unknown" : `Entity: ${row.to_id}`;
1809
+ if (row.edge_type === "HANDLER_RUNS_DB_QUERY") return `Entity: ${row.to_id || "unknown"}`;
1786
1810
  return servicePath && operationPath ? `${servicePath}${operationPath}` : targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
1787
1811
  }
1788
1812
  function trace(db, start, options) {
@@ -1880,7 +1904,7 @@ function trace(db, start, options) {
1880
1904
  nodes.set(targetNode, opNode ?? {
1881
1905
  id: targetNode,
1882
1906
  kind: effectiveRow.to_kind,
1883
- label: effectiveRow.to_id
1907
+ label: effectiveRow.to_kind === "db_entity" ? `Entity: ${effectiveRow.to_id || "unknown"}` : effectiveRow.to_id
1884
1908
  });
1885
1909
  const to = edgeTarget(effectiveRow, evidence);
1886
1910
  edges.push({
@@ -1963,4 +1987,4 @@ export {
1963
1987
  linkWorkspace,
1964
1988
  trace
1965
1989
  };
1966
- //# sourceMappingURL=chunk-Q7W7TE3W.js.map
1990
+ //# sourceMappingURL=chunk-UPXFMLUY.js.map