@saptools/service-flow 0.1.46 → 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`
@@ -481,39 +485,3 @@ MIT
481
485
  ---
482
486
 
483
487
  Made with ❤️ to make your work life easier!
484
-
485
-
486
- ### Service-only trace policy
487
-
488
- `service-flow trace --service <path>` is intentionally not a broad workspace traversal. Provide `--operation`, `--path`, or `--handler`; otherwise the command returns a typed `trace_start_not_found` diagnostic and no edges.
489
-
490
- ### Graph freshness and last-good snapshots
491
-
492
- Repository facts are parsed before publication and committed atomically. Failed indexing attempts record diagnostics and retain the last complete published snapshot and successful fingerprint. Successful fact publication marks the workspace graph stale until `service-flow link` rebuilds dependency, remote-call, and implementation edges for the current fact generation.
493
-
494
- ### Cross-package implementation evidence
495
-
496
- Handler registrations persist parsed class names and import sources. Linking resolves implementation edges only through registered application evidence plus model and handler package dependency edges; a decorator-name match alone is not enough.
497
-
498
- ### Graph variables
499
-
500
- The `graph` command accepts repeatable `--var key=value` options, matching `trace`, for runtime substitution previews in JSON or Mermaid output.
501
-
502
-
503
- ### 0.1.17 parser ownership policy
504
-
505
- Outbound call extraction is AST-based and ignores comments, block comments, and string literals. CAP/service `.on(...)` registrations are indexed only when the receiver has CAP/service evidence, and top-level registrations receive `module:<relative-file>#event:<event-name>:<line>` synthetic owners. Generic event emitters such as desktop or window events are ignored by default rather than guessed as CAP async edges. Unsupported source shapes are surfaced through diagnostics and strict doctor ownerless categories instead of guessed graph edges.
506
-
507
-
508
- ## 0.1.32 hardening notes
509
-
510
- Operation trace starts resolve indexed CDS operations before handler fallback. Ambiguous operation matches, ambiguous/rejected implementation edges, and resolved edges without an executable handler scope now fail closed with structured `resolutionStage` and `resolutionStatus` diagnostics and no speculative traversal. Handler fallback is only considered when no indexed CDS operation matches and the fallback is unique under the existing registration and ownership policy.
511
-
512
- Decorator signals are interpreted as resolved, absent, unsupported, or malformed. Only resolved string/template values, generated `Action<Name>.name`/`Func<Name>.name` constants, qualified generated constants ending in `.name`, and narrow safe `String(identifier)` wrappers can contradict method-name fallback. Unsupported expressions are preserved as evidence but are not treated as proof that a handler targets another operation.
513
-
514
- External HTTP facts persist queryable target kind, stable id, safe label, and dynamic flag on `outbound_calls`; graph evidence uses the same sanitized model. URL user info, fragments, query values, headers, cookies, credentials, tokens, API keys, and request/response bodies are not persisted. Legacy workspaces may relink from old evidence when possible, but full semantic enrichment requires re-index plus relink when old parser facts did not contain safe target metadata.
515
-
516
- Graph taxonomy now keeps CAP operation candidates separate from external HTTP targets. Dynamic, ambiguous, and unresolved CAP calls use operation-candidate semantics, external destinations/endpoints are produced only by `external_http` calls, and terminal local transport-client methods use transport-method semantics rather than external HTTP edges. `doctor --strict` reports external target quality and taxonomy consistency.
517
- ### Runtime and parser hardening
518
-
519
- `service-flow` treats URL and destination templates with substitutions as dynamic external targets, preserves operation-path template evidence without promoting unrelated variables, and parses CDS `@(path: ...)` annotations from original source text. Service extension declarations using both `extend Name` and `extend service Name` are recognized for routing provenance.
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.46",
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(