@saptools/service-flow 0.1.5 → 0.1.7
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 +18 -0
- package/README.md +29 -1
- package/TECHNICAL-NOTE.md +11 -1
- package/dist/{chunk-I33ASGX3.js → chunk-YFT57U54.js} +186 -155
- package/dist/chunk-YFT57U54.js.map +1 -0
- package/dist/cli.js +246 -143
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-I33ASGX3.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -10,12 +10,12 @@ import {
|
|
|
10
10
|
parsePackageJson,
|
|
11
11
|
parseServiceBindings,
|
|
12
12
|
trace
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-YFT57U54.js";
|
|
14
14
|
|
|
15
15
|
// src/cli.ts
|
|
16
16
|
import { Command } from "commander";
|
|
17
|
-
import
|
|
18
|
-
import
|
|
17
|
+
import path5 from "path";
|
|
18
|
+
import fs5 from "fs/promises";
|
|
19
19
|
import pc from "picocolors";
|
|
20
20
|
|
|
21
21
|
// src/config/defaults.ts
|
|
@@ -90,7 +90,7 @@ import path2 from "path";
|
|
|
90
90
|
// src/db/schema.ts
|
|
91
91
|
var schemaSql = `
|
|
92
92
|
CREATE TABLE IF NOT EXISTS workspaces (id INTEGER PRIMARY KEY, root_path TEXT UNIQUE NOT NULL, db_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
93
|
-
CREATE TABLE IF NOT EXISTS repositories (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, name TEXT NOT NULL, absolute_path TEXT NOT NULL, relative_path TEXT NOT NULL, package_name TEXT, package_version TEXT, dependencies_json TEXT DEFAULT '{}', kind TEXT NOT NULL, is_git_repo INTEGER NOT NULL, last_indexed_at TEXT, index_status TEXT DEFAULT 'pending', error_count INTEGER DEFAULT 0, fingerprint TEXT, UNIQUE(workspace_id, absolute_path), FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
|
|
93
|
+
CREATE TABLE IF NOT EXISTS repositories (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, name TEXT NOT NULL, absolute_path TEXT NOT NULL, relative_path TEXT NOT NULL, package_name TEXT, package_version TEXT, dependencies_json TEXT DEFAULT '{}', kind TEXT NOT NULL, is_git_repo INTEGER NOT NULL, last_indexed_at TEXT, index_status TEXT DEFAULT 'pending', error_count INTEGER DEFAULT 0, fingerprint TEXT, fact_generation INTEGER NOT NULL DEFAULT 0, graph_generation INTEGER NOT NULL DEFAULT 0, graph_stale_reason TEXT, graph_stale_at TEXT, UNIQUE(workspace_id, absolute_path), FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
|
|
94
94
|
CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, relative_path TEXT NOT NULL, extension TEXT NOT NULL, sha256 TEXT NOT NULL, size_bytes INTEGER NOT NULL, last_indexed_at TEXT NOT NULL, UNIQUE(repo_id, relative_path), FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
|
|
95
95
|
CREATE TABLE IF NOT EXISTS cds_requires (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, alias TEXT NOT NULL, kind TEXT, model TEXT, destination TEXT, service_path TEXT, request_timeout INTEGER, raw_json TEXT NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
|
|
96
96
|
CREATE TABLE IF NOT EXISTS cds_services (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, namespace TEXT, service_name TEXT NOT NULL, qualified_name TEXT NOT NULL, service_path TEXT NOT NULL, is_extend INTEGER NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
|
|
@@ -98,11 +98,11 @@ CREATE TABLE IF NOT EXISTS cds_operations (id INTEGER PRIMARY KEY, service_id IN
|
|
|
98
98
|
CREATE TABLE IF NOT EXISTS symbols (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, file_id INTEGER, kind TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL, exported INTEGER NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE);
|
|
99
99
|
CREATE TABLE IF NOT EXISTS handler_classes (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, symbol_id INTEGER, class_name TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(symbol_id) REFERENCES symbols(id) ON DELETE SET NULL);
|
|
100
100
|
CREATE TABLE IF NOT EXISTS handler_methods (id INTEGER PRIMARY KEY, handler_class_id INTEGER NOT NULL, method_name TEXT NOT NULL, decorator_kind TEXT NOT NULL, decorator_value TEXT, decorator_raw_expression TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE CASCADE);
|
|
101
|
-
CREATE TABLE IF NOT EXISTS handler_registrations (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, handler_class_id INTEGER, registration_file TEXT NOT NULL, registration_line INTEGER NOT NULL, registration_kind TEXT NOT NULL, confidence REAL NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE SET NULL);
|
|
101
|
+
CREATE TABLE IF NOT EXISTS handler_registrations (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, handler_class_id INTEGER, class_name TEXT, import_source TEXT, registration_file TEXT NOT NULL, registration_line INTEGER NOT NULL, registration_kind TEXT NOT NULL, confidence REAL NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE SET NULL);
|
|
102
102
|
CREATE TABLE IF NOT EXISTS service_bindings (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, symbol_id INTEGER, variable_name TEXT NOT NULL, alias TEXT, alias_expr TEXT, destination_expr TEXT, service_path_expr TEXT, is_dynamic INTEGER NOT NULL, placeholders_json TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, helper_chain_json TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(symbol_id) REFERENCES symbols(id) ON DELETE SET NULL);
|
|
103
103
|
CREATE TABLE IF NOT EXISTS outbound_calls (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, source_symbol_id INTEGER, call_type TEXT NOT NULL, service_binding_id INTEGER, method TEXT, operation_path_expr TEXT, query_entity TEXT, event_name_expr TEXT, payload_summary TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, confidence REAL NOT NULL, unresolved_reason TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(source_symbol_id) REFERENCES symbols(id) ON DELETE SET NULL, FOREIGN KEY(service_binding_id) REFERENCES service_bindings(id) ON DELETE SET NULL);
|
|
104
|
-
CREATE TABLE IF NOT EXISTS graph_edges (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, edge_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'unresolved', from_kind TEXT NOT NULL, from_id TEXT NOT NULL, to_kind TEXT NOT NULL, to_id TEXT NOT NULL, confidence REAL NOT NULL, evidence_json TEXT NOT NULL, is_dynamic INTEGER NOT NULL, unresolved_reason TEXT, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
|
|
105
|
-
CREATE TABLE IF NOT EXISTS index_runs (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, repo_count INTEGER NOT NULL, file_count INTEGER NOT NULL, diagnostic_count INTEGER NOT NULL, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
|
|
104
|
+
CREATE TABLE IF NOT EXISTS graph_edges (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, edge_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'unresolved', from_kind TEXT NOT NULL, from_id TEXT NOT NULL, to_kind TEXT NOT NULL, to_id TEXT NOT NULL, confidence REAL NOT NULL, evidence_json TEXT NOT NULL, is_dynamic INTEGER NOT NULL, unresolved_reason TEXT, generation INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
|
|
105
|
+
CREATE TABLE IF NOT EXISTS index_runs (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, repo_count INTEGER NOT NULL, file_count INTEGER NOT NULL, diagnostic_count INTEGER NOT NULL, error_message TEXT, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
|
|
106
106
|
CREATE TABLE IF NOT EXISTS diagnostics (id INTEGER PRIMARY KEY, repo_id INTEGER, file_id INTEGER, severity TEXT NOT NULL, code TEXT NOT NULL, message TEXT NOT NULL, source_file TEXT, source_line INTEGER, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE SET NULL);
|
|
107
107
|
CREATE INDEX IF NOT EXISTS idx_repo_name ON repositories(name);
|
|
108
108
|
CREATE INDEX IF NOT EXISTS idx_service_path ON cds_services(service_path);
|
|
@@ -112,7 +112,31 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(kind, name, path, rep
|
|
|
112
112
|
`;
|
|
113
113
|
|
|
114
114
|
// src/db/migrations.ts
|
|
115
|
-
var CURRENT_SCHEMA_VERSION =
|
|
115
|
+
var CURRENT_SCHEMA_VERSION = 3;
|
|
116
|
+
var columns = {
|
|
117
|
+
service_bindings: [
|
|
118
|
+
{ name: "helper_chain_json", ddl: "ALTER TABLE service_bindings ADD COLUMN helper_chain_json TEXT" },
|
|
119
|
+
{ name: "alias_expr", ddl: "ALTER TABLE service_bindings ADD COLUMN alias_expr TEXT" }
|
|
120
|
+
],
|
|
121
|
+
repositories: [
|
|
122
|
+
{ name: "fingerprint", ddl: "ALTER TABLE repositories ADD COLUMN fingerprint TEXT" },
|
|
123
|
+
{ name: "fact_generation", ddl: "ALTER TABLE repositories ADD COLUMN fact_generation INTEGER NOT NULL DEFAULT 0" },
|
|
124
|
+
{ name: "graph_generation", ddl: "ALTER TABLE repositories ADD COLUMN graph_generation INTEGER NOT NULL DEFAULT 0" },
|
|
125
|
+
{ name: "graph_stale_reason", ddl: "ALTER TABLE repositories ADD COLUMN graph_stale_reason TEXT" },
|
|
126
|
+
{ name: "graph_stale_at", ddl: "ALTER TABLE repositories ADD COLUMN graph_stale_at TEXT" }
|
|
127
|
+
],
|
|
128
|
+
graph_edges: [
|
|
129
|
+
{ name: "status", ddl: "ALTER TABLE graph_edges ADD COLUMN status TEXT NOT NULL DEFAULT 'unresolved'" },
|
|
130
|
+
{ name: "generation", ddl: "ALTER TABLE graph_edges ADD COLUMN generation INTEGER NOT NULL DEFAULT 0" }
|
|
131
|
+
],
|
|
132
|
+
handler_registrations: [
|
|
133
|
+
{ name: "class_name", ddl: "ALTER TABLE handler_registrations ADD COLUMN class_name TEXT" },
|
|
134
|
+
{ name: "import_source", ddl: "ALTER TABLE handler_registrations ADD COLUMN import_source TEXT" }
|
|
135
|
+
],
|
|
136
|
+
index_runs: [
|
|
137
|
+
{ name: "error_message", ddl: "ALTER TABLE index_runs ADD COLUMN error_message TEXT" }
|
|
138
|
+
]
|
|
139
|
+
};
|
|
116
140
|
function hasColumn(db, table, column) {
|
|
117
141
|
return db.prepare(`PRAGMA table_info(${table})`).all().some((row) => row.name === column);
|
|
118
142
|
}
|
|
@@ -120,25 +144,43 @@ function userVersion(db) {
|
|
|
120
144
|
const row = db.pragma("user_version")[0];
|
|
121
145
|
return Number(row?.user_version ?? 0);
|
|
122
146
|
}
|
|
147
|
+
function addMissingColumns(db) {
|
|
148
|
+
for (const [table, tableColumns] of Object.entries(columns)) {
|
|
149
|
+
for (const column of tableColumns) {
|
|
150
|
+
if (!hasColumn(db, table, column.name)) db.prepare(column.ddl).run();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function normalizeLegacyStatus(db) {
|
|
155
|
+
db.prepare("UPDATE graph_edges SET status=CASE WHEN edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' THEN 'resolved' WHEN edge_type IN ('HANDLER_RUNS_DB_QUERY','HANDLER_CALLS_EXTERNAL_HTTP','HANDLER_EMITS_EVENT','EVENT_CONSUMED_BY_HANDLER') THEN 'terminal' WHEN edge_type='DYNAMIC_EDGE_CANDIDATE' THEN 'dynamic' WHEN status='ambiguous' THEN 'ambiguous' ELSE status END").run();
|
|
156
|
+
db.prepare("UPDATE repositories SET graph_stale_reason='schema_migration_requires_relink', graph_stale_at=COALESCE(graph_stale_at, datetime('now')) WHERE EXISTS (SELECT 1 FROM graph_edges WHERE graph_edges.workspace_id=repositories.workspace_id) AND graph_generation=0").run();
|
|
157
|
+
}
|
|
123
158
|
function migrate(db) {
|
|
124
159
|
db.transaction(() => {
|
|
160
|
+
const version = userVersion(db);
|
|
161
|
+
if (version > CURRENT_SCHEMA_VERSION) throw new Error(`Unsupported future service-flow schema version ${version}`);
|
|
125
162
|
db.exec(schemaSql);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
db.
|
|
131
|
-
db.pragma(`user_version = ${Math.max(userVersion(db), CURRENT_SCHEMA_VERSION)}`);
|
|
163
|
+
addMissingColumns(db);
|
|
164
|
+
normalizeLegacyStatus(db);
|
|
165
|
+
const violations = db.pragma("foreign_key_check");
|
|
166
|
+
if (violations.length > 0) throw new Error("SQLite foreign_key_check failed during migration");
|
|
167
|
+
db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
|
|
132
168
|
});
|
|
133
169
|
}
|
|
134
170
|
|
|
135
171
|
// src/db/connection.ts
|
|
136
172
|
function loadSqlite() {
|
|
137
173
|
try {
|
|
138
|
-
|
|
174
|
+
const moduleValue = process.getBuiltinModule("node:sqlite");
|
|
175
|
+
if (!moduleValue || typeof moduleValue !== "object" || !("DatabaseSync" in moduleValue))
|
|
176
|
+
throw new Error("node:sqlite DatabaseSync is unavailable");
|
|
177
|
+
const sqlite = moduleValue;
|
|
178
|
+
if (typeof sqlite.DatabaseSync !== "function")
|
|
179
|
+
throw new Error("node:sqlite DatabaseSync is not a constructor");
|
|
180
|
+
return sqlite;
|
|
139
181
|
} catch (error) {
|
|
140
182
|
throw new Error(
|
|
141
|
-
"service-flow requires
|
|
183
|
+
"service-flow 0.1.7 requires Node.js >=24 with node:sqlite DatabaseSync support. Upgrade Node.js or install a service-flow build with a compatible SQLite driver.",
|
|
142
184
|
{ cause: error }
|
|
143
185
|
);
|
|
144
186
|
}
|
|
@@ -354,17 +396,23 @@ function insertHandler(db, repoId, h) {
|
|
|
354
396
|
}
|
|
355
397
|
function insertRegistrations(db, repoId, rows) {
|
|
356
398
|
const stmt = db.prepare(
|
|
357
|
-
"INSERT INTO handler_registrations(repo_id,handler_class_id,registration_file,registration_line,registration_kind,confidence) VALUES(
|
|
399
|
+
"INSERT INTO handler_registrations(repo_id,handler_class_id,class_name,import_source,registration_file,registration_line,registration_kind,confidence) VALUES(?,?,?,?,?,?,?,?)"
|
|
358
400
|
);
|
|
359
|
-
for (const r of rows)
|
|
401
|
+
for (const r of rows) {
|
|
402
|
+
const handlerClass = r.className ? db.prepare(
|
|
403
|
+
"SELECT id FROM handler_classes WHERE repo_id=? AND class_name=? ORDER BY id"
|
|
404
|
+
).all(repoId, r.className) : [];
|
|
360
405
|
stmt.run(
|
|
361
406
|
repoId,
|
|
362
|
-
null,
|
|
407
|
+
handlerClass.length === 1 ? handlerClass[0]?.id : null,
|
|
408
|
+
r.className,
|
|
409
|
+
r.importSource,
|
|
363
410
|
r.registrationFile,
|
|
364
411
|
r.registrationLine,
|
|
365
412
|
r.registrationKind,
|
|
366
413
|
r.confidence
|
|
367
414
|
);
|
|
415
|
+
}
|
|
368
416
|
}
|
|
369
417
|
function insertBindings(db, repoId, rows) {
|
|
370
418
|
const stmt = db.prepare(
|
|
@@ -447,11 +495,12 @@ async function findFiles(root, suffix) {
|
|
|
447
495
|
return out;
|
|
448
496
|
}
|
|
449
497
|
|
|
450
|
-
// src/
|
|
451
|
-
|
|
452
|
-
|
|
498
|
+
// src/utils/diagnostics.ts
|
|
499
|
+
function errorMessage(error) {
|
|
500
|
+
return error instanceof Error ? error.message : String(error);
|
|
501
|
+
}
|
|
453
502
|
|
|
454
|
-
// src/indexer/
|
|
503
|
+
// src/indexer/repository-indexer.ts
|
|
455
504
|
import fs4 from "fs/promises";
|
|
456
505
|
import path4 from "path";
|
|
457
506
|
|
|
@@ -465,104 +514,138 @@ function sha256Text(text) {
|
|
|
465
514
|
return createHash("sha256").update(text).digest("hex");
|
|
466
515
|
}
|
|
467
516
|
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
517
|
+
// package.json
|
|
518
|
+
var package_default = {
|
|
519
|
+
name: "@saptools/service-flow",
|
|
520
|
+
version: "0.1.7",
|
|
521
|
+
description: "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
|
|
522
|
+
type: "module",
|
|
523
|
+
publishConfig: {
|
|
524
|
+
access: "public",
|
|
525
|
+
registry: "https://registry.npmjs.org/"
|
|
526
|
+
},
|
|
527
|
+
bin: {
|
|
528
|
+
"service-flow": "dist/cli.js"
|
|
529
|
+
},
|
|
530
|
+
main: "./dist/index.js",
|
|
531
|
+
types: "./dist/index.d.ts",
|
|
532
|
+
exports: {
|
|
533
|
+
".": {
|
|
534
|
+
types: "./dist/index.d.ts",
|
|
535
|
+
import: "./dist/index.js"
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
files: [
|
|
539
|
+
"CHANGELOG.md",
|
|
540
|
+
"README.md",
|
|
541
|
+
"TECHNICAL-NOTE.md",
|
|
542
|
+
"dist"
|
|
543
|
+
],
|
|
544
|
+
engines: {
|
|
545
|
+
node: ">=24.0.0"
|
|
546
|
+
},
|
|
547
|
+
scripts: {
|
|
548
|
+
build: "tsup",
|
|
549
|
+
typecheck: "tsc --noEmit",
|
|
550
|
+
lint: "eslint src tests",
|
|
551
|
+
"test:unit": "vitest run tests/unit",
|
|
552
|
+
"test:e2e": "vitest run tests/e2e",
|
|
553
|
+
"test:e2e:fake": "vitest run tests/e2e"
|
|
554
|
+
},
|
|
555
|
+
keywords: [
|
|
556
|
+
"sap",
|
|
557
|
+
"cap",
|
|
558
|
+
"cds",
|
|
559
|
+
"btp",
|
|
560
|
+
"cli",
|
|
561
|
+
"service-graph",
|
|
562
|
+
"call-graph",
|
|
563
|
+
"sqlite",
|
|
564
|
+
"saptools"
|
|
565
|
+
],
|
|
566
|
+
author: "Dong Tran",
|
|
567
|
+
license: "MIT",
|
|
568
|
+
repository: {
|
|
569
|
+
type: "git",
|
|
570
|
+
url: "git+https://github.com/dongitran/saptools.git",
|
|
571
|
+
directory: "packages/service-flow"
|
|
572
|
+
},
|
|
573
|
+
homepage: "https://github.com/dongitran/saptools/tree/main/packages/service-flow#readme",
|
|
574
|
+
bugs: {
|
|
575
|
+
url: "https://github.com/dongitran/saptools/issues"
|
|
576
|
+
},
|
|
577
|
+
dependencies: {
|
|
578
|
+
commander: "13.1.0",
|
|
579
|
+
picocolors: "1.1.1",
|
|
580
|
+
typescript: "5.9.3",
|
|
581
|
+
zod: "4.4.3"
|
|
582
|
+
},
|
|
583
|
+
devDependencies: {
|
|
584
|
+
"@vitest/coverage-v8": "3.2.4",
|
|
585
|
+
tsup: "8.5.1",
|
|
586
|
+
vitest: "3.2.4"
|
|
587
|
+
}
|
|
588
|
+
};
|
|
484
589
|
|
|
485
|
-
// src/
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
590
|
+
// src/version.ts
|
|
591
|
+
var VERSION = package_default.version;
|
|
592
|
+
var ANALYZER_VERSION = package_default.version;
|
|
489
593
|
|
|
490
594
|
// src/indexer/repository-indexer.ts
|
|
491
595
|
async function indexRepository(db, repo, force) {
|
|
492
|
-
let diagnostics = 0;
|
|
493
|
-
let files = 0;
|
|
494
596
|
const sourceFiles = await findSourceFiles(repo.absolute_path);
|
|
495
|
-
const
|
|
496
|
-
const fingerprint = await repositoryFingerprint(repo.absolute_path, sourceFiles,
|
|
597
|
+
const packageFacts = await parsePackageJson(repo.absolute_path);
|
|
598
|
+
const fingerprint = await repositoryFingerprint(repo.absolute_path, sourceFiles, packageFacts);
|
|
497
599
|
if (!force && repo.fingerprint === fingerprint) return { fileCount: 0, diagnosticCount: 0, skipped: true };
|
|
498
|
-
const kind = await classifyRepository(repo.absolute_path,
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
await parseOutboundCalls(repo.absolute_path, file)
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
} catch (error) {
|
|
538
|
-
diagnostics += 1;
|
|
539
|
-
db.prepare(
|
|
540
|
-
"INSERT INTO diagnostics(repo_id,severity,code,message,source_file) VALUES(?,?,?,?,?)"
|
|
541
|
-
).run(repo.id, "warning", "parse_failed", errorMessage(error), file);
|
|
600
|
+
const kind = await classifyRepository(repo.absolute_path, packageFacts);
|
|
601
|
+
try {
|
|
602
|
+
const parsed = await parseAllSourceFacts(repo.absolute_path, sourceFiles);
|
|
603
|
+
db.transaction(() => {
|
|
604
|
+
db.prepare("UPDATE repositories SET package_name=?, package_version=?, dependencies_json=?, kind=?, index_status=? WHERE id=?").run(packageFacts.packageName, packageFacts.packageVersion, JSON.stringify(packageFacts.dependencies), kind, "indexing", repo.id);
|
|
605
|
+
clearRepoFacts(db, repo.id);
|
|
606
|
+
insertRequires(db, repo.id, packageFacts.cdsRequires);
|
|
607
|
+
const fileStmt = db.prepare("INSERT INTO files(repo_id,relative_path,extension,sha256,size_bytes,last_indexed_at) VALUES(?,?,?,?,?,?) ON CONFLICT(repo_id,relative_path) DO UPDATE SET sha256=excluded.sha256,size_bytes=excluded.size_bytes,last_indexed_at=excluded.last_indexed_at");
|
|
608
|
+
for (const file of parsed.fileRecords) fileStmt.run(repo.id, file.relativePath, file.extension, file.sha256, file.sizeBytes, (/* @__PURE__ */ new Date()).toISOString());
|
|
609
|
+
for (const s of parsed.services) insertService(db, repo.id, s);
|
|
610
|
+
for (const h of parsed.handlers) insertHandler(db, repo.id, h);
|
|
611
|
+
insertRegistrations(db, repo.id, parsed.registrations);
|
|
612
|
+
insertBindings(db, repo.id, parsed.bindings);
|
|
613
|
+
insertCalls(db, repo.id, parsed.calls);
|
|
614
|
+
db.prepare("UPDATE repositories SET last_indexed_at=?, index_status='indexed', error_count=0, fingerprint=?, fact_generation=COALESCE(fact_generation,0)+1, graph_stale_reason='facts_changed', graph_stale_at=? WHERE id=?").run((/* @__PURE__ */ new Date()).toISOString(), fingerprint, (/* @__PURE__ */ new Date()).toISOString(), repo.id);
|
|
615
|
+
});
|
|
616
|
+
return { fileCount: sourceFiles.length, diagnosticCount: 0, skipped: false };
|
|
617
|
+
} catch (error) {
|
|
618
|
+
const message = errorMessage(error);
|
|
619
|
+
db.prepare("UPDATE repositories SET index_status='failed', error_count=1 WHERE id=?").run(repo.id);
|
|
620
|
+
db.prepare("INSERT INTO diagnostics(repo_id,severity,code,message) VALUES(?,?,?,?)").run(repo.id, "error", "index_failed_snapshot_preserved", message);
|
|
621
|
+
return { fileCount: 0, diagnosticCount: 1, skipped: false };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function parseAllSourceFacts(root, files) {
|
|
625
|
+
const facts = { services: [], handlers: [], registrations: [], bindings: [], calls: [], fileRecords: [] };
|
|
626
|
+
for (const file of files) {
|
|
627
|
+
const abs = path4.join(root, file);
|
|
628
|
+
const stat = await fs4.stat(abs);
|
|
629
|
+
facts.fileRecords.push({ relativePath: normalizePath(file), extension: path4.extname(file), sha256: await sha256File(abs), sizeBytes: stat.size });
|
|
630
|
+
if (file.endsWith(".cds")) facts.services.push(...await parseCdsFile(root, file));
|
|
631
|
+
if (/\.[jt]s$/.test(file)) {
|
|
632
|
+
facts.handlers.push(...await parseDecorators(root, file));
|
|
633
|
+
facts.registrations.push(...await parseHandlerRegistrations(root, file));
|
|
634
|
+
facts.bindings.push(...await parseServiceBindings(root, file));
|
|
635
|
+
facts.calls.push(...await parseOutboundCalls(root, file));
|
|
542
636
|
}
|
|
543
637
|
}
|
|
544
|
-
|
|
545
|
-
"UPDATE repositories SET last_indexed_at=?, index_status=?, error_count=?, fingerprint=? WHERE id=?"
|
|
546
|
-
).run(
|
|
547
|
-
(/* @__PURE__ */ new Date()).toISOString(),
|
|
548
|
-
diagnostics ? "partial" : "indexed",
|
|
549
|
-
diagnostics,
|
|
550
|
-
fingerprint,
|
|
551
|
-
repo.id
|
|
552
|
-
);
|
|
553
|
-
return { fileCount: files, diagnosticCount: diagnostics, skipped: false };
|
|
638
|
+
return facts;
|
|
554
639
|
}
|
|
555
640
|
async function findSourceFiles(root) {
|
|
556
641
|
const out = [];
|
|
557
642
|
async function walk(dir, prefix = "") {
|
|
558
|
-
const entries = await
|
|
643
|
+
const entries = await fs4.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
559
644
|
for (const e of entries) {
|
|
560
645
|
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
561
646
|
if (e.isDirectory()) {
|
|
562
|
-
if (!["node_modules", "dist", "gen", "coverage", ".git"].includes(e.name))
|
|
563
|
-
|
|
564
|
-
} else if (/\.(cds|ts|js)$/.test(e.name) && !isDefaultTestFile(rel))
|
|
565
|
-
out.push(rel);
|
|
647
|
+
if (!["node_modules", "dist", "gen", "coverage", ".git"].includes(e.name)) await walk(path4.join(dir, e.name), rel);
|
|
648
|
+
} else if (/\.(cds|ts|js)$/.test(e.name) && !isDefaultTestFile(rel)) out.push(rel);
|
|
566
649
|
}
|
|
567
650
|
}
|
|
568
651
|
await walk(root);
|
|
@@ -570,15 +653,24 @@ async function findSourceFiles(root) {
|
|
|
570
653
|
}
|
|
571
654
|
function isDefaultTestFile(relativeFile) {
|
|
572
655
|
const parts = relativeFile.split("/");
|
|
573
|
-
if (parts.some((part) => ["test", "tests", "__tests__"].includes(part)))
|
|
574
|
-
return true;
|
|
656
|
+
if (parts.some((part) => ["test", "tests", "__tests__"].includes(part))) return true;
|
|
575
657
|
return /\.(test|spec)\.[jt]s$/.test(parts.at(-1) ?? "");
|
|
576
658
|
}
|
|
577
|
-
async function repositoryFingerprint(root, files,
|
|
578
|
-
|
|
579
|
-
const
|
|
659
|
+
async function repositoryFingerprint(root, files, facts) {
|
|
660
|
+
const packageJson = await fs4.readFile(path4.join(root, "package.json"), "utf8").catch(() => "");
|
|
661
|
+
const normalizedFacts = {
|
|
662
|
+
analyzerVersion: ANALYZER_VERSION,
|
|
663
|
+
packageName: facts.packageName,
|
|
664
|
+
packageVersion: facts.packageVersion,
|
|
665
|
+
dependencies: Object.fromEntries(Object.entries(facts.dependencies).sort()),
|
|
666
|
+
cdsRequires: [...facts.cdsRequires].sort((a, b) => a.alias.localeCompare(b.alias)),
|
|
667
|
+
scripts: Object.fromEntries(Object.entries(facts.scripts).sort()),
|
|
668
|
+
includeTests: false,
|
|
669
|
+
packageJsonHash: sha256Text(packageJson)
|
|
670
|
+
};
|
|
671
|
+
const entries = [`facts:${JSON.stringify(normalizedFacts)}`];
|
|
580
672
|
for (const file of files) {
|
|
581
|
-
const content = await
|
|
673
|
+
const content = await fs4.readFile(path4.join(root, file), "utf8");
|
|
582
674
|
entries.push(`${file}:${sha256Text(content)}`);
|
|
583
675
|
}
|
|
584
676
|
return sha256Text(entries.join("\n"));
|
|
@@ -588,30 +680,23 @@ async function repositoryFingerprint(root, files, dependencies, force) {
|
|
|
588
680
|
async function indexWorkspace(db, workspaceId, options) {
|
|
589
681
|
const started = (/* @__PURE__ */ new Date()).toISOString();
|
|
590
682
|
const repos = options.repo ? [repoByName(db, options.repo)].filter((r) => r !== void 0) : listRepositories(db);
|
|
591
|
-
const runId = Number(
|
|
592
|
-
db.prepare(
|
|
593
|
-
"INSERT INTO index_runs(workspace_id,started_at,status,repo_count,file_count,diagnostic_count) VALUES(?,?,?,?,?,?) RETURNING id"
|
|
594
|
-
).get(workspaceId, started, "running", repos.length, 0, 0)?.id
|
|
595
|
-
);
|
|
683
|
+
const runId = Number(db.prepare("INSERT INTO index_runs(workspace_id,started_at,status,repo_count,file_count,diagnostic_count) VALUES(?,?,?,?,?,?) RETURNING id").get(workspaceId, started, "running", repos.length, 0, 0)?.id);
|
|
596
684
|
let fileCount = 0;
|
|
597
685
|
let diagnosticCount = 0;
|
|
598
686
|
let skippedCount = 0;
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
687
|
+
try {
|
|
688
|
+
for (const repo of repos) {
|
|
689
|
+
const result = await indexRepository(db, repo, options.force);
|
|
690
|
+
fileCount += result.fileCount;
|
|
691
|
+
diagnosticCount += result.diagnosticCount;
|
|
692
|
+
skippedCount += result.skipped ? 1 : 0;
|
|
693
|
+
}
|
|
694
|
+
db.prepare("UPDATE index_runs SET finished_at=?, status=?, file_count=?, diagnostic_count=? WHERE id=?").run((/* @__PURE__ */ new Date()).toISOString(), diagnosticCount ? "failed" : "success", fileCount, diagnosticCount, runId);
|
|
695
|
+
return { repoCount: repos.length, indexedCount: repos.length - skippedCount, skippedCount, fileCount, diagnosticCount };
|
|
696
|
+
} catch (error) {
|
|
697
|
+
db.prepare("UPDATE index_runs SET finished_at=?, status='failed', file_count=?, diagnostic_count=?, error_message=? WHERE id=?").run((/* @__PURE__ */ new Date()).toISOString(), fileCount, diagnosticCount + 1, errorMessage(error), runId);
|
|
698
|
+
throw error;
|
|
604
699
|
}
|
|
605
|
-
db.prepare(
|
|
606
|
-
"UPDATE index_runs SET finished_at=?, status=?, file_count=?, diagnostic_count=? WHERE id=?"
|
|
607
|
-
).run(
|
|
608
|
-
(/* @__PURE__ */ new Date()).toISOString(),
|
|
609
|
-
diagnosticCount ? "partial" : "success",
|
|
610
|
-
fileCount,
|
|
611
|
-
diagnosticCount,
|
|
612
|
-
runId
|
|
613
|
-
);
|
|
614
|
-
return { repoCount: repos.length, indexedCount: repos.length - skippedCount, skippedCount, fileCount, diagnosticCount };
|
|
615
700
|
}
|
|
616
701
|
|
|
617
702
|
// src/trace/selectors.ts
|
|
@@ -727,7 +812,7 @@ function createProgram() {
|
|
|
727
812
|
const program = new Command();
|
|
728
813
|
program.name("service-flow").description(
|
|
729
814
|
"Trace SAP CAP service-to-service flows across multi-repository workspaces"
|
|
730
|
-
).version(
|
|
815
|
+
).version(VERSION);
|
|
731
816
|
program.command("init").argument("<workspace>").option("--db <path>").option("--ignore <pattern...>").action(
|
|
732
817
|
(workspace, opts) => void init(workspace, opts).catch(fail)
|
|
733
818
|
);
|
|
@@ -747,7 +832,7 @@ function createProgram() {
|
|
|
747
832
|
(opts) => void withWorkspace(opts.workspace, (db, workspaceId) => {
|
|
748
833
|
const r = linkWorkspace(db, workspaceId);
|
|
749
834
|
process.stdout.write(
|
|
750
|
-
`Linked ${r.edgeCount} edges: ${r.resolvedCount} remote resolved, ${r.unresolvedCount} remote unresolved, ${r.ambiguousCount} ambiguous, ${r.dynamicCount} dynamic, ${r.terminalCount} terminal
|
|
835
|
+
`Linked ${r.edgeCount} edges: ${r.resolvedCount} remote resolved, ${r.unresolvedCount} remote unresolved, ${r.ambiguousCount} remote ambiguous, ${r.dynamicCount} dynamic, ${r.terminalCount} terminal, ${r.dependencyResolvedCount} dependency resolved, ${r.dependencyAmbiguousCount} dependency ambiguous, ${r.implementationResolvedCount} implementation resolved, ${r.implementationAmbiguousCount} implementation ambiguous
|
|
751
836
|
`
|
|
752
837
|
);
|
|
753
838
|
}).catch(fail)
|
|
@@ -794,6 +879,10 @@ function createProgram() {
|
|
|
794
879
|
list.command("services").option("--workspace <path>").option("--repo <name>").action(
|
|
795
880
|
(opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
796
881
|
const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
|
|
882
|
+
if (opts.repo && !repo) {
|
|
883
|
+
process.stdout.write(renderJson([{ severity: "warning", code: "selector_repo_not_found", message: `Repository selector not found: ${opts.repo}` }]));
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
797
886
|
const rows = db.prepare(
|
|
798
887
|
"SELECT r.name repo,s.service_path servicePath,s.qualified_name qualifiedName FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) ORDER BY r.name,s.service_path"
|
|
799
888
|
).all(repo?.id, repo?.id);
|
|
@@ -803,6 +892,10 @@ function createProgram() {
|
|
|
803
892
|
list.command("operations").option("--workspace <path>").option("--repo <name>").option("--service <path>").action(
|
|
804
893
|
(opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
805
894
|
const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
|
|
895
|
+
if (opts.repo && !repo) {
|
|
896
|
+
process.stdout.write(renderJson([{ severity: "warning", code: "selector_repo_not_found", message: `Repository selector not found: ${opts.repo}` }]));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
806
899
|
const rows = db.prepare(
|
|
807
900
|
"SELECT r.name repo,s.service_path servicePath,o.operation_name operation,o.operation_path path FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) AND (? IS NULL OR s.service_path=?)"
|
|
808
901
|
).all(repo?.id, repo?.id, opts.service, opts.service);
|
|
@@ -812,6 +905,10 @@ function createProgram() {
|
|
|
812
905
|
list.command("calls").option("--workspace <path>").option("--repo <name>").option("--operation <name>").action(
|
|
813
906
|
(opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
814
907
|
const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
|
|
908
|
+
if (opts.repo && !repo) {
|
|
909
|
+
process.stdout.write(renderJson([{ severity: "warning", code: "selector_repo_not_found", message: `Repository selector not found: ${opts.repo}` }]));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
815
912
|
const rows = db.prepare(
|
|
816
913
|
"SELECT r.name repo,c.call_type type,c.operation_path_expr path,c.source_file file,c.source_line line FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE (? IS NULL OR c.repo_id=?) AND (? IS NULL OR c.operation_path_expr=? OR c.operation_path_expr=? OR c.payload_summary LIKE ?)"
|
|
817
914
|
).all(
|
|
@@ -885,7 +982,13 @@ function createProgram() {
|
|
|
885
982
|
WHERE NOT EXISTS (SELECT 1 FROM search_index)
|
|
886
983
|
UNION ALL
|
|
887
984
|
SELECT 'error','foreign_key_violation','SQLite foreign_key_check reported integrity failures',NULL,NULL
|
|
888
|
-
WHERE EXISTS (SELECT 1 FROM pragma_foreign_key_check)
|
|
985
|
+
WHERE EXISTS (SELECT 1 FROM pragma_foreign_key_check)
|
|
986
|
+
UNION ALL
|
|
987
|
+
SELECT 'warning','graph_stale','Graph is stale after repository fact changes; run service-flow link',NULL,NULL
|
|
988
|
+
WHERE EXISTS (SELECT 1 FROM repositories WHERE graph_stale_reason IS NOT NULL)
|
|
989
|
+
UNION ALL
|
|
990
|
+
SELECT 'warning','index_run_abandoned','An index run is still marked running',NULL,NULL
|
|
991
|
+
WHERE EXISTS (SELECT 1 FROM index_runs WHERE status='running')`
|
|
889
992
|
).all(Boolean(opts.strict), Boolean(opts.strict));
|
|
890
993
|
const allDiagnostics = [...diagnostics, ...health];
|
|
891
994
|
process.stdout.write(
|
|
@@ -897,20 +1000,20 @@ function createProgram() {
|
|
|
897
1000
|
program.command("clean").option("--workspace <path>").option("--db-only").action(
|
|
898
1001
|
(opts) => void (async () => {
|
|
899
1002
|
const config = await loadWorkspaceConfig(opts.workspace);
|
|
900
|
-
const dbDir =
|
|
901
|
-
const workspaceRoot =
|
|
902
|
-
await
|
|
1003
|
+
const dbDir = path5.resolve(path5.dirname(config.dbPath));
|
|
1004
|
+
const workspaceRoot = path5.resolve(config.rootPath);
|
|
1005
|
+
await fs5.rm(config.dbPath, { force: true });
|
|
903
1006
|
if (!opts.dbOnly) {
|
|
904
|
-
const marker =
|
|
1007
|
+
const marker = path5.join(dbDir, ".service-flow-state");
|
|
905
1008
|
const dangerous = /* @__PURE__ */ new Set([
|
|
906
|
-
|
|
1009
|
+
path5.parse(dbDir).root,
|
|
907
1010
|
"/tmp",
|
|
908
|
-
process.env.HOME ?
|
|
1011
|
+
process.env.HOME ? path5.resolve(process.env.HOME) : "",
|
|
909
1012
|
workspaceRoot
|
|
910
1013
|
]);
|
|
911
1014
|
let ownsState;
|
|
912
1015
|
try {
|
|
913
|
-
ownsState = (await
|
|
1016
|
+
ownsState = (await fs5.stat(marker)).isFile();
|
|
914
1017
|
} catch {
|
|
915
1018
|
ownsState = false;
|
|
916
1019
|
}
|
|
@@ -918,7 +1021,7 @@ function createProgram() {
|
|
|
918
1021
|
throw new Error(
|
|
919
1022
|
`Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`
|
|
920
1023
|
);
|
|
921
|
-
await
|
|
1024
|
+
await fs5.rm(dbDir, { recursive: true, force: true });
|
|
922
1025
|
}
|
|
923
1026
|
process.stdout.write("Cleaned service-flow state\n");
|
|
924
1027
|
})().catch(fail)
|