@saptools/service-flow 0.1.43 → 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 +7 -1
- package/dist/{chunk-KHQK7CFH.js → chunk-UKNPHTUS.js} +445 -307
- package/dist/chunk-UKNPHTUS.js.map +1 -0
- package/dist/cli.js +417 -466
- 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/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 +12 -4
- 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-KHQK7CFH.js.map +0 -1
|
@@ -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
|
|
@@ -338,7 +338,7 @@ function isSupportedEventReceiver(receiver: string | undefined, rootReceiver: st
|
|
|
338
338
|
if (/^(srv|service|serviceClient|messaging|messageClient|eventClient)$/.test(candidate)) return true;
|
|
339
339
|
return false;
|
|
340
340
|
}
|
|
341
|
-
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 }
|
|
342
342
|
function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
343
343
|
const specs = new Map<string, WrapperSpec>();
|
|
344
344
|
const serviceVariables = collectServiceVariables(source);
|
|
@@ -354,7 +354,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
354
354
|
const scanFunction = (name: string, fn: ts.FunctionLikeDeclaration): void => {
|
|
355
355
|
if (!calledNames.has(name)) return;
|
|
356
356
|
const params = fn.parameters.map((param) => ts.isIdentifier(param.name) ? param.name.text : undefined);
|
|
357
|
-
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 }> = [];
|
|
358
358
|
const visit = (node: ts.Node): void => {
|
|
359
359
|
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send' && ts.isIdentifier(node.expression.expression)) {
|
|
360
360
|
const objectArg = node.arguments[0];
|
|
@@ -367,6 +367,14 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
367
367
|
if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
|
|
368
368
|
}
|
|
369
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
|
+
}
|
|
370
378
|
ts.forEachChild(node, visit);
|
|
371
379
|
};
|
|
372
380
|
visit(fn);
|
|
@@ -376,7 +384,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
376
384
|
const pathIndex = params.indexOf(found.path);
|
|
377
385
|
const methodIndex = found.method ? params.indexOf(found.method) : -1;
|
|
378
386
|
const capturesKnownClient = serviceVariables.has(found.client) || /^(srv|service|serviceClient|client|.*Client)$/.test(found.client);
|
|
379
|
-
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 });
|
|
380
388
|
};
|
|
381
389
|
const visitTop = (node: ts.Node): void => {
|
|
382
390
|
if (ts.isFunctionDeclaration(node) && node.name) scanFunction(node.name.text, node);
|
|
@@ -456,7 +464,7 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
456
464
|
const operationPathExpr = operationPathFromStatic(resolvedPath.text);
|
|
457
465
|
const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : undefined;
|
|
458
466
|
if (spec && receiver && operationPathExpr) {
|
|
459
|
-
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 });
|
|
460
468
|
} else if (spec && receiver) {
|
|
461
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' });
|
|
462
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
|
+
}
|