@saptools/service-flow 0.1.20 → 0.1.22

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.22
4
+
5
+ - Propagate conservative same-file identity aliases of connected service clients, including typed, `as`, `satisfies`, helper-returned, and transitive aliases, while preserving `.tx()` alias evidence.
6
+ - Link outbound `send(...)` calls through identity aliases to the closest same-file binding row so destination, service-path, placeholder, and helper-chain evidence remains available.
7
+ - Add narrow same-file wrapper literal-path propagation for wrappers that pass a client parameter directly to `send({ path })`; dynamic wrapper paths remain semantic dynamic targets with parser warnings.
8
+ - Improve trace-time contextual implementation selection evidence and suppress stale ambiguous unresolved reasons when a unique contextual handler is selected.
9
+ - Expand `doctor --strict` with alias-binding, no-binding remote-action, contextual implementation, and wrapper path propagation quality aggregates.
10
+
11
+ ## 0.1.21
12
+
13
+ - Propagate helper-returned connected clients from function declarations, arrow-function variables, function-expression variables, named export lists, and aliased exports into caller destructuring and transaction aliases.
14
+ - Preserve conservative object-return analysis by binding only returned properties backed by local `cds.connect.to(...)` client variables, while ignoring unrelated metadata fields.
15
+ - Document contextual implementation selection evidence and strict doctor checks for operation-path-only remote actions.
16
+
3
17
  ## 0.1.20
4
18
 
5
19
  - Preserve service-binding evidence when helpers return connected clients inside object properties and callers destructure or create simple transaction aliases.
package/README.md CHANGED
@@ -227,7 +227,15 @@ service-flow list calls --workspace /path/to/workspace --repo facade-service --o
227
227
  - Service bindings are matched to outbound calls by repository, source file, and
228
228
  variable name to avoid false cross-file matches. If a helper-returned client is
229
229
  not linked, export the helper from a relative import target and ensure it returns
230
- `cds.connect.to(...)` directly or through a simple wrapper. Trace evidence includes the caller variable, imported helper, source file, and exported symbol.
230
+ `cds.connect.to(...)` directly or returns an object property backed by a local
231
+ connected-client variable. Supported helper shapes include function declarations,
232
+ arrow-function variables, function-expression variables, named export lists, and
233
+ aliased exports such as `export { connectCatalog as createCatalogClient }`.
234
+ Shorthand returns like `return { client }` and explicit returns like
235
+ `return { serviceClient: client }` are followed only when the returned value is
236
+ a concrete client. Trace evidence includes the caller variable, returned
237
+ property, imported helper, source file, exported symbol, placeholders, and
238
+ transaction alias steps.
231
239
  - `SELECT.one.from(Entity)`, `SELECT.from(Entity)`, `INSERT.into(Entity)`,
232
240
  `UPDATE(Entity)`, and `DELETE.from(Entity)` are indexed as local database
233
241
  query entities when statically knowable.
@@ -310,7 +318,7 @@ service-flow trace --workspace /path/to/workspace --repo facade-service --operat
310
318
 
311
319
  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.
312
320
 
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.
321
+ 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, simple identity aliases, transitive same-file aliases, and 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.
314
322
 
315
323
  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.
316
324
 
@@ -410,8 +418,8 @@ Run `service-flow index` after source, CDS, package metadata, or helper-package
410
418
  <details>
411
419
  <summary><b>Why is an expected call unresolved?</b></summary>
412
420
 
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.
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.
421
+ 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`; the key is the full trimmed expression inside `${...}` and is matched literally without JavaScript evaluation. Operation-path-only ambiguous remote actions usually mean the call had no service binding id; same-file identity aliases are propagated, while property/call-expression aliases and ambiguous wrapper flows remain conservative; inspect `list calls`, `inspect operation`, and `doctor --strict` to determine whether helper-return propagation or wrapper support is missing. Contextual implementation selection only continues into a handler when static evidence such as caller repository, resolved service path, destination/alias expression, dependency edges, registration package, and local service ownership makes exactly one candidate stronger; ties remain ambiguous with reasons.
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. Strict mode also reports normalized OData invocation ambiguity, remote-action target quality, likely missed identity aliases, no-binding remote actions, contextual implementation stops, and wrapper dynamic-path candidates, including whether unresolved unknown/dynamic paths are semantic instead of numeric call ids.
415
423
 
416
424
  </details>
417
425
 
package/TECHNICAL-NOTE.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Service Flow Resolution Notes
2
2
 
3
+ ## 0.1.22 alias, wrapper, contextual trace, and strict doctor notes
4
+
5
+ - Same-file service-client identity aliases now create their own service binding rows when the right-hand side is a known connected client variable. The parser accepts direct identifier aliases plus typed, `as`, and `satisfies` forms, supports source-order transitive aliases, and records `aliasKind: identity` helper-chain evidence. It still does not infer aliases from property reads, indexed access, function calls, object metadata, or cross-scope guesses.
6
+ - Outbound calls through identity aliases can attach to the closest alias binding row in the same source file, preserving alias, destination, service path, placeholder, and helper-chain evidence for link and trace.
7
+ - Wrapper path propagation is intentionally narrow: same-file wrappers can resolve literal caller paths only when a wrapper client parameter is passed directly to `.send(...)` and a wrapper path parameter is passed as `send({ path })`. Dynamic caller paths remain dynamic with parser-warning evidence.
8
+ - Contextual implementation selection now records selected/tied score evidence. When trace selects a unique contextual handler, the implementation hop sets `contextualImplementationSelected` and no longer renders the original ambiguous reason as a failure.
9
+ - Strict doctor adds compact aggregates for likely missed identity aliases, remote actions with operation paths but no binding id, contextual implementation stops, and wrapper dynamic-path candidates.
10
+
11
+ ## 0.1.21 helper-return propagation and contextual trace notes
12
+
13
+ - Helper-return binding analysis uses the same returned-object scanner for `function` declarations, `async function` declarations, arrow-function variables, async arrow-function variables, and function-expression variables. Named export lists and aliases are resolved, so `export { connectCatalog as createCatalogClient }` can be destructured by callers without losing evidence.
14
+ - Returned object properties are bound only when their value is a local variable initialized from `cds.connect.to(...)`. Shorthand properties and explicit property assignments are supported; unrelated strings, codes, and metadata fields are ignored. Destructuring renames and `.tx()` aliases append helper-chain evidence instead of replacing the original binding evidence.
15
+ - Trace-time contextual implementation selection may use caller repository, runtime-resolved service path, destination or alias expression, package dependency evidence, handler and registration packages, and local service ownership to continue from an ambiguous operation edge into one handler. If candidates tie or no static signal is strong enough, trace keeps the ambiguous edge and reports the tie or unresolved reason.
16
+ - `doctor --strict` is the intended place for broad regression aggregates, including helper-return coverage, contextual implementation ambiguity, and remote actions that have an operation path but no service binding id.
17
+
3
18
  ## 0.1.20 helper object clients, expression placeholders, and remote-action target notes
4
19
 
5
20
  - 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.
@@ -638,11 +638,47 @@ function isSupportedEventReceiver(receiver, rootReceiver, serviceVariables) {
638
638
  if (/^(srv|service|serviceClient|messaging|messageClient|eventClient)$/.test(candidate)) return true;
639
639
  return false;
640
640
  }
641
+ function collectWrapperSpecs(source) {
642
+ const specs = /* @__PURE__ */ new Map();
643
+ const scanFunction = (name, fn) => {
644
+ const params = fn.parameters.map((param) => ts4.isIdentifier(param.name) ? param.name.text : void 0);
645
+ let found;
646
+ const visit = (node) => {
647
+ if (found) return;
648
+ if (node !== fn && (ts4.isFunctionDeclaration(node) || ts4.isArrowFunction(node) || ts4.isFunctionExpression(node))) {
649
+ ts4.forEachChild(node, visit);
650
+ return;
651
+ }
652
+ if (ts4.isCallExpression(node) && ts4.isPropertyAccessExpression(node.expression) && node.expression.name.text === "send" && ts4.isIdentifier(node.expression.expression)) {
653
+ const objectArg = node.arguments[0];
654
+ if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
655
+ const pathProp = objectArg.properties.find((property) => ts4.isShorthandPropertyAssignment(property) && property.name.text === "path");
656
+ if (pathProp) found = { client: node.expression.expression.text, path: pathProp.name.text, method: objectPropertyText(objectArg, "method") };
657
+ }
658
+ }
659
+ ts4.forEachChild(node, visit);
660
+ };
661
+ visit(fn);
662
+ if (!found) return;
663
+ const clientIndex = params.indexOf(found.client);
664
+ const pathIndex = params.indexOf(found.path);
665
+ const methodIndex = found.method && params.includes(found.method) ? params.indexOf(found.method) : void 0;
666
+ if (clientIndex >= 0 && pathIndex >= 0) specs.set(name, { clientIndex, pathIndex, methodIndex });
667
+ };
668
+ for (const stmt of source.statements) {
669
+ if (ts4.isFunctionDeclaration(stmt) && stmt.name) scanFunction(stmt.name.text, stmt);
670
+ if (ts4.isVariableStatement(stmt)) {
671
+ for (const decl of stmt.declarationList.declarations) if (ts4.isIdentifier(decl.name) && decl.initializer && (ts4.isArrowFunction(decl.initializer) || ts4.isFunctionExpression(decl.initializer))) scanFunction(decl.name.text, decl.initializer);
672
+ }
673
+ }
674
+ return specs;
675
+ }
641
676
  function classifyOutboundCallsInSource(source, filePath) {
642
677
  const calls = [];
643
678
  const sourceFile = normalizePath(filePath);
644
679
  const initializers = variableInitializers(source);
645
680
  const serviceVariables = collectServiceVariables(source);
681
+ const wrapperSpecs = collectWrapperSpecs(source);
646
682
  const add = (node, fact, extra) => {
647
683
  calls.push({ node, fact: { ...fact, sourceFile, sourceLine: lineOf3(source.text, node.getStart(source)), confidence: fact.confidence ?? 0.8, evidence: parserEvidence(source, node, extra) } });
648
684
  };
@@ -664,6 +700,14 @@ function classifyOutboundCallsInSource(source, filePath) {
664
700
  const shorthandPath = objectPropertyIsShorthand(objectArg, "path");
665
701
  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 });
666
702
  }
703
+ } else if (ts4.isCallExpression(expr) && ts4.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text)) {
704
+ const spec = wrapperSpecs.get(expr.expression.text);
705
+ const clientArg = spec ? expr.arguments[spec.clientIndex] : void 0;
706
+ const pathArg = spec ? expr.arguments[spec.pathIndex] : void 0;
707
+ const methodArg = spec?.methodIndex === void 0 ? void 0 : expr.arguments[spec.methodIndex];
708
+ if (spec && clientArg && ts4.isIdentifier(clientArg) && isStringLike(pathArg)) {
709
+ add(node, { callType: "remote_action", serviceVariableName: clientArg.text, method: stripQuotes(methodArg?.getText(source) ?? "POST"), operationPathExpr: `/${pathArg.text.replace(/^\//, "")}`, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver: clientArg.text, classifier: "local_wrapper_literal_path", wrapperFunction: expr.expression.text, literalCallerArgumentDetected: true });
710
+ }
667
711
  } else if (ts4.isPropertyAccessExpression(expr) && ["emit", "publish", "on"].includes(expr.name.text)) {
668
712
  const receiver = receiverName(expr.expression);
669
713
  const rootReceiver = rootReceiverName(expr.expression);
@@ -815,9 +859,15 @@ function connectFactFromCall(call) {
815
859
  }
816
860
  function unwrapCall(expr) {
817
861
  if (ts5.isAwaitExpression(expr)) return unwrapCall(expr.expression);
862
+ if (ts5.isAsExpression(expr) || ts5.isSatisfiesExpression(expr)) return unwrapCall(expr.expression);
818
863
  if (ts5.isCallExpression(expr)) return expr;
819
864
  return void 0;
820
865
  }
866
+ function unwrapIdentityExpression(expr) {
867
+ if (ts5.isAwaitExpression(expr)) return unwrapIdentityExpression(expr.expression);
868
+ if (ts5.isAsExpression(expr) || ts5.isSatisfiesExpression(expr)) return unwrapIdentityExpression(expr.expression);
869
+ return expr;
870
+ }
821
871
  function findConnectInExpression(expr) {
822
872
  const direct = unwrapCall(expr);
823
873
  if (direct) {
@@ -900,6 +950,8 @@ function collectReturnedObjectBindings(fn) {
900
950
  const bindings = /* @__PURE__ */ new Map();
901
951
  const returns = /* @__PURE__ */ new Map();
902
952
  function visit(node) {
953
+ if (node !== fn && (ts5.isFunctionDeclaration(node) || ts5.isArrowFunction(node) || ts5.isFunctionExpression(node)))
954
+ return;
903
955
  if (ts5.isVariableDeclaration(node) && ts5.isIdentifier(node.name) && node.initializer) {
904
956
  const fact = findConnectInExpression(node.initializer);
905
957
  if (fact) bindings.set(node.name.text, fact);
@@ -923,6 +975,35 @@ function collectReturnedObjectBindings(fn) {
923
975
  }
924
976
  return out;
925
977
  }
978
+ function functionLikeInitializer(expr) {
979
+ if (!expr) return void 0;
980
+ if (ts5.isArrowFunction(expr) || ts5.isFunctionExpression(expr)) return expr;
981
+ return void 0;
982
+ }
983
+ function directReturnConnectFact(fn) {
984
+ const localBindings = /* @__PURE__ */ new Map();
985
+ let returned;
986
+ function visit(node) {
987
+ if (node !== fn && (ts5.isFunctionDeclaration(node) || ts5.isArrowFunction(node) || ts5.isFunctionExpression(node)))
988
+ return;
989
+ if (ts5.isVariableDeclaration(node) && ts5.isIdentifier(node.name) && node.initializer) {
990
+ const fact = findConnectInExpression(node.initializer);
991
+ if (fact) localBindings.set(node.name.text, fact);
992
+ }
993
+ if (!returned && ts5.isReturnStatement(node) && node.expression)
994
+ returned = node.expression;
995
+ if (!returned) ts5.forEachChild(node, visit);
996
+ }
997
+ visit(fn);
998
+ if (!returned) return void 0;
999
+ if (ts5.isIdentifier(returned)) return localBindings.get(returned.text);
1000
+ return findConnectInExpression(returned);
1001
+ }
1002
+ function directConnectFactFromFunctionLike(fn) {
1003
+ if (ts5.isArrowFunction(fn) && fn.body && !ts5.isBlock(fn.body))
1004
+ return findConnectInExpression(fn.body);
1005
+ return directReturnConnectFact(fn);
1006
+ }
926
1007
  function exportedLocalNames(sf) {
927
1008
  const exports = /* @__PURE__ */ new Map();
928
1009
  for (const stmt of sf.statements) {
@@ -951,12 +1032,7 @@ async function helperBindings(repoPath, filePath) {
951
1032
  const factsByLocal = /* @__PURE__ */ new Map();
952
1033
  for (const stmt of sf.statements) {
953
1034
  if (ts5.isFunctionDeclaration(stmt) && stmt.name) {
954
- let fact;
955
- stmt.forEachChild(function visit(node) {
956
- if (!fact && ts5.isReturnStatement(node) && node.expression)
957
- fact = findConnectInExpression(node.expression);
958
- if (!fact) ts5.forEachChild(node, visit);
959
- });
1035
+ const fact = directConnectFactFromFunctionLike(stmt);
960
1036
  if (fact) factsByLocal.set(stmt.name.text, { ...fact, sourceLine: lineOf4(sf, stmt) });
961
1037
  for (const [returnedProperty, objectFact] of collectReturnedObjectBindings(stmt))
962
1038
  factsByLocal.set(`${stmt.name.text}#${returnedProperty}`, { ...objectFact, returnedProperty, sourceLine: lineOf4(sf, stmt) });
@@ -964,6 +1040,22 @@ async function helperBindings(repoPath, filePath) {
964
1040
  if (ts5.isVariableStatement(stmt))
965
1041
  for (const decl of stmt.declarationList.declarations) {
966
1042
  if (!ts5.isIdentifier(decl.name) || !decl.initializer) continue;
1043
+ const helper = functionLikeInitializer(decl.initializer);
1044
+ if (helper) {
1045
+ const directReturn = directConnectFactFromFunctionLike(helper);
1046
+ if (directReturn)
1047
+ factsByLocal.set(decl.name.text, {
1048
+ ...directReturn,
1049
+ sourceLine: lineOf4(sourceFileAst, decl)
1050
+ });
1051
+ for (const [returnedProperty, objectFact] of collectReturnedObjectBindings(helper))
1052
+ factsByLocal.set(`${decl.name.text}#${returnedProperty}`, {
1053
+ ...objectFact,
1054
+ returnedProperty,
1055
+ sourceLine: lineOf4(sourceFileAst, decl)
1056
+ });
1057
+ continue;
1058
+ }
967
1059
  const fact = findConnectInExpression(decl.initializer);
968
1060
  if (fact)
969
1061
  factsByLocal.set(decl.name.text, {
@@ -1002,22 +1094,65 @@ async function parseServiceBindings(repoPath, filePath) {
1002
1094
  const classHelpers = collectClassHelpers(sourceFileAst);
1003
1095
  const localObjectHelpers = /* @__PURE__ */ new Map();
1004
1096
  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);
1097
+ if (ts5.isFunctionDeclaration(stmt) && stmt.name) {
1098
+ const rows2 = [];
1099
+ for (const [returnedProperty, fact] of collectReturnedObjectBindings(stmt))
1100
+ rows2.push({ ...fact, exportedName: stmt.name.text, returnedProperty, sourceFile: normalizePath(filePath), sourceLine: lineOf4(sourceFileAst, stmt) });
1101
+ if (rows2.length > 0) localObjectHelpers.set(stmt.name.text, rows2);
1102
+ }
1103
+ if (ts5.isVariableStatement(stmt)) {
1104
+ for (const decl of stmt.declarationList.declarations) {
1105
+ if (!ts5.isIdentifier(decl.name)) continue;
1106
+ const helper = functionLikeInitializer(decl.initializer);
1107
+ if (!helper) continue;
1108
+ const rows2 = [];
1109
+ for (const [returnedProperty, fact] of collectReturnedObjectBindings(helper))
1110
+ rows2.push({ ...fact, exportedName: decl.name.text, returnedProperty, sourceFile: normalizePath(filePath), sourceLine: lineOf4(sourceFileAst, decl) });
1111
+ if (rows2.length > 0) localObjectHelpers.set(decl.name.text, rows2);
1112
+ }
1113
+ }
1010
1114
  }
1011
- async function importedHelper(localName) {
1115
+ async function importedHelpers(localName) {
1012
1116
  const imp = imports.find((i) => i.localName === localName && i.sourceFile);
1013
- if (!imp?.sourceFile) return void 0;
1117
+ if (!imp?.sourceFile) return [];
1014
1118
  if (!helperCache.has(imp.sourceFile))
1015
1119
  helperCache.set(
1016
1120
  imp.sourceFile,
1017
1121
  await helperBindings(repoPath, imp.sourceFile)
1018
1122
  );
1019
- const helper = helperCache.get(imp.sourceFile)?.find((h) => h.exportedName === imp.exportedName);
1020
- return helper ? { imp, helper } : void 0;
1123
+ return (helperCache.get(imp.sourceFile) ?? []).filter((h) => h.exportedName === imp.exportedName).map((helper) => ({ imp, helper }));
1124
+ }
1125
+ async function importedHelper(localName) {
1126
+ return (await importedHelpers(localName)).find((row) => !row.helper.returnedProperty);
1127
+ }
1128
+ function bindingForVariable(variableName) {
1129
+ const sourceFile = normalizePath(filePath);
1130
+ return [...out].reverse().find((row) => row.variableName === variableName && row.sourceFile === sourceFile);
1131
+ }
1132
+ function cloneAliasBinding(decl, sourceName, aliasKind) {
1133
+ if (!ts5.isIdentifier(decl.name)) return;
1134
+ const existing = bindingForVariable(sourceName);
1135
+ if (!existing) return;
1136
+ out.push({
1137
+ ...existing,
1138
+ variableName: decl.name.text,
1139
+ sourceLine: lineOf4(sourceFileAst, decl),
1140
+ helperChain: [
1141
+ ...existing.helperChain ?? [],
1142
+ {
1143
+ callerVariable: decl.name.text,
1144
+ aliasOf: sourceName,
1145
+ aliasKind,
1146
+ scopeRule: "same-file-source-order"
1147
+ }
1148
+ ]
1149
+ });
1150
+ }
1151
+ function recordIdentityAlias(decl) {
1152
+ if (!ts5.isIdentifier(decl.name) || !decl.initializer) return;
1153
+ const unwrapped = unwrapIdentityExpression(decl.initializer);
1154
+ if (!ts5.isIdentifier(unwrapped)) return;
1155
+ cloneAliasBinding(decl, unwrapped.text, "identity");
1021
1156
  }
1022
1157
  async function recordVariable(decl) {
1023
1158
  if (!ts5.isIdentifier(decl.name) || !decl.initializer) return;
@@ -1057,23 +1192,24 @@ async function parseServiceBindings(repoPath, filePath) {
1057
1192
  });
1058
1193
  }
1059
1194
  }
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;
1195
+ async function helpersForCall(call) {
1196
+ if (!ts5.isIdentifier(call.expression)) return [];
1197
+ const local = localObjectHelpers.get(call.expression.text) ?? [];
1198
+ const imported = await importedHelpers(call.expression.text);
1199
+ return [...local.map((helper) => ({ helper })), ...imported];
1066
1200
  }
1067
1201
  async function recordDestructuredHelper(decl) {
1068
1202
  if (!ts5.isObjectBindingPattern(decl.name) || !decl.initializer) return;
1069
1203
  const call = unwrapCall(decl.initializer);
1070
1204
  if (!call) return;
1071
- const resolved = await helperForCall(call);
1072
- if (!resolved) return;
1205
+ const helpers = await helpersForCall(call);
1206
+ if (helpers.length === 0) return;
1073
1207
  for (const el of decl.name.elements) {
1074
1208
  if (!ts5.isIdentifier(el.name)) continue;
1075
1209
  const propertyName = el.propertyName && ts5.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
1076
- if (resolved.helper.returnedProperty !== propertyName) continue;
1210
+ const matches = helpers.filter((row) => row.helper.returnedProperty === propertyName);
1211
+ if (matches.length !== 1) continue;
1212
+ const resolved = matches[0];
1077
1213
  out.push({
1078
1214
  variableName: el.name.text,
1079
1215
  alias: resolved.helper.alias,
@@ -1084,7 +1220,7 @@ async function parseServiceBindings(repoPath, filePath) {
1084
1220
  placeholders: resolved.helper.placeholders,
1085
1221
  sourceFile: normalizePath(filePath),
1086
1222
  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 }]
1223
+ helperChain: [{ callerVariable: el.name.text, helperFunction: call.expression.getText(sourceFileAst), returnedProperty: propertyName, importSource: resolved.imp?.sourceFile, exportedSymbol: resolved.imp?.exportedName, helperSourceFile: resolved.helper.sourceFile, helperSourceLine: resolved.helper.sourceLine }]
1088
1224
  });
1089
1225
  }
1090
1226
  }
@@ -1130,10 +1266,9 @@ async function parseServiceBindings(repoPath, filePath) {
1130
1266
  await recordDestructuredHelper(decl);
1131
1267
  recordDestructuredClassHelper(decl);
1132
1268
  await recordVariable(decl);
1269
+ recordIdentityAlias(decl);
1133
1270
  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" }] });
1271
+ cloneAliasBinding(decl, decl.initializer.expression.expression.text, "transaction");
1137
1272
  }
1138
1273
  }
1139
1274
  return out;
@@ -1941,10 +2076,25 @@ function implementationScope(db, operationId) {
1941
2076
  return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), symbolId: row?.symbolId, edge };
1942
2077
  }
1943
2078
  function contextImplementationMethodId(edge, callerRepoId) {
1944
- if (!edge || edge.status !== "ambiguous" || callerRepoId === void 0) return void 0;
2079
+ if (!edge || edge.status !== "ambiguous" || callerRepoId === void 0) return { evidence: { status: "not_applicable" } };
1945
2080
  const evidence = JSON.parse(String(edge.evidence_json || "{}"));
1946
- const candidate = evidence.candidates?.find((item) => item.accepted && (Number(item.handlerPackage?.id) === callerRepoId || Number(item.applicationPackage?.id) === callerRepoId));
1947
- return candidate?.methodId === void 0 ? void 0 : String(candidate.methodId);
2081
+ const scores = (evidence.candidates ?? []).filter((item) => item.accepted).map((item) => {
2082
+ const reasons = [];
2083
+ let score = Number(item.score ?? 0);
2084
+ if (Number(item.handlerPackage?.id) === callerRepoId) {
2085
+ score += 10;
2086
+ reasons.push("handler_package_matches_caller_repository");
2087
+ }
2088
+ if (Number(item.applicationPackage?.id) === callerRepoId) {
2089
+ score += 10;
2090
+ reasons.push("registration_package_matches_caller_repository");
2091
+ }
2092
+ return { methodId: item.methodId, score, reasons, handlerPackage: item.handlerPackage, applicationPackage: item.applicationPackage };
2093
+ }).sort((a, b) => b.score - a.score);
2094
+ if (scores.length === 0) return { evidence: { status: "not_applicable", candidateScores: [] } };
2095
+ const [first, second] = scores;
2096
+ if (first && first.methodId !== void 0 && first.score > 0 && (!second || first.score > second.score)) return { methodId: String(first.methodId), evidence: { status: "selected", selectedMethodId: first.methodId, candidateScores: scores } };
2097
+ return { evidence: { status: "tied", tieReason: "no_unique_materially_stronger_candidate", candidateScores: scores } };
1948
2098
  }
1949
2099
  function handlerScope(db, methodId) {
1950
2100
  const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile,s.id symbolId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id LEFT JOIN symbols s ON s.repo_id=hc.repo_id AND s.source_file=hc.source_file AND s.name=hm.method_name WHERE hm.id=?").get(methodId);
@@ -2150,7 +2300,8 @@ function trace(db, start, options) {
2150
2300
  });
2151
2301
  if (effectiveRow.to_kind === "operation") {
2152
2302
  const implementation = implementationScope(db, effectiveRow.to_id);
2153
- const contextMethodId = contextImplementationMethodId(implementation.edge, current.repoId);
2303
+ const contextSelection = contextImplementationMethodId(implementation.edge, current.repoId);
2304
+ const contextMethodId = contextSelection.methodId;
2154
2305
  const contextNode = contextMethodId ? handlerMethodNode(db, contextMethodId) : void 0;
2155
2306
  if (implementation.edge) {
2156
2307
  const implEvidence = JSON.parse(String(implementation.edge.evidence_json || "{}"));
@@ -2162,9 +2313,9 @@ function trace(db, start, options) {
2162
2313
  type: "operation_implemented_by_handler",
2163
2314
  from: to,
2164
2315
  to: implTo,
2165
- evidence: implEvidence,
2316
+ evidence: contextMethodId ? { ...implEvidence, contextualImplementationSelected: true, contextualImplementation: contextSelection.evidence } : { ...implEvidence, contextualImplementation: contextSelection.evidence },
2166
2317
  confidence: Number(implementation.edge.confidence ?? 0),
2167
- unresolvedReason: implementation.edge.status === "resolved" ? void 0 : String(implementation.edge.unresolved_reason ?? implementation.edge.status)
2318
+ unresolvedReason: implementation.edge.status === "resolved" || contextMethodId ? void 0 : String(implementation.edge.unresolved_reason ?? implementation.edge.status)
2168
2319
  });
2169
2320
  }
2170
2321
  if (current.depth >= maxDepth) continue;
@@ -2221,4 +2372,4 @@ export {
2221
2372
  linkWorkspace,
2222
2373
  trace
2223
2374
  };
2224
- //# sourceMappingURL=chunk-EKLZJJW7.js.map
2375
+ //# sourceMappingURL=chunk-JIY37C44.js.map