@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.
@@ -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 sourceFiles = await findSourceFiles(repo.absolute_path);
52
- const packageFacts = await parsePackageJson(repo.absolute_path);
53
- const fingerprint = await repositoryFingerprint(repo.absolute_path, sourceFiles, packageFacts);
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
- const message = errorMessage(error);
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 { indexRepository } from './repository-indexer.js';
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
- const result = await indexRepository(db, repo, options.force);
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
- materializeCdsExtensionOperations(db, workspaceId);
25
- db.prepare('UPDATE index_runs SET finished_at=?, status=?, file_count=?, diagnostic_count=? WHERE id=?').run(new Date().toISOString(), diagnosticCount ? 'failed' : 'success', fileCount, diagnosticCount, runId);
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
- 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, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : 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, 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 };
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 unique = winners.length === 1 ? winners[0] : undefined;
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) : winners.map((row) => graphId(row.methodId)).join(','), unique ? 0.95 : 0.5, JSON.stringify(evidence), 0, unique ? null : 'Ambiguous registered handler implementation candidates', generation);
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
- return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...parseLocalServiceCalls(text, filePath)];
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);