@saptools/service-flow 0.1.40 → 0.1.42
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 +14 -0
- package/dist/{chunk-774PPGGK.js → chunk-RP3BJ64F.js} +134 -33
- package/dist/chunk-RP3BJ64F.js.map +1 -0
- package/dist/cli.js +101 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/db/migrations.ts +14 -1
- package/src/db/repositories.ts +9 -2
- package/src/db/schema.ts +4 -2
- package/src/indexer/cds-extension-resolver.ts +88 -0
- package/src/indexer/workspace-indexer.ts +2 -0
- package/src/linker/cross-repo-linker.ts +12 -2
- package/src/parsers/cds-parser.ts +40 -2
- package/src/parsers/outbound-call-parser.ts +68 -27
- package/src/types.ts +10 -0
- package/dist/chunk-774PPGGK.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -30,6 +30,14 @@ interface CdsServiceFact {
|
|
|
30
30
|
sourceFile: string;
|
|
31
31
|
sourceLine: number;
|
|
32
32
|
operations: CdsOperationFact[];
|
|
33
|
+
extension?: CdsExtensionFact;
|
|
34
|
+
}
|
|
35
|
+
interface CdsExtensionFact {
|
|
36
|
+
localReference: string;
|
|
37
|
+
importedSymbol?: string;
|
|
38
|
+
localAlias?: string;
|
|
39
|
+
moduleSpecifier?: string;
|
|
40
|
+
importKind?: 'relative' | 'package' | 'none';
|
|
33
41
|
}
|
|
34
42
|
interface CdsOperationFact {
|
|
35
43
|
operationType: 'action' | 'function' | 'event';
|
|
@@ -39,6 +47,8 @@ interface CdsOperationFact {
|
|
|
39
47
|
returnType?: string;
|
|
40
48
|
sourceFile: string;
|
|
41
49
|
sourceLine: number;
|
|
50
|
+
provenance?: 'direct' | 'inherited';
|
|
51
|
+
baseOperationId?: number;
|
|
42
52
|
}
|
|
43
53
|
interface HandlerClassFact {
|
|
44
54
|
className: string;
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
package/src/db/migrations.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Db } from './connection.js';
|
|
2
2
|
import { schemaSql } from './schema.js';
|
|
3
|
-
const CURRENT_SCHEMA_VERSION =
|
|
3
|
+
const CURRENT_SCHEMA_VERSION = 9;
|
|
4
4
|
const columns: Record<string, Array<{ name: string; ddl: string }>> = {
|
|
5
5
|
service_bindings: [
|
|
6
6
|
{ name: 'helper_chain_json', ddl: 'ALTER TABLE service_bindings ADD COLUMN helper_chain_json TEXT' },
|
|
@@ -29,6 +29,19 @@ const columns: Record<string, Array<{ name: string; ddl: string }>> = {
|
|
|
29
29
|
{ name: 'exported_name', ddl: 'ALTER TABLE symbols ADD COLUMN exported_name TEXT' },
|
|
30
30
|
{ name: 'evidence_json', ddl: 'ALTER TABLE symbols ADD COLUMN evidence_json TEXT' },
|
|
31
31
|
],
|
|
32
|
+
cds_services: [
|
|
33
|
+
{ name: 'extension_local_ref', ddl: 'ALTER TABLE cds_services ADD COLUMN extension_local_ref TEXT' },
|
|
34
|
+
{ name: 'extension_imported_symbol', ddl: 'ALTER TABLE cds_services ADD COLUMN extension_imported_symbol TEXT' },
|
|
35
|
+
{ name: 'extension_local_alias', ddl: 'ALTER TABLE cds_services ADD COLUMN extension_local_alias TEXT' },
|
|
36
|
+
{ name: 'extension_module_specifier', ddl: 'ALTER TABLE cds_services ADD COLUMN extension_module_specifier TEXT' },
|
|
37
|
+
{ name: 'extension_import_kind', ddl: 'ALTER TABLE cds_services ADD COLUMN extension_import_kind TEXT' },
|
|
38
|
+
{ name: 'extension_base_service_id', ddl: 'ALTER TABLE cds_services ADD COLUMN extension_base_service_id INTEGER' },
|
|
39
|
+
{ name: 'extension_base_status', ddl: 'ALTER TABLE cds_services ADD COLUMN extension_base_status TEXT' },
|
|
40
|
+
],
|
|
41
|
+
cds_operations: [
|
|
42
|
+
{ name: 'provenance', ddl: "ALTER TABLE cds_operations ADD COLUMN provenance TEXT NOT NULL DEFAULT 'direct'" },
|
|
43
|
+
{ name: 'base_operation_id', ddl: 'ALTER TABLE cds_operations ADD COLUMN base_operation_id INTEGER' },
|
|
44
|
+
],
|
|
32
45
|
outbound_calls: [
|
|
33
46
|
{ name: 'local_service_name', ddl: 'ALTER TABLE outbound_calls ADD COLUMN local_service_name TEXT' },
|
|
34
47
|
{ name: 'local_service_lookup', ddl: 'ALTER TABLE outbound_calls ADD COLUMN local_service_lookup TEXT' },
|
package/src/db/repositories.ts
CHANGED
|
@@ -139,7 +139,7 @@ export function insertService(
|
|
|
139
139
|
const id = Number(
|
|
140
140
|
db
|
|
141
141
|
.prepare(
|
|
142
|
-
'INSERT INTO cds_services(repo_id,namespace,service_name,qualified_name,service_path,is_extend,source_file,source_line) VALUES(
|
|
142
|
+
'INSERT INTO cds_services(repo_id,namespace,service_name,qualified_name,service_path,is_extend,source_file,source_line,extension_local_ref,extension_imported_symbol,extension_local_alias,extension_module_specifier,extension_import_kind) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id',
|
|
143
143
|
)
|
|
144
144
|
.get(
|
|
145
145
|
repoId,
|
|
@@ -150,10 +150,15 @@ export function insertService(
|
|
|
150
150
|
s.isExtend ? 1 : 0,
|
|
151
151
|
s.sourceFile,
|
|
152
152
|
s.sourceLine,
|
|
153
|
+
s.extension?.localReference,
|
|
154
|
+
s.extension?.importedSymbol,
|
|
155
|
+
s.extension?.localAlias,
|
|
156
|
+
s.extension?.moduleSpecifier,
|
|
157
|
+
s.extension?.importKind,
|
|
153
158
|
)?.id,
|
|
154
159
|
);
|
|
155
160
|
const stmt = db.prepare(
|
|
156
|
-
'INSERT INTO cds_operations(service_id,operation_type,operation_name,operation_path,params_json,return_type,source_file,source_line) VALUES(
|
|
161
|
+
'INSERT INTO cds_operations(service_id,operation_type,operation_name,operation_path,params_json,return_type,source_file,source_line,provenance,base_operation_id) VALUES(?,?,?,?,?,?,?,?,?,?)',
|
|
157
162
|
);
|
|
158
163
|
db.prepare(
|
|
159
164
|
'INSERT INTO search_index(kind,name,path,repo) VALUES(?,?,?,?)',
|
|
@@ -168,6 +173,8 @@ export function insertService(
|
|
|
168
173
|
o.returnType,
|
|
169
174
|
o.sourceFile,
|
|
170
175
|
o.sourceLine,
|
|
176
|
+
o.provenance ?? 'direct',
|
|
177
|
+
o.baseOperationId ?? null,
|
|
171
178
|
);
|
|
172
179
|
const search = db.prepare(
|
|
173
180
|
'INSERT INTO search_index(kind,name,path,repo) VALUES(?,?,?,?)',
|
package/src/db/schema.ts
CHANGED
|
@@ -3,8 +3,8 @@ CREATE TABLE IF NOT EXISTS workspaces (id INTEGER PRIMARY KEY, root_path TEXT UN
|
|
|
3
3
|
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, fact_analyzer_version TEXT, UNIQUE(workspace_id, absolute_path), FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
|
|
4
4
|
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);
|
|
5
5
|
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);
|
|
6
|
-
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);
|
|
7
|
-
CREATE TABLE IF NOT EXISTS cds_operations (id INTEGER PRIMARY KEY, service_id INTEGER NOT NULL, operation_type TEXT NOT NULL, operation_name TEXT NOT NULL, operation_path TEXT NOT NULL, params_json TEXT NOT NULL, return_type TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(service_id) REFERENCES cds_services(id) ON DELETE CASCADE);
|
|
6
|
+
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, extension_local_ref TEXT, extension_imported_symbol TEXT, extension_local_alias TEXT, extension_module_specifier TEXT, extension_import_kind TEXT, extension_base_service_id INTEGER, extension_base_status TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(extension_base_service_id) REFERENCES cds_services(id) ON DELETE SET NULL);
|
|
7
|
+
CREATE TABLE IF NOT EXISTS cds_operations (id INTEGER PRIMARY KEY, service_id INTEGER NOT NULL, operation_type TEXT NOT NULL, operation_name TEXT NOT NULL, operation_path TEXT NOT NULL, params_json TEXT NOT NULL, return_type TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, provenance TEXT NOT NULL DEFAULT 'direct', base_operation_id INTEGER, FOREIGN KEY(service_id) REFERENCES cds_services(id) ON DELETE CASCADE, FOREIGN KEY(base_operation_id) REFERENCES cds_operations(id) ON DELETE SET NULL);
|
|
8
8
|
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, start_offset INTEGER, end_offset INTEGER, source_file TEXT, exported_name TEXT, evidence_json TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE);
|
|
9
9
|
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);
|
|
10
10
|
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);
|
|
@@ -17,7 +17,9 @@ CREATE TABLE IF NOT EXISTS index_runs (id INTEGER PRIMARY KEY, workspace_id INTE
|
|
|
17
17
|
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);
|
|
18
18
|
CREATE INDEX IF NOT EXISTS idx_repo_name ON repositories(name);
|
|
19
19
|
CREATE INDEX IF NOT EXISTS idx_service_path ON cds_services(service_path);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_extension_import ON cds_services(extension_module_specifier, extension_imported_symbol, is_extend);
|
|
20
21
|
CREATE INDEX IF NOT EXISTS idx_operation_name ON cds_operations(operation_name, operation_path);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_operation_base ON cds_operations(base_operation_id);
|
|
21
23
|
CREATE INDEX IF NOT EXISTS idx_calls_repo ON outbound_calls(repo_id, call_type);
|
|
22
24
|
CREATE INDEX IF NOT EXISTS idx_symbol_calls_caller ON symbol_calls(repo_id, caller_symbol_id);
|
|
23
25
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(kind, name, path, repo);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Db } from '../db/connection.js';
|
|
2
|
+
|
|
3
|
+
interface ExtensionRow {
|
|
4
|
+
id: number;
|
|
5
|
+
repoId: number;
|
|
6
|
+
serviceName: string;
|
|
7
|
+
qualifiedName: string;
|
|
8
|
+
sourceFile: string;
|
|
9
|
+
moduleSpecifier?: string | null;
|
|
10
|
+
importedSymbol?: string | null;
|
|
11
|
+
importKind?: string | null;
|
|
12
|
+
}
|
|
13
|
+
interface BaseRow { id: number; repoId: number }
|
|
14
|
+
|
|
15
|
+
export function materializeCdsExtensionOperations(db: Db, workspaceId: number): void {
|
|
16
|
+
const extensions = db.prepare(`SELECT s.id,r.id repoId,s.service_name serviceName,s.qualified_name qualifiedName,s.source_file sourceFile,s.extension_module_specifier moduleSpecifier,s.extension_imported_symbol importedSymbol,s.extension_import_kind importKind
|
|
17
|
+
FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND s.is_extend=1`).all(workspaceId) as unknown as ExtensionRow[];
|
|
18
|
+
for (const extension of extensions) {
|
|
19
|
+
const bases = resolveBase(db, workspaceId, extension);
|
|
20
|
+
const status = bases.length === 1 ? 'resolved' : bases.length > 1 ? 'ambiguous' : 'unresolved';
|
|
21
|
+
db.prepare('UPDATE cds_services SET extension_base_status=?, extension_base_service_id=? WHERE id=?').run(status, status === 'resolved' ? bases[0]?.id : null, extension.id);
|
|
22
|
+
if (bases.length !== 1) {
|
|
23
|
+
db.prepare("DELETE FROM cds_operations WHERE service_id=? AND provenance='inherited'").run(extension.id);
|
|
24
|
+
db.prepare("DELETE FROM search_index WHERE repo=? AND kind='operation' AND name NOT IN (SELECT operation_name FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE s.repo_id=?)").run(String(extension.repoId), extension.repoId);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
reconcileInheritedOperations(db, extension, bases[0]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function reconcileInheritedOperations(db: Db, extension: ExtensionRow, base: BaseRow): void {
|
|
32
|
+
const existing = db.prepare("SELECT id,operation_name operationName,operation_path operationPath,base_operation_id baseOperationId FROM cds_operations WHERE service_id=? AND provenance='inherited'").all(extension.id) as Array<{ id: number; operationName: string; operationPath: string; baseOperationId: number | null }>;
|
|
33
|
+
const desired = db.prepare("SELECT id,operation_type operationType,operation_name operationName,operation_path operationPath,params_json paramsJson,return_type returnType,source_file sourceFile,source_line sourceLine FROM cds_operations WHERE service_id=? AND provenance='direct' AND NOT EXISTS (SELECT 1 FROM cds_operations direct WHERE direct.service_id=? AND direct.provenance='direct' AND direct.operation_name=cds_operations.operation_name AND direct.operation_path=cds_operations.operation_path)").all(base.id, extension.id) as Array<Record<string, unknown>>;
|
|
34
|
+
const desiredKeys = new Set(desired.map((row) => `${String(row.operationName)}\0${String(row.operationPath)}`));
|
|
35
|
+
const byKey = new Map(existing.map((row) => [`${row.operationName}\0${row.operationPath}`, row]));
|
|
36
|
+
for (const row of existing) {
|
|
37
|
+
if (!desiredKeys.has(`${row.operationName}\0${row.operationPath}`)) db.prepare('DELETE FROM cds_operations WHERE id=?').run(row.id);
|
|
38
|
+
}
|
|
39
|
+
const update = db.prepare('UPDATE cds_operations SET operation_type=?,params_json=?,return_type=?,source_file=?,source_line=?,base_operation_id=? WHERE id=?');
|
|
40
|
+
const add = db.prepare("INSERT INTO cds_operations(service_id,operation_type,operation_name,operation_path,params_json,return_type,source_file,source_line,provenance,base_operation_id) VALUES(?,?,?,?,?,?,?,?,'inherited',?)");
|
|
41
|
+
const search = db.prepare('INSERT INTO search_index(kind,name,path,repo) SELECT ?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM search_index WHERE kind=? AND name=? AND path=? AND repo=?)');
|
|
42
|
+
for (const row of desired) {
|
|
43
|
+
const key = `${String(row.operationName)}\0${String(row.operationPath)}`;
|
|
44
|
+
const current = byKey.get(key);
|
|
45
|
+
if (current) update.run(row.operationType, row.paramsJson, row.returnType, row.sourceFile, row.sourceLine, row.id, current.id);
|
|
46
|
+
else add.run(extension.id, row.operationType, row.operationName, row.operationPath, row.paramsJson, row.returnType, row.sourceFile, row.sourceLine, row.id);
|
|
47
|
+
search.run('operation', row.operationName, row.operationPath, String(extension.repoId), 'operation', row.operationName, row.operationPath, String(extension.repoId));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveBase(db: Db, workspaceId: number, extension: ExtensionRow): BaseRow[] {
|
|
52
|
+
const symbol = extension.importedSymbol ?? extension.serviceName;
|
|
53
|
+
if (extension.importKind === 'relative' && extension.moduleSpecifier) return relativeBase(db, extension, symbol);
|
|
54
|
+
if (extension.importKind === 'package' && extension.moduleSpecifier) return packageBase(db, workspaceId, extension.moduleSpecifier, symbol);
|
|
55
|
+
return sameRepoBase(db, extension, symbol);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function relativeBase(db: Db, extension: ExtensionRow, symbol: string): BaseRow[] {
|
|
59
|
+
const modulePath = normalizeModulePath(extension.sourceFile, extension.moduleSpecifier ?? '');
|
|
60
|
+
return db.prepare(`SELECT s.id,s.repo_id repoId FROM cds_services s WHERE s.repo_id=? AND s.is_extend=0 AND (s.qualified_name=? OR s.service_name=?) AND (s.source_file=? OR s.source_file=?) ORDER BY s.id`).all(extension.repoId, symbol, symbol, modulePath, `${modulePath}.cds`) as unknown as BaseRow[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function packageBase(db: Db, workspaceId: number, specifier: string, symbol: string): BaseRow[] {
|
|
64
|
+
const packageName = packageNameFromSpecifier(specifier);
|
|
65
|
+
const moduleSuffix = specifier.slice(packageName.length).replace(/^\//, '');
|
|
66
|
+
return db.prepare(`SELECT s.id,s.repo_id repoId FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND s.is_extend=0 AND r.package_name=? AND (s.qualified_name=? OR s.service_name=?) AND (?='' OR s.source_file=? OR s.source_file=?) ORDER BY s.id`).all(workspaceId, packageName, symbol, symbol, moduleSuffix, moduleSuffix, `${moduleSuffix}.cds`) as unknown as BaseRow[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sameRepoBase(db: Db, extension: ExtensionRow, symbol: string): BaseRow[] {
|
|
70
|
+
return db.prepare('SELECT id,repo_id repoId FROM cds_services WHERE repo_id=? AND is_extend=0 AND (qualified_name=? OR service_name=?) ORDER BY id').all(extension.repoId, symbol, symbol) as unknown as BaseRow[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeModulePath(sourceFile: string, specifier: string): string {
|
|
74
|
+
const base = sourceFile.split('/').slice(0, -1).join('/');
|
|
75
|
+
const parts = `${base}/${specifier}`.split('/');
|
|
76
|
+
const out: string[] = [];
|
|
77
|
+
for (const part of parts) {
|
|
78
|
+
if (!part || part === '.') continue;
|
|
79
|
+
if (part === '..') out.pop();
|
|
80
|
+
else out.push(part);
|
|
81
|
+
}
|
|
82
|
+
return out.join('/').replace(/\.cds$/, '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function packageNameFromSpecifier(specifier: string): string {
|
|
86
|
+
const parts = specifier.split('/');
|
|
87
|
+
return specifier.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0] ?? specifier;
|
|
88
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { Db } from '../db/connection.js';
|
|
|
2
2
|
import { listRepositories, repoByName } from '../db/repositories.js';
|
|
3
3
|
import { errorMessage } from '../utils/diagnostics.js';
|
|
4
4
|
import { indexRepository } from './repository-indexer.js';
|
|
5
|
+
import { materializeCdsExtensionOperations } from './cds-extension-resolver.js';
|
|
5
6
|
export async function indexWorkspace(
|
|
6
7
|
db: Db,
|
|
7
8
|
workspaceId: number,
|
|
@@ -20,6 +21,7 @@ export async function indexWorkspace(
|
|
|
20
21
|
diagnosticCount += result.diagnosticCount;
|
|
21
22
|
skippedCount += result.skipped ? 1 : 0;
|
|
22
23
|
}
|
|
24
|
+
materializeCdsExtensionOperations(db, workspaceId);
|
|
23
25
|
db.prepare('UPDATE index_runs SET finished_at=?, status=?, file_count=?, diagnostic_count=? WHERE id=?').run(new Date().toISOString(), diagnosticCount ? 'failed' : 'success', fileCount, diagnosticCount, runId);
|
|
24
26
|
return { repoCount: repos.length, indexedCount: repos.length - skippedCount, skippedCount, fileCount, diagnosticCount };
|
|
25
27
|
} catch (error) {
|
|
@@ -150,13 +150,14 @@ function callEvidence(call: Record<string, unknown>, resolution: { target?: { re
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
|
|
153
|
-
const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=?`).all(workspaceId) as Array<Record<string, unknown>>;
|
|
153
|
+
const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,o.provenance provenance,o.base_operation_id baseOperationId,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=?`).all(workspaceId) as Array<Record<string, unknown>>;
|
|
154
154
|
let edgeCount = 0;
|
|
155
155
|
let resolvedCount = 0;
|
|
156
156
|
let ambiguousCount = 0;
|
|
157
157
|
let unresolvedCount = 0;
|
|
158
158
|
for (const operation of operations) {
|
|
159
|
-
const
|
|
159
|
+
const implementationContext = implementationContextForOperation(db, operation);
|
|
160
|
+
const candidates = rankedImplementationCandidates(db, workspaceId, implementationContext);
|
|
160
161
|
if (candidates.length === 0) continue;
|
|
161
162
|
const accepted = candidates.filter((candidate) => candidate.accepted);
|
|
162
163
|
const topScore = accepted[0]?.score ?? 0;
|
|
@@ -167,6 +168,9 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
|
|
|
167
168
|
operationPath: operation.operationPath,
|
|
168
169
|
operationName: operation.operationName,
|
|
169
170
|
modelPackage: { id: operation.modelRepoId, name: operation.modelRepo, packageName: operation.modelPackage },
|
|
171
|
+
implementationSource: implementationContext.operationId === operation.operationId ? 'direct_or_concrete_override' : 'inherited_from_base_operation',
|
|
172
|
+
baseOperationId: operation.baseOperationId,
|
|
173
|
+
implementationOperationId: implementationContext.operationId,
|
|
170
174
|
candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
|
|
171
175
|
};
|
|
172
176
|
if (accepted.length === 0) {
|
|
@@ -190,6 +194,12 @@ interface ImplementationCandidate extends Record<string, unknown> {
|
|
|
190
194
|
acceptedReasons: string[];
|
|
191
195
|
rejectedReasons: string[];
|
|
192
196
|
}
|
|
197
|
+
function implementationContextForOperation(db: Db, operation: Record<string, unknown>): Record<string, unknown> {
|
|
198
|
+
if (operation.provenance !== 'inherited' || !operation.baseOperationId) return operation;
|
|
199
|
+
const base = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,o.provenance provenance,o.base_operation_id baseOperationId,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operation.baseOperationId) as Record<string, unknown> | undefined;
|
|
200
|
+
if (!base) return operation;
|
|
201
|
+
return { ...base, effectiveOperationId: operation.operationId, effectiveServicePath: operation.servicePath, effectiveOperationPath: operation.operationPath };
|
|
202
|
+
}
|
|
193
203
|
function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
|
|
194
204
|
const rows = implementationCandidates(db, workspaceId, operation);
|
|
195
205
|
return deduplicateCandidates(rows.map((row) => scoreImplementationCandidate(row, operation))).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
|
|
@@ -45,6 +45,23 @@ function maskCommentsAndStrings(text: string): string {
|
|
|
45
45
|
return out;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function maskComments(text: string): string {
|
|
49
|
+
let out = '';
|
|
50
|
+
let mode: 'code' | 'line' | 'block' | 'single' | 'double' | 'template' = 'code';
|
|
51
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
52
|
+
const c = text[i] ?? '';
|
|
53
|
+
const n = text[i + 1] ?? '';
|
|
54
|
+
if (mode === 'code' && c === '/' && n === '/') { mode = 'line'; out += ' '; i += 1; continue; }
|
|
55
|
+
if (mode === 'code' && c === '/' && n === '*') { mode = 'block'; out += ' '; i += 1; continue; }
|
|
56
|
+
if (mode === 'line' && c === '\n') mode = 'code';
|
|
57
|
+
if (mode === 'block' && c === '*' && n === '/') { mode = 'code'; out += ' '; i += 1; continue; }
|
|
58
|
+
if (mode === 'code' && (c === "'" || c === '"' || c === '`')) mode = c === "'" ? 'single' : c === '"' ? 'double' : 'template';
|
|
59
|
+
else if ((mode === 'single' && c === "'") || (mode === 'double' && c === '"') || (mode === 'template' && c === '`')) mode = 'code';
|
|
60
|
+
out += mode === 'line' || mode === 'block' ? (c === '\n' ? '\n' : ' ') : c;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
function readAnnotation(text: string, index: number): { end: number; raw: string } | undefined {
|
|
49
66
|
if (text[index] !== '@') return undefined;
|
|
50
67
|
let i = index + 1;
|
|
@@ -90,6 +107,23 @@ function annotationRawAt(original: string, masked: string, index: number): { end
|
|
|
90
107
|
return { end: collected.end, raw: original.slice(index, collected.end) };
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
|
|
111
|
+
interface CdsUsing { importedSymbol: string; localAlias: string; moduleSpecifier: string; importKind: 'relative' | 'package' }
|
|
112
|
+
function collectUsings(masked: string): Map<string, CdsUsing> {
|
|
113
|
+
const imports = new Map<string, CdsUsing>();
|
|
114
|
+
for (const m of masked.matchAll(/\busing\s*\{([^}]*)\}\s*from\s*(['"])(.*?)\2\s*;/gs)) {
|
|
115
|
+
const moduleSpecifier = m[3] ?? '';
|
|
116
|
+
for (const part of (m[1] ?? '').split(',')) {
|
|
117
|
+
const text = part.trim();
|
|
118
|
+
if (!text) continue;
|
|
119
|
+
const alias = /^(\w+)\s+as\s+(\w+)$/.exec(text) ?? /^(\w+)\s*:\s*(\w+)$/.exec(text);
|
|
120
|
+
const importedSymbol = alias?.[1] ?? text;
|
|
121
|
+
const localAlias = alias?.[2] ?? importedSymbol;
|
|
122
|
+
imports.set(localAlias, { importedSymbol, localAlias, moduleSpecifier, importKind: moduleSpecifier.startsWith('.') ? 'relative' : 'package' });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return imports;
|
|
126
|
+
}
|
|
93
127
|
function operationsFromBody(text: string, maskedBody: string, bodyOffset: number, filePath: string): CdsOperationFact[] {
|
|
94
128
|
return [...maskedBody.matchAll(/\b(action|function|event)\s+(\w+)\s*(?:\(([^)]*)\))?\s*(?:returns\s+([^;{]+))?/g)].map((m) => ({
|
|
95
129
|
operationType: (m[1] as 'action' | 'function' | 'event') ?? 'action',
|
|
@@ -109,6 +143,7 @@ export async function parseCdsFile(repoPath: string, filePath: string): Promise<
|
|
|
109
143
|
const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
|
|
110
144
|
const services: CdsServiceFact[] = [];
|
|
111
145
|
const pendingAnnotations: Array<{ end: number; raw: string }> = [];
|
|
146
|
+
const usings = collectUsings(maskComments(text));
|
|
112
147
|
for (const a of masked.matchAll(/@\s*\(/g)) pendingAnnotations.push(annotationRawAt(text, masked, a.index ?? 0));
|
|
113
148
|
const serviceRegex = /\b(?:(extend)\s+)?(?:(service)\s+)?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\b/g;
|
|
114
149
|
let match: RegExpExecArray | null;
|
|
@@ -131,6 +166,7 @@ export async function parseCdsFile(repoPath: string, filePath: string): Promise<
|
|
|
131
166
|
const body = masked.slice(open + 1, end);
|
|
132
167
|
const name = match[3] ?? 'UnknownService';
|
|
133
168
|
const serviceName = name.split('.').pop() ?? name;
|
|
169
|
+
const imported = isExtend ? usings.get(name) ?? usings.get(serviceName) : undefined;
|
|
134
170
|
const servicePath = ensureLeadingSlash(pathAnnotation(annotations) ?? serviceName);
|
|
135
171
|
services.push({
|
|
136
172
|
namespace,
|
|
@@ -140,14 +176,16 @@ export async function parseCdsFile(repoPath: string, filePath: string): Promise<
|
|
|
140
176
|
isExtend,
|
|
141
177
|
sourceFile: normalizePath(filePath),
|
|
142
178
|
sourceLine: lineOf(text, match.index),
|
|
143
|
-
operations: operationsFromBody(text, body, open + 1, filePath)
|
|
179
|
+
operations: operationsFromBody(text, body, open + 1, filePath),
|
|
180
|
+
extension: isExtend ? { localReference: name, importedSymbol: imported?.importedSymbol, localAlias: imported?.localAlias, moduleSpecifier: imported?.moduleSpecifier, importKind: imported?.importKind ?? 'none' } : undefined
|
|
144
181
|
});
|
|
145
182
|
serviceRegex.lastIndex = end + 1;
|
|
146
183
|
}
|
|
147
184
|
const baseOps = new Map(services.filter((s) => !s.isExtend).map((s) => [s.qualifiedName, s.operations]));
|
|
148
185
|
for (const service of services.filter((s) => s.isExtend && s.operations.length === 0)) {
|
|
186
|
+
if (service.extension?.moduleSpecifier) continue;
|
|
149
187
|
const inherited = baseOps.get(service.qualifiedName) ?? baseOps.get(service.serviceName);
|
|
150
|
-
if (inherited) service.operations = inherited.map((op) => ({ ...op,
|
|
188
|
+
if (inherited) service.operations = inherited.map((op) => ({ ...op, provenance: 'inherited' }));
|
|
151
189
|
}
|
|
152
190
|
return services;
|
|
153
191
|
}
|
|
@@ -105,37 +105,54 @@ function placeholders(expr: ts.TemplateExpression): string[] {
|
|
|
105
105
|
function isFunctionLikeScope(node: ts.Node): boolean {
|
|
106
106
|
return ts.isFunctionLike(node) || ts.isSourceFile(node);
|
|
107
107
|
}
|
|
108
|
-
function containingScope(node: ts.Node): ts.Node {
|
|
109
|
-
let current: ts.Node | undefined = node.parent;
|
|
110
|
-
while (current && !isFunctionLikeScope(current)) current = current.parent;
|
|
111
|
-
return current ?? node.getSourceFile();
|
|
112
|
-
}
|
|
113
108
|
function nodeContains(parent: ts.Node, child: ts.Node): boolean {
|
|
114
|
-
|
|
109
|
+
const source = child.getSourceFile();
|
|
110
|
+
return child.getStart(source) >= parent.getStart(source) && child.getEnd() <= parent.getEnd();
|
|
111
|
+
}
|
|
112
|
+
function declarationScope(node: ts.VariableDeclaration | ts.ParameterDeclaration): ts.Node {
|
|
113
|
+
if (ts.isParameter(node)) return node.parent;
|
|
114
|
+
const list = node.parent;
|
|
115
|
+
const blockScoped = (list.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) !== 0;
|
|
116
|
+
let current: ts.Node = list.parent;
|
|
117
|
+
if (!blockScoped) {
|
|
118
|
+
while (current.parent && !isFunctionLikeScope(current)) current = current.parent;
|
|
119
|
+
return current;
|
|
120
|
+
}
|
|
121
|
+
while (current.parent && !ts.isBlock(current) && !ts.isSourceFile(current) && !ts.isModuleBlock(current) && !ts.isCaseBlock(current) && !ts.isCatchClause(current) && !ts.isForStatement(current) && !ts.isForInStatement(current) && !ts.isForOfStatement(current) && !isFunctionLikeScope(current)) current = current.parent;
|
|
122
|
+
return current;
|
|
115
123
|
}
|
|
116
|
-
function
|
|
117
|
-
let current =
|
|
118
|
-
while (current
|
|
119
|
-
if (
|
|
124
|
+
function declarationScopedAncestor(node: ts.Node): ts.Node | undefined {
|
|
125
|
+
let current: ts.Node | undefined = node.parent;
|
|
126
|
+
while (current) {
|
|
127
|
+
if (ts.isCatchClause(current) || ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) return current;
|
|
128
|
+
if (ts.isSourceFile(current) || ts.isFunctionLike(current)) return undefined;
|
|
120
129
|
current = current.parent;
|
|
121
130
|
}
|
|
122
|
-
return
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
function isAccessibleDeclaration(declaration: ts.VariableDeclaration | ts.ParameterDeclaration, use: ts.Node): boolean {
|
|
134
|
+
const source = use.getSourceFile();
|
|
135
|
+
if (declaration.name.getStart(source) >= use.getStart(source)) return false;
|
|
136
|
+
const scopedAncestor = declarationScopedAncestor(declaration);
|
|
137
|
+
if (scopedAncestor && ts.isCatchClause(scopedAncestor)) return nodeContains(scopedAncestor.block, use);
|
|
138
|
+
if (scopedAncestor && (ts.isForStatement(scopedAncestor) || ts.isForInStatement(scopedAncestor) || ts.isForOfStatement(scopedAncestor))) return nodeContains(scopedAncestor.statement, use);
|
|
139
|
+
const scope = declarationScope(declaration);
|
|
140
|
+
if (ts.isCatchClause(scope)) return nodeContains(scope.block, use);
|
|
141
|
+
if (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) return nodeContains(scope.statement, use);
|
|
142
|
+
return ts.isSourceFile(scope) || nodeContains(scope, use);
|
|
123
143
|
}
|
|
124
144
|
function resolveBinding(identifier: ts.Identifier, use: ts.Node): BindingResolution {
|
|
125
|
-
const scope = containingScope(use);
|
|
126
145
|
const source = use.getSourceFile();
|
|
127
146
|
let best: ts.VariableDeclaration | ts.ParameterDeclaration | undefined;
|
|
128
147
|
const visit = (node: ts.Node): void => {
|
|
129
|
-
if (node
|
|
130
|
-
if (node.
|
|
131
|
-
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === identifier.text && !nestedFunctionContains(scope, node, use)) best = node;
|
|
132
|
-
if (ts.isParameter(node) && ts.isIdentifier(node.name) && node.name.text === identifier.text && !nestedFunctionContains(scope, node, use)) best = node;
|
|
148
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === identifier.text && isAccessibleDeclaration(node, use)) best = node;
|
|
149
|
+
if (ts.isParameter(node) && ts.isIdentifier(node.name) && node.name.text === identifier.text && isAccessibleDeclaration(node, use)) best = node;
|
|
133
150
|
ts.forEachChild(node, visit);
|
|
134
151
|
};
|
|
135
|
-
visit(
|
|
152
|
+
visit(source);
|
|
136
153
|
if (!best) return { immutable: false, evidence: ['binding_not_found'] };
|
|
137
154
|
const immutable = ts.isVariableDeclaration(best) && (best.parent.flags & ts.NodeFlags.Const) !== 0;
|
|
138
|
-
return { declaration: best, initializer: ts.isVariableDeclaration(best) ? best.initializer : undefined, immutable, evidence: [immutable ? '
|
|
155
|
+
return { declaration: best, initializer: ts.isVariableDeclaration(best) ? best.initializer : undefined, immutable, evidence: [immutable ? 'lexical_const_binding_before_use' : 'lexical_mutable_or_parameter_binding'] };
|
|
139
156
|
}
|
|
140
157
|
function resolveExpression(expr: ts.Expression | undefined, use: ts.Node, policy: 'operation_path' | 'external' | 'literal', depth = 0, seen = new Set<ts.Node>()): ExpressionResolution {
|
|
141
158
|
if (!expr) return { status: 'unknown', sourceKind: 'dynamic_expression', placeholderKeys: [], evidence: ['expression_missing'] };
|
|
@@ -177,7 +194,6 @@ function staticPathCandidates(identifier: ts.Identifier, call: ts.Node, method:
|
|
|
177
194
|
const binding = resolveBinding(identifier, call);
|
|
178
195
|
const declaration = binding.declaration;
|
|
179
196
|
if (!declaration) return undefined;
|
|
180
|
-
const scope = containingScope(call);
|
|
181
197
|
const source = call.getSourceFile();
|
|
182
198
|
const paths: string[] = [];
|
|
183
199
|
let hasDynamicAssignments = false;
|
|
@@ -188,12 +204,15 @@ function staticPathCandidates(identifier: ts.Identifier, call: ts.Node, method:
|
|
|
188
204
|
};
|
|
189
205
|
if (ts.isVariableDeclaration(declaration)) addExpr(declaration.initializer, declaration);
|
|
190
206
|
const visit = (node: ts.Node): void => {
|
|
191
|
-
if (node !==
|
|
207
|
+
if (node !== source && isFunctionLikeScope(node) && !nodeContains(node, call)) return;
|
|
192
208
|
if (node.getStart(source) >= call.getStart(source)) return;
|
|
193
|
-
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)
|
|
209
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)) {
|
|
210
|
+
const target = resolveBinding(node.left, node);
|
|
211
|
+
if (target.declaration === declaration) addExpr(node.right, node);
|
|
212
|
+
}
|
|
194
213
|
ts.forEachChild(node, visit);
|
|
195
214
|
};
|
|
196
|
-
visit(
|
|
215
|
+
visit(source);
|
|
197
216
|
const candidatePaths = [...new Set(paths)];
|
|
198
217
|
if (candidatePaths.length === 0) return undefined;
|
|
199
218
|
const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, method).topLevelOperationName).filter((value): value is string => Boolean(value)))];
|
|
@@ -404,6 +423,20 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
404
423
|
const entityCallType = entityCallTypes[intent.kind];
|
|
405
424
|
const isODataQueryRead = method.toUpperCase() === 'GET' && ['entity_query', 'entity_key_read', 'entity_navigation_query'].includes(intent.kind);
|
|
406
425
|
add(node, { callType: query ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : undefined, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, rawPathExpression: resolvedPath.rawExpression, literalPathSource: resolvedPath.text ? (resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : resolvedPath.sourceKind) : undefined, odataPathIntent: operationPathExpr ? intent : undefined, staticPathCandidates: candidateEvidence, parserWarning: !query && pathExpr && !operationPathExpr ? 'dynamic_operation_path_identifier' : undefined });
|
|
426
|
+
} else {
|
|
427
|
+
const receiver = receiverName(expr.expression);
|
|
428
|
+
const rootReceiver = rootReceiverName(expr.expression);
|
|
429
|
+
const method = resolveExpression(node.arguments[0], node, 'literal').value?.toUpperCase();
|
|
430
|
+
const pathArg = node.arguments[1];
|
|
431
|
+
const supported = method && ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(method);
|
|
432
|
+
if (receiver && supported && serviceVariables.has(rootReceiver ?? receiver)) {
|
|
433
|
+
const resolvedPath = staticPathExpression(pathArg, node);
|
|
434
|
+
const operationPathExpr = operationPathFromStatic(resolvedPath.text);
|
|
435
|
+
const intent = classifyODataPathIntent(operationPathExpr, method);
|
|
436
|
+
add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.8 : 0.45, unresolvedReason: operationPathExpr ? undefined : 'dynamic_operation_path_identifier' }, { receiver, rootReceiver, classifier: 'service_client_send_method_path', rawPathExpression: resolvedPath.rawExpression, literalPathSource: resolvedPath.text ? (resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : resolvedPath.sourceKind) : undefined, odataPathIntent: operationPathExpr ? intent : undefined, parserWarning: operationPathExpr ? undefined : 'dynamic_operation_path_identifier' });
|
|
437
|
+
} else if (receiver && serviceVariables.has(rootReceiver ?? receiver)) {
|
|
438
|
+
add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.35, unresolvedReason: 'unsupported_cap_send_signature' }, { receiver, rootReceiver, classifier: 'service_client_send_unsupported_signature' });
|
|
439
|
+
}
|
|
407
440
|
}
|
|
408
441
|
} else if (((ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text)) || (ts.isIdentifier(expr) && wrapperSpecs.has(expr.text)))) {
|
|
409
442
|
const wrapperName = ts.isIdentifier(expr) ? expr.text : ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) ? expr.expression.text : '';
|
|
@@ -467,7 +500,7 @@ function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFac
|
|
|
467
500
|
if (origin) aliases.set(node.name.text, { ...origin, chain: [...origin.chain, node.name.text] });
|
|
468
501
|
}
|
|
469
502
|
if (ts.isCallExpression(node)) {
|
|
470
|
-
const parsed = serviceOperationCall(node
|
|
503
|
+
const parsed = serviceOperationCall(node, aliases);
|
|
471
504
|
if (parsed && parsed.operation !== 'entities') calls.push({
|
|
472
505
|
callType: 'local_service_call',
|
|
473
506
|
operationPathExpr: `/${parsed.operation}`,
|
|
@@ -480,7 +513,8 @@ function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFac
|
|
|
480
513
|
confidence: 0.9,
|
|
481
514
|
unresolvedReason: ['send', 'emit', 'publish', 'on'].includes(parsed.operation) ? 'transport_client_method' : undefined,
|
|
482
515
|
evidence: parserEvidence(source, node, {
|
|
483
|
-
classifier:
|
|
516
|
+
classifier: parsed.classifier,
|
|
517
|
+
parserCallType: parsed.operation === 'send' ? 'transport_client_method' : parsed.classifier,
|
|
484
518
|
localServiceLookup: parsed.lookup,
|
|
485
519
|
localServiceName: parsed.service,
|
|
486
520
|
operation: parsed.operation,
|
|
@@ -499,10 +533,17 @@ function serviceLookup(expr: ts.Expression, aliases: Map<string, { service: stri
|
|
|
499
533
|
if (ts.isElementAccessExpression(expr) && expr.expression.getText() === 'cds.services' && ts.isStringLiteral(expr.argumentExpression)) return { service: expr.argumentExpression.text, lookup: expr.getText(), chain: [expr.getText()] };
|
|
500
534
|
return undefined;
|
|
501
535
|
}
|
|
502
|
-
function serviceOperationCall(
|
|
536
|
+
function serviceOperationCall(node: ts.CallExpression, aliases: Map<string, { service: string; lookup: string; chain: string[] }>): { service: string; lookup: string; chain: string[]; operation: string; classifier: string } | undefined {
|
|
537
|
+
const expr = node.expression;
|
|
503
538
|
if (!ts.isPropertyAccessExpression(expr)) return undefined;
|
|
504
|
-
const operation = expr.name.text;
|
|
505
539
|
const origin = serviceLookup(expr.expression, aliases);
|
|
506
540
|
if (!origin) return undefined;
|
|
507
|
-
|
|
541
|
+
if (expr.name.text === 'send') {
|
|
542
|
+
const first = literalText(node.arguments[0]);
|
|
543
|
+
const second = literalText(node.arguments[1]);
|
|
544
|
+
const method = first?.toUpperCase();
|
|
545
|
+
if (method && ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(method) && second) return { ...origin, operation: second.replace(/^\//, ''), classifier: 'cap_service_send_method_path' };
|
|
546
|
+
if (first) return { ...origin, operation: first.replace(/^\//, ''), classifier: 'cap_service_send_local_dispatch' };
|
|
547
|
+
}
|
|
548
|
+
return { ...origin, operation: expr.name.text, classifier: 'local_cap_service_call' };
|
|
508
549
|
}
|
package/src/types.ts
CHANGED
|
@@ -70,6 +70,14 @@ export interface CdsServiceFact {
|
|
|
70
70
|
sourceFile: string;
|
|
71
71
|
sourceLine: number;
|
|
72
72
|
operations: CdsOperationFact[];
|
|
73
|
+
extension?: CdsExtensionFact;
|
|
74
|
+
}
|
|
75
|
+
export interface CdsExtensionFact {
|
|
76
|
+
localReference: string;
|
|
77
|
+
importedSymbol?: string;
|
|
78
|
+
localAlias?: string;
|
|
79
|
+
moduleSpecifier?: string;
|
|
80
|
+
importKind?: 'relative' | 'package' | 'none';
|
|
73
81
|
}
|
|
74
82
|
export interface CdsOperationFact {
|
|
75
83
|
operationType: 'action' | 'function' | 'event';
|
|
@@ -79,6 +87,8 @@ export interface CdsOperationFact {
|
|
|
79
87
|
returnType?: string;
|
|
80
88
|
sourceFile: string;
|
|
81
89
|
sourceLine: number;
|
|
90
|
+
provenance?: 'direct' | 'inherited';
|
|
91
|
+
baseOperationId?: number;
|
|
82
92
|
}
|
|
83
93
|
export interface HandlerClassFact {
|
|
84
94
|
className: string;
|