@saptools/service-flow 0.1.11 → 0.1.13

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/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  parsePackageJson,
11
11
  parseServiceBindings,
12
12
  trace
13
- } from "./chunk-GG4XJGES.js";
13
+ } from "./chunk-Q7W7TE3W.js";
14
14
 
15
15
  // src/cli.ts
16
16
  import { Command } from "commander";
@@ -185,7 +185,7 @@ function migrate(db) {
185
185
  // package.json
186
186
  var package_default = {
187
187
  name: "@saptools/service-flow",
188
- version: "0.1.11",
188
+ version: "0.1.13",
189
189
  description: "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
190
190
  type: "module",
191
191
  publishConfig: {
@@ -545,7 +545,7 @@ function insertExecutableSymbols(db, repoId, rows) {
545
545
  function insertSymbolCalls(db, repoId, rows) {
546
546
  const stmt = db.prepare(`INSERT INTO symbol_calls(repo_id,caller_symbol_id,callee_symbol_id,callee_expression,import_source,source_file,source_line,status,confidence,evidence_json,unresolved_reason) VALUES(?,(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),(SELECT id FROM symbols WHERE repo_id=? AND ((source_file=? AND (name=? OR qualified_name=?)) OR (source_file<>? AND exported=1 AND (exported_name=? OR name=? OR qualified_name=?))) ORDER BY CASE WHEN source_file=? THEN 0 ELSE 1 END,id LIMIT 1),?,?,?,?,?,0.8,?,CASE WHEN (SELECT id FROM symbols WHERE repo_id=? AND ((source_file=? AND (name=? OR qualified_name=?)) OR (source_file<>? AND exported=1 AND (exported_name=? OR name=? OR qualified_name=?))) ORDER BY CASE WHEN source_file=? THEN 0 ELSE 1 END,id LIMIT 1) IS NULL THEN 'No local symbol target matched exactly' ELSE NULL END)`);
547
547
  for (const r of rows) stmt.run(repoId, repoId, r.sourceFile, r.callerQualifiedName, repoId, r.sourceFile, r.calleeLocalName, r.calleeLocalName, r.sourceFile, r.calleeLocalName, r.calleeLocalName, r.calleeLocalName, r.sourceFile, r.calleeExpression, r.importSource, r.sourceFile, r.sourceLine, JSON.stringify(r.evidence), repoId, r.sourceFile, r.calleeLocalName, r.calleeLocalName, r.sourceFile, r.calleeLocalName, r.calleeLocalName, r.calleeLocalName, r.sourceFile);
548
- db.prepare("UPDATE symbol_calls SET status=CASE WHEN callee_symbol_id IS NULL THEN 'unresolved' ELSE 'resolved' END WHERE repo_id=?").run(repoId);
548
+ db.prepare("UPDATE symbol_calls SET status=CASE WHEN callee_symbol_id IS NULL THEN 'unresolved' ELSE 'resolved' END, unresolved_reason=CASE WHEN callee_symbol_id IS NULL THEN COALESCE(unresolved_reason,'No local symbol target matched exactly') ELSE NULL END WHERE repo_id=?").run(repoId);
549
549
  }
550
550
  function insertCalls(db, repoId, rows) {
551
551
  const stmt = db.prepare(
@@ -643,11 +643,31 @@ function isFunctionLike(node) {
643
643
  function exported(node) {
644
644
  return Boolean(ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export);
645
645
  }
646
+ function exportDeclarations(source) {
647
+ const exports = /* @__PURE__ */ new Map();
648
+ const visit = (node) => {
649
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
650
+ for (const el of node.exportClause.elements) exports.set((el.propertyName ?? el.name).text, el.name.text);
651
+ }
652
+ ts.forEachChild(node, visit);
653
+ };
654
+ visit(source);
655
+ return exports;
656
+ }
657
+ function isRelativeImport(value) {
658
+ return Boolean(value?.startsWith("."));
659
+ }
660
+ function isObjectFunction(node) {
661
+ return ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node);
662
+ }
663
+ var commonTerminalMembers = /* @__PURE__ */ new Set(["push", "includes", "find", "findIndex", "map", "filter", "reduce", "forEach", "some", "every", "toUpperCase", "toLowerCase", "trim", "split", "join", "get", "set", "has"]);
664
+ var loggerMembers = /* @__PURE__ */ new Set(["trace", "debug", "info", "warn", "error", "fatal", "log"]);
665
+ var globalObjects = /* @__PURE__ */ new Set(["JSON", "Object", "Array", "String", "Number", "Boolean", "Math", "Date", "Promise", "Reflect"]);
646
666
  function callName(expr) {
647
667
  if (ts.isIdentifier(expr)) return { expression: expr.text, local: expr.text };
648
668
  if (ts.isPropertyAccessExpression(expr)) {
649
669
  const left = expr.expression.getText();
650
- return { expression: expr.getText(), local: left === "this" ? void 0 : left, member: expr.name.text };
670
+ return { expression: expr.getText(), local: left === "this" ? void 0 : left, member: expr.name.text, receiver: left };
651
671
  }
652
672
  return { expression: expr.getText() };
653
673
  }
@@ -661,8 +681,13 @@ async function parseExecutableSymbols(repoPath, filePath) {
661
681
  const symbols = [];
662
682
  const calls = [];
663
683
  const imports = /* @__PURE__ */ new Map();
684
+ const exportNames = exportDeclarations(source);
685
+ const objectExports = /* @__PURE__ */ new Set();
664
686
  const addSymbol = (kind, localName, node, parentName, exportedName) => {
665
- symbols.push({ kind, localName, exportedName, qualifiedName: parentName ? `${parentName}.${localName}` : localName, sourceFile, startLine: lineOf(source, node.getStart(source)), endLine: lineOf(source, node.getEnd()), startOffset: node.getStart(source), endOffset: node.getEnd(), exported: exported(node) || Boolean(exportedName), importExportEvidence: exportedName ? { exportedName } : void 0 });
687
+ const declaredExportName = exportedName ?? exportNames.get(parentName ? parentName.split(".")[0] ?? localName : localName);
688
+ const qualifiedName = parentName ? `${parentName}.${localName}` : localName;
689
+ const objectExported = parentName ? objectExports.has(parentName.split(".")[0] ?? "") : false;
690
+ symbols.push({ kind, localName: kind === "object_method" ? qualifiedName : localName, exportedName: objectExported ? qualifiedName : declaredExportName, qualifiedName, sourceFile, startLine: lineOf(source, node.getStart(source)), endLine: lineOf(source, node.getEnd()), startOffset: node.getStart(source), endOffset: node.getEnd(), exported: exported(node) || Boolean(declaredExportName) || objectExported, importExportEvidence: declaredExportName ? { exportedName: declaredExportName, source: "export_declaration" } : objectExported ? { exportedName: qualifiedName, source: "exported_object_literal" } : void 0 });
666
691
  };
667
692
  const visitImports = (node) => {
668
693
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
@@ -686,20 +711,46 @@ async function parseExecutableSymbols(repoPath, filePath) {
686
711
  if (localName) addSymbol("method", localName, node, parentClass);
687
712
  } else if (ts.isFunctionDeclaration(node) && node.name) addSymbol("function", node.name.text, node, void 0, exported(node) ? node.name.text : void 0);
688
713
  else if (ts.isVariableStatement(node)) {
689
- for (const d of node.declarationList.declarations) if (d.initializer && isFunctionLike(d.initializer)) {
714
+ for (const d of node.declarationList.declarations) {
690
715
  const localName = nameOf(d.name);
691
- if (localName) addSymbol("function", localName, d.initializer, void 0, exported(node) ? localName : void 0);
716
+ if (!localName || !d.initializer) continue;
717
+ if (isFunctionLike(d.initializer)) addSymbol("function", localName, d.initializer, void 0, exported(node) ? localName : exportNames.get(localName));
718
+ if (ts.isObjectLiteralExpression(d.initializer)) {
719
+ if (exported(node) || exportNames.has(localName)) objectExports.add(localName);
720
+ for (const prop of d.initializer.properties) {
721
+ if (ts.isPropertyAssignment(prop) && isObjectFunction(prop.initializer)) {
722
+ const propName = nameOf(prop.name);
723
+ if (propName) addSymbol("object_method", propName, prop.initializer, localName);
724
+ } else if (ts.isMethodDeclaration(prop)) {
725
+ const propName = nameOf(prop.name);
726
+ if (propName) addSymbol("object_method", propName, prop, localName);
727
+ }
728
+ }
729
+ }
692
730
  }
693
731
  } else ts.forEachChild(node, (child) => visitSymbols(child, parentClass));
694
732
  };
695
733
  visitSymbols(source);
734
+ const localCallables = new Set(symbols.flatMap((sym) => [sym.localName, sym.qualifiedName]));
696
735
  const visitCalls = (node) => {
697
736
  if (ts.isCallExpression(node)) {
698
737
  const line = lineOf(source, node.getStart(source));
699
738
  const caller = nearest(symbols, line);
700
739
  if (caller) {
701
740
  const callee = callName(node.expression);
702
- calls.push({ callerQualifiedName: caller.qualifiedName, calleeExpression: callee.expression, calleeLocalName: callee.member ?? callee.local, receiverLocalName: callee.member ? callee.local : void 0, importSource: (callee.local ? imports.get(callee.local) : void 0) ?? (callee.member && callee.local ? imports.get(callee.local) : void 0), sourceFile, sourceLine: line, evidence: { relation: callee.importSource ? "imported" : callee.expression.startsWith("this.") ? "this_method" : "local" } });
741
+ const importSource = (callee.local ? imports.get(callee.local) : void 0) ?? (callee.member && callee.local ? imports.get(callee.local) : void 0);
742
+ const targetName = callee.expression.startsWith("this.") ? callee.member : callee.member && callee.local ? `${callee.local}.${callee.member}` : callee.local;
743
+ const className = caller.qualifiedName.includes(".") ? caller.qualifiedName.split(".")[0] : void 0;
744
+ const thisTarget = className && callee.member ? `${className}.${callee.member}` : void 0;
745
+ const loggerLike = callee.receiver?.endsWith(".logger") || callee.local === "logger" || (callee.expression.startsWith("this.logger.") && callee.member ? loggerMembers.has(callee.member) : false);
746
+ const terminalMember = callee.member ? commonTerminalMembers.has(callee.member) || loggerMembers.has(callee.member) : false;
747
+ const provenLocal = Boolean(targetName) && localCallables.has(String(targetName));
748
+ const provenThisMethod = Boolean(thisTarget && localCallables.has(thisTarget));
749
+ const provenRelativeImport = Boolean(isRelativeImport(importSource));
750
+ const globalLike = callee.local ? globalObjects.has(callee.local) : false;
751
+ const importedFromPackage = Boolean(importSource && !isRelativeImport(importSource));
752
+ const keep = Boolean(targetName) && !loggerLike && !globalLike && !importedFromPackage && (provenLocal || provenThisMethod || provenRelativeImport || !terminalMember && !callee.expression.startsWith("this."));
753
+ if (keep) calls.push({ callerQualifiedName: caller.qualifiedName, calleeExpression: callee.expression, calleeLocalName: provenThisMethod ? thisTarget : targetName, receiverLocalName: callee.member ? callee.local : void 0, importSource, sourceFile, sourceLine: line, evidence: { relation: importSource ? "relative_import" : provenThisMethod ? "indexed_this_method" : provenLocal ? "indexed_local_symbol" : "unresolved_actionable_property_call", caller: caller.qualifiedName, targetName: provenThisMethod ? thisTarget : targetName } });
703
754
  }
704
755
  }
705
756
  ts.forEachChild(node, visitCalls);
@@ -854,10 +905,14 @@ function location(evidence) {
854
905
  }
855
906
  return ":";
856
907
  }
908
+ function displayTarget(value, evidence) {
909
+ if (/^\d+$/.test(value) && evidence.parserWarning) return "Entity: unknown";
910
+ return value;
911
+ }
857
912
  function renderTraceTable(result) {
858
913
  const lines = ["Step Type From To Evidence"];
859
914
  for (const e of result.edges) {
860
- lines.push(`${String(e.step).padEnd(5)} ${e.type.padEnd(20)} ${e.from.slice(0, 34).padEnd(35)} ${e.to.slice(0, 35).padEnd(36)} ${location(e.evidence)}`);
915
+ lines.push(`${String(e.step).padEnd(5)} ${e.type.padEnd(20)} ${e.from.slice(0, 34).padEnd(35)} ${displayTarget(e.to, e.evidence).slice(0, 35).padEnd(36)} ${location(e.evidence)}`);
861
916
  }
862
917
  if (result.diagnostics.length > 0) lines.push("", "Diagnostics:", ...result.diagnostics.map((d) => `${String(d.severity ?? "info")} ${String(d.code ?? "diagnostic")} ${String(d.message ?? "")}`));
863
918
  return `${lines.join("\n")}
@@ -877,11 +932,17 @@ function renderTraceJson(trace2) {
877
932
  function safe(value) {
878
933
  return value.replace(/[^\w-]/g, "_").slice(0, 60);
879
934
  }
935
+ function label(trace2, idOrLabel) {
936
+ const edge = trace2.edges.find((item) => item.to === idOrLabel);
937
+ if (/^\d+$/.test(idOrLabel) && edge?.evidence.parserWarning) return "Entity: unknown";
938
+ const node = trace2.nodes.find((item) => item.id === idOrLabel || item.label === idOrLabel);
939
+ return String(node?.label ?? idOrLabel);
940
+ }
880
941
  function renderMermaid(trace2) {
881
942
  const lines = ["flowchart TD"];
882
943
  for (const e of trace2.edges)
883
944
  lines.push(
884
- ` ${safe(e.from)}["${e.from}"] -->|${e.type}| ${safe(e.to)}["${e.to}"]`
945
+ ` ${safe(e.from)}["${label(trace2, e.from)}"] -->|${e.type}| ${safe(e.to)}["${label(trace2, e.to)}"]`
885
946
  );
886
947
  return `${lines.join("\n")}
887
948
  `;
@@ -941,6 +1002,19 @@ async function withReadOnlyWorkspace(workspace, fn) {
941
1002
  db.close();
942
1003
  }
943
1004
  }
1005
+ function localServiceDiagnostics(db, strict) {
1006
+ const rows = db.prepare(`SELECT e.status status,e.unresolved_reason reason,e.evidence_json evidenceJson FROM graph_edges e JOIN outbound_calls c ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER) WHERE c.call_type='local_service_call'`).all();
1007
+ const implementationContext = rows.filter((row) => row.status === "resolved" && String(row.evidenceJson ?? "").includes("implementation_context_caller_ownership")).length;
1008
+ const withoutOwnership = rows.filter((row) => row.reason === "local_service_candidate_without_caller_ownership" || String(row.evidenceJson ?? "").includes("local_service_candidate_without_caller_ownership")).length;
1009
+ const unresolved = rows.filter((row) => row.status === "unresolved").length;
1010
+ const outsideScope = rows.filter((row) => row.status === "unresolved" && String(row.evidenceJson ?? "").includes("candidateCount")).length;
1011
+ const out = [];
1012
+ if (withoutOwnership > 0) out.push({ severity: "warning", code: "local_service_candidate_without_caller_ownership", message: `Local service calls have operation candidates but no caller ownership evidence: ${withoutOwnership}` });
1013
+ if (outsideScope > 0) out.push({ severity: "warning", code: "local_service_candidates_outside_local_scope", message: `Local service calls found candidates outside same-repository scope: ${outsideScope}` });
1014
+ if (strict && unresolved > 0) out.push({ severity: "warning", code: "local_service_calls_unresolved", message: `Unresolved local service calls: ${unresolved}` });
1015
+ if (strict && implementationContext > 0) out.push({ severity: "info", code: "local_service_calls_resolved_by_implementation_context", message: `Local service calls resolved by implementation-context ownership: ${implementationContext}` });
1016
+ return out;
1017
+ }
944
1018
  function createProgram() {
945
1019
  const program = new Command();
946
1020
  program.name("service-flow").description(
@@ -1138,8 +1212,8 @@ function createProgram() {
1138
1212
  SELECT 'error','local_service_accessor_misclassified','Entity accessor calls were indexed as /entities operations',source_file,source_line
1139
1213
  FROM outbound_calls WHERE call_type='local_service_call' AND operation_path_expr='/entities' AND (? OR 1)
1140
1214
  UNION ALL
1141
- SELECT CASE WHEN ? THEN 'warning' ELSE 'error' END,'outbound_calls_without_source_symbol','Outbound calls lack source symbol ownership: ' || COUNT(*),NULL,NULL
1142
- FROM outbound_calls WHERE source_symbol_id IS NULL HAVING COUNT(*) >= CASE WHEN ? THEN 1 ELSE 10 END
1215
+ SELECT 'warning','outbound_calls_without_source_symbol','Outbound calls lack source symbol ownership: ' || COUNT(*),NULL,NULL
1216
+ FROM outbound_calls WHERE source_symbol_id IS NULL AND ? HAVING COUNT(*) >= 1
1143
1217
  UNION ALL
1144
1218
  SELECT 'warning','trace_scope_fell_back_to_file','Trace may fall back to source-file scope for calls without symbols',NULL,NULL
1145
1219
  WHERE EXISTS (SELECT 1 FROM outbound_calls WHERE source_symbol_id IS NULL) AND ?
@@ -1149,8 +1223,9 @@ function createProgram() {
1149
1223
  UNION ALL
1150
1224
  SELECT 'warning','index_run_abandoned','Index run ' || id || ' started at ' || started_at || ' is still running after the 60 minute abandonment threshold',NULL,NULL
1151
1225
  FROM index_runs WHERE status='running' AND datetime(started_at) < datetime('now','-60 minutes')`
1152
- ).all(Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict));
1153
- const allDiagnostics = [...diagnostics, ...health];
1226
+ ).all(Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict), Boolean(opts.strict));
1227
+ const localServiceHealth = localServiceDiagnostics(db, Boolean(opts.strict));
1228
+ const allDiagnostics = [...diagnostics, ...health, ...localServiceHealth];
1154
1229
  process.stdout.write(
1155
1230
  allDiagnostics.length ? renderJson(allDiagnostics) : `${pc.green("No diagnostics recorded")}
1156
1231
  `