@saptools/service-flow 0.1.42 → 0.1.44

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