@saptools/service-flow 0.1.42 → 0.1.44
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 -1
- package/dist/{chunk-RP3BJ64F.js → chunk-UKNPHTUS.js} +466 -323
- package/dist/chunk-UKNPHTUS.js.map +1 -0
- package/dist/cli.js +466 -485
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +398 -0
- package/src/cli.ts +9 -452
- package/src/indexer/cds-extension-resolver.ts +58 -19
- package/src/indexer/repository-indexer.ts +52 -26
- package/src/indexer/workspace-indexer.ts +17 -5
- package/src/linker/cross-repo-linker.ts +22 -2
- package/src/parsers/outbound-call-parser.ts +33 -20
- package/src/parsers/service-binding-parser-helpers.ts +160 -0
- package/src/parsers/service-binding-parser.ts +57 -242
- package/src/trace/evidence.ts +205 -0
- package/src/trace/trace-engine.ts +57 -106
- package/dist/chunk-RP3BJ64F.js.map +0 -1
|
@@ -12,40 +12,79 @@ interface ExtensionRow {
|
|
|
12
12
|
}
|
|
13
13
|
interface BaseRow { id: number; repoId: number }
|
|
14
14
|
|
|
15
|
+
interface DesiredOperation { id: number; operationType: string; operationName: string; operationPath: string; paramsJson: string; returnType: string | null; sourceFile: string; sourceLine: number }
|
|
16
|
+
interface ExistingOperation extends DesiredOperation { baseOperationId: number | null }
|
|
17
|
+
|
|
15
18
|
export function materializeCdsExtensionOperations(db: Db, workspaceId: number): void {
|
|
16
19
|
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
20
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
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;
|
|
21
|
+
db.transaction(() => {
|
|
22
|
+
const changedRepos = new Set<number>();
|
|
23
|
+
for (const extension of extensions) {
|
|
24
|
+
if (reconcileExtension(db, workspaceId, extension)) changedRepos.add(extension.repoId);
|
|
26
25
|
}
|
|
27
|
-
|
|
28
|
-
}
|
|
26
|
+
for (const repoId of changedRepos) markRepositoryDerivedFactsChanged(db, repoId);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function reconcileExtension(db: Db, workspaceId: number, extension: ExtensionRow): boolean {
|
|
31
|
+
const bases = resolveBase(db, workspaceId, extension);
|
|
32
|
+
const status = bases.length === 1 ? 'resolved' : bases.length > 1 ? 'ambiguous' : 'unresolved';
|
|
33
|
+
const baseId = status === 'resolved' ? bases[0]?.id ?? null : null;
|
|
34
|
+
const desired = baseId === null ? [] : desiredInheritedOperations(db, extension, baseId);
|
|
35
|
+
const statusChanged = updateExtensionStatus(db, extension.id, status, baseId);
|
|
36
|
+
const operationChanged = reconcileInheritedOperations(db, extension, desired);
|
|
37
|
+
reconcileOperationSearchRows(db, extension.repoId);
|
|
38
|
+
return statusChanged || operationChanged;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function desiredInheritedOperations(db: Db, extension: ExtensionRow, baseId: number): DesiredOperation[] {
|
|
42
|
+
return 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) ORDER BY operation_name,operation_path,id").all(baseId, extension.id) as unknown as DesiredOperation[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function updateExtensionStatus(db: Db, serviceId: number, status: string, baseId: number | null): boolean {
|
|
46
|
+
const row = db.prepare('SELECT extension_base_status status,extension_base_service_id baseId FROM cds_services WHERE id=?').get(serviceId) as { status?: string | null; baseId?: number | null } | undefined;
|
|
47
|
+
if (row?.status === status && (row.baseId ?? null) === baseId) return false;
|
|
48
|
+
db.prepare('UPDATE cds_services SET extension_base_status=?, extension_base_service_id=? WHERE id=?').run(status, baseId, serviceId);
|
|
49
|
+
return true;
|
|
29
50
|
}
|
|
30
51
|
|
|
31
|
-
function reconcileInheritedOperations(db: Db, extension: ExtensionRow,
|
|
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
|
|
33
|
-
const
|
|
34
|
-
const desiredKeys = new Set(desired.map((row) => `${String(row.operationName)}\0${String(row.operationPath)}`));
|
|
52
|
+
function reconcileInheritedOperations(db: Db, extension: ExtensionRow, desired: DesiredOperation[]): boolean {
|
|
53
|
+
const existing = 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,base_operation_id baseOperationId FROM cds_operations WHERE service_id=? AND provenance='inherited'").all(extension.id) as unknown as ExistingOperation[];
|
|
54
|
+
const desiredKeys = new Set(desired.map((row) => `${row.operationName}\0${row.operationPath}`));
|
|
35
55
|
const byKey = new Map(existing.map((row) => [`${row.operationName}\0${row.operationPath}`, row]));
|
|
56
|
+
let changed = false;
|
|
36
57
|
for (const row of existing) {
|
|
37
|
-
if (
|
|
58
|
+
if (desiredKeys.has(`${row.operationName}\0${row.operationPath}`)) continue;
|
|
59
|
+
db.prepare('DELETE FROM cds_operations WHERE id=?').run(row.id);
|
|
60
|
+
changed = true;
|
|
38
61
|
}
|
|
39
62
|
const update = db.prepare('UPDATE cds_operations SET operation_type=?,params_json=?,return_type=?,source_file=?,source_line=?,base_operation_id=? WHERE id=?');
|
|
40
63
|
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
64
|
for (const row of desired) {
|
|
43
|
-
const
|
|
44
|
-
|
|
65
|
+
const current = byKey.get(`${row.operationName}\0${row.operationPath}`);
|
|
66
|
+
if (current && operationMatches(current, row)) continue;
|
|
45
67
|
if (current) update.run(row.operationType, row.paramsJson, row.returnType, row.sourceFile, row.sourceLine, row.id, current.id);
|
|
46
68
|
else add.run(extension.id, row.operationType, row.operationName, row.operationPath, row.paramsJson, row.returnType, row.sourceFile, row.sourceLine, row.id);
|
|
47
|
-
|
|
69
|
+
changed = true;
|
|
48
70
|
}
|
|
71
|
+
return changed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function operationMatches(current: ExistingOperation, desired: DesiredOperation): boolean {
|
|
75
|
+
return current.operationType === desired.operationType && current.paramsJson === desired.paramsJson && current.returnType === desired.returnType && current.sourceFile === desired.sourceFile && current.sourceLine === desired.sourceLine && current.baseOperationId === desired.id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reconcileOperationSearchRows(db: Db, repoId: number): void {
|
|
79
|
+
db.prepare("DELETE FROM search_index WHERE repo=? AND kind='operation'").run(String(repoId));
|
|
80
|
+
db.prepare(`INSERT INTO search_index(kind,name,path,repo)
|
|
81
|
+
SELECT 'operation',o.operation_name,o.operation_path,?
|
|
82
|
+
FROM cds_operations o JOIN cds_services s ON s.id=o.service_id
|
|
83
|
+
WHERE s.repo_id=? ORDER BY o.operation_name,o.operation_path,o.id`).run(String(repoId), repoId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function markRepositoryDerivedFactsChanged(db: Db, repoId: number): void {
|
|
87
|
+
db.prepare("UPDATE repositories SET fact_generation=COALESCE(fact_generation,0)+1, graph_stale_reason='derived_facts_changed', graph_stale_at=? WHERE id=?").run(new Date().toISOString(), repoId);
|
|
49
88
|
}
|
|
50
89
|
|
|
51
90
|
function resolveBase(db: Db, workspaceId: number, extension: ExtensionRow): BaseRow[] {
|
|
@@ -42,42 +42,68 @@ interface ParsedFacts {
|
|
|
42
42
|
symbolCalls: SymbolCallFact[];
|
|
43
43
|
fileRecords: Array<{ relativePath: string; extension: string; sha256: string; sizeBytes: number }>;
|
|
44
44
|
}
|
|
45
|
+
export interface PreparedRepositoryIndex extends IndexRepoResult {
|
|
46
|
+
repo: RepoRow;
|
|
47
|
+
packageFacts?: Awaited<ReturnType<typeof parsePackageJson>>;
|
|
48
|
+
fingerprint?: string;
|
|
49
|
+
kind?: string;
|
|
50
|
+
parsed?: ParsedFacts;
|
|
51
|
+
}
|
|
45
52
|
export async function indexRepository(
|
|
46
53
|
db: Db,
|
|
47
54
|
repo: RepoRow,
|
|
48
55
|
force: boolean,
|
|
49
56
|
): Promise<IndexRepoResult> {
|
|
50
57
|
try {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!force && repo.fingerprint === fingerprint) return { fileCount: 0, diagnosticCount: 0, skipped: true };
|
|
55
|
-
const kind = await classifyRepository(repo.absolute_path, packageFacts);
|
|
56
|
-
const parsed = await parseAllSourceFacts(repo.absolute_path, sourceFiles);
|
|
57
|
-
db.transaction(() => {
|
|
58
|
-
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);
|
|
59
|
-
clearRepoFacts(db, repo.id);
|
|
60
|
-
insertRequires(db, repo.id, packageFacts.cdsRequires);
|
|
61
|
-
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');
|
|
62
|
-
for (const file of parsed.fileRecords) fileStmt.run(repo.id, file.relativePath, file.extension, file.sha256, file.sizeBytes, new Date().toISOString());
|
|
63
|
-
for (const s of parsed.services) insertService(db, repo.id, s);
|
|
64
|
-
for (const h of parsed.handlers) insertHandler(db, repo.id, h);
|
|
65
|
-
insertExecutableSymbols(db, repo.id, parsed.symbols);
|
|
66
|
-
insertSymbolCalls(db, repo.id, parsed.symbolCalls);
|
|
67
|
-
insertRegistrations(db, repo.id, parsed.registrations);
|
|
68
|
-
insertBindings(db, repo.id, parsed.bindings);
|
|
69
|
-
insertCalls(db, repo.id, parsed.calls);
|
|
70
|
-
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=?, fact_analyzer_version=? WHERE id=?").run(new Date().toISOString(), fingerprint, new Date().toISOString(), ANALYZER_VERSION, repo.id);
|
|
71
|
-
});
|
|
72
|
-
return { fileCount: sourceFiles.length, diagnosticCount: 0, skipped: false };
|
|
58
|
+
const prepared = await prepareRepositoryIndex(repo, force);
|
|
59
|
+
if (!prepared.skipped) db.transaction(() => publishPreparedRepositoryIndex(db, prepared));
|
|
60
|
+
return { fileCount: prepared.fileCount, diagnosticCount: prepared.diagnosticCount, skipped: prepared.skipped };
|
|
73
61
|
} catch (error) {
|
|
74
|
-
|
|
75
|
-
db.prepare("UPDATE repositories SET index_status='failed', error_count=1 WHERE id=?").run(repo.id);
|
|
76
|
-
db.prepare("DELETE FROM diagnostics WHERE repo_id=? AND code IN ('index_failed_snapshot_preserved','source_read_failed')").run(repo.id);
|
|
77
|
-
db.prepare('INSERT INTO diagnostics(repo_id,severity,code,message) VALUES(?,?,?,?)').run(repo.id, 'error', 'source_read_failed', `Index failed before publication; previous facts and fingerprint were preserved. ${message}`);
|
|
62
|
+
recordIndexFailure(db, repo.id, error);
|
|
78
63
|
return { fileCount: 0, diagnosticCount: 1, skipped: false };
|
|
79
64
|
}
|
|
80
65
|
}
|
|
66
|
+
export async function prepareRepositoryIndex(repo: RepoRow, force: boolean): Promise<PreparedRepositoryIndex> {
|
|
67
|
+
const sourceFiles = await findSourceFiles(repo.absolute_path);
|
|
68
|
+
const packageFacts = await parsePackageJson(repo.absolute_path);
|
|
69
|
+
const fingerprint = await repositoryFingerprint(repo.absolute_path, sourceFiles, packageFacts);
|
|
70
|
+
if (!force && repo.fingerprint === fingerprint) return { repo, fileCount: 0, diagnosticCount: 0, skipped: true };
|
|
71
|
+
return {
|
|
72
|
+
repo,
|
|
73
|
+
packageFacts,
|
|
74
|
+
fingerprint,
|
|
75
|
+
kind: await classifyRepository(repo.absolute_path, packageFacts),
|
|
76
|
+
parsed: await parseAllSourceFacts(repo.absolute_path, sourceFiles),
|
|
77
|
+
fileCount: sourceFiles.length,
|
|
78
|
+
diagnosticCount: 0,
|
|
79
|
+
skipped: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function publishPreparedRepositoryIndex(db: Db, prepared: PreparedRepositoryIndex): void {
|
|
83
|
+
if (prepared.skipped) return;
|
|
84
|
+
if (!prepared.packageFacts || !prepared.parsed || !prepared.fingerprint || !prepared.kind) throw new Error('Prepared repository index is missing publication facts');
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
const repoId = prepared.repo.id;
|
|
87
|
+
db.prepare('UPDATE repositories SET package_name=?, package_version=?, dependencies_json=?, kind=?, index_status=? WHERE id=?').run(prepared.packageFacts.packageName, prepared.packageFacts.packageVersion, JSON.stringify(prepared.packageFacts.dependencies), prepared.kind, 'indexing', repoId);
|
|
88
|
+
clearRepoFacts(db, repoId);
|
|
89
|
+
insertRequires(db, repoId, prepared.packageFacts.cdsRequires);
|
|
90
|
+
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');
|
|
91
|
+
for (const file of prepared.parsed.fileRecords) fileStmt.run(repoId, file.relativePath, file.extension, file.sha256, file.sizeBytes, now);
|
|
92
|
+
for (const service of prepared.parsed.services) insertService(db, repoId, service);
|
|
93
|
+
for (const handler of prepared.parsed.handlers) insertHandler(db, repoId, handler);
|
|
94
|
+
insertExecutableSymbols(db, repoId, prepared.parsed.symbols);
|
|
95
|
+
insertSymbolCalls(db, repoId, prepared.parsed.symbolCalls);
|
|
96
|
+
insertRegistrations(db, repoId, prepared.parsed.registrations);
|
|
97
|
+
insertBindings(db, repoId, prepared.parsed.bindings);
|
|
98
|
+
insertCalls(db, repoId, prepared.parsed.calls);
|
|
99
|
+
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=?, fact_analyzer_version=? WHERE id=?").run(now, prepared.fingerprint, now, ANALYZER_VERSION, repoId);
|
|
100
|
+
}
|
|
101
|
+
export function recordIndexFailure(db: Db, repoId: number, error: unknown): void {
|
|
102
|
+
const message = errorMessage(error);
|
|
103
|
+
db.prepare("UPDATE repositories SET index_status='failed', error_count=1 WHERE id=?").run(repoId);
|
|
104
|
+
db.prepare("DELETE FROM diagnostics WHERE repo_id=? AND code IN ('index_failed_snapshot_preserved','source_read_failed')").run(repoId);
|
|
105
|
+
db.prepare('INSERT INTO diagnostics(repo_id,severity,code,message) VALUES(?,?,?,?)').run(repoId, 'error', 'source_read_failed', `Index failed before publication; previous facts and fingerprint were preserved. ${message}`);
|
|
106
|
+
}
|
|
81
107
|
async function parseAllSourceFacts(root: string, files: string[]): Promise<ParsedFacts> {
|
|
82
108
|
const facts: ParsedFacts = { services: [], handlers: [], registrations: [], bindings: [], calls: [], symbols: [], symbolCalls: [], fileRecords: [] };
|
|
83
109
|
for (const file of files) {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
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
|
-
import {
|
|
4
|
+
import { prepareRepositoryIndex, publishPreparedRepositoryIndex, recordIndexFailure, type PreparedRepositoryIndex } from './repository-indexer.js';
|
|
5
5
|
import { materializeCdsExtensionOperations } from './cds-extension-resolver.js';
|
|
6
6
|
export async function indexWorkspace(
|
|
7
7
|
db: Db,
|
|
8
8
|
workspaceId: number,
|
|
9
|
-
options: { repo?: string; force: boolean },
|
|
9
|
+
options: { repo?: string; force: boolean; injectDerivedMaterializationFailure?: boolean },
|
|
10
10
|
): Promise<{ repoCount: number; indexedCount: number; skippedCount: number; fileCount: number; diagnosticCount: number }> {
|
|
11
11
|
const started = new Date().toISOString();
|
|
12
12
|
const repos = options.repo ? [repoByName(db, options.repo)].filter((r) => r !== undefined) : listRepositories(db);
|
|
@@ -14,17 +14,29 @@ export async function indexWorkspace(
|
|
|
14
14
|
let fileCount = 0;
|
|
15
15
|
let diagnosticCount = 0;
|
|
16
16
|
let skippedCount = 0;
|
|
17
|
+
const preparedRows: PreparedRepositoryIndex[] = [];
|
|
18
|
+
let activeRepoId: number | undefined;
|
|
17
19
|
try {
|
|
18
20
|
for (const repo of repos) {
|
|
19
|
-
|
|
21
|
+
activeRepoId = repo.id;
|
|
22
|
+
const result = await prepareRepositoryIndex(repo, options.force);
|
|
23
|
+
preparedRows.push(result);
|
|
20
24
|
fileCount += result.fileCount;
|
|
21
25
|
diagnosticCount += result.diagnosticCount;
|
|
22
26
|
skippedCount += result.skipped ? 1 : 0;
|
|
23
27
|
}
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
db.transaction(() => {
|
|
29
|
+
for (const row of preparedRows) {
|
|
30
|
+
activeRepoId = row.repo.id;
|
|
31
|
+
publishPreparedRepositoryIndex(db, row);
|
|
32
|
+
}
|
|
33
|
+
if (options.injectDerivedMaterializationFailure) throw new Error('Injected derived materialization failure');
|
|
34
|
+
materializeCdsExtensionOperations(db, workspaceId);
|
|
35
|
+
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);
|
|
36
|
+
});
|
|
26
37
|
return { repoCount: repos.length, indexedCount: repos.length - skippedCount, skippedCount, fileCount, diagnosticCount };
|
|
27
38
|
} catch (error) {
|
|
39
|
+
if (activeRepoId && preparedRows.length < repos.length) recordIndexFailure(db, activeRepoId, error);
|
|
28
40
|
db.prepare("UPDATE index_runs SET finished_at=?, status='failed', file_count=?, diagnostic_count=?, error_message=? WHERE id=?").run(new Date().toISOString(), fileCount, diagnosticCount + 1, errorMessage(error), runId);
|
|
29
41
|
throw error;
|
|
30
42
|
}
|
|
@@ -162,7 +162,11 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
|
|
|
162
162
|
const accepted = candidates.filter((candidate) => candidate.accepted);
|
|
163
163
|
const topScore = accepted[0]?.score ?? 0;
|
|
164
164
|
const winners = accepted.filter((candidate) => candidate.score === topScore);
|
|
165
|
-
const
|
|
165
|
+
const duplicateFamilies = duplicatePackageFamilies(accepted);
|
|
166
|
+
const duplicatePackageAmbiguous = duplicateFamilies.length > 0 && !accepted.some(hasDirectOwnershipEvidence);
|
|
167
|
+
const selected = duplicatePackageAmbiguous ? accepted : winners;
|
|
168
|
+
const unique = !duplicatePackageAmbiguous && winners.length === 1 ? winners[0] : undefined;
|
|
169
|
+
const ambiguityReasons = duplicatePackageAmbiguous ? ['duplicate_package_name_candidates'] : winners.length > 1 ? ['multiple_equal_score_implementation_candidates'] : [];
|
|
166
170
|
const evidence = {
|
|
167
171
|
servicePath: operation.servicePath,
|
|
168
172
|
operationPath: operation.operationPath,
|
|
@@ -171,6 +175,8 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
|
|
|
171
175
|
implementationSource: implementationContext.operationId === operation.operationId ? 'direct_or_concrete_override' : 'inherited_from_base_operation',
|
|
172
176
|
baseOperationId: operation.baseOperationId,
|
|
173
177
|
implementationOperationId: implementationContext.operationId,
|
|
178
|
+
ambiguityReasons,
|
|
179
|
+
candidateFamilies: duplicateFamilies,
|
|
174
180
|
candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
|
|
175
181
|
};
|
|
176
182
|
if (accepted.length === 0) {
|
|
@@ -179,7 +185,7 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
|
|
|
179
185
|
unresolvedCount += 1;
|
|
180
186
|
continue;
|
|
181
187
|
}
|
|
182
|
-
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', unique ? 'resolved' : 'ambiguous', 'operation', graphId(operation.operationId), unique ? 'handler_method' : 'handler_method_candidates', unique ? graphId(unique.methodId) :
|
|
188
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', unique ? 'resolved' : 'ambiguous', 'operation', graphId(operation.operationId), unique ? 'handler_method' : 'handler_method_candidates', unique ? graphId(unique.methodId) : selected.map((row) => graphId(row.methodId)).join(','), unique ? 0.95 : 0.5, JSON.stringify(evidence), 0, unique ? null : 'Ambiguous registered handler implementation candidates', generation);
|
|
183
189
|
edgeCount += 1;
|
|
184
190
|
if (unique) resolvedCount += 1;
|
|
185
191
|
else ambiguousCount += 1;
|
|
@@ -232,6 +238,20 @@ function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record
|
|
|
232
238
|
return true;
|
|
233
239
|
});
|
|
234
240
|
}
|
|
241
|
+
function duplicatePackageFamilies(candidates: ImplementationCandidate[]): Array<Record<string, unknown>> {
|
|
242
|
+
const byPackage = new Map<string, ImplementationCandidate[]>();
|
|
243
|
+
for (const candidate of candidates) {
|
|
244
|
+
const packageName = typeof candidate.handlerPackage === 'string' ? candidate.handlerPackage : undefined;
|
|
245
|
+
if (!packageName) continue;
|
|
246
|
+
byPackage.set(packageName, [...(byPackage.get(packageName) ?? []), candidate]);
|
|
247
|
+
}
|
|
248
|
+
return [...byPackage.entries()]
|
|
249
|
+
.filter(([, rows]) => new Set(rows.map((row) => Number(row.handlerRepoId))).size > 1)
|
|
250
|
+
.map(([packageName, rows]) => ({ reason: 'duplicate_package_name_candidates', packageName, count: rows.length, repositories: rows.map((row) => row.handlerRepo).sort() }));
|
|
251
|
+
}
|
|
252
|
+
function hasDirectOwnershipEvidence(candidate: ImplementationCandidate): boolean {
|
|
253
|
+
return candidate.acceptedReasons.some((reason) => reason === 'model package equals registration package' || reason === 'model package equals handler package' || reason === 'registration package contains exact local service path');
|
|
254
|
+
}
|
|
235
255
|
function implementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
236
256
|
const modelRepoGraphId = graphId(operation.modelRepoId);
|
|
237
257
|
return db.prepare(`SELECT DISTINCT
|
|
@@ -111,6 +111,7 @@ function nodeContains(parent: ts.Node, child: ts.Node): boolean {
|
|
|
111
111
|
}
|
|
112
112
|
function declarationScope(node: ts.VariableDeclaration | ts.ParameterDeclaration): ts.Node {
|
|
113
113
|
if (ts.isParameter(node)) return node.parent;
|
|
114
|
+
if (ts.isCatchClause(node.parent) && node.parent.variableDeclaration === node) return node.parent;
|
|
114
115
|
const list = node.parent;
|
|
115
116
|
const blockScoped = (list.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) !== 0;
|
|
116
117
|
let current: ts.Node = list.parent;
|
|
@@ -118,26 +119,23 @@ function declarationScope(node: ts.VariableDeclaration | ts.ParameterDeclaration
|
|
|
118
119
|
while (current.parent && !isFunctionLikeScope(current)) current = current.parent;
|
|
119
120
|
return current;
|
|
120
121
|
}
|
|
121
|
-
while (current.parent && !ts.isBlock(current) && !ts.isSourceFile(current) && !ts.isModuleBlock(current) && !ts.isCaseBlock(current) && !
|
|
122
|
+
while (current.parent && !ts.isBlock(current) && !ts.isSourceFile(current) && !ts.isModuleBlock(current) && !ts.isCaseBlock(current) && !isLoopInitializerScope(node, current) && !isFunctionLikeScope(current)) current = current.parent;
|
|
122
123
|
return current;
|
|
123
124
|
}
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return undefined;
|
|
125
|
+
function isLoopInitializerScope(declaration: ts.VariableDeclaration, scope: ts.Node): boolean {
|
|
126
|
+
const list = declaration.parent;
|
|
127
|
+
return (ts.isForStatement(scope) && scope.initializer === list) || ((ts.isForInStatement(scope) || ts.isForOfStatement(scope)) && scope.initializer === list);
|
|
128
|
+
}
|
|
129
|
+
function catchBindingScope(declaration: ts.VariableDeclaration | ts.ParameterDeclaration): ts.CatchClause | undefined {
|
|
130
|
+
if (ts.isParameter(declaration)) return undefined;
|
|
131
|
+
return ts.isCatchClause(declaration.parent) && declaration.parent.variableDeclaration === declaration ? declaration.parent : undefined;
|
|
132
132
|
}
|
|
133
133
|
function isAccessibleDeclaration(declaration: ts.VariableDeclaration | ts.ParameterDeclaration, use: ts.Node): boolean {
|
|
134
134
|
const source = use.getSourceFile();
|
|
135
135
|
if (declaration.name.getStart(source) >= use.getStart(source)) return false;
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
if (scopedAncestor && (ts.isForStatement(scopedAncestor) || ts.isForInStatement(scopedAncestor) || ts.isForOfStatement(scopedAncestor))) return nodeContains(scopedAncestor.statement, use);
|
|
136
|
+
const catchScope = catchBindingScope(declaration);
|
|
137
|
+
if (catchScope) return nodeContains(catchScope.block, use);
|
|
139
138
|
const scope = declarationScope(declaration);
|
|
140
|
-
if (ts.isCatchClause(scope)) return nodeContains(scope.block, use);
|
|
141
139
|
if (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) return nodeContains(scope.statement, use);
|
|
142
140
|
return ts.isSourceFile(scope) || nodeContains(scope, use);
|
|
143
141
|
}
|
|
@@ -247,6 +245,11 @@ function httpMethodFromObject(object: ts.ObjectLiteralExpression, use: ts.Node):
|
|
|
247
245
|
const text = resolveExpression(propertyInitializer(object, 'method'), use, 'literal').value;
|
|
248
246
|
return text ? stripQuotes(text).toUpperCase() : undefined;
|
|
249
247
|
}
|
|
248
|
+
const supportedHttpMethods = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']);
|
|
249
|
+
function safeOperationName(value: string | undefined): string | undefined {
|
|
250
|
+
if (!value || !/^[A-Za-z_$][\w$]*(?:[./][A-Za-z_$][\w$]*)*$/.test(value)) return undefined;
|
|
251
|
+
return operationPathFromStatic(value);
|
|
252
|
+
}
|
|
250
253
|
function hasTemplatePlaceholder(value: string): boolean { return /\$\{|%7B|%7D/i.test(value); }
|
|
251
254
|
function urlTargetFromExpression(expr: ts.Expression | undefined, use: ts.Node): Record<string, unknown> {
|
|
252
255
|
const resolved = resolveExpression(expr, use, 'external');
|
|
@@ -335,7 +338,7 @@ function isSupportedEventReceiver(receiver: string | undefined, rootReceiver: st
|
|
|
335
338
|
if (/^(srv|service|serviceClient|messaging|messageClient|eventClient)$/.test(candidate)) return true;
|
|
336
339
|
return false;
|
|
337
340
|
}
|
|
338
|
-
interface WrapperSpec { clientIndex?: number; clientName?: string; pathIndex: number; methodIndex?: number; methodName?: string; methodLiteral?: string; definitionLine: number; internalStart: number; internalEnd: number }
|
|
341
|
+
interface WrapperSpec { clientIndex?: number; clientName?: string; pathIndex: number; methodIndex?: number; methodName?: string; methodLiteral?: string; nestedWrapperFunction?: string; definitionLine: number; internalStart: number; internalEnd: number }
|
|
339
342
|
function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
340
343
|
const specs = new Map<string, WrapperSpec>();
|
|
341
344
|
const serviceVariables = collectServiceVariables(source);
|
|
@@ -351,7 +354,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
351
354
|
const scanFunction = (name: string, fn: ts.FunctionLikeDeclaration): void => {
|
|
352
355
|
if (!calledNames.has(name)) return;
|
|
353
356
|
const params = fn.parameters.map((param) => ts.isIdentifier(param.name) ? param.name.text : undefined);
|
|
354
|
-
const sends: Array<{ client: string; path: string; method?: string; methodLiteral?: string; start: number; end: number }> = [];
|
|
357
|
+
const sends: Array<{ client: string; path: string; method?: string; methodLiteral?: string; nestedWrapperFunction?: string; start: number; end: number }> = [];
|
|
355
358
|
const visit = (node: ts.Node): void => {
|
|
356
359
|
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send' && ts.isIdentifier(node.expression.expression)) {
|
|
357
360
|
const objectArg = node.arguments[0];
|
|
@@ -364,6 +367,14 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
364
367
|
if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
|
|
365
368
|
}
|
|
366
369
|
}
|
|
370
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && specs.has(node.expression.text)) {
|
|
371
|
+
const nested = specs.get(node.expression.text);
|
|
372
|
+
const pathArg = nested ? node.arguments[nested.pathIndex] : undefined;
|
|
373
|
+
const clientArg = nested?.clientIndex === undefined ? undefined : node.arguments[nested.clientIndex];
|
|
374
|
+
const pathName = pathArg && ts.isIdentifier(pathArg) ? pathArg.text : undefined;
|
|
375
|
+
const clientName = clientArg && ts.isIdentifier(clientArg) ? clientArg.text : nested?.clientName;
|
|
376
|
+
if (nested && pathName && clientName) sends.push({ client: clientName, path: pathName, method: nested.methodName, methodLiteral: nested.methodLiteral, nestedWrapperFunction: node.expression.text, start: node.getStart(source), end: node.getEnd() });
|
|
377
|
+
}
|
|
367
378
|
ts.forEachChild(node, visit);
|
|
368
379
|
};
|
|
369
380
|
visit(fn);
|
|
@@ -373,7 +384,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
373
384
|
const pathIndex = params.indexOf(found.path);
|
|
374
385
|
const methodIndex = found.method ? params.indexOf(found.method) : -1;
|
|
375
386
|
const capturesKnownClient = serviceVariables.has(found.client) || /^(srv|service|serviceClient|client|.*Client)$/.test(found.client);
|
|
376
|
-
if (pathIndex >= 0 && (clientIndex >= 0 || capturesKnownClient)) specs.set(name, { clientIndex: clientIndex >= 0 ? clientIndex : undefined, clientName: clientIndex >= 0 ? undefined : found.client, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodName: found.method, methodLiteral: found.methodLiteral, definitionLine: lineOf(source.text, fn.getStart(source)), internalStart: found.start, internalEnd: found.end });
|
|
387
|
+
if (pathIndex >= 0 && (clientIndex >= 0 || capturesKnownClient)) specs.set(name, { clientIndex: clientIndex >= 0 ? clientIndex : undefined, clientName: clientIndex >= 0 ? undefined : found.client, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodName: found.method, methodLiteral: found.methodLiteral, nestedWrapperFunction: found.nestedWrapperFunction, definitionLine: lineOf(source.text, fn.getStart(source)), internalStart: found.start, internalEnd: found.end });
|
|
377
388
|
};
|
|
378
389
|
const visitTop = (node: ts.Node): void => {
|
|
379
390
|
if (ts.isFunctionDeclaration(node) && node.name) scanFunction(node.name.text, node);
|
|
@@ -426,16 +437,18 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
426
437
|
} else {
|
|
427
438
|
const receiver = receiverName(expr.expression);
|
|
428
439
|
const rootReceiver = rootReceiverName(expr.expression);
|
|
429
|
-
const
|
|
440
|
+
const firstArg = resolveExpression(node.arguments[0], node, 'literal');
|
|
441
|
+
const method = firstArg.value?.toUpperCase();
|
|
430
442
|
const pathArg = node.arguments[1];
|
|
431
|
-
const supported = method &&
|
|
443
|
+
const supported = method && supportedHttpMethods.has(method);
|
|
432
444
|
if (receiver && supported && serviceVariables.has(rootReceiver ?? receiver)) {
|
|
433
445
|
const resolvedPath = staticPathExpression(pathArg, node);
|
|
434
446
|
const operationPathExpr = operationPathFromStatic(resolvedPath.text);
|
|
435
447
|
const intent = classifyODataPathIntent(operationPathExpr, method);
|
|
436
448
|
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
449
|
} else if (receiver && serviceVariables.has(rootReceiver ?? receiver)) {
|
|
438
|
-
|
|
450
|
+
const operationPathExpr = safeOperationName(firstArg.value);
|
|
451
|
+
add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.75 : 0.35, unresolvedReason: operationPathExpr ? undefined : 'unsupported_cap_send_signature' }, { receiver, rootReceiver, classifier: operationPathExpr ? 'service_client_send_operation_event' : 'service_client_send_unsupported_signature', rawOperationExpression: firstArg.rawExpression, literalOperationSource: firstArg.value ? firstArg.sourceKind : undefined, parserWarning: operationPathExpr ? undefined : 'unsupported_cap_send_signature' });
|
|
439
452
|
}
|
|
440
453
|
}
|
|
441
454
|
} else if (((ts.isCallExpression(expr) && ts.isIdentifier(expr.expression) && wrapperSpecs.has(expr.expression.text)) || (ts.isIdentifier(expr) && wrapperSpecs.has(expr.text)))) {
|
|
@@ -451,7 +464,7 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
451
464
|
const operationPathExpr = operationPathFromStatic(resolvedPath.text);
|
|
452
465
|
const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : undefined;
|
|
453
466
|
if (spec && receiver && operationPathExpr) {
|
|
454
|
-
add(node, { callType: 'remote_action', serviceVariableName: receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver, classifier: resolvedPath.sourceKind === 'literal' ? 'higher_order_wrapper_literal_path' : 'higher_order_wrapper_static_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, normalizedOperationPath, literalPathSource: resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : `wrapper_call_${resolvedPath.sourceKind}`, literalCallerArgumentDetected: true });
|
|
467
|
+
add(node, { callType: 'remote_action', serviceVariableName: receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver, classifier: resolvedPath.sourceKind === 'literal' ? 'higher_order_wrapper_literal_path' : 'higher_order_wrapper_static_path', wrapperFunction: wrapperName, nestedWrapperFunction: spec.nestedWrapperFunction, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, normalizedOperationPath, literalPathSource: resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : `wrapper_call_${resolvedPath.sourceKind}`, literalCallerArgumentDetected: true });
|
|
455
468
|
} else if (spec && receiver) {
|
|
456
469
|
add(node, { callType: 'remote_action', serviceVariableName: receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason: 'dynamic_operation_path_identifier' }, { receiver, classifier: 'higher_order_wrapper_dynamic_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, parserWarning: 'dynamic_operation_path_identifier' });
|
|
457
470
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
import { normalizePath } from '../utils/path-utils.js';
|
|
5
|
+
|
|
6
|
+
export interface HelperBinding {
|
|
7
|
+
exportedName: string;
|
|
8
|
+
returnedProperty?: string;
|
|
9
|
+
alias?: string;
|
|
10
|
+
aliasExpr?: string;
|
|
11
|
+
destinationExpr?: string;
|
|
12
|
+
servicePathExpr?: string;
|
|
13
|
+
isDynamic: boolean;
|
|
14
|
+
placeholders: string[];
|
|
15
|
+
helperChain?: Array<Record<string, unknown>>;
|
|
16
|
+
sourceFile: string;
|
|
17
|
+
sourceLine: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ImportBinding {
|
|
20
|
+
localName: string;
|
|
21
|
+
exportedName: string;
|
|
22
|
+
sourceFile?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ClassHelperReturn {
|
|
25
|
+
className: string;
|
|
26
|
+
helperName: string;
|
|
27
|
+
propertyName: string;
|
|
28
|
+
variableName: string;
|
|
29
|
+
fact: Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'>;
|
|
30
|
+
sourceLine: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function lineOf(sf: ts.SourceFile, node: ts.Node): number {
|
|
34
|
+
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stringValue(node: ts.Expression | undefined): string | undefined {
|
|
38
|
+
if (!node) return undefined;
|
|
39
|
+
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
40
|
+
if (ts.isTemplateExpression(node)) return node.getText().replace(/^`|`$/g, '');
|
|
41
|
+
return node.getText();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function placeholders(value?: string): string[] {
|
|
45
|
+
return [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((m) => (m[1] ?? '').trim()).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function connectFactFromCall(call: ts.CallExpression): Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'> | undefined {
|
|
49
|
+
const expr = call.expression;
|
|
50
|
+
if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== 'to') return undefined;
|
|
51
|
+
const inner = expr.expression;
|
|
52
|
+
if (!ts.isPropertyAccessExpression(inner) || inner.name.text !== 'connect' || inner.expression.getText() !== 'cds') return undefined;
|
|
53
|
+
const first = call.arguments[0];
|
|
54
|
+
if (!first) return undefined;
|
|
55
|
+
const second = call.arguments[1];
|
|
56
|
+
const objectArg = ts.isObjectLiteralExpression(first) ? first : second && ts.isObjectLiteralExpression(second) ? second : undefined;
|
|
57
|
+
let alias: string | undefined;
|
|
58
|
+
let aliasExpr: string | undefined;
|
|
59
|
+
if (ts.isStringLiteralLike(first) || ts.isNoSubstitutionTemplateLiteral(first)) alias = first.text;
|
|
60
|
+
else if (!ts.isObjectLiteralExpression(first)) aliasExpr = stringValue(first);
|
|
61
|
+
if ((ts.isStringLiteralLike(first) || ts.isNoSubstitutionTemplateLiteral(first)) && !objectArg) return { alias: first.text, isDynamic: false, placeholders: [] };
|
|
62
|
+
if (!objectArg && aliasExpr) return { aliasExpr, isDynamic: true, placeholders: placeholders(aliasExpr) };
|
|
63
|
+
const expressions = objectArg ? objectExpressions(objectArg) : {};
|
|
64
|
+
const ph = [...placeholders(aliasExpr ?? alias), ...placeholders(expressions.destinationExpr), ...placeholders(expressions.servicePathExpr)];
|
|
65
|
+
return { alias, aliasExpr, ...expressions, isDynamic: ph.length > 0 || (!expressions.destinationExpr && !expressions.servicePathExpr), placeholders: ph };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function objectExpressions(objectArg: ts.ObjectLiteralExpression): { destinationExpr?: string; servicePathExpr?: string } {
|
|
69
|
+
const out: { destinationExpr?: string; servicePathExpr?: string } = {};
|
|
70
|
+
function visitObject(obj: ts.ObjectLiteralExpression): void {
|
|
71
|
+
for (const prop of obj.properties) {
|
|
72
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
73
|
+
const name = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : undefined;
|
|
74
|
+
if (name === 'destination') out.destinationExpr = stringValue(prop.initializer);
|
|
75
|
+
if (name === 'path' || name === 'servicePath') out.servicePathExpr = stringValue(prop.initializer);
|
|
76
|
+
if (ts.isObjectLiteralExpression(prop.initializer)) visitObject(prop.initializer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
visitObject(objectArg);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function unwrapCall(expr: ts.Expression): ts.CallExpression | undefined {
|
|
84
|
+
if (ts.isAwaitExpression(expr)) return unwrapCall(expr.expression);
|
|
85
|
+
if (ts.isParenthesizedExpression(expr)) return unwrapCall(expr.expression);
|
|
86
|
+
if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) return unwrapCall(expr.expression);
|
|
87
|
+
if (ts.isTypeAssertionExpression(expr)) return unwrapCall(expr.expression);
|
|
88
|
+
if (ts.isCallExpression(expr)) return expr;
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function unwrapIdentityExpression(expr: ts.Expression): ts.Expression {
|
|
93
|
+
if (ts.isAwaitExpression(expr)) return unwrapIdentityExpression(expr.expression);
|
|
94
|
+
if (ts.isParenthesizedExpression(expr)) return unwrapIdentityExpression(expr.expression);
|
|
95
|
+
if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) return unwrapIdentityExpression(expr.expression);
|
|
96
|
+
if (ts.isTypeAssertionExpression(expr)) return unwrapIdentityExpression(expr.expression);
|
|
97
|
+
return expr;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function transactionReceiverName(expr: ts.Expression): string | undefined {
|
|
101
|
+
const call = unwrapCall(expr);
|
|
102
|
+
if (call && ts.isPropertyAccessExpression(call.expression) && ['tx', 'transaction'].includes(call.expression.name.text) && ts.isIdentifier(call.expression.expression)) return call.expression.expression.text;
|
|
103
|
+
const unwrapped = unwrapIdentityExpression(expr);
|
|
104
|
+
if (!ts.isConditionalExpression(unwrapped)) return undefined;
|
|
105
|
+
const left = transactionReceiverName(unwrapped.whenTrue);
|
|
106
|
+
const right = transactionReceiverName(unwrapped.whenFalse);
|
|
107
|
+
return left && left === right ? left : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function findConnectInExpression(expr: ts.Expression): Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'> | undefined {
|
|
111
|
+
const direct = unwrapCall(expr);
|
|
112
|
+
if (direct) {
|
|
113
|
+
const fact = connectFactFromCall(direct);
|
|
114
|
+
if (fact) return fact;
|
|
115
|
+
}
|
|
116
|
+
let found: Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'> | undefined;
|
|
117
|
+
function visit(node: ts.Node): void {
|
|
118
|
+
if (found) return;
|
|
119
|
+
if (ts.isCallExpression(node)) found = connectFactFromCall(node);
|
|
120
|
+
if (!found) ts.forEachChild(node, visit);
|
|
121
|
+
}
|
|
122
|
+
visit(expr);
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function readSource(abs: string): Promise<ts.SourceFile | undefined> {
|
|
127
|
+
try {
|
|
128
|
+
const text = await fs.readFile(abs, 'utf8');
|
|
129
|
+
return ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function resolveImport(repoPath: string, fromFile: string, spec: string): Promise<string | undefined> {
|
|
136
|
+
if (!spec.startsWith('.')) return undefined;
|
|
137
|
+
const rawBase = path.resolve(repoPath, path.dirname(fromFile), spec);
|
|
138
|
+
const parsed = path.parse(rawBase);
|
|
139
|
+
const base = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(parsed.ext) ? path.join(parsed.dir, parsed.name) : rawBase;
|
|
140
|
+
for (const candidate of [base, `${base}.ts`, `${base}.js`, path.join(base, 'index.ts'), path.join(base, 'index.js')]) {
|
|
141
|
+
const stat = await fs.stat(candidate).catch(() => undefined);
|
|
142
|
+
if (stat?.isFile()) return normalizePath(path.relative(repoPath, candidate));
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function importsFor(repoPath: string, filePath: string, sf: ts.SourceFile): Promise<ImportBinding[]> {
|
|
148
|
+
const imports: ImportBinding[] = [];
|
|
149
|
+
for (const stmt of sf.statements) {
|
|
150
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteralLike(stmt.moduleSpecifier)) continue;
|
|
151
|
+
const sourceFile = await resolveImport(repoPath, filePath, stmt.moduleSpecifier.text);
|
|
152
|
+
const clause = stmt.importClause;
|
|
153
|
+
if (!clause) continue;
|
|
154
|
+
if (clause.name) imports.push({ localName: clause.name.text, exportedName: 'default', sourceFile });
|
|
155
|
+
const bindings = clause.namedBindings;
|
|
156
|
+
if (bindings && ts.isNamedImports(bindings))
|
|
157
|
+
for (const el of bindings.elements) imports.push({ localName: el.name.text, exportedName: el.propertyName?.text ?? el.name.text, sourceFile });
|
|
158
|
+
}
|
|
159
|
+
return imports;
|
|
160
|
+
}
|