@saptools/service-flow 0.1.47 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -15,13 +15,12 @@ import {
15
15
  parsePackageJson,
16
16
  parseServiceBindings,
17
17
  trace
18
- } from "./chunk-EGY2A4AT.js";
18
+ } from "./chunk-XOROZHW4.js";
19
19
 
20
20
  // src/cli.ts
21
21
  import { Command } from "commander";
22
22
  import path6 from "path";
23
23
  import fs6 from "fs/promises";
24
- import pc from "picocolors";
25
24
 
26
25
  // src/config/defaults.ts
27
26
  var DEFAULT_IGNORES = [
@@ -211,7 +210,7 @@ function migrate(db) {
211
210
  // package.json
212
211
  var package_default = {
213
212
  name: "@saptools/service-flow",
214
- version: "0.1.47",
213
+ version: "0.1.49",
215
214
  description: "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
216
215
  type: "module",
217
216
  publishConfig: {
@@ -555,11 +554,15 @@ function insertRegistrations(db, repoId, rows) {
555
554
  }
556
555
  function insertBindings(db, repoId, rows) {
557
556
  const stmt = db.prepare(
558
- "INSERT INTO service_bindings(repo_id,variable_name,alias,alias_expr,destination_expr,service_path_expr,is_dynamic,placeholders_json,source_file,source_line,helper_chain_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)"
557
+ "INSERT INTO service_bindings(repo_id,symbol_id,variable_name,alias,alias_expr,destination_expr,service_path_expr,is_dynamic,placeholders_json,source_file,source_line,helper_chain_json) VALUES(?,(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1),?,?,?,?,?,?,?,?,?,?)"
559
558
  );
560
559
  for (const r of rows)
561
560
  stmt.run(
562
561
  repoId,
562
+ repoId,
563
+ r.sourceFile,
564
+ r.sourceLine,
565
+ r.sourceLine,
563
566
  r.variableName,
564
567
  r.alias,
565
568
  r.aliasExpr,
@@ -613,9 +616,14 @@ function resolveSymbolCallTarget(db, repoId, r) {
613
616
  }
614
617
  function insertCalls(db, repoId, rows) {
615
618
  const stmt = db.prepare(
616
- "INSERT INTO outbound_calls(repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,event_name_expr,payload_summary,source_file,source_line,confidence,unresolved_reason,local_service_name,local_service_lookup,alias_chain_json,evidence_json,external_target_kind,external_target_id,external_target_label,external_target_dynamic,service_binding_id) VALUES(?,COALESCE((SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1)),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,(SELECT id FROM service_bindings WHERE repo_id=? AND variable_name=? AND source_file=? ORDER BY CASE WHEN source_line<=? THEN 0 ELSE 1 END, ABS(source_line-?) ASC, id DESC LIMIT 1))"
619
+ "INSERT INTO outbound_calls(repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,event_name_expr,payload_summary,source_file,source_line,confidence,unresolved_reason,local_service_name,local_service_lookup,alias_chain_json,evidence_json,external_target_kind,external_target_id,external_target_label,external_target_dynamic,service_binding_id) VALUES(?,COALESCE((SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1)),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
617
620
  );
618
- for (const r of rows)
621
+ for (const r of rows) {
622
+ const binding = resolvePersistedBinding(db, repoId, r);
623
+ const evidence = {
624
+ ...r.evidence ?? {},
625
+ serviceBindingResolution: binding.evidence
626
+ };
619
627
  stmt.run(
620
628
  repoId,
621
629
  repoId,
@@ -634,21 +642,146 @@ function insertCalls(db, repoId, rows) {
634
642
  r.sourceFile,
635
643
  r.sourceLine,
636
644
  r.confidence,
637
- r.unresolvedReason,
645
+ r.unresolvedReason ?? binding.unresolvedReason,
638
646
  r.localServiceName,
639
647
  r.localServiceLookup,
640
648
  r.aliasChain ? JSON.stringify(r.aliasChain) : null,
641
- r.evidence ? JSON.stringify(r.evidence) : null,
649
+ JSON.stringify(evidence),
642
650
  r.externalTarget?.kind ?? null,
643
651
  r.externalTarget?.stableId ?? null,
644
652
  r.externalTarget?.label ?? null,
645
653
  r.externalTarget?.dynamic ? 1 : 0,
646
- repoId,
647
- r.serviceVariableName,
648
- r.sourceFile,
649
- r.sourceLine,
650
- r.sourceLine
654
+ binding.bindingId
651
655
  );
656
+ }
657
+ }
658
+ function resolvePersistedBinding(db, repoId, call) {
659
+ if (!call.serviceVariableName)
660
+ return { bindingId: null, evidence: { status: "not_applicable", candidateCount: 0 } };
661
+ const candidates = bindingCandidates(db, repoId, call);
662
+ const prior = candidates.filter((candidate) => candidate.sourceLine <= call.sourceLine);
663
+ const families = new Set(prior.map(bindingSignature));
664
+ if (prior.length > 0 && families.size === 1) {
665
+ const selected = prior.at(-1);
666
+ return {
667
+ bindingId: selected?.id ?? null,
668
+ evidence: bindingEvidence("selected", prior, selected)
669
+ };
670
+ }
671
+ if (prior.length > 1) {
672
+ return {
673
+ bindingId: null,
674
+ unresolvedReason: "ambiguous_service_binding_candidates",
675
+ evidence: bindingEvidence("ambiguous", prior)
676
+ };
677
+ }
678
+ if (candidates.length > 0) {
679
+ return {
680
+ bindingId: null,
681
+ unresolvedReason: "service_binding_declared_after_call",
682
+ evidence: bindingEvidence("rejected_future_binding", candidates)
683
+ };
684
+ }
685
+ return {
686
+ bindingId: null,
687
+ evidence: bindingEvidence("unrecoverable", [])
688
+ };
689
+ }
690
+ function bindingCandidates(db, repoId, call) {
691
+ const ownerId = callSymbolId(db, repoId, call);
692
+ const rows = db.prepare(`
693
+ SELECT id,symbol_id symbolId,variable_name variableName,alias,alias_expr aliasExpr,
694
+ destination_expr destinationExpr,service_path_expr servicePathExpr,
695
+ source_file sourceFile,source_line sourceLine,helper_chain_json helperChainJson
696
+ FROM service_bindings
697
+ WHERE repo_id=? AND variable_name=? AND source_file=?
698
+ AND (? IS NULL OR symbol_id IS NULL OR symbol_id=?)
699
+ ORDER BY source_line,id
700
+ `).all(
701
+ repoId,
702
+ call.serviceVariableName,
703
+ call.sourceFile,
704
+ ownerId,
705
+ ownerId
706
+ );
707
+ return rows.flatMap((row) => {
708
+ if (typeof row.id !== "number" || typeof row.variableName !== "string" || typeof row.sourceFile !== "string" || typeof row.sourceLine !== "number")
709
+ return [];
710
+ return [{
711
+ id: row.id,
712
+ symbolId: nullableNumber(row.symbolId),
713
+ variableName: row.variableName,
714
+ alias: nullableString(row.alias),
715
+ aliasExpr: nullableString(row.aliasExpr),
716
+ destinationExpr: nullableString(row.destinationExpr),
717
+ servicePathExpr: nullableString(row.servicePathExpr),
718
+ sourceFile: row.sourceFile,
719
+ sourceLine: row.sourceLine,
720
+ helperChainJson: nullableString(row.helperChainJson)
721
+ }];
722
+ });
723
+ }
724
+ function nullableString(value) {
725
+ return value === null || typeof value === "string" ? value : void 0;
726
+ }
727
+ function nullableNumber(value) {
728
+ return value === null || typeof value === "number" ? value : void 0;
729
+ }
730
+ function callSymbolId(db, repoId, call) {
731
+ const row = db.prepare(`
732
+ SELECT id FROM symbols
733
+ WHERE repo_id=? AND source_file=?
734
+ AND ((? IS NOT NULL AND qualified_name=?)
735
+ OR (start_line<=? AND end_line>=?))
736
+ ORDER BY CASE WHEN qualified_name=? THEN 0 ELSE 1 END,
737
+ (end_line-start_line),id
738
+ LIMIT 1
739
+ `).get(
740
+ repoId,
741
+ call.sourceFile,
742
+ call.sourceSymbolQualifiedName,
743
+ call.sourceSymbolQualifiedName,
744
+ call.sourceLine,
745
+ call.sourceLine,
746
+ call.sourceSymbolQualifiedName
747
+ );
748
+ return typeof row?.id === "number" ? row.id : void 0;
749
+ }
750
+ function bindingEvidence(status, candidates, selected) {
751
+ return {
752
+ status,
753
+ candidateCount: candidates.length,
754
+ selectedBindingId: selected?.id,
755
+ sourceOrderRule: "binding_source_line_must_not_follow_call",
756
+ candidates: candidates.map((candidate) => ({
757
+ bindingId: candidate.id,
758
+ symbolId: candidate.symbolId,
759
+ variableName: candidate.variableName,
760
+ alias: candidate.alias,
761
+ aliasExpr: candidate.aliasExpr,
762
+ destinationExpr: candidate.destinationExpr,
763
+ servicePathExpr: candidate.servicePathExpr,
764
+ sourceFile: candidate.sourceFile,
765
+ sourceLine: candidate.sourceLine,
766
+ helperChain: parseBindingChain(candidate.helperChainJson)
767
+ }))
768
+ };
769
+ }
770
+ function bindingSignature(candidate) {
771
+ return JSON.stringify([
772
+ candidate.alias,
773
+ candidate.aliasExpr,
774
+ candidate.destinationExpr,
775
+ candidate.servicePathExpr
776
+ ]);
777
+ }
778
+ function parseBindingChain(value) {
779
+ if (!value) return void 0;
780
+ try {
781
+ return JSON.parse(value);
782
+ } catch {
783
+ return void 0;
784
+ }
652
785
  }
653
786
 
654
787
  // src/discovery/classify-repository.ts
@@ -1312,6 +1445,7 @@ function doctorDiagnostics(db, strict, options = {}) {
1312
1445
  return [
1313
1446
  ...diagnostics,
1314
1447
  ...healthDiagnostics(db, strict),
1448
+ ...remoteTargetWithoutImplementationDiagnostics(db, strict, Boolean(options.detail)),
1315
1449
  ...localServiceDiagnostics(db, strict),
1316
1450
  ...schemaDriftDiagnostics(db, strict),
1317
1451
  ...analyzerVersionDiagnostics(db, strict),
@@ -1339,10 +1473,6 @@ function healthDiagnostics(db, strict) {
1339
1473
  SELECT 'warning','legacy_schema_weaker_foreign_keys','Legacy table lacks fresh-schema foreign-key metadata; rebuild the database or re-run init/index in a new database',NULL,NULL
1340
1474
  WHERE (SELECT COUNT(*) FROM pragma_foreign_key_list('graph_edges'))=0 OR (SELECT COUNT(*) FROM pragma_foreign_key_list('index_runs'))=0 OR (SELECT COUNT(*) FROM pragma_foreign_key_list('diagnostics'))=0
1341
1475
  UNION ALL
1342
- SELECT 'warning','remote_target_without_implementation','Remote target operation has no implementation edge: ' || s.service_path || o.operation_path,o.source_file,o.source_line
1343
- FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
1344
- WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation' AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id) AND ?
1345
- UNION ALL
1346
1476
  SELECT CASE WHEN ? THEN 'warning' ELSE 'error' END,'local_service_calls_all_unresolved','All local service calls are unresolved; verify local service alias parsing and linking',NULL,NULL
1347
1477
  WHERE EXISTS (SELECT 1 FROM outbound_calls WHERE call_type='local_service_call') AND NOT EXISTS (SELECT 1 FROM graph_edges e JOIN outbound_calls c ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER) WHERE c.call_type='local_service_call' AND e.status='resolved')
1348
1478
  UNION ALL
@@ -1360,7 +1490,41 @@ function healthDiagnostics(db, strict) {
1360
1490
  UNION ALL
1361
1491
  SELECT 'warning','index_run_abandoned','Index run ' || id || ' started at ' || started_at || ' is still running after the 60 minute abandonment threshold',NULL,NULL
1362
1492
  FROM index_runs WHERE status='running' AND datetime(started_at) < datetime('now','-60 minutes')`
1363
- ).all(strict, strict, strict, strict, strict, strict, strict);
1493
+ ).all(strict, strict, strict, strict, strict, strict);
1494
+ }
1495
+ function remoteTargetWithoutImplementationDiagnostics(db, strict, detail) {
1496
+ if (!strict) return [];
1497
+ const groups = db.prepare(`SELECT s.service_path servicePath,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,COUNT(*) callSiteCount
1498
+ FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
1499
+ WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
1500
+ AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id)
1501
+ GROUP BY s.service_path,o.operation_path,o.source_file,o.source_line
1502
+ ORDER BY s.service_path,o.operation_path`).all();
1503
+ return groups.map((group) => {
1504
+ const examples = remoteTargetWithoutImplementationExamples(db, String(group.servicePath ?? ""), String(group.operationPath ?? ""));
1505
+ return {
1506
+ severity: "warning",
1507
+ code: "remote_target_without_implementation",
1508
+ message: `Remote target operation has no implementation edge: ${String(group.servicePath ?? "")}${String(group.operationPath ?? "")}`,
1509
+ sourceFile: group.sourceFile,
1510
+ sourceLine: group.sourceLine,
1511
+ servicePath: group.servicePath,
1512
+ operationPath: group.operationPath,
1513
+ callSiteCount: Number(group.callSiteCount ?? 0),
1514
+ examples: examples.slice(0, 3),
1515
+ expandedExamples: detail ? examples : void 0
1516
+ };
1517
+ });
1518
+ }
1519
+ function remoteTargetWithoutImplementationExamples(db, servicePath, operationPath) {
1520
+ return db.prepare(`SELECT r.name repo,c.source_file sourceFile,c.source_line sourceLine,c.operation_path_expr operationPathExpr
1521
+ FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
1522
+ LEFT JOIN outbound_calls c ON remote.from_kind='call' AND c.id=CAST(remote.from_id AS INTEGER)
1523
+ LEFT JOIN repositories r ON r.id=c.repo_id
1524
+ WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
1525
+ AND s.service_path=? AND o.operation_path=?
1526
+ AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id)
1527
+ ORDER BY r.name,c.source_file,c.source_line`).all(servicePath, operationPath);
1364
1528
  }
1365
1529
  function schemaDriftDiagnostics(db, strict) {
1366
1530
  if (!strict) return [];
@@ -1404,6 +1568,7 @@ function parserQualityDiagnostics(db, strict, options) {
1404
1568
  implementationCandidateQuality(db, Boolean(options.detail)),
1405
1569
  classInstanceNoiseQuality(db),
1406
1570
  contextualBindingPropagationQuality(db),
1571
+ serviceBindingQuality(db, Boolean(options.detail)),
1407
1572
  nestedThisReceiverQuality(db),
1408
1573
  wrapperPathPropagationQuality(db),
1409
1574
  remoteQueryTargetQuality(db),
@@ -1598,6 +1763,83 @@ function contextualBindingPropagationQuality(db) {
1598
1763
  const opportunities = db.prepare("SELECT c.source_file sourceFile,c.source_line sourceLine,json_extract(c.evidence_json,'$.receiver') receiverName,c.operation_path_expr operationPath FROM outbound_calls c WHERE c.call_type='remote_action' AND json_extract(c.evidence_json,'$.receiver') IS NOT NULL AND c.service_binding_id IS NULL ORDER BY c.source_file,c.source_line LIMIT 8").all();
1599
1764
  return { severity: missing.count + opportunities.length > 0 ? "warning" : "info", code: "strict_contextual_binding_propagation_quality", message: "Contextual service-client propagation opportunities for trace-time helper resolution", calleeSymbolsMissingParameterMetadata: missing.count, traceTimeContextualOpportunities: opportunities.length, examples: opportunities };
1600
1765
  }
1766
+ function serviceBindingQuality(db, detail) {
1767
+ const rows = db.prepare(`
1768
+ SELECT c.source_file sourceFile,c.source_line sourceLine,
1769
+ c.unresolved_reason unresolvedReason,c.evidence_json evidenceJson,
1770
+ s.evidence_json symbolEvidenceJson
1771
+ FROM outbound_calls c
1772
+ LEFT JOIN symbols s ON s.id=c.source_symbol_id
1773
+ WHERE c.call_type='remote_action'
1774
+ AND c.operation_path_expr IS NOT NULL
1775
+ AND c.service_binding_id IS NULL
1776
+ ORDER BY c.source_file,c.source_line
1777
+ `).all();
1778
+ const groups = /* @__PURE__ */ new Map();
1779
+ for (const row of rows) {
1780
+ const category = bindingCategory(row);
1781
+ groups.set(category, [...groups.get(category) ?? [], bindingExample(row)]);
1782
+ }
1783
+ const categories = [...groups.entries()].map(([category, examples]) => ({
1784
+ category,
1785
+ count: examples.length,
1786
+ severity: "warning",
1787
+ suggestedAction: bindingCategoryAction(category),
1788
+ examples: examples.slice(0, 3),
1789
+ expandedExamples: detail ? examples : void 0
1790
+ }));
1791
+ return {
1792
+ severity: rows.length > 0 ? "warning" : "info",
1793
+ code: "strict_service_binding_quality",
1794
+ message: "Remote service-client binding quality aggregate",
1795
+ total: rows.length,
1796
+ categories
1797
+ };
1798
+ }
1799
+ function bindingCategory(row) {
1800
+ const evidence = parseObject(row.evidenceJson);
1801
+ const resolution = parseObject(evidence.serviceBindingResolution);
1802
+ if (resolution.status === "rejected_future_binding") return "direct_binding_missing";
1803
+ if (resolution.status === "ambiguous") return "ambiguous_binding_candidates";
1804
+ const receiver = evidence.receiver;
1805
+ const symbolEvidence = parseObject(row.symbolEvidenceJson);
1806
+ if (symbolHasReceiverParameter(symbolEvidence, receiver))
1807
+ return "contextual_binding_recoverable";
1808
+ if (!Array.isArray(symbolEvidence.parameterBindings))
1809
+ return "missing_symbol_parameter_metadata";
1810
+ return "unrecoverable_binding";
1811
+ }
1812
+ function symbolHasReceiverParameter(evidence, receiver) {
1813
+ if (typeof receiver !== "string" || !Array.isArray(evidence.parameterBindings))
1814
+ return false;
1815
+ return asRecords(evidence.parameterBindings).some((binding) => {
1816
+ if (binding.kind === "identifier") return binding.name === receiver;
1817
+ if (binding.kind === "object_pattern")
1818
+ return asRecords(binding.properties).some((property) => property.local === receiver);
1819
+ return asRecords(binding.elements).some((element) => element.local === receiver);
1820
+ });
1821
+ }
1822
+ function bindingExample(row) {
1823
+ const evidence = parseObject(row.evidenceJson);
1824
+ return {
1825
+ sourceFile: row.sourceFile,
1826
+ sourceLine: row.sourceLine,
1827
+ receiver: evidence.receiver,
1828
+ unresolvedReason: row.unresolvedReason,
1829
+ bindingResolution: evidence.serviceBindingResolution
1830
+ };
1831
+ }
1832
+ function bindingCategoryAction(category) {
1833
+ if (category === "direct_binding_missing")
1834
+ return "Move the binding before the call or bind the call to an earlier immutable client.";
1835
+ if (category === "contextual_binding_recoverable")
1836
+ return "Trace from the caller so parameter binding evidence can be applied.";
1837
+ if (category === "ambiguous_binding_candidates")
1838
+ return "Split mutable client alternatives or add a statically unique client assignment.";
1839
+ if (category === "missing_symbol_parameter_metadata")
1840
+ return "Use named or destructured parameters on an indexed helper symbol.";
1841
+ return "Add a direct CAP client binding or statically provable helper-return binding.";
1842
+ }
1601
1843
  function wrapperPathPropagationQuality(db) {
1602
1844
  const examples = db.prepare("SELECT source_file sourceFile,source_line sourceLine,json_extract(evidence_json,'$.receiver') receiverName,json_extract(evidence_json,'$.operationPathExpression') pathIdentifier FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier' ORDER BY source_file,source_line LIMIT 5").all();
1603
1845
  return { severity: examples.length > 0 ? "warning" : "info", code: "strict_wrapper_path_propagation_quality", message: "Dynamic path sends where send({ path }) used a path identifier", dynamicPathIdentifierCalls: examples.length, examples };
@@ -1669,10 +1911,33 @@ function externalHttpTargetQuality(db) {
1669
1911
  return { severity: bad > 0 ? "warning" : "info", code: "strict_external_http_target_quality", message: "External HTTP semantic target aggregate", totalExternalHttpCalls: Number(row.total ?? 0), numericTargetCount: Number(row.numericTargets ?? 0), invalidOrMissingExternalTargetEvidence: Number(row.invalidEvidence ?? 0) };
1670
1912
  }
1671
1913
  function odataInvocationResolutionQuality(db) {
1672
- const rows = db.prepare("SELECT c.operation_path_expr operationPathExpr,e.status status FROM outbound_calls c JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT) WHERE c.call_type='remote_action' AND c.operation_path_expr LIKE '%(%'").all();
1914
+ const rows = db.prepare(`SELECT c.operation_path_expr operationPathExpr,
1915
+ c.source_file sourceFile,c.source_line sourceLine,e.id graphEdgeId,
1916
+ e.status status,e.evidence_json evidenceJson
1917
+ FROM outbound_calls c JOIN graph_edges e
1918
+ ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
1919
+ WHERE c.call_type='remote_action' AND c.operation_path_expr LIKE '%(%'
1920
+ ORDER BY c.source_file,c.source_line`).all();
1673
1921
  const unresolved = rows.filter((row) => row.status === "unresolved" && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
1674
1922
  const ambiguous = rows.filter((row) => row.status === "ambiguous" && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
1675
- return { severity: unresolved + ambiguous > 0 ? "warning" : "info", code: "strict_odata_invocation_resolution_quality", message: "OData invocation-path resolution quality aggregate", totalInvocationRemoteActions: rows.length, resolvedInvocationCalls: rows.filter((row) => row.status === "resolved").length, dynamicInvocationCalls: rows.filter((row) => row.status === "dynamic").length, ambiguousInvocationCalls: rows.filter((row) => row.status === "ambiguous").length, unresolvedInvocationCalls: rows.filter((row) => row.status === "unresolved").length, ambiguousNormalizedCalls: ambiguous, unresolvedNormalizedCallsWithIndexedCandidates: unresolved };
1923
+ const examples = rows.filter((row) => row.status === "ambiguous" || row.status === "unresolved").map(odataInvocationExample).slice(0, 5);
1924
+ return { severity: unresolved + ambiguous > 0 ? "warning" : "info", code: "strict_odata_invocation_resolution_quality", message: "OData invocation-path resolution quality aggregate", totalInvocationRemoteActions: rows.length, resolvedInvocationCalls: rows.filter((row) => row.status === "resolved").length, dynamicInvocationCalls: rows.filter((row) => row.status === "dynamic").length, ambiguousInvocationCalls: rows.filter((row) => row.status === "ambiguous").length, unresolvedInvocationCalls: rows.filter((row) => row.status === "unresolved").length, ambiguousNormalizedCalls: ambiguous, unresolvedNormalizedCallsWithIndexedCandidates: unresolved, examples };
1925
+ }
1926
+ function odataInvocationExample(row) {
1927
+ const evidence = parseObject(row.evidenceJson);
1928
+ const normalized = normalizeODataOperationInvocationPath(row.operationPathExpr);
1929
+ return {
1930
+ sourceFile: row.sourceFile,
1931
+ sourceLine: row.sourceLine,
1932
+ graphEdgeId: row.graphEdgeId,
1933
+ status: row.status,
1934
+ rawPath: row.operationPathExpr,
1935
+ normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : void 0,
1936
+ indexedOperationCandidateCount: evidence.indexedOperationCandidateCount,
1937
+ candidateScores: evidence.candidateScores,
1938
+ entityOperationPrecedence: evidence.entityOperationPrecedence,
1939
+ resolutionReasons: evidence.resolutionReasons
1940
+ };
1676
1941
  }
1677
1942
  function identityAliasBindingQuality(db) {
1678
1943
  const examples = db.prepare("SELECT c.source_file sourceFile,c.source_line sourceLine FROM outbound_calls c WHERE c.call_type='remote_action' AND c.service_binding_id IS NULL AND json_extract(c.evidence_json,'$.receiver') IS NOT NULL ORDER BY c.source_file,c.source_line LIMIT 5").all();
@@ -1725,8 +1990,8 @@ function renderTraceTable(result) {
1725
1990
  const lines = ["Step Type From To Evidence"];
1726
1991
  for (const e of result.edges) {
1727
1992
  lines.push(`${String(e.step).padEnd(5)} ${e.type.padEnd(20)} ${e.from.slice(0, 34).padEnd(35)} ${e.to.slice(0, 35).padEnd(36)} ${location(e.evidence)}`);
1728
- const hint = firstHint(e.evidence);
1729
- if (e.unresolvedReason && hint) lines.push(` try ${hint}`);
1993
+ if (e.unresolvedReason)
1994
+ lines.push(...hintLines(e.evidence).map((hint) => ` ${hint}`));
1730
1995
  }
1731
1996
  if (result.diagnostics.length > 0) lines.push("", "Diagnostics:", ...result.diagnostics.flatMap(diagnosticLines));
1732
1997
  return `${lines.join("\n")}
@@ -1734,14 +1999,20 @@ function renderTraceTable(result) {
1734
1999
  }
1735
2000
  function diagnosticLines(diagnostic) {
1736
2001
  const first = `${String(diagnostic.severity ?? "info")} ${String(diagnostic.code ?? "diagnostic")} ${String(diagnostic.message ?? "")}`;
1737
- const hint = firstHint(diagnostic);
1738
- return hint ? [first, ` try ${hint}`] : [first];
2002
+ return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
1739
2003
  }
1740
- function firstHint(evidence) {
2004
+ function hintLines(evidence) {
1741
2005
  const suggestions = evidence.implementationHintSuggestions;
1742
- if (!Array.isArray(suggestions)) return void 0;
1743
- const first = suggestions.find((item) => Boolean(item) && typeof item === "object");
1744
- return typeof first?.cli === "string" ? first.cli : void 0;
2006
+ if (!Array.isArray(suggestions)) return [];
2007
+ const hints = suggestions.flatMap((item) => isRecord(item) && typeof item.cli === "string" ? [item.cli] : []);
2008
+ const unique = [...new Set(hints)];
2009
+ const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
2010
+ if (unique.length > shown.length)
2011
+ shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
2012
+ return shown;
2013
+ }
2014
+ function isRecord(value) {
2015
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
1745
2016
  }
1746
2017
 
1747
2018
  // src/output/json-output.ts
@@ -1753,6 +2024,92 @@ function renderTraceJson(trace2) {
1753
2024
  return renderJson(trace2);
1754
2025
  }
1755
2026
 
2027
+ // src/output/doctor-output.ts
2028
+ import pc from "picocolors";
2029
+ function renderDoctorDiagnostics(diagnostics, format) {
2030
+ if (format === "json") return renderJson(diagnostics);
2031
+ if (format === "table") return renderDoctorTable(diagnostics);
2032
+ if (format) throw new Error(`Unsupported doctor format: ${format}. Expected json or table.`);
2033
+ return renderLegacyDoctorOutput(diagnostics);
2034
+ }
2035
+ function renderLegacyDoctorOutput(diagnostics) {
2036
+ if (diagnostics.length === 0) return cleanDoctorMessage();
2037
+ return renderJson(diagnostics);
2038
+ }
2039
+ function renderDoctorTable(diagnostics) {
2040
+ if (diagnostics.length === 0) return cleanDoctorMessage();
2041
+ const rows = diagnostics.map((diagnostic) => ({
2042
+ severity: String(diagnostic.severity ?? "info"),
2043
+ code: String(diagnostic.code ?? "diagnostic"),
2044
+ location: diagnosticLocation(diagnostic),
2045
+ message: compactMessage(diagnostic),
2046
+ hints: suggestedHintLines(diagnostic)
2047
+ }));
2048
+ const widths = {
2049
+ severity: columnWidth("Severity", rows.map((row) => row.severity), 10),
2050
+ code: columnWidth("Code", rows.map((row) => row.code), 44),
2051
+ location: columnWidth("Location", rows.map((row) => row.location), 28)
2052
+ };
2053
+ const lines = [
2054
+ `${"Severity".padEnd(widths.severity)} ${"Code".padEnd(widths.code)} ${"Location".padEnd(widths.location)} Message`,
2055
+ `${"-".repeat(widths.severity)} ${"-".repeat(widths.code)} ${"-".repeat(widths.location)} ${"-".repeat(7)}`
2056
+ ];
2057
+ for (const row of rows) {
2058
+ lines.push(`${truncate(row.severity, widths.severity).padEnd(widths.severity)} ${truncate(row.code, widths.code).padEnd(widths.code)} ${truncate(row.location, widths.location).padEnd(widths.location)} ${row.message}`);
2059
+ lines.push(...row.hints.map((hint) => ` try ${hint}`));
2060
+ }
2061
+ return `${lines.join("\n")}
2062
+ `;
2063
+ }
2064
+ function diagnosticLocation(diagnostic) {
2065
+ const file = diagnostic.sourceFile ?? diagnostic.file;
2066
+ const line = diagnostic.sourceLine ?? diagnostic.line;
2067
+ if (file || line) return `${String(file ?? "")}:${String(line ?? "")}`;
2068
+ return "-";
2069
+ }
2070
+ function compactMessage(diagnostic) {
2071
+ const message = String(diagnostic.message ?? "");
2072
+ const count = typeof diagnostic.count === "number" ? ` count=${diagnostic.count}` : "";
2073
+ const total = typeof diagnostic.total === "number" ? ` total=${diagnostic.total}` : "";
2074
+ return `${message}${count}${total}`.trim();
2075
+ }
2076
+ function suggestedHintLines(diagnostic) {
2077
+ const direct = cliHints(diagnostic.suggestedHints);
2078
+ if (direct.length > 0) return cappedHints(direct);
2079
+ return cappedHints(cliHintsFromSuggestions(diagnostic.implementationHintSuggestions));
2080
+ }
2081
+ function cliHints(value) {
2082
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
2083
+ }
2084
+ function cliHintsFromSuggestions(value) {
2085
+ if (!Array.isArray(value)) return [];
2086
+ return value.flatMap((item) => {
2087
+ if (!isRecord2(item)) return [];
2088
+ return typeof item.cli === "string" ? [item.cli] : [];
2089
+ });
2090
+ }
2091
+ function isRecord2(value) {
2092
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
2093
+ }
2094
+ function cappedHints(hints) {
2095
+ const unique = [...new Set(hints)];
2096
+ const shown = unique.slice(0, 3);
2097
+ if (unique.length > shown.length) shown.push(`... ${unique.length - shown.length} more hint(s) available in --format json`);
2098
+ return shown;
2099
+ }
2100
+ function cleanDoctorMessage() {
2101
+ return `${pc.green("No diagnostics recorded")}
2102
+ `;
2103
+ }
2104
+ function columnWidth(header, values, max) {
2105
+ return Math.min(max, Math.max(header.length, ...values.map((value) => value.length)));
2106
+ }
2107
+ function truncate(value, width) {
2108
+ if (value.length <= width) return value;
2109
+ if (width <= 1) return value.slice(0, width);
2110
+ return `${value.slice(0, width - 1)}\u2026`;
2111
+ }
2112
+
1756
2113
  // src/output/mermaid-output.ts
1757
2114
  function safe(value) {
1758
2115
  return value.replace(/[^\w-]/g, "_").slice(0, 60);
@@ -1985,13 +2342,10 @@ function createProgram() {
1985
2342
  process.stdout.write(renderJson(rows));
1986
2343
  }).catch(fail)
1987
2344
  );
1988
- program.command("doctor").option("--workspace <path>").option("--strict").option("--detail").action(
2345
+ program.command("doctor").option("--workspace <path>").option("--strict").option("--detail").option("--format <format>", "json|table").action(
1989
2346
  (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
1990
2347
  const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
1991
- process.stdout.write(
1992
- allDiagnostics.length ? renderJson(allDiagnostics) : `${pc.green("No diagnostics recorded")}
1993
- `
1994
- );
2348
+ process.stdout.write(renderDoctorDiagnostics(allDiagnostics, opts.format));
1995
2349
  }).catch(fail)
1996
2350
  );
1997
2351
  program.command("clean").option("--workspace <path>").option("--db-only").action(