@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
4
4
  "description": "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/src/cli/doctor.ts CHANGED
@@ -18,6 +18,7 @@ export function doctorDiagnostics(db: Db, strict: boolean, options: DoctorOption
18
18
  return [
19
19
  ...diagnostics,
20
20
  ...healthDiagnostics(db, strict),
21
+ ...remoteTargetWithoutImplementationDiagnostics(db, strict, Boolean(options.detail)),
21
22
  ...localServiceDiagnostics(db, strict),
22
23
  ...schemaDriftDiagnostics(db, strict),
23
24
  ...analyzerVersionDiagnostics(db, strict),
@@ -46,10 +47,6 @@ function healthDiagnostics(db: Db, strict: boolean): Diagnostic[] {
46
47
  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
47
48
  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
48
49
  UNION ALL
49
- 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
50
- 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
51
- 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 ?
52
- UNION ALL
53
50
  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
54
51
  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')
55
52
  UNION ALL
@@ -67,7 +64,44 @@ function healthDiagnostics(db: Db, strict: boolean): Diagnostic[] {
67
64
  UNION ALL
68
65
  SELECT 'warning','index_run_abandoned','Index run ' || id || ' started at ' || started_at || ' is still running after the 60 minute abandonment threshold',NULL,NULL
69
66
  FROM index_runs WHERE status='running' AND datetime(started_at) < datetime('now','-60 minutes')`,
70
- ).all(strict, strict, strict, strict, strict, strict, strict) as Diagnostic[];
67
+ ).all(strict, strict, strict, strict, strict, strict) as Diagnostic[];
68
+ }
69
+
70
+
71
+ function remoteTargetWithoutImplementationDiagnostics(db: Db, strict: boolean, detail: boolean): Diagnostic[] {
72
+ if (!strict) return [];
73
+ const groups = db.prepare(`SELECT s.service_path servicePath,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,COUNT(*) callSiteCount
74
+ 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
75
+ WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
76
+ 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)
77
+ GROUP BY s.service_path,o.operation_path,o.source_file,o.source_line
78
+ ORDER BY s.service_path,o.operation_path`).all() as Array<{ servicePath?: string; operationPath?: string; sourceFile?: string | null; sourceLine?: number | null; callSiteCount?: number }>;
79
+ return groups.map((group) => {
80
+ const examples = remoteTargetWithoutImplementationExamples(db, String(group.servicePath ?? ''), String(group.operationPath ?? ''));
81
+ return {
82
+ severity: 'warning',
83
+ code: 'remote_target_without_implementation',
84
+ message: `Remote target operation has no implementation edge: ${String(group.servicePath ?? '')}${String(group.operationPath ?? '')}`,
85
+ sourceFile: group.sourceFile,
86
+ sourceLine: group.sourceLine,
87
+ servicePath: group.servicePath,
88
+ operationPath: group.operationPath,
89
+ callSiteCount: Number(group.callSiteCount ?? 0),
90
+ examples: examples.slice(0, 3),
91
+ expandedExamples: detail ? examples : undefined,
92
+ };
93
+ });
94
+ }
95
+
96
+ function remoteTargetWithoutImplementationExamples(db: Db, servicePath: string, operationPath: string): Diagnostic[] {
97
+ return db.prepare(`SELECT r.name repo,c.source_file sourceFile,c.source_line sourceLine,c.operation_path_expr operationPathExpr
98
+ 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
99
+ LEFT JOIN outbound_calls c ON remote.from_kind='call' AND c.id=CAST(remote.from_id AS INTEGER)
100
+ LEFT JOIN repositories r ON r.id=c.repo_id
101
+ WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
102
+ AND s.service_path=? AND o.operation_path=?
103
+ 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)
104
+ ORDER BY r.name,c.source_file,c.source_line`).all(servicePath, operationPath) as Diagnostic[];
71
105
  }
72
106
 
73
107
  function schemaDriftDiagnostics(db: Db, strict: boolean): Diagnostic[] {
package/src/cli.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs/promises';
4
- import pc from 'picocolors';
5
4
  import { DEFAULT_IGNORES } from './config/defaults.js';
6
5
  import {
7
6
  createWorkspaceConfig,
@@ -27,6 +26,7 @@ import { parseVars } from './trace/selectors.js';
27
26
  import { parseImplementationHint } from './trace/implementation-hints.js';
28
27
  import { renderTraceTable } from './output/table-output.js';
29
28
  import { renderTraceJson, renderJson } from './output/json-output.js';
29
+ import { renderDoctorDiagnostics } from './output/doctor-output.js';
30
30
  import { renderMermaid } from './output/mermaid-output.js';
31
31
  import { VERSION } from './version.js';
32
32
  async function init(
@@ -370,15 +370,12 @@ export function createProgram(): Command {
370
370
  .option('--workspace <path>')
371
371
  .option('--strict')
372
372
  .option('--detail')
373
+ .option('--format <format>', 'json|table')
373
374
  .action(
374
- (opts: { workspace?: string; strict?: boolean; detail?: boolean }) =>
375
+ (opts: { workspace?: string; strict?: boolean; detail?: boolean; format?: string }) =>
375
376
  void withReadOnlyWorkspace(opts.workspace, (db) => {
376
377
  const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
377
- process.stdout.write(
378
- allDiagnostics.length
379
- ? renderJson(allDiagnostics)
380
- : `${pc.green('No diagnostics recorded')}\n`,
381
- );
378
+ process.stdout.write(renderDoctorDiagnostics(allDiagnostics, opts.format));
382
379
  }).catch(fail),
383
380
  );
384
381
  program
@@ -0,0 +1,98 @@
1
+ import pc from 'picocolors';
2
+ import { renderJson } from './json-output.js';
3
+
4
+ type Diagnostic = Record<string, unknown>;
5
+
6
+ export function renderDoctorDiagnostics(diagnostics: Diagnostic[], format: string | undefined): string {
7
+ if (format === 'json') return renderJson(diagnostics);
8
+ if (format === 'table') return renderDoctorTable(diagnostics);
9
+ if (format) throw new Error(`Unsupported doctor format: ${format}. Expected json or table.`);
10
+ return renderLegacyDoctorOutput(diagnostics);
11
+ }
12
+
13
+ function renderLegacyDoctorOutput(diagnostics: Diagnostic[]): string {
14
+ if (diagnostics.length === 0) return cleanDoctorMessage();
15
+ return renderJson(diagnostics);
16
+ }
17
+
18
+ export function renderDoctorTable(diagnostics: Diagnostic[]): string {
19
+ if (diagnostics.length === 0) return cleanDoctorMessage();
20
+ const rows = diagnostics.map((diagnostic) => ({
21
+ severity: String(diagnostic.severity ?? 'info'),
22
+ code: String(diagnostic.code ?? 'diagnostic'),
23
+ location: diagnosticLocation(diagnostic),
24
+ message: compactMessage(diagnostic),
25
+ hints: suggestedHintLines(diagnostic),
26
+ }));
27
+ const widths = {
28
+ severity: columnWidth('Severity', rows.map((row) => row.severity), 10),
29
+ code: columnWidth('Code', rows.map((row) => row.code), 44),
30
+ location: columnWidth('Location', rows.map((row) => row.location), 28),
31
+ };
32
+ const lines = [
33
+ `${'Severity'.padEnd(widths.severity)} ${'Code'.padEnd(widths.code)} ${'Location'.padEnd(widths.location)} Message`,
34
+ `${'-'.repeat(widths.severity)} ${'-'.repeat(widths.code)} ${'-'.repeat(widths.location)} ${'-'.repeat(7)}`,
35
+ ];
36
+ for (const row of rows) {
37
+ 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}`);
38
+ lines.push(...row.hints.map((hint) => ` try ${hint}`));
39
+ }
40
+ return `${lines.join('\n')}\n`;
41
+ }
42
+
43
+ function diagnosticLocation(diagnostic: Diagnostic): string {
44
+ const file = diagnostic.sourceFile ?? diagnostic.file;
45
+ const line = diagnostic.sourceLine ?? diagnostic.line;
46
+ if (file || line) return `${String(file ?? '')}:${String(line ?? '')}`;
47
+ return '-';
48
+ }
49
+
50
+ function compactMessage(diagnostic: Diagnostic): string {
51
+ const message = String(diagnostic.message ?? '');
52
+ const count = typeof diagnostic.count === 'number' ? ` count=${diagnostic.count}` : '';
53
+ const total = typeof diagnostic.total === 'number' ? ` total=${diagnostic.total}` : '';
54
+ return `${message}${count}${total}`.trim();
55
+ }
56
+
57
+ function suggestedHintLines(diagnostic: Diagnostic): string[] {
58
+ const direct = cliHints(diagnostic.suggestedHints);
59
+ if (direct.length > 0) return cappedHints(direct);
60
+ return cappedHints(cliHintsFromSuggestions(diagnostic.implementationHintSuggestions));
61
+ }
62
+
63
+ function cliHints(value: unknown): string[] {
64
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
65
+ }
66
+
67
+ function cliHintsFromSuggestions(value: unknown): string[] {
68
+ if (!Array.isArray(value)) return [];
69
+ return value.flatMap((item) => {
70
+ if (!isRecord(item)) return [];
71
+ return typeof item.cli === 'string' ? [item.cli] : [];
72
+ });
73
+ }
74
+
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
77
+ }
78
+
79
+ function cappedHints(hints: string[]): string[] {
80
+ const unique = [...new Set(hints)];
81
+ const shown = unique.slice(0, 3);
82
+ if (unique.length > shown.length) shown.push(`... ${unique.length - shown.length} more hint(s) available in --format json`);
83
+ return shown;
84
+ }
85
+
86
+ function cleanDoctorMessage(): string {
87
+ return `${pc.green('No diagnostics recorded')}\n`;
88
+ }
89
+
90
+ function columnWidth(header: string, values: string[], max: number): number {
91
+ return Math.min(max, Math.max(header.length, ...values.map((value) => value.length)));
92
+ }
93
+
94
+ function truncate(value: string, width: number): string {
95
+ if (value.length <= width) return value;
96
+ if (width <= 1) return value.slice(0, width);
97
+ return `${value.slice(0, width - 1)}…`;
98
+ }