@saptools/service-flow 0.1.43 → 0.1.45
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/README.md +9 -1
- package/dist/{chunk-KHQK7CFH.js → chunk-BXSCE5CR.js} +1978 -1510
- package/dist/chunk-BXSCE5CR.js.map +1 -0
- package/dist/cli.js +471 -476
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +435 -0
- package/src/cli.ts +18 -453
- package/src/index.ts +2 -0
- package/src/indexer/repository-indexer.ts +52 -26
- package/src/indexer/workspace-indexer.ts +17 -5
- package/src/linker/cross-repo-linker.ts +45 -3
- package/src/linker/odata-path-normalizer.ts +4 -1
- package/src/parsers/imported-wrapper-parser.ts +262 -0
- package/src/parsers/outbound-call-parser.ts +17 -5
- package/src/parsers/service-binding-parser-helpers.ts +160 -0
- package/src/parsers/service-binding-parser.ts +57 -242
- package/src/parsers/symbol-parser.ts +25 -10
- package/src/trace/evidence.ts +210 -0
- package/src/trace/implementation-hints.ts +151 -0
- package/src/trace/trace-engine.ts +75 -117
- package/src/types.ts +8 -0
- 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
|
}
|
|
@@ -146,7 +146,29 @@ function objectJson(value: unknown): Record<string, unknown> | undefined {
|
|
|
146
146
|
}
|
|
147
147
|
function callEvidence(call: Record<string, unknown>, resolution: { target?: { repoName?: string; servicePath?: string; operationPath?: string; operationName?: string }; candidates: unknown[]; status: string; reasons: string[] }, servicePath: string | undefined, op: string | undefined, destination: string | undefined, normalized?: NormalizedODataOperationPath, odataPathIntent?: ReturnType<typeof classifyODataPathIntent>): Record<string, unknown> {
|
|
148
148
|
const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
|
|
149
|
-
|
|
149
|
+
const routingPlaceholderKeys = placeholderKeys([servicePath, destination, stringValue(call.aliasExpr), stringValue(call.alias)]);
|
|
150
|
+
return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, rawOperationPath: normalized?.wasInvocation ? normalized.rawOperationPath : odataPathIntent?.rawPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArguments: normalized?.wasInvocation ? normalized.invocationArguments : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, routingPlaceholderKeys: routingPlaceholderKeys.length ? routingPlaceholderKeys : undefined, odataOperationNormalizationReason: normalized?.normalizationReason, odataOperationNormalizationRejectedReason: normalized?.normalizationRejectedReason, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: parseJson(call.alias_chain_json), transport: call.call_type === 'local_service_call' ? 'local' : undefined, targetRepo: resolution.target?.repoName, targetServicePath: resolution.target?.servicePath, targetOperationPath: resolution.target?.operationPath, targetOperation: resolution.target?.operationName, helperChain: parseJson(call.helperChainJson), candidates: resolution.candidates, candidateScores: compactCandidateScores(resolution.candidates), candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, odataPathIntent, queryStringPresent: odataPathIntent?.hasQueryString || undefined, queryPlaceholderKeys: odataPathIntent?.placeholderKeys.length ? odataPathIntent.placeholderKeys : undefined, bindingHasDynamicExpression: bindingHasDynamicExpression || undefined, outboundEvidence: objectJson(call.evidence_json), analysisCompleteness: call.unresolved_reason ? 'partial' : 'complete', parserWarning: call.unresolved_reason ? { code: 'parser_warning', message: call.unresolved_reason } : undefined };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function compactCandidateScores(candidates: unknown[]): Array<Record<string, unknown>> {
|
|
154
|
+
return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
|
|
155
|
+
const row = objectValue(candidate);
|
|
156
|
+
if (!row) return [];
|
|
157
|
+
return [{ repo: row.repoName, servicePath: row.servicePath, operationPath: row.operationPath, score: row.score, reasons: row.reasons }];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function placeholderKeys(values: Array<string | undefined>): string[] {
|
|
162
|
+
const keys = values.flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim()).filter(Boolean));
|
|
163
|
+
return [...new Set(keys)].sort();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
167
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function stringValue(value: unknown): string | undefined {
|
|
171
|
+
return typeof value === 'string' ? value : undefined;
|
|
150
172
|
}
|
|
151
173
|
|
|
152
174
|
function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
|
|
@@ -162,7 +184,11 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
|
|
|
162
184
|
const accepted = candidates.filter((candidate) => candidate.accepted);
|
|
163
185
|
const topScore = accepted[0]?.score ?? 0;
|
|
164
186
|
const winners = accepted.filter((candidate) => candidate.score === topScore);
|
|
165
|
-
const
|
|
187
|
+
const duplicateFamilies = duplicatePackageFamilies(accepted);
|
|
188
|
+
const duplicatePackageAmbiguous = duplicateFamilies.length > 0 && !accepted.some(hasDirectOwnershipEvidence);
|
|
189
|
+
const selected = duplicatePackageAmbiguous ? accepted : winners;
|
|
190
|
+
const unique = !duplicatePackageAmbiguous && winners.length === 1 ? winners[0] : undefined;
|
|
191
|
+
const ambiguityReasons = duplicatePackageAmbiguous ? ['duplicate_package_name_candidates'] : winners.length > 1 ? ['multiple_equal_score_implementation_candidates'] : [];
|
|
166
192
|
const evidence = {
|
|
167
193
|
servicePath: operation.servicePath,
|
|
168
194
|
operationPath: operation.operationPath,
|
|
@@ -171,6 +197,8 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
|
|
|
171
197
|
implementationSource: implementationContext.operationId === operation.operationId ? 'direct_or_concrete_override' : 'inherited_from_base_operation',
|
|
172
198
|
baseOperationId: operation.baseOperationId,
|
|
173
199
|
implementationOperationId: implementationContext.operationId,
|
|
200
|
+
ambiguityReasons,
|
|
201
|
+
candidateFamilies: duplicateFamilies,
|
|
174
202
|
candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
|
|
175
203
|
};
|
|
176
204
|
if (accepted.length === 0) {
|
|
@@ -179,7 +207,7 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
|
|
|
179
207
|
unresolvedCount += 1;
|
|
180
208
|
continue;
|
|
181
209
|
}
|
|
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) :
|
|
210
|
+
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
211
|
edgeCount += 1;
|
|
184
212
|
if (unique) resolvedCount += 1;
|
|
185
213
|
else ambiguousCount += 1;
|
|
@@ -232,6 +260,20 @@ function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record
|
|
|
232
260
|
return true;
|
|
233
261
|
});
|
|
234
262
|
}
|
|
263
|
+
function duplicatePackageFamilies(candidates: ImplementationCandidate[]): Array<Record<string, unknown>> {
|
|
264
|
+
const byPackage = new Map<string, ImplementationCandidate[]>();
|
|
265
|
+
for (const candidate of candidates) {
|
|
266
|
+
const packageName = typeof candidate.handlerPackage === 'string' ? candidate.handlerPackage : undefined;
|
|
267
|
+
if (!packageName) continue;
|
|
268
|
+
byPackage.set(packageName, [...(byPackage.get(packageName) ?? []), candidate]);
|
|
269
|
+
}
|
|
270
|
+
return [...byPackage.entries()]
|
|
271
|
+
.filter(([, rows]) => new Set(rows.map((row) => Number(row.handlerRepoId))).size > 1)
|
|
272
|
+
.map(([packageName, rows]) => ({ reason: 'duplicate_package_name_candidates', packageName, count: rows.length, repositories: rows.map((row) => row.handlerRepo).sort() }));
|
|
273
|
+
}
|
|
274
|
+
function hasDirectOwnershipEvidence(candidate: ImplementationCandidate): boolean {
|
|
275
|
+
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');
|
|
276
|
+
}
|
|
235
277
|
function implementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
236
278
|
const modelRepoGraphId = graphId(operation.modelRepoId);
|
|
237
279
|
return db.prepare(`SELECT DISTINCT
|
|
@@ -2,6 +2,7 @@ export interface NormalizedODataOperationPath {
|
|
|
2
2
|
rawOperationPath: string;
|
|
3
3
|
normalizedOperationPath: string;
|
|
4
4
|
wasInvocation: boolean;
|
|
5
|
+
invocationArguments?: string;
|
|
5
6
|
invocationArgumentPlaceholderKeys: string[];
|
|
6
7
|
normalizationReason?: string;
|
|
7
8
|
normalizationRejectedReason?: string;
|
|
@@ -20,6 +21,7 @@ export interface ODataPathIntent {
|
|
|
20
21
|
placeholderKeys: string[];
|
|
21
22
|
keyPredicatePlaceholderKeys: string[];
|
|
22
23
|
invocationArgumentPlaceholderKeys: string[];
|
|
24
|
+
invocationArguments?: string;
|
|
23
25
|
navigationSuffix?: string;
|
|
24
26
|
mediaOrPropertySuffix?: string;
|
|
25
27
|
hasEntityKeyPredicate: boolean;
|
|
@@ -51,6 +53,7 @@ export function normalizeODataOperationInvocationPath(path: string | undefined):
|
|
|
51
53
|
rawOperationPath: raw,
|
|
52
54
|
normalizedOperationPath: operationSegment,
|
|
53
55
|
wasInvocation: true,
|
|
56
|
+
invocationArguments: raw.slice(open + 1, close),
|
|
54
57
|
invocationArgumentPlaceholderKeys: [...new Set(extractTemplatePlaceholders(raw.slice(open + 1, close)))],
|
|
55
58
|
normalizationReason: 'balanced_top_level_operation_invocation',
|
|
56
59
|
};
|
|
@@ -81,7 +84,7 @@ export function classifyODataPathIntent(path: string | undefined, method: string
|
|
|
81
84
|
const topLevelOperationInvocation = Boolean(invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment));
|
|
82
85
|
const keyPredicatePlaceholderKeys = topLevelOperationInvocation ? [] : rawKeyPredicatePlaceholderKeys;
|
|
83
86
|
const topLevelOperationNameCandidate = Boolean(topLevelOperationName && !hasNavigationSegments && !firstSegment.includes('('));
|
|
84
|
-
const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
|
|
87
|
+
const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArguments: invocation?.invocationArguments, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
|
|
85
88
|
if (!rawPath || !rawPath.startsWith('/')) return { ...base, kind: 'unknown', reason: 'path_missing_or_not_absolute' };
|
|
86
89
|
const upperEntityLike = /^[A-Z][A-Za-z0-9_]*$/.test(entitySegment ?? firstSegment);
|
|
87
90
|
const mediaLike = isMediaOrPropertySuffix(segments.at(-1) ?? '');
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
4
|
+
import type { OutboundCallFact } from '../types.js';
|
|
5
|
+
import { normalizePath } from '../utils/path-utils.js';
|
|
6
|
+
import { importsFor, lineOf, readSource, type ImportBinding } from './service-binding-parser-helpers.js';
|
|
7
|
+
|
|
8
|
+
interface WrapperSpec {
|
|
9
|
+
clientIndex: number;
|
|
10
|
+
pathIndex: number;
|
|
11
|
+
methodIndex?: number;
|
|
12
|
+
methodLiteral?: string;
|
|
13
|
+
sourceFile: string;
|
|
14
|
+
sourceLine: number;
|
|
15
|
+
chain: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PathValue {
|
|
19
|
+
value?: string;
|
|
20
|
+
sourceKind: string;
|
|
21
|
+
rawExpression: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function parseImportedWrapperCalls(
|
|
25
|
+
repoPath: string,
|
|
26
|
+
filePath: string,
|
|
27
|
+
source: ts.SourceFile,
|
|
28
|
+
serviceBindings: Set<string>,
|
|
29
|
+
): Promise<OutboundCallFact[]> {
|
|
30
|
+
const imports = await importsFor(repoPath, filePath, source);
|
|
31
|
+
const importedByLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
|
|
32
|
+
const calls = collectImportedCalls(source, importedByLocal);
|
|
33
|
+
const out: OutboundCallFact[] = [];
|
|
34
|
+
const cache = new Map<string, Promise<WrapperSpec | undefined>>();
|
|
35
|
+
for (const call of calls) {
|
|
36
|
+
if (!ts.isIdentifier(call.expression)) continue;
|
|
37
|
+
const imported = importedByLocal.get(call.expression.text);
|
|
38
|
+
if (!imported?.sourceFile) continue;
|
|
39
|
+
const spec = await loadWrapperSpec(repoPath, imported, cache, 0);
|
|
40
|
+
const fact = spec ? wrapperCallFact(source, filePath, call, spec, serviceBindings) : undefined;
|
|
41
|
+
if (fact) out.push(fact);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectImportedCalls(source: ts.SourceFile, imports: Map<string, ImportBinding>): ts.CallExpression[] {
|
|
47
|
+
const calls: ts.CallExpression[] = [];
|
|
48
|
+
const visit = (node: ts.Node): void => {
|
|
49
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && imports.has(node.expression.text)) calls.push(node);
|
|
50
|
+
ts.forEachChild(node, visit);
|
|
51
|
+
};
|
|
52
|
+
visit(source);
|
|
53
|
+
return calls;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function loadWrapperSpec(
|
|
57
|
+
repoPath: string,
|
|
58
|
+
imported: ImportBinding,
|
|
59
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
60
|
+
depth: number,
|
|
61
|
+
): Promise<WrapperSpec | undefined> {
|
|
62
|
+
if (!imported.sourceFile || depth > 5) return undefined;
|
|
63
|
+
const key = `${imported.sourceFile}#${imported.exportedName}`;
|
|
64
|
+
const existing = cache.get(key);
|
|
65
|
+
if (existing) return existing;
|
|
66
|
+
const pending = inspectWrapper(repoPath, imported.sourceFile, imported.exportedName, cache, depth);
|
|
67
|
+
cache.set(key, pending);
|
|
68
|
+
return pending;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function inspectWrapper(
|
|
72
|
+
repoPath: string,
|
|
73
|
+
sourceFile: string,
|
|
74
|
+
exportedName: string,
|
|
75
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
76
|
+
depth: number,
|
|
77
|
+
): Promise<WrapperSpec | undefined> {
|
|
78
|
+
const source = await readSource(path.join(repoPath, sourceFile));
|
|
79
|
+
if (!source) return undefined;
|
|
80
|
+
const named = findFunction(source, exportedName);
|
|
81
|
+
if (!named) return undefined;
|
|
82
|
+
const direct = directSendSpec(source, sourceFile, named.name, named.fn);
|
|
83
|
+
if (direct) return direct;
|
|
84
|
+
return nestedSendSpec(repoPath, sourceFile, source, named.name, named.fn, cache, depth);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function directSendSpec(source: ts.SourceFile, sourceFile: string, name: string, fn: ts.FunctionLikeDeclaration): WrapperSpec | undefined {
|
|
88
|
+
const params = parameterNames(fn);
|
|
89
|
+
const sends: ts.CallExpression[] = [];
|
|
90
|
+
visitFunctionBody(fn, (node) => {
|
|
91
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send') sends.push(node);
|
|
92
|
+
});
|
|
93
|
+
if (sends.length !== 1) return undefined;
|
|
94
|
+
const send = sends[0];
|
|
95
|
+
const receiver = send && ts.isPropertyAccessExpression(send.expression) && ts.isIdentifier(send.expression.expression) ? send.expression.expression.text : undefined;
|
|
96
|
+
const object = send?.arguments[0];
|
|
97
|
+
if (!receiver || !object || !ts.isObjectLiteralExpression(object)) return undefined;
|
|
98
|
+
const pathExpr = propertyExpression(object, 'path');
|
|
99
|
+
const methodExpr = propertyExpression(object, 'method');
|
|
100
|
+
const pathName = pathExpr && ts.isIdentifier(pathExpr) ? pathExpr.text : undefined;
|
|
101
|
+
const clientIndex = params.indexOf(receiver);
|
|
102
|
+
const pathIndex = pathName ? params.indexOf(pathName) : -1;
|
|
103
|
+
if (clientIndex < 0 || pathIndex < 0) return undefined;
|
|
104
|
+
const methodName = methodExpr && ts.isIdentifier(methodExpr) ? methodExpr.text : undefined;
|
|
105
|
+
const methodIndex = methodName ? params.indexOf(methodName) : -1;
|
|
106
|
+
return { clientIndex, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodLiteral: literal(methodExpr), sourceFile, sourceLine: lineOf(source, fn), chain: [name] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function nestedSendSpec(
|
|
110
|
+
repoPath: string,
|
|
111
|
+
sourceFile: string,
|
|
112
|
+
source: ts.SourceFile,
|
|
113
|
+
name: string,
|
|
114
|
+
fn: ts.FunctionLikeDeclaration,
|
|
115
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
116
|
+
depth: number,
|
|
117
|
+
): Promise<WrapperSpec | undefined> {
|
|
118
|
+
const imports = await importsFor(repoPath, sourceFile, source);
|
|
119
|
+
const byLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
|
|
120
|
+
const calls: ts.CallExpression[] = [];
|
|
121
|
+
visitFunctionBody(fn, (node) => {
|
|
122
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && byLocal.has(node.expression.text)) calls.push(node);
|
|
123
|
+
});
|
|
124
|
+
if (calls.length !== 1) return undefined;
|
|
125
|
+
const call = calls[0];
|
|
126
|
+
const imported = call && ts.isIdentifier(call.expression) ? byLocal.get(call.expression.text) : undefined;
|
|
127
|
+
const nested = imported ? await loadWrapperSpec(repoPath, imported, cache, depth + 1) : undefined;
|
|
128
|
+
if (!call || !nested) return undefined;
|
|
129
|
+
const params = parameterNames(fn);
|
|
130
|
+
const clientIndex = mappedParameterIndex(call.arguments[nested.clientIndex], params);
|
|
131
|
+
const pathIndex = mappedParameterIndex(call.arguments[nested.pathIndex], params);
|
|
132
|
+
if (clientIndex < 0 || pathIndex < 0) return undefined;
|
|
133
|
+
const methodIndex = nested.methodIndex === undefined ? undefined : mappedParameterIndex(call.arguments[nested.methodIndex], params);
|
|
134
|
+
return { clientIndex, pathIndex, methodIndex: methodIndex !== undefined && methodIndex >= 0 ? methodIndex : undefined, methodLiteral: nested.methodLiteral, sourceFile, sourceLine: lineOf(source, fn), chain: [name, ...nested.chain] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function wrapperCallFact(
|
|
138
|
+
source: ts.SourceFile,
|
|
139
|
+
filePath: string,
|
|
140
|
+
call: ts.CallExpression,
|
|
141
|
+
spec: WrapperSpec,
|
|
142
|
+
serviceBindings: Set<string>,
|
|
143
|
+
): OutboundCallFact | undefined {
|
|
144
|
+
const client = call.arguments[spec.clientIndex];
|
|
145
|
+
if (!client || !ts.isIdentifier(client) || !serviceBindings.has(client.text)) return undefined;
|
|
146
|
+
const pathValue = resolveCallerPath(call.arguments[spec.pathIndex], call);
|
|
147
|
+
const methodValue = spec.methodIndex === undefined ? spec.methodLiteral : literal(call.arguments[spec.methodIndex]);
|
|
148
|
+
const method = (methodValue ?? 'POST').toUpperCase();
|
|
149
|
+
const rawPath = pathValue.rawExpression;
|
|
150
|
+
const operationPathExpr = pathValue.value ? normalizeOperationPath(pathValue.value) : runtimePathExpression(rawPath);
|
|
151
|
+
return {
|
|
152
|
+
callType: 'remote_action',
|
|
153
|
+
serviceVariableName: client.text,
|
|
154
|
+
method,
|
|
155
|
+
operationPathExpr,
|
|
156
|
+
payloadSummary: call.getText(source),
|
|
157
|
+
sourceFile: normalizePath(filePath),
|
|
158
|
+
sourceLine: lineOf(source, call),
|
|
159
|
+
confidence: operationPathExpr ? 0.85 : 0.5,
|
|
160
|
+
unresolvedReason: pathValue.value ? undefined : 'dynamic_operation_path_identifier',
|
|
161
|
+
evidence: {
|
|
162
|
+
parser: 'typescript_ast',
|
|
163
|
+
classifier: pathValue.value ? 'imported_wrapper_literal_path' : 'imported_wrapper_dynamic_path',
|
|
164
|
+
receiver: client.text,
|
|
165
|
+
wrapperFunction: spec.chain[0],
|
|
166
|
+
wrapperChain: spec.chain,
|
|
167
|
+
callerSite: { sourceFile: normalizePath(filePath), sourceLine: lineOf(source, call) },
|
|
168
|
+
calleeSite: { sourceFile: spec.sourceFile, sourceLine: spec.sourceLine },
|
|
169
|
+
rawPathExpression: rawPath,
|
|
170
|
+
missingPathIdentifier: pathValue.value ? undefined : rawPath,
|
|
171
|
+
odataPathIntent: pathValue.value ? classifyODataPathIntent(operationPathExpr, method) : undefined,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveCallerPath(expr: ts.Expression | undefined, use: ts.Node): PathValue {
|
|
177
|
+
if (!expr) return { sourceKind: 'missing', rawExpression: '' };
|
|
178
|
+
if (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { value: expr.text, sourceKind: 'literal', rawExpression: expr.getText() };
|
|
179
|
+
if (ts.isTemplateExpression(expr)) return { value: expr.getText().slice(1, -1), sourceKind: 'template', rawExpression: expr.getText() };
|
|
180
|
+
if (ts.isIdentifier(expr)) {
|
|
181
|
+
const initializer = constInitializer(expr.text, use);
|
|
182
|
+
if (initializer) {
|
|
183
|
+
const resolved = resolveCallerPath(initializer, initializer);
|
|
184
|
+
return { ...resolved, sourceKind: 'const', rawExpression: expr.text };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { sourceKind: 'dynamic', rawExpression: expr.getText() };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function constInitializer(name: string, use: ts.Node): ts.Expression | undefined {
|
|
191
|
+
let found: ts.Expression | undefined;
|
|
192
|
+
const source = use.getSourceFile();
|
|
193
|
+
const visit = (node: ts.Node): void => {
|
|
194
|
+
if (node.getStart(source) >= use.getStart(source)) return;
|
|
195
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer && (node.parent.flags & ts.NodeFlags.Const) !== 0) found = node.initializer;
|
|
196
|
+
ts.forEachChild(node, visit);
|
|
197
|
+
};
|
|
198
|
+
visit(source);
|
|
199
|
+
return found;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function findFunction(source: ts.SourceFile, exportedName: string): { name: string; fn: ts.FunctionLikeDeclaration } | undefined {
|
|
203
|
+
const localName = exportedLocalName(source, exportedName);
|
|
204
|
+
for (const statement of source.statements) {
|
|
205
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === localName) return { name: exportedName, fn: statement };
|
|
206
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
207
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
208
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === localName && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return { name: exportedName, fn: declaration.initializer };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function exportedLocalName(source: ts.SourceFile, exportedName: string): string {
|
|
215
|
+
for (const statement of source.statements) {
|
|
216
|
+
if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue;
|
|
217
|
+
const match = statement.exportClause.elements.find((item) => item.name.text === exportedName);
|
|
218
|
+
if (match) return match.propertyName?.text ?? match.name.text;
|
|
219
|
+
}
|
|
220
|
+
return exportedName;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function visitFunctionBody(fn: ts.FunctionLikeDeclaration, visitor: (node: ts.Node) => void): void {
|
|
224
|
+
const visit = (node: ts.Node): void => {
|
|
225
|
+
if (node !== fn && ts.isFunctionLike(node)) return;
|
|
226
|
+
visitor(node);
|
|
227
|
+
ts.forEachChild(node, visit);
|
|
228
|
+
};
|
|
229
|
+
if (fn.body) visit(fn.body);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parameterNames(fn: ts.FunctionLikeDeclaration): string[] {
|
|
233
|
+
return fn.parameters.map((parameter) => ts.isIdentifier(parameter.name) ? parameter.name.text : '');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function mappedParameterIndex(expr: ts.Expression | undefined, parameters: string[]): number {
|
|
237
|
+
return expr && ts.isIdentifier(expr) ? parameters.indexOf(expr.text) : -1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function propertyExpression(object: ts.ObjectLiteralExpression, key: string): ts.Expression | undefined {
|
|
241
|
+
for (const property of object.properties) {
|
|
242
|
+
if (ts.isPropertyAssignment(property) && propertyName(property.name) === key) return property.initializer;
|
|
243
|
+
if (ts.isShorthandPropertyAssignment(property) && property.name.text === key) return property.name;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function propertyName(name: ts.PropertyName): string | undefined {
|
|
249
|
+
return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function literal(expr: ts.Expression | undefined): string | undefined {
|
|
253
|
+
return expr && (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) ? expr.text : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeOperationPath(value: string): string {
|
|
257
|
+
return value.startsWith('/') ? value : `/${value}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function runtimePathExpression(value: string): string | undefined {
|
|
261
|
+
return value ? `\${${value}}` : undefined;
|
|
262
|
+
}
|
|
@@ -6,6 +6,8 @@ import type { OutboundCallFact } from '../types.js';
|
|
|
6
6
|
import { normalizePath, stripQuotes } from '../utils/path-utils.js';
|
|
7
7
|
import { summarizeExpression } from '../utils/redaction.js';
|
|
8
8
|
import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
9
|
+
import { parseServiceBindings } from './service-binding-parser.js';
|
|
10
|
+
import { parseImportedWrapperCalls } from './imported-wrapper-parser.js';
|
|
9
11
|
function lineOf(text: string, idx: number): number {
|
|
10
12
|
return text.slice(0, idx).split('\n').length;
|
|
11
13
|
}
|
|
@@ -338,7 +340,7 @@ function isSupportedEventReceiver(receiver: string | undefined, rootReceiver: st
|
|
|
338
340
|
if (/^(srv|service|serviceClient|messaging|messageClient|eventClient)$/.test(candidate)) return true;
|
|
339
341
|
return false;
|
|
340
342
|
}
|
|
341
|
-
interface WrapperSpec { clientIndex?: number; clientName?: string; pathIndex: number; methodIndex?: number; methodName?: string; methodLiteral?: string; definitionLine: number; internalStart: number; internalEnd: number }
|
|
343
|
+
interface WrapperSpec { clientIndex?: number; clientName?: string; pathIndex: number; methodIndex?: number; methodName?: string; methodLiteral?: string; nestedWrapperFunction?: string; definitionLine: number; internalStart: number; internalEnd: number }
|
|
342
344
|
function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
343
345
|
const specs = new Map<string, WrapperSpec>();
|
|
344
346
|
const serviceVariables = collectServiceVariables(source);
|
|
@@ -354,7 +356,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
354
356
|
const scanFunction = (name: string, fn: ts.FunctionLikeDeclaration): void => {
|
|
355
357
|
if (!calledNames.has(name)) return;
|
|
356
358
|
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 }> = [];
|
|
359
|
+
const sends: Array<{ client: string; path: string; method?: string; methodLiteral?: string; nestedWrapperFunction?: string; start: number; end: number }> = [];
|
|
358
360
|
const visit = (node: ts.Node): void => {
|
|
359
361
|
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send' && ts.isIdentifier(node.expression.expression)) {
|
|
360
362
|
const objectArg = node.arguments[0];
|
|
@@ -367,6 +369,14 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
367
369
|
if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
|
|
368
370
|
}
|
|
369
371
|
}
|
|
372
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && specs.has(node.expression.text)) {
|
|
373
|
+
const nested = specs.get(node.expression.text);
|
|
374
|
+
const pathArg = nested ? node.arguments[nested.pathIndex] : undefined;
|
|
375
|
+
const clientArg = nested?.clientIndex === undefined ? undefined : node.arguments[nested.clientIndex];
|
|
376
|
+
const pathName = pathArg && ts.isIdentifier(pathArg) ? pathArg.text : undefined;
|
|
377
|
+
const clientName = clientArg && ts.isIdentifier(clientArg) ? clientArg.text : nested?.clientName;
|
|
378
|
+
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() });
|
|
379
|
+
}
|
|
370
380
|
ts.forEachChild(node, visit);
|
|
371
381
|
};
|
|
372
382
|
visit(fn);
|
|
@@ -376,7 +386,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
376
386
|
const pathIndex = params.indexOf(found.path);
|
|
377
387
|
const methodIndex = found.method ? params.indexOf(found.method) : -1;
|
|
378
388
|
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 });
|
|
389
|
+
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
390
|
};
|
|
381
391
|
const visitTop = (node: ts.Node): void => {
|
|
382
392
|
if (ts.isFunctionDeclaration(node) && node.name) scanFunction(node.name.text, node);
|
|
@@ -456,7 +466,7 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
456
466
|
const operationPathExpr = operationPathFromStatic(resolvedPath.text);
|
|
457
467
|
const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : undefined;
|
|
458
468
|
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 });
|
|
469
|
+
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
470
|
} else if (spec && receiver) {
|
|
461
471
|
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
472
|
}
|
|
@@ -493,7 +503,9 @@ export async function parseOutboundCalls(
|
|
|
493
503
|
): Promise<OutboundCallFact[]> {
|
|
494
504
|
const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
495
505
|
const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
496
|
-
|
|
506
|
+
const bindingNames = new Set((await parseServiceBindings(repoPath, filePath)).map((binding) => binding.variableName));
|
|
507
|
+
const importedWrappers = await parseImportedWrapperCalls(repoPath, filePath, source, bindingNames);
|
|
508
|
+
return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...importedWrappers, ...parseLocalServiceCalls(text, filePath)];
|
|
497
509
|
}
|
|
498
510
|
function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFact[] {
|
|
499
511
|
const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|