@saptools/service-flow 0.1.3 → 0.1.5

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.
@@ -24,6 +24,27 @@ async function discoverRepositories(rootPath, ignore) {
24
24
  const root = path2.resolve(rootPath);
25
25
  const ignored = new Set(ignore);
26
26
  const found = [];
27
+ async function isRealGitMarker(dir) {
28
+ const gitPath = path2.join(dir, ".git");
29
+ try {
30
+ const st = await fs.stat(gitPath);
31
+ if (st.isDirectory()) {
32
+ const children = await fs.readdir(gitPath);
33
+ return children.includes("HEAD") || children.includes("config");
34
+ }
35
+ if (st.isFile()) {
36
+ const text = await fs.readFile(gitPath, "utf8");
37
+ return text.trimStart().startsWith("gitdir:");
38
+ }
39
+ } catch {
40
+ }
41
+ try {
42
+ const fixture = await fs.stat(path2.join(dir, ".git-fixture"));
43
+ return fixture.isFile() || fixture.isDirectory();
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
27
48
  async function walk(dir) {
28
49
  const rel = relativePath(root, dir);
29
50
  if (rel !== "." && rel.split("/").some((part) => ignored.has(part))) return;
@@ -33,14 +54,14 @@ async function discoverRepositories(rootPath, ignore) {
33
54
  } catch {
34
55
  return;
35
56
  }
36
- if (entries.some((e) => e.name === ".git" || e.name === ".git-fixture")) {
57
+ const hasMarker = entries.some((e) => e.name === ".git" || e.name === ".git-fixture");
58
+ if (hasMarker && await isRealGitMarker(dir)) {
37
59
  found.push({
38
60
  name: path2.basename(dir),
39
61
  absolutePath: dir,
40
62
  relativePath: relativePath(root, dir),
41
63
  isGitRepo: true
42
64
  });
43
- return;
44
65
  }
45
66
  for (const entry of entries)
46
67
  if (entry.isDirectory() && !ignore.includes(entry.name))
@@ -507,13 +528,21 @@ function connectFactFromCall(call) {
507
528
  return void 0;
508
529
  const first = call.arguments[0];
509
530
  if (!first) return void 0;
531
+ const second = call.arguments[1];
532
+ const objectArg = ts3.isObjectLiteralExpression(first) ? first : second && ts3.isObjectLiteralExpression(second) ? second : void 0;
533
+ let alias;
534
+ let aliasExpr;
510
535
  if (ts3.isStringLiteralLike(first) || ts3.isNoSubstitutionTemplateLiteral(first))
536
+ alias = first.text;
537
+ else if (!ts3.isObjectLiteralExpression(first))
538
+ aliasExpr = stringValue(first);
539
+ if ((ts3.isStringLiteralLike(first) || ts3.isNoSubstitutionTemplateLiteral(first)) && !objectArg)
511
540
  return { alias: first.text, isDynamic: false, placeholders: [] };
512
- if (!ts3.isObjectLiteralExpression(first))
541
+ if (!objectArg && aliasExpr)
513
542
  return {
514
- servicePathExpr: first.getText(),
543
+ aliasExpr,
515
544
  isDynamic: true,
516
- placeholders: []
545
+ placeholders: placeholders(aliasExpr)
517
546
  };
518
547
  let destinationExpr;
519
548
  let servicePathExpr;
@@ -529,12 +558,15 @@ function connectFactFromCall(call) {
529
558
  visitObject(prop.initializer);
530
559
  }
531
560
  }
532
- visitObject(first);
561
+ if (objectArg) visitObject(objectArg);
533
562
  const ph = [
563
+ ...placeholders(aliasExpr ?? alias),
534
564
  ...placeholders(destinationExpr),
535
565
  ...placeholders(servicePathExpr)
536
566
  ];
537
567
  return {
568
+ alias,
569
+ aliasExpr,
538
570
  destinationExpr,
539
571
  servicePathExpr,
540
572
  isDynamic: ph.length > 0 || !destinationExpr && !servicePathExpr,
@@ -577,7 +609,9 @@ async function readSource(abs) {
577
609
  }
578
610
  async function resolveImport(repoPath, fromFile, spec) {
579
611
  if (!spec.startsWith(".")) return void 0;
580
- const base = path8.resolve(repoPath, path8.dirname(fromFile), spec);
612
+ const rawBase = path8.resolve(repoPath, path8.dirname(fromFile), spec);
613
+ const parsed = path8.parse(rawBase);
614
+ const base = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"].includes(parsed.ext) ? path8.join(parsed.dir, parsed.name) : rawBase;
581
615
  for (const candidate of [
582
616
  base,
583
617
  `${base}.ts`,
@@ -622,15 +656,33 @@ async function importsFor(repoPath, filePath, sf) {
622
656
  }
623
657
  return imports;
624
658
  }
659
+ function exportedLocalNames(sf) {
660
+ const exports = /* @__PURE__ */ new Map();
661
+ for (const stmt of sf.statements) {
662
+ const direct = ts3.canHaveModifiers(stmt) ? ts3.getModifiers(stmt)?.some(
663
+ (m) => m.kind === ts3.SyntaxKind.ExportKeyword
664
+ ) ?? false : false;
665
+ if (direct && ts3.isFunctionDeclaration(stmt) && stmt.name)
666
+ exports.set(stmt.name.text, stmt.name.text);
667
+ if (direct && ts3.isVariableStatement(stmt)) {
668
+ for (const decl of stmt.declarationList.declarations)
669
+ if (ts3.isIdentifier(decl.name)) exports.set(decl.name.text, decl.name.text);
670
+ }
671
+ if (!ts3.isExportDeclaration(stmt) || !stmt.exportClause) continue;
672
+ if (!ts3.isNamedExports(stmt.exportClause)) continue;
673
+ for (const el of stmt.exportClause.elements)
674
+ exports.set(el.name.text, el.propertyName?.text ?? el.name.text);
675
+ }
676
+ return exports;
677
+ }
625
678
  async function helperBindings(repoPath, filePath) {
626
679
  const sf = await readSource(path8.join(repoPath, filePath));
627
680
  if (!sf) return [];
628
681
  const sourceFileAst = sf;
629
682
  const out = [];
683
+ const exportedLocals = exportedLocalNames(sf);
684
+ const factsByLocal = /* @__PURE__ */ new Map();
630
685
  for (const stmt of sf.statements) {
631
- const exported = ts3.canHaveModifiers(stmt) ? ts3.getModifiers(stmt)?.some(
632
- (m) => m.kind === ts3.SyntaxKind.ExportKeyword
633
- ) ?? false : false;
634
686
  if (ts3.isFunctionDeclaration(stmt) && stmt.name) {
635
687
  let fact;
636
688
  stmt.forEachChild(function visit(node) {
@@ -638,27 +690,29 @@ async function helperBindings(repoPath, filePath) {
638
690
  fact = findConnectInExpression(node.expression);
639
691
  if (!fact) ts3.forEachChild(node, visit);
640
692
  });
641
- if (fact && exported)
642
- out.push({
643
- ...fact,
644
- exportedName: stmt.name.text,
645
- sourceFile: normalizePath(filePath),
646
- sourceLine: lineOf4(sf, stmt)
647
- });
693
+ if (fact) factsByLocal.set(stmt.name.text, { ...fact, sourceLine: lineOf4(sf, stmt) });
648
694
  }
649
695
  if (ts3.isVariableStatement(stmt))
650
696
  for (const decl of stmt.declarationList.declarations) {
651
697
  if (!ts3.isIdentifier(decl.name) || !decl.initializer) continue;
652
698
  const fact = findConnectInExpression(decl.initializer);
653
- if (fact && exported)
654
- out.push({
699
+ if (fact)
700
+ factsByLocal.set(decl.name.text, {
655
701
  ...fact,
656
- exportedName: decl.name.text,
657
- sourceFile: normalizePath(filePath),
658
702
  sourceLine: lineOf4(sourceFileAst, decl)
659
703
  });
660
704
  }
661
705
  }
706
+ for (const [exportedName, localName] of exportedLocals) {
707
+ const fact = factsByLocal.get(localName);
708
+ if (fact)
709
+ out.push({
710
+ ...fact,
711
+ exportedName,
712
+ sourceFile: normalizePath(filePath),
713
+ sourceLine: fact.sourceLine
714
+ });
715
+ }
662
716
  return out;
663
717
  }
664
718
  async function parseServiceBindings(repoPath, filePath) {
@@ -668,6 +722,7 @@ async function parseServiceBindings(repoPath, filePath) {
668
722
  const out = [];
669
723
  const imports = await importsFor(repoPath, filePath, sf);
670
724
  const helperCache = /* @__PURE__ */ new Map();
725
+ const classHelpers = collectClassHelpers(sourceFileAst);
671
726
  async function importedHelper(localName) {
672
727
  const imp = imports.find((i) => i.localName === localName && i.sourceFile);
673
728
  if (!imp?.sourceFile) return void 0;
@@ -697,6 +752,7 @@ async function parseServiceBindings(repoPath, filePath) {
697
752
  out.push({
698
753
  variableName: decl.name.text,
699
754
  alias: resolved.helper.alias,
755
+ aliasExpr: resolved.helper.aliasExpr,
700
756
  destinationExpr: resolved.helper.destinationExpr,
701
757
  servicePathExpr: resolved.helper.servicePathExpr,
702
758
  isDynamic: resolved.helper.isDynamic,
@@ -716,15 +772,105 @@ async function parseServiceBindings(repoPath, filePath) {
716
772
  });
717
773
  }
718
774
  }
775
+ function recordDestructuredClassHelper(decl) {
776
+ if (!ts3.isObjectBindingPattern(decl.name) || !decl.initializer) return;
777
+ const call = unwrapCall(decl.initializer);
778
+ if (!call || !ts3.isPropertyAccessExpression(call.expression)) return;
779
+ const target = call.expression;
780
+ if (target.expression.kind !== ts3.SyntaxKind.ThisKeyword) return;
781
+ for (const el of decl.name.elements) {
782
+ if (!ts3.isIdentifier(el.name)) continue;
783
+ const propertyName = el.propertyName && ts3.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
784
+ const helper = classHelpers.find(
785
+ (h) => h.helperName === target.name.text && h.propertyName === propertyName
786
+ );
787
+ if (!helper) continue;
788
+ out.push({
789
+ variableName: el.name.text,
790
+ ...helper.fact,
791
+ sourceFile: normalizePath(filePath),
792
+ sourceLine: lineOf4(sourceFileAst, decl),
793
+ helperChain: [
794
+ {
795
+ callerVariable: el.name.text,
796
+ className: helper.className,
797
+ classHelper: helper.helperName,
798
+ returnedProperty: helper.propertyName,
799
+ helperVariable: helper.variableName,
800
+ helperSourceFile: normalizePath(filePath),
801
+ helperSourceLine: helper.sourceLine
802
+ }
803
+ ]
804
+ });
805
+ }
806
+ }
719
807
  const declarations = [];
720
808
  function collectDeclarations(node) {
721
809
  if (ts3.isVariableDeclaration(node)) declarations.push(node);
722
810
  ts3.forEachChild(node, collectDeclarations);
723
811
  }
724
812
  collectDeclarations(sourceFileAst);
725
- for (const decl of declarations) await recordVariable(decl);
813
+ for (const decl of declarations) {
814
+ recordDestructuredClassHelper(decl);
815
+ await recordVariable(decl);
816
+ }
726
817
  return out;
727
818
  }
819
+ function collectClassHelpers(sf) {
820
+ const helpers = [];
821
+ for (const stmt of sf.statements) {
822
+ if (!ts3.isClassDeclaration(stmt) || !stmt.name) continue;
823
+ for (const member of stmt.members) {
824
+ let visit2 = function(node) {
825
+ if (ts3.isVariableDeclaration(node) && ts3.isIdentifier(node.name) && node.initializer) {
826
+ const fact = findConnectInExpression(node.initializer);
827
+ if (fact) bindings.set(node.name.text, fact);
828
+ }
829
+ if (ts3.isReturnStatement(node) && node.expression && ts3.isObjectLiteralExpression(node.expression)) {
830
+ for (const prop of node.expression.properties) {
831
+ if (ts3.isShorthandPropertyAssignment(prop)) {
832
+ const fact = bindings.get(prop.name.text);
833
+ if (fact)
834
+ helpers.push({
835
+ className,
836
+ helperName,
837
+ propertyName: prop.name.text,
838
+ variableName: prop.name.text,
839
+ fact,
840
+ sourceLine: lineOf4(sf, prop)
841
+ });
842
+ }
843
+ if (ts3.isPropertyAssignment(prop) && ts3.isIdentifier(prop.initializer)) {
844
+ const propertyName = ts3.isIdentifier(prop.name) || ts3.isStringLiteralLike(prop.name) ? prop.name.text : void 0;
845
+ const fact = propertyName ? bindings.get(prop.initializer.text) : void 0;
846
+ if (propertyName && fact)
847
+ helpers.push({
848
+ className,
849
+ helperName,
850
+ propertyName,
851
+ variableName: prop.initializer.text,
852
+ fact,
853
+ sourceLine: lineOf4(sf, prop)
854
+ });
855
+ }
856
+ }
857
+ }
858
+ ts3.forEachChild(node, visit2);
859
+ };
860
+ var visit = visit2;
861
+ if (!ts3.isPropertyDeclaration(member) || !member.initializer) continue;
862
+ if (!ts3.isIdentifier(member.name)) continue;
863
+ const className = stmt.name.text;
864
+ const helperName = member.name.text;
865
+ const initializer = member.initializer;
866
+ if (!ts3.isArrowFunction(initializer) && !ts3.isFunctionExpression(initializer))
867
+ continue;
868
+ const bindings = /* @__PURE__ */ new Map();
869
+ visit2(initializer);
870
+ }
871
+ }
872
+ return helpers;
873
+ }
728
874
 
729
875
  // src/linker/dynamic-edge-resolver.ts
730
876
  function applyVariables(template, vars) {
@@ -749,6 +895,13 @@ function rows(db, operationPath, workspaceId) {
749
895
  );
750
896
  }
751
897
  function resolveOperation(db, signals, workspaceId) {
898
+ const missing = [signals.servicePath, signals.alias, signals.destination, signals.operationPath].flatMap((value) => [...(value ?? "").matchAll(/\$\{\s*(\w+)\s*\}/g)].map((match) => match[1] ?? "")).filter(Boolean);
899
+ if (missing.length > 0)
900
+ return {
901
+ status: "dynamic",
902
+ candidates: signals.operationPath ? rows(db, signals.operationPath, workspaceId) : [],
903
+ reasons: [...new Set(missing)].map((name) => `missing_variable:${name}`)
904
+ };
752
905
  if (!signals.operationPath)
753
906
  return {
754
907
  status: "unresolved",
@@ -800,7 +953,7 @@ function resolveOperation(db, signals, workspaceId) {
800
953
  candidates,
801
954
  reasons: ["operation_path_only_has_no_strong_target_signal"]
802
955
  };
803
- if (best && best.score >= 0.9 && (!second || best.score - second.score >= 0.25))
956
+ if (best && best.score >= 0.9 && best.servicePath === signals.servicePath && (best.operationPath === signals.operationPath || best.operationName === signals.operationPath.replace(/^\//, "")) && (!second || best.score - second.score >= 0.25))
804
957
  return {
805
958
  status: "resolved",
806
959
  target: best,
@@ -847,90 +1000,105 @@ function linkHelperPackages(db, workspaceId) {
847
1000
 
848
1001
  // src/linker/cross-repo-linker.ts
849
1002
  function linkWorkspace(db, workspaceId, vars = {}) {
850
- db.prepare("DELETE FROM graph_edges WHERE workspace_id=?").run(workspaceId);
851
- let edges = linkHelperPackages(db, workspaceId);
852
- let unresolved = 0;
853
- const calls = db.prepare(
854
- `SELECT c.*,r.name repoName,b.alias,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`
855
- ).all(workspaceId);
856
- for (const call of calls) {
857
- const callType = String(call.call_type);
858
- const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
859
- const servicePath = applyVariables(
860
- call.servicePathExpr ?? call.requireServicePath,
861
- vars
862
- );
863
- const destination = call.destinationExpr ?? call.requireDestination;
864
- const isDynamic = Boolean(Number(call.isDynamic ?? 0));
865
- const resolution = callType.startsWith("remote") ? resolveOperation(
866
- db,
867
- {
1003
+ return db.transaction(() => {
1004
+ db.prepare("DELETE FROM graph_edges WHERE workspace_id=?").run(workspaceId);
1005
+ let edges = linkHelperPackages(db, workspaceId);
1006
+ let unresolved = 0;
1007
+ let resolvedCount = 0;
1008
+ let ambiguousCount = 0;
1009
+ let dynamicCount = 0;
1010
+ let terminalCount = 0;
1011
+ const calls = db.prepare(
1012
+ `SELECT c.*,r.name repoName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`
1013
+ ).all(workspaceId);
1014
+ for (const call of calls) {
1015
+ const callType = String(call.call_type);
1016
+ const op = applyVariables(String(call.operation_path_expr ?? ""), vars);
1017
+ const servicePath = applyVariables(
1018
+ call.servicePathExpr ?? call.requireServicePath,
1019
+ vars
1020
+ );
1021
+ const destination = call.destinationExpr ?? call.requireDestination;
1022
+ const isDynamic = Boolean(Number(call.isDynamic ?? 0));
1023
+ const resolution = callType.startsWith("remote") ? resolveOperation(
1024
+ db,
1025
+ {
1026
+ servicePath,
1027
+ operationPath: op,
1028
+ alias: applyVariables(call.aliasExpr ?? call.alias, vars),
1029
+ destination: applyVariables(destination, vars),
1030
+ isDynamic,
1031
+ hasExplicitOverride: Object.keys(vars).length > 0
1032
+ },
1033
+ workspaceId
1034
+ ) : { status: "unresolved", candidates: [], reasons: [] };
1035
+ const target = resolution.target;
1036
+ const evidence = {
1037
+ sourceFile: call.source_file,
1038
+ sourceLine: call.source_line,
1039
+ file: call.source_file,
1040
+ line: call.source_line,
1041
+ repo: call.repoName,
1042
+ serviceAlias: call.alias,
1043
+ serviceAliasExpr: call.aliasExpr,
1044
+ destination: applyVariables(destination, vars),
868
1045
  servicePath,
869
1046
  operationPath: op,
870
- alias: call.alias,
871
- destination,
872
- isDynamic,
873
- hasExplicitOverride: Object.keys(vars).length > 0
874
- },
875
- workspaceId
876
- ) : { status: "unresolved", candidates: [], reasons: [] };
877
- const target = resolution.target;
878
- const evidence = {
879
- sourceFile: call.source_file,
880
- sourceLine: call.source_line,
881
- file: call.source_file,
882
- line: call.source_line,
883
- repo: call.repoName,
884
- serviceAlias: call.alias,
885
- destination,
886
- servicePath,
887
- operationPath: op,
888
- targetRepo: target?.repoName,
889
- targetOperation: target?.operationName,
890
- helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0,
891
- candidates: resolution.candidates,
892
- candidateCount: resolution.candidates.length,
893
- resolutionStatus: resolution.status,
894
- resolutionReasons: resolution.reasons
895
- };
896
- if (target) {
897
- db.prepare(
898
- "INSERT INTO graph_edges(workspace_id,edge_type,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic) VALUES(?,?,?,?,?,?,?,?,?)"
899
- ).run(
900
- workspaceId,
901
- "REMOTE_CALL_RESOLVES_TO_OPERATION",
902
- "call",
903
- String(call.id),
904
- "operation",
905
- String(target.operationId),
906
- target.score,
907
- JSON.stringify(evidence),
908
- isDynamic ? 1 : 0
909
- );
910
- edges += 1;
911
- } else {
912
- 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";
913
- db.prepare(
914
- "INSERT INTO graph_edges(workspace_id,edge_type,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason) VALUES(?,?,?,?,?,?,?,?,?,?)"
915
- ).run(
916
- workspaceId,
917
- edgeType,
918
- "call",
919
- String(call.id),
920
- callType.startsWith("async_") ? "event" : "external",
921
- String(call.event_name_expr ?? call.query_entity ?? op ?? call.id),
922
- Number(call.confidence ?? 0.2),
923
- JSON.stringify(evidence),
924
- isDynamic || resolution.status === "dynamic" ? 1 : 0,
925
- String(
926
- call.unresolved_reason ?? (resolution.status === "ambiguous" ? "Ambiguous operation candidates require a strong service signal" : resolution.status === "dynamic" ? "Dynamic target requires runtime variable overrides" : "No indexed target operation matched")
927
- )
928
- );
929
- edges += 1;
930
- unresolved += edgeType === "UNRESOLVED_EDGE" ? 1 : 0;
1047
+ targetRepo: target?.repoName,
1048
+ targetOperation: target?.operationName,
1049
+ helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0,
1050
+ candidates: resolution.candidates,
1051
+ candidateCount: resolution.candidates.length,
1052
+ resolutionStatus: resolution.status,
1053
+ resolutionReasons: resolution.reasons
1054
+ };
1055
+ if (target) {
1056
+ db.prepare(
1057
+ "INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic) VALUES(?,?,?,?,?,?,?,?,?,?)"
1058
+ ).run(
1059
+ workspaceId,
1060
+ "REMOTE_CALL_RESOLVES_TO_OPERATION",
1061
+ "resolved",
1062
+ "call",
1063
+ String(call.id),
1064
+ "operation",
1065
+ String(target.operationId),
1066
+ target.score,
1067
+ JSON.stringify(evidence),
1068
+ isDynamic ? 1 : 0
1069
+ );
1070
+ edges += 1;
1071
+ resolvedCount += 1;
1072
+ } else {
1073
+ 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";
1074
+ const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1075
+ const unresolvedReason = status === "terminal" ? null : String(
1076
+ 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")
1077
+ );
1078
+ db.prepare(
1079
+ "INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)"
1080
+ ).run(
1081
+ workspaceId,
1082
+ edgeType,
1083
+ status,
1084
+ "call",
1085
+ String(call.id),
1086
+ callType.startsWith("async_") ? "event" : "external",
1087
+ String(call.event_name_expr ?? call.query_entity ?? op ?? call.id),
1088
+ Number(call.confidence ?? 0.2),
1089
+ JSON.stringify(evidence),
1090
+ isDynamic || resolution.status === "dynamic" ? 1 : 0,
1091
+ unresolvedReason
1092
+ );
1093
+ edges += 1;
1094
+ unresolved += status === "unresolved" ? 1 : 0;
1095
+ ambiguousCount += status === "ambiguous" ? 1 : 0;
1096
+ dynamicCount += status === "dynamic" ? 1 : 0;
1097
+ terminalCount += status === "terminal" ? 1 : 0;
1098
+ }
931
1099
  }
932
- }
933
- return { edgeCount: edges, unresolvedCount: unresolved };
1100
+ return { edgeCount: edges, unresolvedCount: unresolved, resolvedCount, ambiguousCount, dynamicCount, terminalCount };
1101
+ });
934
1102
  }
935
1103
 
936
1104
  // src/trace/trace-engine.ts
@@ -949,7 +1117,8 @@ function sourceFilesForStart(db, repoId, start) {
949
1117
  `SELECT DISTINCT hc.source_file sourceFile
950
1118
  FROM handler_classes hc LEFT JOIN handler_methods hm ON hm.handler_class_id=hc.id
951
1119
  WHERE (? IS NULL OR hc.repo_id=?) AND (? IS NULL OR hc.class_name=? OR hm.method_name=?)
952
- AND (? IS NULL OR hm.decorator_value=? OR hm.method_name=?)`
1120
+ AND (? IS NULL OR hm.decorator_value=? OR hm.method_name=?)
1121
+ AND (? IS NULL OR EXISTS (SELECT 1 FROM cds_services s JOIN cds_operations o ON o.service_id=s.id WHERE s.repo_id=hc.repo_id AND s.service_path=? AND (? IS NULL OR o.operation_path=? OR o.operation_name=? OR hm.decorator_value=? OR hm.method_name=?)))`
953
1122
  ).all(
954
1123
  repoId,
955
1124
  repoId,
@@ -958,8 +1127,16 @@ function sourceFilesForStart(db, repoId, start) {
958
1127
  handler,
959
1128
  operation,
960
1129
  operation,
1130
+ operation,
1131
+ start.servicePath,
1132
+ start.servicePath,
1133
+ operation,
1134
+ operation,
1135
+ operation,
1136
+ operation,
961
1137
  operation
962
1138
  );
1139
+ if (start.servicePath && rows2.length === 0) return void 0;
963
1140
  if (rows2.length === 0) return void 0;
964
1141
  return new Set(rows2.map((row) => row.sourceFile).filter(Boolean));
965
1142
  }
@@ -1003,7 +1180,7 @@ function graphForCalls(db, callIds) {
1003
1180
  if (callIds.length === 0) return map;
1004
1181
  const rows2 = db.prepare(
1005
1182
  `SELECT * FROM graph_edges WHERE from_kind='call' AND from_id IN (${callIds.map(() => "?").join(",")}) ORDER BY id`
1006
- ).all(...callIds);
1183
+ ).all(...callIds.map((id) => String(id)));
1007
1184
  for (const row of rows2) {
1008
1185
  const id = Number(row.from_id);
1009
1186
  map.set(id, [...map.get(id) ?? [], row]);
@@ -1035,6 +1212,30 @@ function evidenceWithRuntimeVariables(evidence, vars) {
1035
1212
  } : void 0
1036
1213
  };
1037
1214
  }
1215
+ function operationNode(db, operationId) {
1216
+ const row = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_type operationType,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.id serviceId,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,r.id repoId,r.name repoName FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operationId);
1217
+ if (!row) return void 0;
1218
+ return { id: `operation:${operationId}`, kind: "operation", label: `${String(row.repoName)}:${String(row.servicePath)}${String(row.operationPath)}`, ...row };
1219
+ }
1220
+ function workspaceIdForCall(db, callId) {
1221
+ return db.prepare("SELECT r.workspace_id workspaceId FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE c.id=?").get(callId)?.workspaceId;
1222
+ }
1223
+ function runtimeResolution(db, row, evidence, vars) {
1224
+ if (!vars || Object.keys(vars).length === 0) return { row, evidence, unresolvedReason: row.unresolved_reason };
1225
+ const nextEvidence = evidenceWithRuntimeVariables(evidence, vars);
1226
+ const servicePath = typeof nextEvidence.servicePath === "string" ? nextEvidence.servicePath : void 0;
1227
+ const operationPath = typeof nextEvidence.operationPath === "string" ? nextEvidence.operationPath : void 0;
1228
+ const alias = typeof nextEvidence.serviceAliasExpr === "string" ? applyVariables(nextEvidence.serviceAliasExpr, vars) : typeof nextEvidence.serviceAlias === "string" ? applyVariables(nextEvidence.serviceAlias, vars) : void 0;
1229
+ const destination = typeof nextEvidence.destination === "string" ? applyVariables(nextEvidence.destination, vars) : void 0;
1230
+ const resolution = resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: Boolean(row.status === "dynamic" || nextEvidence.resolutionStatus === "dynamic") }, workspaceIdForCall(db, row.from_id));
1231
+ nextEvidence.runtimeResolutionStatus = resolution.status;
1232
+ nextEvidence.runtimeResolutionReasons = resolution.reasons;
1233
+ if (resolution.target) {
1234
+ nextEvidence.runtimeResolvedCandidate = resolution.target;
1235
+ return { row: { ...row, to_kind: "operation", to_id: String(resolution.target.operationId), unresolved_reason: void 0, confidence: resolution.target.score }, evidence: nextEvidence, target: resolution.target };
1236
+ }
1237
+ return { row, evidence: nextEvidence, unresolvedReason: resolution.status === "dynamic" ? `Dynamic target is missing runtime variables: ${resolution.reasons.join(", ")}` : resolution.status === "ambiguous" ? "Ambiguous runtime operation candidates" : "No runtime operation candidate matched substituted service and operation path" };
1238
+ }
1038
1239
  function edgeTarget(row, evidence) {
1039
1240
  const runtimeCandidate = evidence.runtimeResolvedCandidate;
1040
1241
  if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath)
@@ -1095,33 +1296,32 @@ function trace(db, start, options) {
1095
1296
  const rawEvidence = JSON.parse(
1096
1297
  String(row.evidence_json || "{}")
1097
1298
  );
1098
- const evidence = evidenceWithRuntimeVariables(
1099
- rawEvidence,
1100
- options.vars
1101
- );
1102
- const targetNode = `${row.to_kind}:${row.to_id}`;
1103
- nodes.set(targetNode, {
1299
+ const effective = runtimeResolution(db, row, rawEvidence, options.vars);
1300
+ const evidence = effective.evidence;
1301
+ const effectiveRow = effective.row;
1302
+ const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
1303
+ const opNode = effectiveRow.to_kind === "operation" ? operationNode(db, effectiveRow.to_id) : void 0;
1304
+ nodes.set(targetNode, opNode ?? {
1104
1305
  id: targetNode,
1105
- kind: row.to_kind,
1106
- label: row.to_id,
1107
- ...evidence
1306
+ kind: effectiveRow.to_kind,
1307
+ label: effectiveRow.to_id
1108
1308
  });
1109
- const to = edgeTarget(row, evidence);
1309
+ const to = edgeTarget(effectiveRow, evidence);
1110
1310
  edges.push({
1111
1311
  step: current.depth,
1112
1312
  type: String(call.call_type),
1113
1313
  from: `${call.repoName}:${call.source_file}`,
1114
1314
  to,
1115
1315
  evidence,
1116
- confidence: Number(row.confidence ?? call.confidence),
1117
- unresolvedReason: row.unresolved_reason
1316
+ confidence: Number(effectiveRow.confidence ?? call.confidence),
1317
+ unresolvedReason: effective.unresolvedReason
1118
1318
  });
1119
- if (row.to_kind === "operation" && current.depth < maxDepth) {
1120
- const files = handlerFilesForOperation(db, row.to_id);
1319
+ if (effectiveRow.to_kind === "operation" && current.depth < maxDepth) {
1320
+ const files = handlerFilesForOperation(db, effectiveRow.to_id);
1121
1321
  if (files.size > 0) {
1122
1322
  const targetRepoId = db.prepare(
1123
1323
  "SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?"
1124
- ).get(row.to_id)?.repoId;
1324
+ ).get(effectiveRow.to_id)?.repoId;
1125
1325
  const nextKey = `${targetRepoId ?? "*"}:${[...files].sort().join(",")}`;
1126
1326
  if (seenScopes.has(nextKey))
1127
1327
  edges.push({
@@ -1163,4 +1363,4 @@ export {
1163
1363
  linkWorkspace,
1164
1364
  trace
1165
1365
  };
1166
- //# sourceMappingURL=chunk-SIKIGT42.js.map
1366
+ //# sourceMappingURL=chunk-I33ASGX3.js.map