@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 +6 -0
- package/README.md +5 -1
- package/dist/cli.js +125 -12
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +39 -5
- package/src/cli.ts +4 -7
- package/src/output/doctor-output.ts +98 -0
package/package.json
CHANGED
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
|
|
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
|
+
}
|