@saptools/service-flow 0.1.8 → 0.1.10

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.10
4
+
5
+ - Persist unresolved `OPERATION_IMPLEMENTED_BY_HANDLER` audit edges when implementation candidates exist but all are rejected, including ranked candidate evidence and rejected reasons.
6
+ - Added a conservative helper-owned implementation path for unique registered helper handlers that implement model-oriented CDS operations without direct package dependency evidence, while keeping multiple helper matches ambiguous and local service-path contradictions rejected.
7
+ - Trace output now includes operation-to-handler implementation hops and terminal handler nodes in JSON/table/Mermaid-compatible edge data, including runtime-resolved operation targets.
8
+ - Doctor now reports rejected implementation candidates and strict remote-target implementation coverage gaps without making entity-only services noisy by default.
9
+ - Updated Node compatibility errors, package metadata, and README wording so current behavior is not described with stale release-specific version strings.
10
+
11
+ ## 0.1.9
12
+
13
+ - Fixed implementation dependency matching by binding graph repository ids as text when comparing against `graph_edges.from_id` and `graph_edges.to_id`, restoring cross-package application-to-model and application-to-handler evidence under `node:sqlite`.
14
+ - Improved implementation candidate ranking with model/application/handler package ownership, exact local service-path evidence, and cross-package dependency/import signals so duplicate operation names in different services resolve to the correct registered handler without false ambiguity.
15
+ - Expanded `OPERATION_IMPLEMENTED_BY_HANDLER` evidence with candidate ranks, scores, accepted/rejected reasons, package identities, service/operation paths, and dependency/ownership signals for auditability.
16
+ - Added neutral regression coverage for cross-package app/model/handler registration, duplicate same-name service operations, and graph-id string comparison behavior.
17
+
3
18
  ## 0.1.8
4
19
 
5
20
  - Replaced regex-only handler registration extraction with TypeScript AST evidence for direct handler arrays, identifier arrays, spreads, imported arrays, default exports, aliases, and safe relative re-exports.
package/README.md CHANGED
@@ -47,12 +47,12 @@ npm install @saptools/service-flow
47
47
  ```
48
48
 
49
49
  > [!NOTE]
50
- > Requires **Node.js ≥ 24.0.0** for the bundled `node:sqlite` runtime. Version 0.1.8 uses a persistent SQLite driver (`node:sqlite` in supported Node builds) for bound parameters, transactions, WAL, busy timeouts, and read-only query commands. The analyzer is static: it reads files and package metadata, but it does not start CAP services, connect to SAP BTP, or execute application code.
50
+ > Requires **Node.js ≥ 24.0.0** for the bundled `node:sqlite` runtime. The CLI uses a persistent SQLite driver (`node:sqlite` in supported Node builds) for bound parameters, transactions, WAL, busy timeouts, and read-only query commands. The analyzer is static: it reads files and package metadata, but it does not start CAP services, connect to SAP BTP, or execute application code.
51
51
 
52
52
  ---
53
53
 
54
54
 
55
- ### Correctness notes for 0.1.8
55
+ ### Correctness notes
56
56
 
57
57
  - Runtime `--var` values are considered only for dynamic, ambiguous, or unresolved **remote** graph edges whose alias, destination, service path, or operation path expressions contain supplied placeholders. Local database, external HTTP, event, and already resolved static edges keep their persisted status, target, reason, and confidence. Partial substitutions remain dynamic and report the missing placeholder names.
58
58
  - `trace` and `graph` both accept repeatable `--var key=value` options. Effective substitutions are rendered in trace evidence without mutating the persisted graph. Confidence values are bounded to `[0, 1]`.
@@ -60,7 +60,7 @@ npm install @saptools/service-flow
60
60
  - Helper-package dependency edges prefer exact indexed package names. Duplicate package-name candidates are persisted as ambiguous evidence rather than silently selecting one repository.
61
61
  - Handler registration parsing is AST-based for common `createCombinedHandler({ handler: ... })` forms: direct arrays, arrays assembled with spreads, non-`handlers` array names, aliased class imports, default-imported arrays, named exported arrays, and safe relative re-exports. Class-level rows keep registration file/line and import evidence.
62
62
  - Implementation edges require both operation compatibility and registration evidence. Same-repository registrations do not need a self-dependency edge; cross-package matches use registration or handler-package dependencies on the model package. Duplicate strong candidates are stored as ambiguous implementation edges.
63
- - Traces prefer persisted `OPERATION_IMPLEMENTED_BY_HANDLER` edges after static or runtime remote operation resolution, then fall back to same-repository decorator matching for simple projects.
63
+ - Traces render persisted `OPERATION_IMPLEMENTED_BY_HANDLER` hops after static or runtime remote operation resolution, including terminal handler nodes and ambiguous or unresolved implementation evidence when traversal cannot continue.
64
64
  - Repository fingerprints include source content, package name/version, dependencies and devDependencies, scripts, normalized `cds.requires` (including nested credentials), package file content, and the analyzer version. Metadata-only changes therefore trigger reindexing.
65
65
  - Index publication is designed around the last-good snapshot: failed parse or persistence attempts are recorded as diagnostics and must not be mixed with older graph facts. After indexing changes, relink before relying on graph/trace output; doctor reports stale or inconsistent stores where detectable.
66
66
  - Source discovery, file reads, hashing, parsing, and publication are all inside the repository-level protected indexing flow. A failed read keeps the previous fingerprint and facts, marks the repository failed, and records a `source_read_failed` diagnostic; a later successful index clears superseded read-failure diagnostics during fact publication.
@@ -1177,7 +1177,7 @@ function linkWorkspace(db, workspaceId, vars = {}) {
1177
1177
  const callSummary = linkCalls(db, workspaceId, vars, generation);
1178
1178
  const impl = linkImplementations(db, workspaceId, generation);
1179
1179
  db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
1180
- return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount };
1180
+ return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount };
1181
1181
  });
1182
1182
  }
1183
1183
  function nextGraphGeneration(db, workspaceId) {
@@ -1225,23 +1225,45 @@ function callEvidence(call, resolution, servicePath, op, destination) {
1225
1225
  return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, targetRepo: resolution.target?.repoName, targetOperation: resolution.target?.operationName, helperChain: call.helperChainJson ? JSON.parse(String(call.helperChainJson)) : void 0, candidates: resolution.candidates, candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, analysisCompleteness: call.unresolved_reason ? "partial" : "complete", parserWarning: call.unresolved_reason ? { code: "parser_warning", message: call.unresolved_reason } : void 0 };
1226
1226
  }
1227
1227
  function linkImplementations(db, workspaceId, generation) {
1228
- const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,s.service_path servicePath,s.repo_id modelRepoId,r.package_name modelPackage FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=?`).all(workspaceId);
1228
+ const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=?`).all(workspaceId);
1229
1229
  let edgeCount = 0;
1230
1230
  let resolvedCount = 0;
1231
1231
  let ambiguousCount = 0;
1232
+ let unresolvedCount = 0;
1232
1233
  for (const operation of operations) {
1233
- const rows2 = implementationCandidates(db, workspaceId, operation);
1234
- if (rows2.length === 0) continue;
1235
- const unique = rows2.length === 1 ? rows2[0] : void 0;
1236
- 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, "OPERATION_IMPLEMENTED_BY_HANDLER", unique ? "resolved" : "ambiguous", "operation", String(operation.operationId), unique ? "handler_method" : "handler_method_candidates", unique ? String(unique.methodId) : rows2.map((row) => row.methodId).join(","), unique ? 0.95 : 0.5, JSON.stringify({ servicePath: operation.servicePath, operationPath: operation.operationPath, operationName: operation.operationName, candidates: rows2, evidence: "registered_application_dependency" }), 0, unique ? null : "Ambiguous registered handler implementation candidates", generation);
1234
+ const candidates = rankedImplementationCandidates(db, workspaceId, operation);
1235
+ if (candidates.length === 0) continue;
1236
+ const accepted = candidates.filter((candidate) => candidate.accepted);
1237
+ const topScore = accepted[0]?.score ?? 0;
1238
+ const winners = accepted.filter((candidate) => candidate.score === topScore);
1239
+ const unique = winners.length === 1 ? winners[0] : void 0;
1240
+ const evidence = {
1241
+ servicePath: operation.servicePath,
1242
+ operationPath: operation.operationPath,
1243
+ operationName: operation.operationName,
1244
+ modelPackage: { id: operation.modelRepoId, name: operation.modelRepo, packageName: operation.modelPackage },
1245
+ candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1))
1246
+ };
1247
+ if (accepted.length === 0) {
1248
+ 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, "OPERATION_IMPLEMENTED_BY_HANDLER", "unresolved", "operation", graphId(operation.operationId), "handler_method_candidates", candidates.map((row) => graphId(row.methodId)).join(","), 0, JSON.stringify(evidence), 0, "No implementation candidate passed policy", generation);
1249
+ edgeCount += 1;
1250
+ unresolvedCount += 1;
1251
+ continue;
1252
+ }
1253
+ 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, "OPERATION_IMPLEMENTED_BY_HANDLER", unique ? "resolved" : "ambiguous", "operation", graphId(operation.operationId), unique ? "handler_method" : "handler_method_candidates", unique ? graphId(unique.methodId) : winners.map((row) => graphId(row.methodId)).join(","), unique ? 0.95 : 0.5, JSON.stringify(evidence), 0, unique ? null : "Ambiguous registered handler implementation candidates", generation);
1237
1254
  edgeCount += 1;
1238
1255
  if (unique) resolvedCount += 1;
1239
1256
  else ambiguousCount += 1;
1240
1257
  }
1241
- return { edgeCount, resolvedCount, ambiguousCount };
1258
+ return { edgeCount, resolvedCount, ambiguousCount, unresolvedCount };
1259
+ }
1260
+ function rankedImplementationCandidates(db, workspaceId, operation) {
1261
+ const rows2 = implementationCandidates(db, workspaceId, operation);
1262
+ return rows2.map((row) => scoreImplementationCandidate(row, operation)).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
1242
1263
  }
1243
1264
  function implementationCandidates(db, workspaceId, operation) {
1244
- const rows2 = db.prepare(`SELECT DISTINCT
1265
+ const modelRepoGraphId = graphId(operation.modelRepoId);
1266
+ return db.prepare(`SELECT DISTINCT
1245
1267
  hm.id methodId,
1246
1268
  hc.id classId,
1247
1269
  hc.class_name className,
@@ -1250,21 +1272,153 @@ function implementationCandidates(db, workspaceId, operation) {
1250
1272
  hr.repo_id applicationRepoId,
1251
1273
  hr.registration_file registrationFile,
1252
1274
  hr.registration_line registrationLine,
1275
+ hr.registration_kind registrationKind,
1253
1276
  hr.import_source importSource,
1254
1277
  handlerRepo.id handlerRepoId,
1255
1278
  handlerRepo.name handlerRepo,
1279
+ handlerRepo.package_name handlerPackage,
1256
1280
  appRepo.name applicationRepo,
1281
+ appRepo.package_name applicationPackage,
1282
+ ? modelRepoId,
1283
+ ? modelRepo,
1284
+ ? modelPackage,
1285
+ ? modelKind,
1286
+ ? servicePath,
1287
+ ? operationPath,
1288
+ ? operationName,
1289
+ CASE WHEN appRepo.id=? THEN 1 ELSE 0 END modelIsApplicationRepo,
1290
+ CASE WHEN handlerRepo.id=? THEN 1 ELSE 0 END modelIsHandlerRepo,
1257
1291
  CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
1258
- CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(? AS TEXT)) THEN 1 ELSE 0 END appDependsOnModel,
1259
- CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=CAST(? AS TEXT)) THEN 1 ELSE 0 END handlerDependsOnModel
1292
+ CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id AND localService.service_path=?) THEN 1 ELSE 0 END localServicePathMatch,
1293
+ CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
1294
+ CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON (localClass.id=localReg.handler_class_id OR localClass.class_name=localReg.class_name) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
1295
+ CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END appDependsOnModel,
1296
+ CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(handlerRepo.id AS TEXT)) THEN 1 ELSE 0 END appDependsOnHandler,
1297
+ CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END handlerDependsOnModel
1260
1298
  FROM handler_methods hm
1261
1299
  JOIN handler_classes hc ON hc.id=hm.handler_class_id
1262
1300
  JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id
1263
1301
  JOIN handler_registrations hr ON (hr.handler_class_id=hc.id OR (hr.class_name=hc.class_name AND (hr.repo_id=hc.repo_id OR hr.import_source IS NOT NULL)))
1264
1302
  JOIN repositories appRepo ON appRepo.id=hr.repo_id
1265
1303
  WHERE appRepo.workspace_id=?
1266
- AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=?)`).all(operation.modelRepoId, operation.modelRepoId, workspaceId, normalizedOperation(String(operation.operationPath ?? "")), operation.operationName, operation.operationName);
1267
- return rows2.filter((row) => Boolean(Number(row.sameRepoRegistration)) || Boolean(Number(row.appDependsOnModel)) || Boolean(Number(row.handlerDependsOnModel)));
1304
+ AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=?)`).all(
1305
+ operation.modelRepoId,
1306
+ operation.modelRepo,
1307
+ operation.modelPackage,
1308
+ operation.modelKind,
1309
+ operation.servicePath,
1310
+ operation.operationPath,
1311
+ operation.operationName,
1312
+ operation.modelRepoId,
1313
+ operation.modelRepoId,
1314
+ operation.servicePath,
1315
+ normalizedOperation(String(operation.operationPath ?? "")),
1316
+ operation.operationName,
1317
+ operation.operationName,
1318
+ modelRepoGraphId,
1319
+ modelRepoGraphId,
1320
+ workspaceId,
1321
+ normalizedOperation(String(operation.operationPath ?? "")),
1322
+ operation.operationName,
1323
+ operation.operationName
1324
+ );
1325
+ }
1326
+ function scoreImplementationCandidate(row, operation) {
1327
+ const acceptedReasons = [];
1328
+ const rejectedReasons = [];
1329
+ let score = 0;
1330
+ const modelIsApplicationRepo = flag(row.modelIsApplicationRepo);
1331
+ const modelIsHandlerRepo = flag(row.modelIsHandlerRepo);
1332
+ const localServicePathMatch = flag(row.localServicePathMatch);
1333
+ const applicationHasLocalServices = flag(row.applicationHasLocalServices);
1334
+ const appDependsOnModel = flag(row.appDependsOnModel);
1335
+ const applicationHasLocalRegistrationForOperation = flag(row.applicationHasLocalRegistrationForOperation);
1336
+ const appDependsOnHandler = flag(row.appDependsOnHandler);
1337
+ const handlerDependsOnModel = flag(row.handlerDependsOnModel);
1338
+ const importSource = typeof row.importSource === "string" && row.importSource.length > 0;
1339
+ const sameRepoRegistration = flag(row.sameRepoRegistration);
1340
+ const modelOriented = row.modelKind === "cap-db-model" || !applicationHasLocalRegistrationForOperation;
1341
+ const methodMatches = true;
1342
+ const registeredAndLinked = sameRepoRegistration && importSource;
1343
+ const helperOwned = modelOriented && methodMatches && registeredAndLinked && sameRepoRegistration && !applicationHasLocalServices && !modelIsApplicationRepo && !modelIsHandlerRepo && !localServicePathMatch && !appDependsOnModel && !appDependsOnHandler && !handlerDependsOnModel;
1344
+ if (modelIsApplicationRepo) {
1345
+ score += 100;
1346
+ acceptedReasons.push("model package equals registration package");
1347
+ }
1348
+ if (modelIsHandlerRepo) {
1349
+ score += 100;
1350
+ acceptedReasons.push("model package equals handler package");
1351
+ }
1352
+ if (localServicePathMatch) {
1353
+ score += 80;
1354
+ acceptedReasons.push("registration package contains exact local service path");
1355
+ } else if (applicationHasLocalServices && !appDependsOnModel && !modelIsApplicationRepo) {
1356
+ rejectedReasons.push(`registration package has local services but none match ${String(operation.servicePath ?? "")}`);
1357
+ }
1358
+ if (appDependsOnModel) {
1359
+ score += 70;
1360
+ acceptedReasons.push("registration package depends on model package");
1361
+ }
1362
+ if (appDependsOnHandler) {
1363
+ score += 30;
1364
+ acceptedReasons.push("registration package depends on handler package");
1365
+ }
1366
+ if (handlerDependsOnModel) {
1367
+ score += 20;
1368
+ acceptedReasons.push("handler package depends on model package");
1369
+ }
1370
+ if (helperOwned) {
1371
+ score += 60;
1372
+ acceptedReasons.push("unique registered helper implementation for model-only operation");
1373
+ }
1374
+ if (importSource) {
1375
+ score += 10;
1376
+ acceptedReasons.push("registration imports handler class");
1377
+ }
1378
+ const hasOwnership = modelIsApplicationRepo || modelIsHandlerRepo;
1379
+ const hasCrossPackage = appDependsOnModel && (modelIsHandlerRepo || appDependsOnHandler || !importSource);
1380
+ const contradicted = applicationHasLocalServices && !localServicePathMatch && !appDependsOnModel && !hasOwnership;
1381
+ if (!hasOwnership && !localServicePathMatch && !hasCrossPackage && !helperOwned) rejectedReasons.push("missing direct ownership, exact local service path, or validated cross-package dependency evidence");
1382
+ const accepted = !contradicted && (hasOwnership || localServicePathMatch || hasCrossPackage || handlerDependsOnModel || helperOwned);
1383
+ if (!accepted && rejectedReasons.length === 0) rejectedReasons.push("candidate did not meet implementation ownership policy");
1384
+ return { ...row, methodId: Number(row.methodId), score, accepted, acceptedReasons, rejectedReasons };
1385
+ }
1386
+ function candidateEvidence(candidate, rank) {
1387
+ return {
1388
+ rank,
1389
+ score: candidate.score,
1390
+ accepted: candidate.accepted,
1391
+ acceptedReasons: candidate.acceptedReasons,
1392
+ rejectedReasons: candidate.rejectedReasons,
1393
+ methodId: candidate.methodId,
1394
+ classId: candidate.classId,
1395
+ className: candidate.className,
1396
+ sourceFile: candidate.sourceFile,
1397
+ sourceLine: candidate.sourceLine,
1398
+ registration: { file: candidate.registrationFile, line: candidate.registrationLine, kind: candidate.registrationKind, importSource: candidate.importSource },
1399
+ applicationPackage: { id: candidate.applicationRepoId, name: candidate.applicationRepo, packageName: candidate.applicationPackage },
1400
+ handlerPackage: { id: candidate.handlerRepoId, name: candidate.handlerRepo, packageName: candidate.handlerPackage },
1401
+ modelPackage: { id: candidate.modelRepoId, name: candidate.modelRepo, packageName: candidate.modelPackage },
1402
+ servicePath: candidate.servicePath,
1403
+ operationPath: candidate.operationPath,
1404
+ operationName: candidate.operationName,
1405
+ signals: {
1406
+ directOwnership: { modelIsApplicationRepo: flag(candidate.modelIsApplicationRepo), modelIsHandlerRepo: flag(candidate.modelIsHandlerRepo) },
1407
+ localServicePathMatch: flag(candidate.localServicePathMatch),
1408
+ applicationHasLocalServices: flag(candidate.applicationHasLocalServices),
1409
+ applicationHasLocalRegistrationForOperation: flag(candidate.applicationHasLocalRegistrationForOperation),
1410
+ appDependsOnModel: flag(candidate.appDependsOnModel),
1411
+ appDependsOnHandler: flag(candidate.appDependsOnHandler),
1412
+ handlerDependsOnModel: flag(candidate.handlerDependsOnModel),
1413
+ sameRepoRegistration: flag(candidate.sameRepoRegistration)
1414
+ }
1415
+ };
1416
+ }
1417
+ function flag(value) {
1418
+ return Boolean(Number(value ?? 0));
1419
+ }
1420
+ function graphId(value) {
1421
+ return String(value);
1268
1422
  }
1269
1423
  function normalizedOperation(value) {
1270
1424
  return value.startsWith("/") ? value.slice(1) : value;
@@ -1305,9 +1459,17 @@ function sourceFilesForStart(db, repoId, start) {
1305
1459
  operation,
1306
1460
  operation
1307
1461
  );
1308
- if (start.servicePath && rows2.length === 0) return void 0;
1309
- if (rows2.length === 0) return void 0;
1310
- return new Set(rows2.map((row) => row.sourceFile).filter(Boolean));
1462
+ if (rows2.length > 0) return new Set(rows2.map((row) => row.sourceFile).filter(Boolean));
1463
+ if (start.servicePath && operation) {
1464
+ const implRows = db.prepare(`SELECT DISTINCT hc.source_file sourceFile
1465
+ FROM cds_services s JOIN cds_operations o ON o.service_id=s.id
1466
+ JOIN graph_edges e ON e.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND e.status='resolved' AND e.from_kind='operation' AND e.from_id=CAST(o.id AS TEXT)
1467
+ JOIN handler_methods hm ON hm.id=CAST(e.to_id AS INTEGER)
1468
+ JOIN handler_classes hc ON hc.id=hm.handler_class_id
1469
+ WHERE (? IS NULL OR s.repo_id=?) AND s.service_path=? AND (o.operation_path=? OR o.operation_name=?)`).all(repoId, repoId, start.servicePath, operation, operation);
1470
+ if (implRows.length > 0) return new Set(implRows.map((row) => row.sourceFile).filter(Boolean));
1471
+ }
1472
+ return void 0;
1311
1473
  }
1312
1474
  function startScope(db, start) {
1313
1475
  const repo = start.repo ? db.prepare(
@@ -1340,11 +1502,19 @@ function handlerFilesForOperation(db, operationId) {
1340
1502
  ).all(op.repoId, operation, operation, op.operationName);
1341
1503
  return new Set(rows2.map((row) => row.sourceFile).filter(Boolean));
1342
1504
  }
1505
+ function implementationEdge(db, operationId) {
1506
+ return db.prepare("SELECT * FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND from_kind='operation' AND from_id=? ORDER BY CASE status WHEN 'resolved' THEN 0 WHEN 'ambiguous' THEN 1 ELSE 2 END,id LIMIT 1").get(operationId);
1507
+ }
1508
+ function handlerMethodNode(db, methodId) {
1509
+ const row = db.prepare(`SELECT hm.id methodId,hm.method_name methodName,hm.decorator_value decoratorValue,hm.source_line sourceLine,hc.class_name className,hc.source_file sourceFile,r.name repoName,r.id repoId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id JOIN repositories r ON r.id=hc.repo_id WHERE hm.id=?`).get(methodId);
1510
+ if (!row) return void 0;
1511
+ return { id: `handler_method:${methodId}`, kind: "handler_method", label: `${String(row.repoName)}:${String(row.className)}.${String(row.methodName)}`, ...row };
1512
+ }
1343
1513
  function implementationScope(db, operationId) {
1344
- const edge = db.prepare("SELECT * FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND status='resolved' AND from_kind='operation' AND from_id=? ORDER BY id LIMIT 1").get(operationId);
1345
- if (!edge) return { files: /* @__PURE__ */ new Set() };
1514
+ const edge = implementationEdge(db, operationId);
1515
+ if (!edge || edge.status !== "resolved") return { files: /* @__PURE__ */ new Set(), edge };
1346
1516
  const row = db.prepare("SELECT hc.repo_id repoId,hc.source_file sourceFile FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id WHERE hm.id=?").get(edge.to_id);
1347
- return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []) };
1517
+ return { repoId: row?.repoId, files: new Set(row?.sourceFile ? [row.sourceFile] : []), edge };
1348
1518
  }
1349
1519
  function includeCall(type, options) {
1350
1520
  if (!options.includeDb && type === "local_db_query") return false;
@@ -1443,6 +1613,22 @@ function trace(db, start, options) {
1443
1613
  const edges = [];
1444
1614
  const nodes = /* @__PURE__ */ new Map();
1445
1615
  const queue = scope.selectorMatched ? [{ repoId: scope.repo?.id, files: scope.sourceFiles, depth: 1 }] : [];
1616
+ if (start.servicePath && (start.operation ?? start.operationPath)) {
1617
+ const startOperation = normalizeOperation(start.operation ?? start.operationPath);
1618
+ const row = db.prepare(`SELECT o.id operationId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE s.service_path=? AND (o.operation_path=? OR o.operation_name=?) ORDER BY o.id LIMIT 1`).get(start.servicePath, startOperation, startOperation);
1619
+ if (row?.operationId !== void 0) {
1620
+ const opId = String(row.operationId);
1621
+ const op = operationNode(db, opId);
1622
+ const impl = implementationScope(db, opId);
1623
+ if (op) nodes.set(String(op.id), op);
1624
+ if (impl.edge) {
1625
+ const implEvidence = JSON.parse(String(impl.edge.evidence_json || "{}"));
1626
+ const handlerNode = impl.edge.status === "resolved" ? handlerMethodNode(db, impl.edge.to_id) : void 0;
1627
+ if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
1628
+ 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) });
1629
+ }
1630
+ }
1631
+ }
1446
1632
  const seenScopes = /* @__PURE__ */ new Set();
1447
1633
  const seenEdges = /* @__PURE__ */ new Set();
1448
1634
  while (queue.length > 0) {
@@ -1498,10 +1684,26 @@ function trace(db, start, options) {
1498
1684
  confidence: Number(effectiveRow.confidence ?? call.confidence),
1499
1685
  unresolvedReason: effective.unresolvedReason
1500
1686
  });
1501
- if (effectiveRow.to_kind === "operation" && current.depth < maxDepth) {
1687
+ if (effectiveRow.to_kind === "operation") {
1502
1688
  const implementation = implementationScope(db, effectiveRow.to_id);
1689
+ if (implementation.edge) {
1690
+ const implEvidence = JSON.parse(String(implementation.edge.evidence_json || "{}"));
1691
+ const handlerNode = implementation.edge.status === "resolved" ? handlerMethodNode(db, implementation.edge.to_id) : void 0;
1692
+ const implTo = handlerNode?.label ? String(handlerNode.label) : `${implementation.edge.to_kind}:${implementation.edge.to_id}`;
1693
+ if (handlerNode) nodes.set(String(handlerNode.id), handlerNode);
1694
+ edges.push({
1695
+ step: current.depth + 1,
1696
+ type: "operation_implemented_by_handler",
1697
+ from: to,
1698
+ to: implTo,
1699
+ evidence: implEvidence,
1700
+ confidence: Number(implementation.edge.confidence ?? 0),
1701
+ unresolvedReason: implementation.edge.status === "resolved" ? void 0 : String(implementation.edge.unresolved_reason ?? implementation.edge.status)
1702
+ });
1703
+ }
1704
+ if (current.depth >= maxDepth) continue;
1503
1705
  const files = implementation.files.size > 0 ? implementation.files : handlerFilesForOperation(db, effectiveRow.to_id);
1504
- if (files.size > 0) {
1706
+ if (implementation.edge?.status === "resolved" && files.size > 0) {
1505
1707
  const targetRepoId = implementation.repoId ?? db.prepare(
1506
1708
  "SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?"
1507
1709
  ).get(effectiveRow.to_id)?.repoId;
@@ -1548,4 +1750,4 @@ export {
1548
1750
  linkWorkspace,
1549
1751
  trace
1550
1752
  };
1551
- //# sourceMappingURL=chunk-NBAO2R2H.js.map
1753
+ //# sourceMappingURL=chunk-LQT67AU5.js.map