@saptools/service-flow 0.1.30 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.31
4
+
5
+ - Resolve operation and path trace selectors from indexed CDS operations and persisted implementation edges before conservative handler fallback, including generated `Action<Name>` and `Func<Name>` decorator constants whose method names differ from public operation names.
6
+ - Emit the selected start operation and initial implementation hop once, with structured start-resolution evidence and ambiguity/not-found diagnostics that point to the operation, implementation edge, or handler-scope stage.
7
+ - Replace numeric external HTTP terminal targets with semantic external destination and endpoint nodes, preserving redacted structured target evidence for destinations, static URLs, dynamic URL expressions, and unknown calls.
8
+ - Add schema version 6 migration columns for queryable external target metadata and a strict doctor aggregate for external HTTP target quality.
9
+
3
10
  ## 0.1.30
4
11
 
5
12
  - Normalize balanced OData operation invocations when multiline template placeholders appear inside function/action argument lists.
package/README.md CHANGED
@@ -219,7 +219,15 @@ service-flow list calls --workspace /path/to/workspace --repo facade-service --o
219
219
  | `list operations` | Print indexed actions/functions/events, optionally filtered by repo and service |
220
220
  | `list calls` | Print indexed outbound calls, optionally filtered by repo and operation/path |
221
221
 
222
- ### Troubleshooting resolution accuracy
222
+ ### Operation selector resolution
223
+
224
+ Operation-based trace starts first resolve indexed CDS operations, then follow the persisted `OPERATION_IMPLEMENTED_BY_HANDLER` graph edge to the exact handler method symbol. Generated decorator constants such as `ActionPublishRecord.name` and `FuncLookupRecord.name` are normalized through the same shared helper used by linking and diagnostics, so public operations like `/publishRecord` can trace into methods such as `executePublish`. If the same operation name exists in multiple services or repositories, `service-flow` returns `trace_start_ambiguous`; add `--service /CatalogService` or `--repo catalog-api` to disambiguate. Unique operation selectors emit the initial operation node and implementation hop exactly once before traversing handler-owned calls.
225
+
226
+ ### External HTTP targets
227
+
228
+ External HTTP facts use semantic terminal nodes instead of outbound-call row ids. Literal destinations render as `External destination: ANALYTICS_API`; static absolute or relative URLs render as redacted `External endpoint` labels; dynamic URL expressions render as `External endpoint: dynamic URL`; unavailable target evidence renders as `External endpoint: unknown`. URL user information, query-string values, credentials, tokens, cookies, headers, and payload bodies are not stored in labels. Run `service-flow link` after schema migration so legacy numeric targets are rebuilt from the current parser evidence. `service-flow doctor --strict` reports `strict_external_http_target_quality` with semantic, dynamic, unknown, numeric, and malformed-evidence counts.
229
+
230
+ ## Troubleshooting resolution accuracy
223
231
 
224
232
  - If a remote edge is unresolved, run `service-flow list calls --operation <name>`
225
233
  and `service-flow inspect operation <name>` to compare the captured call path
package/TECHNICAL-NOTE.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Service Flow Resolution Notes
2
2
 
3
+
4
+ ## 0.1.31 selector and external target notes
5
+
6
+ Operation trace selectors are resolved from CDS operation facts before handler-source fallback. The start resolver scopes by repository and service path, requires disambiguation when multiple repositories or services match, and only queues traversal from a resolved implementation edge. Generated operation decorator constants are normalized in `operation-decorator-normalizer` so linker, trace fallback, diagnostics, and tests share one conservative implementation.
7
+
8
+ Schema version 6 adds queryable external target metadata columns to `outbound_calls`. The linker writes semantic `external_destination` or `external_endpoint` graph targets and keeps redacted structured target details in edge evidence. Existing databases migrate forward without dropping facts; relinking rebuilds legacy numeric external HTTP targets.
9
+
3
10
  ## 0.1.30 OData invocation argument placeholder notes
4
11
 
5
12
  - Balanced top-level OData function/action imports such as `/readDetails(ID='${id}',version=0)` now normalize to the operation segment even when placeholder expressions span multiple lines or contain nested JavaScript parentheses.
@@ -798,6 +798,70 @@ function nameOfProperty(name) {
798
798
  if (ts4.isIdentifier(name) || ts4.isStringLiteral(name) || ts4.isNumericLiteral(name)) return name.text;
799
799
  return void 0;
800
800
  }
801
+ function staticExpressionText(expr, initializers) {
802
+ if (!expr) return void 0;
803
+ if (isStringLike(expr)) return expr.text;
804
+ if (ts4.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
805
+ return void 0;
806
+ }
807
+ function propertyInitializer(object, key) {
808
+ for (const property of object.properties) {
809
+ if (ts4.isPropertyAssignment(property) && nameOfProperty(property.name) === key) return property.initializer;
810
+ if (ts4.isShorthandPropertyAssignment(property) && property.name.text === key) return property.name;
811
+ }
812
+ return void 0;
813
+ }
814
+ function httpMethodFromObject(object, initializers) {
815
+ const text = staticExpressionText(propertyInitializer(object, "method"), initializers);
816
+ return text ? stripQuotes(text).toUpperCase() : void 0;
817
+ }
818
+ function urlTargetFromExpression(expr, initializers) {
819
+ const text = staticExpressionText(expr, initializers);
820
+ if (text) return { kind: "static_url", expression: text, dynamic: false };
821
+ if (expr && (ts4.isTemplateExpression(expr) || ts4.isIdentifier(expr) || ts4.isPropertyAccessExpression(expr) || ts4.isCallExpression(expr))) return { kind: "url_expression", expression: expr.getText(expr.getSourceFile()), dynamic: true };
822
+ return { kind: "unknown", dynamic: false };
823
+ }
824
+ function destinationTargetFromExpression(expr, initializers) {
825
+ const text = staticExpressionText(expr, initializers);
826
+ if (text) return { kind: "destination", expression: text, dynamic: false };
827
+ if (expr && ts4.isIdentifier(expr)) return { kind: "destination", expression: expr.text, dynamic: true };
828
+ return void 0;
829
+ }
830
+ function externalHttpEvidence(node, source, initializers) {
831
+ const expr = node.expression;
832
+ const exprText = expr.getText(source);
833
+ if (exprText === "useOrFetchDestination") {
834
+ const objectArg = node.arguments[0];
835
+ if (objectArg && ts4.isObjectLiteralExpression(objectArg)) {
836
+ const destination = destinationTargetFromExpression(propertyInitializer(objectArg, "destinationName"), initializers);
837
+ return { externalTarget: destination ?? { kind: "unknown", dynamic: false }, classifier: "sap_destination_lookup", sourceCallShape: "useOrFetchDestination" };
838
+ }
839
+ }
840
+ if (exprText === "executeHttpRequest") {
841
+ const destination = destinationTargetFromExpression(node.arguments[0], initializers);
842
+ const config = node.arguments[1];
843
+ const method = config && ts4.isObjectLiteralExpression(config) ? httpMethodFromObject(config, initializers) : void 0;
844
+ const url = config && ts4.isObjectLiteralExpression(config) ? urlTargetFromExpression(propertyInitializer(config, "url"), initializers) : { kind: "unknown", dynamic: false };
845
+ return { method, externalTarget: destination ? { ...url, destination } : url, classifier: "sap_execute_http_request", sourceCallShape: "executeHttpRequest" };
846
+ }
847
+ if (exprText === "axios") {
848
+ const config = node.arguments[0];
849
+ if (config && ts4.isObjectLiteralExpression(config)) {
850
+ const method = httpMethodFromObject(config, initializers);
851
+ return { method, externalTarget: urlTargetFromExpression(propertyInitializer(config, "url"), initializers), classifier: "axios_config_call", sourceCallShape: "axios(config)" };
852
+ }
853
+ return { externalTarget: { kind: "unknown", dynamic: false }, classifier: "axios_unknown_call", sourceCallShape: "axios(...)" };
854
+ }
855
+ if (exprText === "fetch") {
856
+ const init = node.arguments[1];
857
+ const method = init && ts4.isObjectLiteralExpression(init) ? httpMethodFromObject(init, initializers) : void 0;
858
+ return { method, externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: "fetch_call", sourceCallShape: "fetch" };
859
+ }
860
+ if (ts4.isPropertyAccessExpression(expr) && ["get", "post", "put", "patch", "delete", "head"].includes(expr.name.text) && expr.expression.getText(source) === "axios") {
861
+ return { method: expr.name.text.toUpperCase(), externalTarget: urlTargetFromExpression(node.arguments[0], initializers), classifier: "axios_member_call", sourceCallShape: `axios.${expr.name.text}` };
862
+ }
863
+ return void 0;
864
+ }
801
865
  function collectServiceVariables(source) {
802
866
  const vars = /* @__PURE__ */ new Set(["cds", "messaging", "messageClient", "eventClient"]);
803
867
  const visit = (node) => {
@@ -930,8 +994,9 @@ function classifyOutboundCallsInSource(source, filePath) {
930
994
  const eventName = literalText(node.arguments[0]);
931
995
  if (eventName) add(node, { callType: expr.name.text === "on" ? "async_subscribe" : "async_emit", serviceVariableName: rootReceiver ?? receiver, eventNameExpr: eventName }, { receiver, rootReceiver, classifier: expr.name.text === "on" ? "cap_service_event_subscription" : "cap_service_event_emit", receiverClassification: "cap_evidence" });
932
996
  }
933
- } else if (exprText === "axios" || exprText === "executeHttpRequest" || exprText === "useOrFetchDestination") {
934
- add(node, { callType: "external_http", payloadSummary: summarizeExpression(node.arguments.map((arg) => arg.getText(source)).join(", ")), confidence: 0.7, unresolvedReason: "External HTTP destination is outside indexed CAP services" });
997
+ } else {
998
+ const external = externalHttpEvidence(node, source, initializers);
999
+ if (external) add(node, { callType: "external_http", method: external.method, payloadSummary: summarizeExpression(node.arguments.map((arg) => arg.getText(source)).join(", ")), confidence: 0.7, unresolvedReason: "External HTTP destination is outside indexed CAP services" }, { classifier: external.classifier, externalTarget: { ...external.externalTarget, method: external.method, parserClassifier: external.classifier, sourceCallShape: external.sourceCallShape }, sourceCallShape: external.sourceCallShape });
935
1000
  }
936
1001
  }
937
1002
  ts4.forEachChild(node, visit);
@@ -1861,6 +1926,80 @@ function linkHelperPackages(db, workspaceId, generation) {
1861
1926
  return summary;
1862
1927
  }
1863
1928
 
1929
+ // src/linker/operation-decorator-normalizer.ts
1930
+ function lowerFirst(value) {
1931
+ return value ? `${value[0]?.toLowerCase() ?? ""}${value.slice(1)}` : value;
1932
+ }
1933
+ function normalizedOperationName(value) {
1934
+ return value.replace(/^\//, "");
1935
+ }
1936
+ function clean(value) {
1937
+ return value.replace(/^[`'"]|[`'"]$/g, "");
1938
+ }
1939
+ function generatedFromConstantName(value) {
1940
+ for (const prefix of ["Action", "Func"]) {
1941
+ if (value.startsWith(prefix) && value.length > prefix.length && /^[A-Z]/.test(value.slice(prefix.length))) return lowerFirst(value.slice(prefix.length));
1942
+ }
1943
+ return void 0;
1944
+ }
1945
+ function normalizeDecoratorOperation(value, raw) {
1946
+ if (value) {
1947
+ const literal = clean(value);
1948
+ const generated = generatedFromConstantName(literal);
1949
+ return generated ?? normalizedOperationName(literal);
1950
+ }
1951
+ if (!raw) return void 0;
1952
+ const expression = raw.trim();
1953
+ const nameMatch = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/.exec(expression);
1954
+ if (nameMatch?.[1]) return generatedFromConstantName(nameMatch[1]);
1955
+ const tail = expression.split(".").filter(Boolean).at(-1);
1956
+ return tail ? normalizedOperationName(clean(tail)) : void 0;
1957
+ }
1958
+
1959
+ // src/linker/external-http-target.ts
1960
+ import { createHash } from "crypto";
1961
+ var sensitiveKeys = /* @__PURE__ */ new Set(["token", "access_token", "id_token", "api_key", "apikey", "key", "password", "passwd", "pwd", "secret", "client_secret", "authorization", "cookie", "signature"]);
1962
+ function hash(value) {
1963
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
1964
+ }
1965
+ function methodPrefix(method) {
1966
+ return typeof method === "string" && method.length > 0 ? `${method.toUpperCase()} ` : "";
1967
+ }
1968
+ function redactUrl(value) {
1969
+ try {
1970
+ const url = new URL(value, value.startsWith("/") ? "https://relative.invalid" : void 0);
1971
+ url.username = "";
1972
+ url.password = "";
1973
+ for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, sensitiveKeys.has(key.toLowerCase()) ? "<redacted>" : "<redacted>");
1974
+ const path9 = `${url.pathname}${url.search ? url.search : ""}`;
1975
+ return value.startsWith("/") ? path9 : `${url.origin}${path9}`;
1976
+ } catch {
1977
+ return value.replace(/([?&][^=;&]*(?:token|key|password|secret|cookie|authorization)[^=;&]*=)[^&]*/gi, "$1<redacted>");
1978
+ }
1979
+ }
1980
+ function externalHttpTarget(call) {
1981
+ const evidence = typeof call.evidence_json === "string" ? safeParse(call.evidence_json) : {};
1982
+ const target = evidence.externalTarget && typeof evidence.externalTarget === "object" && !Array.isArray(evidence.externalTarget) ? evidence.externalTarget : {};
1983
+ const method = typeof call.method === "string" ? call.method : typeof target.method === "string" ? target.method : void 0;
1984
+ const kind = typeof target.kind === "string" ? target.kind : "unknown";
1985
+ const expression = typeof target.expression === "string" ? target.expression : void 0;
1986
+ if (kind === "destination" && expression) return { kind, toKind: "external_destination", toId: `destination:${expression}`, label: `External destination: ${expression}`, method, dynamic: false, expression };
1987
+ if (kind === "static_url" && expression) {
1988
+ const redacted = redactUrl(expression);
1989
+ return { kind, toKind: "external_endpoint", toId: `endpoint:${hash(`${method ?? ""}:${redacted}`)}`, label: `External endpoint: ${methodPrefix(method)}${redacted}`, method, dynamic: false, expression: redacted };
1990
+ }
1991
+ if (kind === "url_expression" && expression) return { kind, toKind: "external_endpoint", toId: `dynamic:${hash(expression)}`, label: `External endpoint: ${methodPrefix(method)}dynamic URL`, method, dynamic: true, expression: `expr:${hash(expression)}` };
1992
+ return { kind: "unknown", toKind: "external_endpoint", toId: "unknown", label: "External endpoint: unknown", method, dynamic: false };
1993
+ }
1994
+ function safeParse(value) {
1995
+ try {
1996
+ const parsed = JSON.parse(value);
1997
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1998
+ } catch {
1999
+ return {};
2000
+ }
2001
+ }
2002
+
1864
2003
  // src/linker/cross-repo-linker.ts
1865
2004
  function linkWorkspace(db, workspaceId, vars = {}) {
1866
2005
  return db.transaction(() => {
@@ -1930,10 +2069,12 @@ function insertCallEdge(db, workspaceId, call, vars, generation) {
1930
2069
  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";
1931
2070
  const status = edgeType === "DYNAMIC_EDGE_CANDIDATE" ? "dynamic" : resolution.status === "ambiguous" ? "ambiguous" : edgeType === "UNRESOLVED_EDGE" ? "unresolved" : "terminal";
1932
2071
  const unresolvedReason = status === "terminal" ? null : String(call.unresolved_reason ?? unresolvedOperationReason(resolution));
1933
- const targetKind = callType === "local_db_query" ? "db_entity" : callType.startsWith("async_") ? "event" : "external";
1934
- const targetId = callType === "local_db_query" ? String(call.query_entity ?? "unknown") : callType === "remote_action" ? op ? `Remote action: ${op}` : call.unresolved_reason === "dynamic_operation_path_identifier" ? "Remote action: dynamic path" : "Remote action: unknown path" : String(call.event_name_expr ?? op ?? call.id);
2072
+ const externalTarget = callType === "external_http" ? externalHttpTarget(call) : void 0;
2073
+ const targetKind = callType === "local_db_query" ? "db_entity" : callType.startsWith("async_") ? "event" : externalTarget?.toKind ?? "external_endpoint";
2074
+ const targetId = callType === "local_db_query" ? String(call.query_entity ?? "unknown") : callType === "remote_action" ? op ? `Remote action: ${op}` : call.unresolved_reason === "dynamic_operation_path_identifier" ? "Remote action: dynamic path" : "Remote action: unknown path" : callType === "external_http" ? String(externalTarget?.toId ?? "unknown") : String(call.event_name_expr ?? op ?? "unknown");
1935
2075
  const graphLevelDynamic = edgeType === "DYNAMIC_EDGE_CANDIDATE" && resolution.status === "dynamic";
1936
- 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), graphLevelDynamic ? 1 : 0, unresolvedReason, generation);
2076
+ const finalEvidence = externalTarget ? { ...evidence, externalTarget } : evidence;
2077
+ 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(finalEvidence), graphLevelDynamic ? 1 : 0, unresolvedReason, generation);
1937
2078
  return { status, callType };
1938
2079
  }
1939
2080
  function unresolvedOperationReason(resolution) {
@@ -2187,28 +2328,16 @@ function candidateEvidence(candidate, rank) {
2187
2328
  };
2188
2329
  }
2189
2330
  function implementationMethodSignal(row, operation) {
2190
- const op = normalizedOperation(String(operation.operationPath ?? operation.operationName ?? ""));
2331
+ const op = normalizedOperationName(String(operation.operationPath ?? operation.operationName ?? ""));
2191
2332
  const decorator = normalizeDecoratorOperation(typeof row.decoratorValue === "string" ? row.decoratorValue : void 0, typeof row.decoratorRawExpression === "string" ? row.decoratorRawExpression : void 0);
2192
2333
  if (decorator && decorator === op) return { matches: true, contradicted: false, acceptedReasons: ["decorator targets operation"], rejectedReasons: [] };
2193
2334
  if (decorator && decorator !== op) return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ["method_name_matches_but_decorator_targets_different_operation"] };
2194
2335
  if (String(row.methodName ?? "") === op) return { matches: true, contradicted: false, acceptedReasons: ["method name fallback matched operation"], rejectedReasons: [] };
2195
2336
  return { matches: false, contradicted: false, acceptedReasons: [], rejectedReasons: ["method name does not match operation"] };
2196
2337
  }
2197
- function normalizeDecoratorOperation(value, raw) {
2198
- const candidate = value ?? raw?.split(".").filter(Boolean).at(-2);
2199
- if (!candidate) return void 0;
2200
- const cleaned = candidate.replace(/^['"`]|['"`]$/g, "");
2201
- for (const prefix of ["Func", "Action"]) {
2202
- if (cleaned.startsWith(prefix) && cleaned.length > prefix.length) return lowerFirst(cleaned.slice(prefix.length));
2203
- }
2204
- return normalizedOperation(cleaned);
2205
- }
2206
2338
  function upperFirst(value) {
2207
2339
  return value ? `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}` : value;
2208
2340
  }
2209
- function lowerFirst(value) {
2210
- return value ? `${value[0]?.toLowerCase() ?? ""}${value.slice(1)}` : value;
2211
- }
2212
2341
  function flag(value) {
2213
2342
  return Boolean(Number(value ?? 0));
2214
2343
  }
@@ -2227,6 +2356,25 @@ function normalizeOperation(value) {
2227
2356
  function positiveDepth(value) {
2228
2357
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : 25;
2229
2358
  }
2359
+ function operationStartScope(db, repoId, start) {
2360
+ const requested = normalizeOperation(start.operationPath ?? start.operation);
2361
+ if (!requested) return void 0;
2362
+ const rows2 = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_path operationPath,s.service_path servicePath,r.id repoId,r.name repoName
2363
+ FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
2364
+ WHERE (? IS NULL OR r.id=?) AND (? IS NULL OR s.service_path=?) AND (o.operation_name=? OR o.operation_path=? OR o.operation_path=?)
2365
+ ORDER BY r.name,s.service_path,o.operation_name,o.id`).all(repoId, repoId, start.servicePath, start.servicePath, requested, requested, requested.startsWith("/") ? requested : `/${requested}`);
2366
+ if (rows2.length === 0) return { diagnostics: [{ severity: "warning", code: "trace_start_not_found", message: "No indexed operation matched the requested trace selector", normalizedSelectorValue: requested }] };
2367
+ const repoCount = new Set(rows2.map((row) => String(row.repoName))).size;
2368
+ const serviceCount = new Set(rows2.map((row) => `${String(row.repoName)}:${String(row.servicePath)}`)).size;
2369
+ if (!repoId && repoCount > 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple repositories; add --repo to disambiguate", normalizedSelectorValue: requested, candidates: rows2 }] };
2370
+ if (!start.servicePath && serviceCount > 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple services; add --service to disambiguate", normalizedSelectorValue: requested, candidates: rows2 }] };
2371
+ if (rows2.length !== 1) return { diagnostics: [{ severity: "warning", code: "trace_start_ambiguous", message: "Operation trace start matched multiple indexed operations", normalizedSelectorValue: requested, candidates: rows2 }] };
2372
+ const operationId = String(rows2[0]?.operationId);
2373
+ const impl = implementationScope(db, operationId);
2374
+ if (impl.edge?.status === "resolved" && impl.files.size > 0) return { files: impl.files, symbols: impl.symbolId ? /* @__PURE__ */ new Set([impl.symbolId]) : void 0, operationId, diagnostics: [] };
2375
+ if (impl.edge) return { operationId, diagnostics: [{ severity: "warning", code: impl.edge.status === "ambiguous" ? "trace_start_ambiguous" : "trace_start_not_found", message: `Indexed operation matched but implementation edge is ${String(impl.edge.status ?? "unresolved")}`, implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, candidates: parseEvidence(impl.edge.evidence_json).candidates }] };
2376
+ return { operationId, diagnostics: [{ severity: "warning", code: "trace_start_not_found", message: "Indexed operation matched but no implementation candidate exists" }] };
2377
+ }
2230
2378
  function sourceFilesForStart(db, repoId, start) {
2231
2379
  const handler = start.handler;
2232
2380
  const operation = normalizeOperation(start.operation ?? start.operationPath);
@@ -2272,7 +2420,8 @@ function startScope(db, start) {
2272
2420
  "SELECT id,name FROM repositories WHERE name=? OR package_name=?"
2273
2421
  ).get(start.repo, start.repo) : void 0;
2274
2422
  if (start.repo && !repo) return { repo, selectorMatched: false };
2275
- const sourceScope = sourceFilesForStart(db, repo?.id, start);
2423
+ const operationScope = operationStartScope(db, repo?.id, start);
2424
+ const sourceScope = operationScope?.files ? operationScope : sourceFilesForStart(db, repo?.id, start);
2276
2425
  const sourceFiles = sourceScope?.files;
2277
2426
  const hasSelector = Boolean(
2278
2427
  start.handler ?? start.operation ?? start.operationPath ?? start.servicePath
@@ -2283,7 +2432,9 @@ function startScope(db, start) {
2283
2432
  repo,
2284
2433
  sourceFiles,
2285
2434
  symbolIds: sourceScope?.symbols,
2286
- selectorMatched: !hasSelector || sourceFiles !== void 0
2435
+ selectorMatched: !hasSelector || sourceFiles !== void 0,
2436
+ startOperationId: operationScope?.operationId,
2437
+ startDiagnostics: operationScope?.diagnostics
2287
2438
  };
2288
2439
  }
2289
2440
  function handlerFilesForOperation(db, operationId) {
@@ -2530,6 +2681,10 @@ function edgeTarget(row, evidence) {
2530
2681
  const targetRepo = typeof evidence.targetRepo === "string" ? evidence.targetRepo : "";
2531
2682
  if (row.edge_type === "HANDLER_RUNS_DB_QUERY") return `Entity: ${row.to_id || "unknown"}`;
2532
2683
  if (row.edge_type === "HANDLER_RUNS_REMOTE_QUERY") return typeof evidence.remoteQueryTarget === "string" ? evidence.remoteQueryTarget : `Remote query: ${row.to_id || "unknown"}`;
2684
+ if (row.edge_type === "HANDLER_CALLS_EXTERNAL_HTTP") {
2685
+ const target = evidence.externalTarget;
2686
+ return typeof target?.label === "string" ? target.label : `External endpoint: ${row.to_id || "unknown"}`;
2687
+ }
2533
2688
  return servicePath && operationPath ? `${servicePath}${operationPath}` : targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
2534
2689
  }
2535
2690
  function trace(db, start, options) {
@@ -2540,7 +2695,8 @@ function trace(db, start, options) {
2540
2695
  const stale = db.prepare("SELECT name,graph_stale_reason reason FROM repositories WHERE graph_stale_reason IS NOT NULL AND (? IS NULL OR id=?)").all(scope.repo?.id, scope.repo?.id);
2541
2696
  for (const row of stale)
2542
2697
  diagnostics.unshift({ severity: "warning", code: "graph_stale", message: `Graph is stale for ${row.name ?? "repository"}: ${row.reason ?? "facts_changed"}. Run service-flow link.` });
2543
- if (!scope.selectorMatched)
2698
+ for (const diagnostic of scope.startDiagnostics ?? []) diagnostics.unshift(diagnostic);
2699
+ if (!scope.selectorMatched && !scope.startDiagnostics?.length)
2544
2700
  diagnostics.unshift({
2545
2701
  severity: "warning",
2546
2702
  code: "trace_start_not_found",
@@ -2549,27 +2705,21 @@ function trace(db, start, options) {
2549
2705
  const maxDepth = positiveDepth(options.depth);
2550
2706
  const edges = [];
2551
2707
  const nodes = /* @__PURE__ */ new Map();
2708
+ const seenEdges = /* @__PURE__ */ new Set();
2552
2709
  const queue = scope.selectorMatched ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: /* @__PURE__ */ new Map() }] : [];
2553
- if (start.servicePath && (start.operation ?? start.operationPath)) {
2554
- const startOperation = normalizeOperation(start.operation ?? start.operationPath);
2555
- const startRows = db.prepare(`SELECT o.id operationId,r.name repoName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) AND s.service_path=? AND (o.operation_path=? OR o.operation_name=?) ORDER BY r.name,o.id LIMIT 2`).all(scope.repo?.id, scope.repo?.id, start.servicePath, startOperation, startOperation);
2556
- if (!scope.repo && startRows.length > 1) diagnostics.unshift({ severity: "warning", code: "trace_start_ambiguous", message: "Service/path trace start matched multiple repositories; add --repo to disambiguate", candidates: startRows });
2557
- const row = startRows.length === 1 ? startRows[0] : void 0;
2558
- if (row?.operationId !== void 0) {
2559
- const opId = String(row.operationId);
2560
- const op = operationNode(db, opId);
2561
- const impl = implementationScope(db, opId);
2562
- if (op) nodes.set(String(op.id), op);
2563
- if (impl.edge) {
2564
- const implEvidence = JSON.parse(String(impl.edge.evidence_json || "{}"));
2565
- const handlerNode = impl.edge.status === "resolved" ? handlerMethodNode(db, impl.edge.to_id) : void 0;
2566
- if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
2567
- edges.push({ step: 1, type: "operation_implemented_by_handler", from: op?.label ? String(op.label) : `${start.servicePath}/${startOperation ?? ""}`, to: handlerNode?.label ? String(handlerNode.label) : `${impl.edge.to_kind}:${impl.edge.to_id}`, evidence: implEvidence, confidence: Number(impl.edge.confidence ?? 0), unresolvedReason: impl.edge.status === "resolved" ? void 0 : String(impl.edge.unresolved_reason ?? impl.edge.status) });
2568
- }
2710
+ if (scope.startOperationId) {
2711
+ const op = operationNode(db, scope.startOperationId);
2712
+ const impl = implementationScope(db, scope.startOperationId);
2713
+ if (op) nodes.set(String(op.id), op);
2714
+ if (impl.edge) {
2715
+ const implEvidence = { ...parseEvidence(impl.edge.evidence_json), startResolution: { strategy: "indexed_operation_graph", matchedOperationId: scope.startOperationId, implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, selectedHandlerMethodId: impl.edge.status === "resolved" ? impl.edge.to_id : void 0 } };
2716
+ const handlerNode = impl.edge.status === "resolved" ? handlerMethodNode(db, impl.edge.to_id) : void 0;
2717
+ if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
2718
+ seenEdges.add(Number(impl.edge.id));
2719
+ edges.push({ step: 1, type: "operation_implemented_by_handler", from: op?.label ? String(op.label) : `operation:${scope.startOperationId}`, to: handlerNode?.label ? String(handlerNode.label) : `${impl.edge.to_kind}:${impl.edge.to_id}`, evidence: implEvidence, confidence: Number(impl.edge.confidence ?? 0), unresolvedReason: impl.edge.status === "resolved" ? void 0 : String(impl.edge.unresolved_reason ?? impl.edge.status) });
2569
2720
  }
2570
2721
  }
2571
2722
  const seenScopes = /* @__PURE__ */ new Set();
2572
- const seenEdges = /* @__PURE__ */ new Set();
2573
2723
  while (queue.length > 0) {
2574
2724
  const current = queue.shift();
2575
2725
  if (!current || current.depth > maxDepth) continue;
@@ -2717,4 +2867,4 @@ export {
2717
2867
  linkWorkspace,
2718
2868
  trace
2719
2869
  };
2720
- //# sourceMappingURL=chunk-UHYHSYCS.js.map
2870
+ //# sourceMappingURL=chunk-AM2JZFQ6.js.map