@saptools/service-flow 0.1.47 → 0.1.48

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.48
4
+
5
+ - Added deterministic `doctor --format json|table` output while preserving legacy-compatible default doctor output; JSON mode returns `[]` for clean workspaces and table mode renders concise diagnostic rows with capped hint lines.
6
+ - Documented the 0.1.47 audit follow-up evidence fields for service-client ownership chains, normalized OData operation paths, wrapper path candidates, and implementation hint suggestions.
7
+ - Bumped the package patch version for the service-flow audit follow-up release.
8
+
3
9
  ## 0.1.46
4
10
 
5
11
  - Improved ambiguous implementation diagnostics with ready-to-copy scoped hint suggestions for each blocked helper hop.
package/README.md CHANGED
@@ -277,14 +277,18 @@ service-flow inspect operation /doWork --workspace /path/to/workspace
277
277
 
278
278
  ### 🩺 `service-flow doctor`
279
279
 
280
- Print stored diagnostics. Default output suppresses high-noise entity-only service checks; `--strict` includes them. A clean workspace prints `No diagnostics recorded`.
280
+ Print stored diagnostics. Default output suppresses high-noise entity-only service checks; `--strict` includes them. Without `--format`, doctor keeps the legacy-compatible behavior: JSON when diagnostics exist and `No diagnostics recorded` for a clean workspace. Deterministic JSON mode prints `[]` when clean.
281
281
 
282
282
  ```bash
283
283
  service-flow doctor --workspace /path/to/workspace
284
284
  service-flow doctor --workspace /path/to/workspace --strict
285
285
  service-flow doctor --workspace /path/to/workspace --strict --detail
286
+ service-flow doctor --workspace /path/to/workspace --strict --format json
287
+ service-flow doctor --workspace /path/to/workspace --strict --format table
286
288
  ```
287
289
 
290
+ `--format json` always returns a JSON array, including `[]` for clean workspaces. `--format table` prints a concise human-readable table with capped copyable hint lines; use JSON when automation needs every field or every hint alternative. Omit `--format` when relying on the pre-0.1.48 compatible output contract.
291
+
288
292
  Strict output keeps stable category, count, severity, and capped example fields. Use `--detail` to include full `expandedExamples` for implementation candidate, parameter metadata, and dynamic wrapper categories.
289
293
 
290
294
  ### 🧹 `service-flow clean`
package/dist/cli.js CHANGED
@@ -21,7 +21,6 @@ import {
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.48",
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: {
@@ -1312,6 +1311,7 @@ function doctorDiagnostics(db, strict, options = {}) {
1312
1311
  return [
1313
1312
  ...diagnostics,
1314
1313
  ...healthDiagnostics(db, strict),
1314
+ ...remoteTargetWithoutImplementationDiagnostics(db, strict, Boolean(options.detail)),
1315
1315
  ...localServiceDiagnostics(db, strict),
1316
1316
  ...schemaDriftDiagnostics(db, strict),
1317
1317
  ...analyzerVersionDiagnostics(db, strict),
@@ -1339,10 +1339,6 @@ function healthDiagnostics(db, strict) {
1339
1339
  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
1340
  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
1341
  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
1342
  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
1343
  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
1344
  UNION ALL
@@ -1360,7 +1356,41 @@ function healthDiagnostics(db, strict) {
1360
1356
  UNION ALL
1361
1357
  SELECT 'warning','index_run_abandoned','Index run ' || id || ' started at ' || started_at || ' is still running after the 60 minute abandonment threshold',NULL,NULL
1362
1358
  FROM index_runs WHERE status='running' AND datetime(started_at) < datetime('now','-60 minutes')`
1363
- ).all(strict, strict, strict, strict, strict, strict, strict);
1359
+ ).all(strict, strict, strict, strict, strict, strict);
1360
+ }
1361
+ function remoteTargetWithoutImplementationDiagnostics(db, strict, detail) {
1362
+ if (!strict) return [];
1363
+ const groups = db.prepare(`SELECT s.service_path servicePath,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,COUNT(*) callSiteCount
1364
+ 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
1365
+ WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
1366
+ 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)
1367
+ GROUP BY s.service_path,o.operation_path,o.source_file,o.source_line
1368
+ ORDER BY s.service_path,o.operation_path`).all();
1369
+ return groups.map((group) => {
1370
+ const examples = remoteTargetWithoutImplementationExamples(db, String(group.servicePath ?? ""), String(group.operationPath ?? ""));
1371
+ return {
1372
+ severity: "warning",
1373
+ code: "remote_target_without_implementation",
1374
+ message: `Remote target operation has no implementation edge: ${String(group.servicePath ?? "")}${String(group.operationPath ?? "")}`,
1375
+ sourceFile: group.sourceFile,
1376
+ sourceLine: group.sourceLine,
1377
+ servicePath: group.servicePath,
1378
+ operationPath: group.operationPath,
1379
+ callSiteCount: Number(group.callSiteCount ?? 0),
1380
+ examples: examples.slice(0, 3),
1381
+ expandedExamples: detail ? examples : void 0
1382
+ };
1383
+ });
1384
+ }
1385
+ function remoteTargetWithoutImplementationExamples(db, servicePath, operationPath) {
1386
+ return db.prepare(`SELECT r.name repo,c.source_file sourceFile,c.source_line sourceLine,c.operation_path_expr operationPathExpr
1387
+ 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
1388
+ LEFT JOIN outbound_calls c ON remote.from_kind='call' AND c.id=CAST(remote.from_id AS INTEGER)
1389
+ LEFT JOIN repositories r ON r.id=c.repo_id
1390
+ WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
1391
+ AND s.service_path=? AND o.operation_path=?
1392
+ 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)
1393
+ ORDER BY r.name,c.source_file,c.source_line`).all(servicePath, operationPath);
1364
1394
  }
1365
1395
  function schemaDriftDiagnostics(db, strict) {
1366
1396
  if (!strict) return [];
@@ -1753,6 +1783,92 @@ function renderTraceJson(trace2) {
1753
1783
  return renderJson(trace2);
1754
1784
  }
1755
1785
 
1786
+ // src/output/doctor-output.ts
1787
+ import pc from "picocolors";
1788
+ function renderDoctorDiagnostics(diagnostics, format) {
1789
+ if (format === "json") return renderJson(diagnostics);
1790
+ if (format === "table") return renderDoctorTable(diagnostics);
1791
+ if (format) throw new Error(`Unsupported doctor format: ${format}. Expected json or table.`);
1792
+ return renderLegacyDoctorOutput(diagnostics);
1793
+ }
1794
+ function renderLegacyDoctorOutput(diagnostics) {
1795
+ if (diagnostics.length === 0) return cleanDoctorMessage();
1796
+ return renderJson(diagnostics);
1797
+ }
1798
+ function renderDoctorTable(diagnostics) {
1799
+ if (diagnostics.length === 0) return cleanDoctorMessage();
1800
+ const rows = diagnostics.map((diagnostic) => ({
1801
+ severity: String(diagnostic.severity ?? "info"),
1802
+ code: String(diagnostic.code ?? "diagnostic"),
1803
+ location: diagnosticLocation(diagnostic),
1804
+ message: compactMessage(diagnostic),
1805
+ hints: suggestedHintLines(diagnostic)
1806
+ }));
1807
+ const widths = {
1808
+ severity: columnWidth("Severity", rows.map((row) => row.severity), 10),
1809
+ code: columnWidth("Code", rows.map((row) => row.code), 44),
1810
+ location: columnWidth("Location", rows.map((row) => row.location), 28)
1811
+ };
1812
+ const lines = [
1813
+ `${"Severity".padEnd(widths.severity)} ${"Code".padEnd(widths.code)} ${"Location".padEnd(widths.location)} Message`,
1814
+ `${"-".repeat(widths.severity)} ${"-".repeat(widths.code)} ${"-".repeat(widths.location)} ${"-".repeat(7)}`
1815
+ ];
1816
+ for (const row of rows) {
1817
+ 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}`);
1818
+ lines.push(...row.hints.map((hint) => ` try ${hint}`));
1819
+ }
1820
+ return `${lines.join("\n")}
1821
+ `;
1822
+ }
1823
+ function diagnosticLocation(diagnostic) {
1824
+ const file = diagnostic.sourceFile ?? diagnostic.file;
1825
+ const line = diagnostic.sourceLine ?? diagnostic.line;
1826
+ if (file || line) return `${String(file ?? "")}:${String(line ?? "")}`;
1827
+ return "-";
1828
+ }
1829
+ function compactMessage(diagnostic) {
1830
+ const message = String(diagnostic.message ?? "");
1831
+ const count = typeof diagnostic.count === "number" ? ` count=${diagnostic.count}` : "";
1832
+ const total = typeof diagnostic.total === "number" ? ` total=${diagnostic.total}` : "";
1833
+ return `${message}${count}${total}`.trim();
1834
+ }
1835
+ function suggestedHintLines(diagnostic) {
1836
+ const direct = cliHints(diagnostic.suggestedHints);
1837
+ if (direct.length > 0) return cappedHints(direct);
1838
+ return cappedHints(cliHintsFromSuggestions(diagnostic.implementationHintSuggestions));
1839
+ }
1840
+ function cliHints(value) {
1841
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1842
+ }
1843
+ function cliHintsFromSuggestions(value) {
1844
+ if (!Array.isArray(value)) return [];
1845
+ return value.flatMap((item) => {
1846
+ if (!isRecord(item)) return [];
1847
+ return typeof item.cli === "string" ? [item.cli] : [];
1848
+ });
1849
+ }
1850
+ function isRecord(value) {
1851
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
1852
+ }
1853
+ function cappedHints(hints) {
1854
+ const unique = [...new Set(hints)];
1855
+ const shown = unique.slice(0, 3);
1856
+ if (unique.length > shown.length) shown.push(`... ${unique.length - shown.length} more hint(s) available in --format json`);
1857
+ return shown;
1858
+ }
1859
+ function cleanDoctorMessage() {
1860
+ return `${pc.green("No diagnostics recorded")}
1861
+ `;
1862
+ }
1863
+ function columnWidth(header, values, max) {
1864
+ return Math.min(max, Math.max(header.length, ...values.map((value) => value.length)));
1865
+ }
1866
+ function truncate(value, width) {
1867
+ if (value.length <= width) return value;
1868
+ if (width <= 1) return value.slice(0, width);
1869
+ return `${value.slice(0, width - 1)}\u2026`;
1870
+ }
1871
+
1756
1872
  // src/output/mermaid-output.ts
1757
1873
  function safe(value) {
1758
1874
  return value.replace(/[^\w-]/g, "_").slice(0, 60);
@@ -1985,13 +2101,10 @@ function createProgram() {
1985
2101
  process.stdout.write(renderJson(rows));
1986
2102
  }).catch(fail)
1987
2103
  );
1988
- program.command("doctor").option("--workspace <path>").option("--strict").option("--detail").action(
2104
+ program.command("doctor").option("--workspace <path>").option("--strict").option("--detail").option("--format <format>", "json|table").action(
1989
2105
  (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
1990
2106
  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
- );
2107
+ process.stdout.write(renderDoctorDiagnostics(allDiagnostics, opts.format));
1995
2108
  }).catch(fail)
1996
2109
  );
1997
2110
  program.command("clean").option("--workspace <path>").option("--db-only").action(