@saptools/service-flow 0.1.43 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -338,7 +338,7 @@ function isSupportedEventReceiver(receiver: string | undefined, rootReceiver: st
338
338
  if (/^(srv|service|serviceClient|messaging|messageClient|eventClient)$/.test(candidate)) return true;
339
339
  return false;
340
340
  }
341
- interface WrapperSpec { clientIndex?: number; clientName?: string; pathIndex: number; methodIndex?: number; methodName?: string; methodLiteral?: string; definitionLine: number; internalStart: number; internalEnd: number }
341
+ interface WrapperSpec { clientIndex?: number; clientName?: string; pathIndex: number; methodIndex?: number; methodName?: string; methodLiteral?: string; nestedWrapperFunction?: string; definitionLine: number; internalStart: number; internalEnd: number }
342
342
  function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
343
343
  const specs = new Map<string, WrapperSpec>();
344
344
  const serviceVariables = collectServiceVariables(source);
@@ -354,7 +354,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
354
354
  const scanFunction = (name: string, fn: ts.FunctionLikeDeclaration): void => {
355
355
  if (!calledNames.has(name)) return;
356
356
  const params = fn.parameters.map((param) => ts.isIdentifier(param.name) ? param.name.text : undefined);
357
- const sends: Array<{ client: string; path: string; method?: string; methodLiteral?: string; start: number; end: number }> = [];
357
+ const sends: Array<{ client: string; path: string; method?: string; methodLiteral?: string; nestedWrapperFunction?: string; start: number; end: number }> = [];
358
358
  const visit = (node: ts.Node): void => {
359
359
  if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send' && ts.isIdentifier(node.expression.expression)) {
360
360
  const objectArg = node.arguments[0];
@@ -367,6 +367,14 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
367
367
  if (pathName) sends.push({ client: node.expression.expression.text, path: pathName, method: methodName, methodLiteral, start: node.getStart(source), end: node.getEnd() });
368
368
  }
369
369
  }
370
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && specs.has(node.expression.text)) {
371
+ const nested = specs.get(node.expression.text);
372
+ const pathArg = nested ? node.arguments[nested.pathIndex] : undefined;
373
+ const clientArg = nested?.clientIndex === undefined ? undefined : node.arguments[nested.clientIndex];
374
+ const pathName = pathArg && ts.isIdentifier(pathArg) ? pathArg.text : undefined;
375
+ const clientName = clientArg && ts.isIdentifier(clientArg) ? clientArg.text : nested?.clientName;
376
+ if (nested && pathName && clientName) sends.push({ client: clientName, path: pathName, method: nested.methodName, methodLiteral: nested.methodLiteral, nestedWrapperFunction: node.expression.text, start: node.getStart(source), end: node.getEnd() });
377
+ }
370
378
  ts.forEachChild(node, visit);
371
379
  };
372
380
  visit(fn);
@@ -376,7 +384,7 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
376
384
  const pathIndex = params.indexOf(found.path);
377
385
  const methodIndex = found.method ? params.indexOf(found.method) : -1;
378
386
  const capturesKnownClient = serviceVariables.has(found.client) || /^(srv|service|serviceClient|client|.*Client)$/.test(found.client);
379
- if (pathIndex >= 0 && (clientIndex >= 0 || capturesKnownClient)) specs.set(name, { clientIndex: clientIndex >= 0 ? clientIndex : undefined, clientName: clientIndex >= 0 ? undefined : found.client, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodName: found.method, methodLiteral: found.methodLiteral, definitionLine: lineOf(source.text, fn.getStart(source)), internalStart: found.start, internalEnd: found.end });
387
+ if (pathIndex >= 0 && (clientIndex >= 0 || capturesKnownClient)) specs.set(name, { clientIndex: clientIndex >= 0 ? clientIndex : undefined, clientName: clientIndex >= 0 ? undefined : found.client, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodName: found.method, methodLiteral: found.methodLiteral, nestedWrapperFunction: found.nestedWrapperFunction, definitionLine: lineOf(source.text, fn.getStart(source)), internalStart: found.start, internalEnd: found.end });
380
388
  };
381
389
  const visitTop = (node: ts.Node): void => {
382
390
  if (ts.isFunctionDeclaration(node) && node.name) scanFunction(node.name.text, node);
@@ -456,7 +464,7 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
456
464
  const operationPathExpr = operationPathFromStatic(resolvedPath.text);
457
465
  const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : undefined;
458
466
  if (spec && receiver && operationPathExpr) {
459
- add(node, { callType: 'remote_action', serviceVariableName: receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver, classifier: resolvedPath.sourceKind === 'literal' ? 'higher_order_wrapper_literal_path' : 'higher_order_wrapper_static_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, normalizedOperationPath, literalPathSource: resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : `wrapper_call_${resolvedPath.sourceKind}`, literalCallerArgumentDetected: true });
467
+ add(node, { callType: 'remote_action', serviceVariableName: receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver, classifier: resolvedPath.sourceKind === 'literal' ? 'higher_order_wrapper_literal_path' : 'higher_order_wrapper_static_path', wrapperFunction: wrapperName, nestedWrapperFunction: spec.nestedWrapperFunction, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, normalizedOperationPath, literalPathSource: resolvedPath.sourceKind === 'const' ? 'same_scope_const_initializer' : `wrapper_call_${resolvedPath.sourceKind}`, literalCallerArgumentDetected: true });
460
468
  } else if (spec && receiver) {
461
469
  add(node, { callType: 'remote_action', serviceVariableName: receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason: 'dynamic_operation_path_identifier' }, { receiver, classifier: 'higher_order_wrapper_dynamic_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: resolvedPath.sourceKind, rawPathExpression: resolvedPath.rawExpression, parserWarning: 'dynamic_operation_path_identifier' });
462
470
  }
@@ -0,0 +1,160 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import ts from 'typescript';
4
+ import { normalizePath } from '../utils/path-utils.js';
5
+
6
+ export interface HelperBinding {
7
+ exportedName: string;
8
+ returnedProperty?: string;
9
+ alias?: string;
10
+ aliasExpr?: string;
11
+ destinationExpr?: string;
12
+ servicePathExpr?: string;
13
+ isDynamic: boolean;
14
+ placeholders: string[];
15
+ helperChain?: Array<Record<string, unknown>>;
16
+ sourceFile: string;
17
+ sourceLine: number;
18
+ }
19
+ export interface ImportBinding {
20
+ localName: string;
21
+ exportedName: string;
22
+ sourceFile?: string;
23
+ }
24
+ export interface ClassHelperReturn {
25
+ className: string;
26
+ helperName: string;
27
+ propertyName: string;
28
+ variableName: string;
29
+ fact: Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'>;
30
+ sourceLine: number;
31
+ }
32
+
33
+ export function lineOf(sf: ts.SourceFile, node: ts.Node): number {
34
+ return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
35
+ }
36
+
37
+ function stringValue(node: ts.Expression | undefined): string | undefined {
38
+ if (!node) return undefined;
39
+ if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
40
+ if (ts.isTemplateExpression(node)) return node.getText().replace(/^`|`$/g, '');
41
+ return node.getText();
42
+ }
43
+
44
+ function placeholders(value?: string): string[] {
45
+ return [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((m) => (m[1] ?? '').trim()).filter(Boolean);
46
+ }
47
+
48
+ export function connectFactFromCall(call: ts.CallExpression): Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'> | undefined {
49
+ const expr = call.expression;
50
+ if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== 'to') return undefined;
51
+ const inner = expr.expression;
52
+ if (!ts.isPropertyAccessExpression(inner) || inner.name.text !== 'connect' || inner.expression.getText() !== 'cds') return undefined;
53
+ const first = call.arguments[0];
54
+ if (!first) return undefined;
55
+ const second = call.arguments[1];
56
+ const objectArg = ts.isObjectLiteralExpression(first) ? first : second && ts.isObjectLiteralExpression(second) ? second : undefined;
57
+ let alias: string | undefined;
58
+ let aliasExpr: string | undefined;
59
+ if (ts.isStringLiteralLike(first) || ts.isNoSubstitutionTemplateLiteral(first)) alias = first.text;
60
+ else if (!ts.isObjectLiteralExpression(first)) aliasExpr = stringValue(first);
61
+ if ((ts.isStringLiteralLike(first) || ts.isNoSubstitutionTemplateLiteral(first)) && !objectArg) return { alias: first.text, isDynamic: false, placeholders: [] };
62
+ if (!objectArg && aliasExpr) return { aliasExpr, isDynamic: true, placeholders: placeholders(aliasExpr) };
63
+ const expressions = objectArg ? objectExpressions(objectArg) : {};
64
+ const ph = [...placeholders(aliasExpr ?? alias), ...placeholders(expressions.destinationExpr), ...placeholders(expressions.servicePathExpr)];
65
+ return { alias, aliasExpr, ...expressions, isDynamic: ph.length > 0 || (!expressions.destinationExpr && !expressions.servicePathExpr), placeholders: ph };
66
+ }
67
+
68
+ function objectExpressions(objectArg: ts.ObjectLiteralExpression): { destinationExpr?: string; servicePathExpr?: string } {
69
+ const out: { destinationExpr?: string; servicePathExpr?: string } = {};
70
+ function visitObject(obj: ts.ObjectLiteralExpression): void {
71
+ for (const prop of obj.properties) {
72
+ if (!ts.isPropertyAssignment(prop)) continue;
73
+ const name = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : undefined;
74
+ if (name === 'destination') out.destinationExpr = stringValue(prop.initializer);
75
+ if (name === 'path' || name === 'servicePath') out.servicePathExpr = stringValue(prop.initializer);
76
+ if (ts.isObjectLiteralExpression(prop.initializer)) visitObject(prop.initializer);
77
+ }
78
+ }
79
+ visitObject(objectArg);
80
+ return out;
81
+ }
82
+
83
+ export function unwrapCall(expr: ts.Expression): ts.CallExpression | undefined {
84
+ if (ts.isAwaitExpression(expr)) return unwrapCall(expr.expression);
85
+ if (ts.isParenthesizedExpression(expr)) return unwrapCall(expr.expression);
86
+ if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) return unwrapCall(expr.expression);
87
+ if (ts.isTypeAssertionExpression(expr)) return unwrapCall(expr.expression);
88
+ if (ts.isCallExpression(expr)) return expr;
89
+ return undefined;
90
+ }
91
+
92
+ export function unwrapIdentityExpression(expr: ts.Expression): ts.Expression {
93
+ if (ts.isAwaitExpression(expr)) return unwrapIdentityExpression(expr.expression);
94
+ if (ts.isParenthesizedExpression(expr)) return unwrapIdentityExpression(expr.expression);
95
+ if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) return unwrapIdentityExpression(expr.expression);
96
+ if (ts.isTypeAssertionExpression(expr)) return unwrapIdentityExpression(expr.expression);
97
+ return expr;
98
+ }
99
+
100
+ export function transactionReceiverName(expr: ts.Expression): string | undefined {
101
+ const call = unwrapCall(expr);
102
+ if (call && ts.isPropertyAccessExpression(call.expression) && ['tx', 'transaction'].includes(call.expression.name.text) && ts.isIdentifier(call.expression.expression)) return call.expression.expression.text;
103
+ const unwrapped = unwrapIdentityExpression(expr);
104
+ if (!ts.isConditionalExpression(unwrapped)) return undefined;
105
+ const left = transactionReceiverName(unwrapped.whenTrue);
106
+ const right = transactionReceiverName(unwrapped.whenFalse);
107
+ return left && left === right ? left : undefined;
108
+ }
109
+
110
+ export function findConnectInExpression(expr: ts.Expression): Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'> | undefined {
111
+ const direct = unwrapCall(expr);
112
+ if (direct) {
113
+ const fact = connectFactFromCall(direct);
114
+ if (fact) return fact;
115
+ }
116
+ let found: Omit<HelperBinding, 'exportedName' | 'sourceFile' | 'sourceLine'> | undefined;
117
+ function visit(node: ts.Node): void {
118
+ if (found) return;
119
+ if (ts.isCallExpression(node)) found = connectFactFromCall(node);
120
+ if (!found) ts.forEachChild(node, visit);
121
+ }
122
+ visit(expr);
123
+ return found;
124
+ }
125
+
126
+ export async function readSource(abs: string): Promise<ts.SourceFile | undefined> {
127
+ try {
128
+ const text = await fs.readFile(abs, 'utf8');
129
+ return ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ async function resolveImport(repoPath: string, fromFile: string, spec: string): Promise<string | undefined> {
136
+ if (!spec.startsWith('.')) return undefined;
137
+ const rawBase = path.resolve(repoPath, path.dirname(fromFile), spec);
138
+ const parsed = path.parse(rawBase);
139
+ const base = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(parsed.ext) ? path.join(parsed.dir, parsed.name) : rawBase;
140
+ for (const candidate of [base, `${base}.ts`, `${base}.js`, path.join(base, 'index.ts'), path.join(base, 'index.js')]) {
141
+ const stat = await fs.stat(candidate).catch(() => undefined);
142
+ if (stat?.isFile()) return normalizePath(path.relative(repoPath, candidate));
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ export async function importsFor(repoPath: string, filePath: string, sf: ts.SourceFile): Promise<ImportBinding[]> {
148
+ const imports: ImportBinding[] = [];
149
+ for (const stmt of sf.statements) {
150
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteralLike(stmt.moduleSpecifier)) continue;
151
+ const sourceFile = await resolveImport(repoPath, filePath, stmt.moduleSpecifier.text);
152
+ const clause = stmt.importClause;
153
+ if (!clause) continue;
154
+ if (clause.name) imports.push({ localName: clause.name.text, exportedName: 'default', sourceFile });
155
+ const bindings = clause.namedBindings;
156
+ if (bindings && ts.isNamedImports(bindings))
157
+ for (const el of bindings.elements) imports.push({ localName: el.name.text, exportedName: el.propertyName?.text ?? el.name.text, sourceFile });
158
+ }
159
+ return imports;
160
+ }