@saptools/service-flow 0.1.48 → 0.1.50

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.
@@ -78,7 +78,38 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
78
78
  const operationLikeRemoteEntity = isRemoteEntityCall && Boolean(op) && credibleOperationSignal && (!strongEntitySignal || indexedOperationCandidateCount > 0);
79
79
  const isOperationCall = operationLikeRemoteEntity || ((callType === 'remote_action' || callType === 'local_service_call') || (callType === 'remote_query' && Boolean(op)));
80
80
  const resolution = isOperationCall ? resolveOperation(db, { servicePath, operationPath: op, serviceName: call.local_service_name as string | undefined, repoId: callType === 'local_service_call' ? Number(call.repo_id) : undefined, alias: applyVariables((call.aliasExpr as string | undefined) ?? (call.alias as string | undefined), vars), destination: destination ? applyVariables(destination, vars) : undefined, isDynamic, hasExplicitOverride: Object.keys(vars).length > 0 || callType === 'local_service_call' }, workspaceId) : { status: 'unresolved' as const, candidates: [], reasons: [] };
81
- const evidence: Record<string, unknown> = { ...callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : undefined, normalized, intent), indexedOperationCandidateCount, parserCallType: callType };
81
+ const evidence: Record<string, unknown> = {
82
+ ...callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : undefined, normalized, intent),
83
+ indexedOperationCandidateCount,
84
+ parserCallType: callType,
85
+ entityOperationPrecedence: operationPrecedence(
86
+ callType,
87
+ intent,
88
+ indexedOperationCandidateCount,
89
+ Boolean(resolution.target),
90
+ ),
91
+ };
92
+ const pathAnalysis = objectValue(objectJson(call.evidence_json)?.pathAnalysis);
93
+ if (callType === 'remote_action' && pathAnalysis?.status === 'ambiguous') {
94
+ const candidatePaths = Array.isArray(pathAnalysis.candidateRawPaths)
95
+ ? pathAnalysis.candidateRawPaths
96
+ : [];
97
+ 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(
98
+ workspaceId,
99
+ 'UNRESOLVED_EDGE',
100
+ 'ambiguous',
101
+ 'call',
102
+ String(call.id),
103
+ 'operation_candidates',
104
+ candidatePaths.join(','),
105
+ Number(call.confidence ?? 0.5),
106
+ JSON.stringify(evidence),
107
+ 0,
108
+ 'Ambiguous operation path candidates require explicit disambiguation',
109
+ generation,
110
+ );
111
+ return { status: 'ambiguous', callType };
112
+ }
82
113
  if (isRemoteEntityCall && (resolution.target || resolution.candidates.length > 0 || resolution.status === 'dynamic')) {
83
114
  if (resolution.target) {
84
115
  db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'REMOTE_CALL_RESOLVES_TO_OPERATION', 'resolved', 'call', String(call.id), 'operation', String(resolution.target.operationId), resolution.target.score, JSON.stringify({ ...evidence, operationEntityPrecedence: 'indexed_operation_over_parser_entity' }), 0, generation);
@@ -125,6 +156,44 @@ function operationCandidateCount(db: Db, workspaceId: number, operationPath: str
125
156
  return Number(row?.count ?? 0);
126
157
  }
127
158
 
159
+ function operationPrecedence(
160
+ callType: string,
161
+ intent: ReturnType<typeof classifyODataPathIntent>,
162
+ indexedOperationCandidateCount: number,
163
+ resolvedOperation: boolean,
164
+ ): Record<string, unknown> {
165
+ if (resolvedOperation) {
166
+ return {
167
+ decision: 'operation',
168
+ reason: 'indexed_operation_with_strong_service_context',
169
+ indexedOperationCandidateCount,
170
+ };
171
+ }
172
+ if (callType === 'remote_action' && intent.kind === 'operation_invocation') {
173
+ return {
174
+ decision: 'operation_candidate',
175
+ rejectionReason: indexedOperationCandidateCount > 0
176
+ ? 'indexed_candidates_lack_unique_strong_service_context'
177
+ : 'no_indexed_operation_candidate',
178
+ indexedOperationCandidateCount,
179
+ };
180
+ }
181
+ if (intent.kind.startsWith('entity_')) {
182
+ return {
183
+ decision: 'entity',
184
+ rejectionReason: indexedOperationCandidateCount > 0
185
+ ? 'entity_shape_has_precedence_without_resolved_operation_context'
186
+ : 'entity_shape_has_no_indexed_operation_evidence',
187
+ indexedOperationCandidateCount,
188
+ };
189
+ }
190
+ return {
191
+ decision: 'unresolved',
192
+ rejectionReason: 'path_has_no_safe_entity_or_operation_precedence',
193
+ indexedOperationCandidateCount,
194
+ };
195
+ }
196
+
128
197
  function unresolvedOperationReason(resolution: { candidates: unknown[]; status: string; reasons: string[] }): string {
129
198
  if (resolution.status === 'dynamic') return `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ['missing runtime variables']).join(', ')}`;
130
199
  if (resolution.candidates.length === 0) return 'No indexed target operation matched';
@@ -232,14 +301,64 @@ function implementationContextForOperation(db: Db, operation: Record<string, unk
232
301
  }
233
302
  function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
234
303
  const rows = implementationCandidates(db, workspaceId, operation);
235
- return deduplicateCandidates(rows.map((row) => scoreImplementationCandidate(row, operation))).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
304
+ const invalid = rows.filter((row) => !validRegistrationPair(row));
305
+ recordRegistrationInvariantDiagnostics(db, invalid);
306
+ return deduplicateCandidates(
307
+ rows.filter(validRegistrationPair)
308
+ .map((row) => scoreImplementationCandidate(row, operation)),
309
+ ).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
310
+ }
311
+ function validRegistrationPair(row: Record<string, unknown>): boolean {
312
+ if (row.registrationHandlerClassId === null
313
+ || row.registrationHandlerClassId === undefined)
314
+ return registrationPairingStrategy(row) !== 'unproven';
315
+ return Number(row.registrationHandlerClassId) === Number(row.classId);
316
+ }
317
+ function registrationPairingStrategy(row: Record<string, unknown>): string {
318
+ if (row.registrationHandlerClassId !== null
319
+ && row.registrationHandlerClassId !== undefined)
320
+ return 'exact_handler_class_id';
321
+ if (Number(row.applicationRepoId) === Number(row.handlerRepoId))
322
+ return 'same_repository_class_name_fallback';
323
+ const source = stringValue(row.importSource);
324
+ const separator = source?.lastIndexOf('#') ?? -1;
325
+ if (!source || separator <= 0) return 'unproven';
326
+ const moduleName = source.slice(0, separator);
327
+ const importedName = source.slice(separator + 1);
328
+ const matchesClass = importedName === row.className
329
+ || (importedName === 'default' && row.registrationClassName === row.className);
330
+ return moduleName === row.handlerPackage && matchesClass
331
+ ? 'explicit_package_import'
332
+ : 'unproven';
333
+ }
334
+ function recordRegistrationInvariantDiagnostics(
335
+ db: Db,
336
+ rows: Array<Record<string, unknown>>,
337
+ ): void {
338
+ const insert = db.prepare(`INSERT INTO diagnostics(repo_id,severity,code,message,source_file,source_line)
339
+ SELECT ?,'error','handler_registration_class_mismatch',
340
+ 'Implementation candidate registration did not match its persisted handler class id',?,?
341
+ WHERE NOT EXISTS (
342
+ SELECT 1 FROM diagnostics
343
+ WHERE repo_id=? AND code='handler_registration_class_mismatch'
344
+ AND source_file=? AND source_line=?
345
+ )`);
346
+ for (const row of rows)
347
+ insert.run(
348
+ row.applicationRepoId,
349
+ row.registrationFile,
350
+ row.registrationLine,
351
+ row.applicationRepoId,
352
+ row.registrationFile,
353
+ row.registrationLine,
354
+ );
236
355
  }
237
356
 
238
357
  function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationCandidate[] {
239
358
  const merged = new Map<string, ImplementationCandidate>();
240
359
  for (const row of rows) {
241
360
  const key = [row.methodId, row.classId, row.handlerRepoId].join(':');
242
- const registration = { file: row.registrationFile, line: row.registrationLine, kind: row.registrationKind, importSource: row.importSource };
361
+ const registration = registrationEvidence(row);
243
362
  const existing = merged.get(key);
244
363
  if (!existing) {
245
364
  merged.set(key, { ...row, registrations: [registration] });
@@ -253,6 +372,19 @@ function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationC
253
372
  }
254
373
  return [...merged.values()];
255
374
  }
375
+ function registrationEvidence(
376
+ row: Record<string, unknown>,
377
+ ): Record<string, unknown> {
378
+ return {
379
+ id: row.registrationId,
380
+ handlerClassId: row.registrationHandlerClassId,
381
+ file: row.registrationFile,
382
+ line: row.registrationLine,
383
+ kind: row.registrationKind,
384
+ importSource: row.importSource,
385
+ pairingStrategy: registrationPairingStrategy(row),
386
+ };
387
+ }
256
388
  function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
257
389
  const seen = new Set<string>();
258
390
  return rows.filter((row) => {
@@ -283,10 +415,14 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
283
415
  hm.method_name methodName,
284
416
  hm.decorator_value decoratorValue,
285
417
  hm.decorator_raw_expression decoratorRawExpression,
418
+ hm.decorator_resolution_json decoratorResolutionJson,
286
419
  hc.id classId,
287
420
  hc.class_name className,
288
421
  hc.source_file sourceFile,
289
422
  hc.source_line sourceLine,
423
+ hr.id registrationId,
424
+ hr.handler_class_id registrationHandlerClassId,
425
+ hr.class_name registrationClassName,
290
426
  hr.repo_id applicationRepoId,
291
427
  hr.registration_file registrationFile,
292
428
  hr.registration_line registrationLine,
@@ -309,14 +445,36 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
309
445
  CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
310
446
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id AND localService.service_path=?) THEN 1 ELSE 0 END localServicePathMatch,
311
447
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
312
- CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON (localClass.id=localReg.handler_class_id OR localClass.class_name=localReg.class_name) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
448
+ CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON ((localReg.handler_class_id IS NOT NULL AND localClass.id=localReg.handler_class_id) OR (localReg.handler_class_id IS NULL AND localClass.class_name=localReg.class_name AND localClass.repo_id=localReg.repo_id)) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
313
449
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END appDependsOnModel,
314
450
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(handlerRepo.id AS TEXT)) THEN 1 ELSE 0 END appDependsOnHandler,
315
451
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END handlerDependsOnModel
316
452
  FROM handler_methods hm
317
453
  JOIN handler_classes hc ON hc.id=hm.handler_class_id
318
454
  JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id
319
- JOIN handler_registrations hr ON (hr.handler_class_id=hc.id OR (hr.class_name=hc.class_name AND (hr.repo_id=hc.repo_id OR hr.import_source IS NOT NULL)))
455
+ JOIN handler_registrations hr ON (
456
+ (hr.handler_class_id IS NOT NULL AND hr.handler_class_id=hc.id)
457
+ OR (
458
+ hr.handler_class_id IS NULL
459
+ AND (
460
+ (hr.class_name=hc.class_name AND hr.repo_id=hc.repo_id)
461
+ OR (
462
+ instr(hr.import_source,'#')>1
463
+ AND substr(hr.import_source,1,instr(hr.import_source,'#')-1)
464
+ =handlerRepo.package_name
465
+ AND (
466
+ substr(hr.import_source,instr(hr.import_source,'#')+1)
467
+ =hc.class_name
468
+ OR (
469
+ substr(hr.import_source,instr(hr.import_source,'#')+1)
470
+ ='default'
471
+ AND hr.class_name=hc.class_name
472
+ )
473
+ )
474
+ )
475
+ )
476
+ )
477
+ )
320
478
  JOIN repositories appRepo ON appRepo.id=hr.repo_id
321
479
  WHERE appRepo.workspace_id=?
322
480
  AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
@@ -418,8 +576,16 @@ function candidateEvidence(candidate: ImplementationCandidate, rank: number): Re
418
576
  className: candidate.className,
419
577
  sourceFile: candidate.sourceFile,
420
578
  sourceLine: candidate.sourceLine,
421
- registration: { file: candidate.registrationFile, line: candidate.registrationLine, kind: candidate.registrationKind, importSource: candidate.importSource },
579
+ decoratorResolution: objectJson(candidate.decoratorResolutionJson),
580
+ registration: registrationEvidence(candidate),
422
581
  registrations: candidate.registrations ?? [],
582
+ registrationPairing: {
583
+ strategy: registrationPairingStrategy(candidate),
584
+ registrationId: candidate.registrationId,
585
+ registrationHandlerClassId: candidate.registrationHandlerClassId,
586
+ candidateHandlerClassId: candidate.classId,
587
+ invariantStatus: validRegistrationPair(candidate) ? 'valid' : 'invalid',
588
+ },
423
589
  applicationPackage: { id: candidate.applicationRepoId, name: candidate.applicationRepo, packageName: candidate.applicationPackage },
424
590
  handlerPackage: { id: candidate.handlerRepoId, name: candidate.handlerRepo, packageName: candidate.handlerPackage },
425
591
  modelPackage: { id: candidate.modelRepoId, name: candidate.modelRepo, packageName: candidate.modelPackage },
@@ -12,7 +12,7 @@ export function normalizedOperationName(value: string): string {
12
12
  function clean(value: string): string {
13
13
  return value.replace(/^[`'"]|[`'"]$/g, '');
14
14
  }
15
- function generatedFromConstantName(value: string): string | undefined {
15
+ export function generatedOperationNameFromConstant(value: string): string | undefined {
16
16
  for (const prefix of ['Action', 'Func']) {
17
17
  if (value.startsWith(prefix) && value.length > prefix.length && /^[A-Z]/.test(value.slice(prefix.length))) return lowerFirst(value.slice(prefix.length));
18
18
  }
@@ -20,7 +20,7 @@ function generatedFromConstantName(value: string): string | undefined {
20
20
  }
21
21
  function resolved(value: string, raw?: string): DecoratorOperationSignal {
22
22
  const literal = clean(value);
23
- const generated = generatedFromConstantName(literal);
23
+ const generated = generatedOperationNameFromConstant(literal);
24
24
  return { status: 'resolved', operationName: generated ?? normalizedOperationName(literal), raw };
25
25
  }
26
26
  export function normalizeDecoratorOperationSignal(value: string | undefined, raw: string | undefined, candidateOperation?: string): DecoratorOperationSignal {
@@ -32,7 +32,7 @@ export function normalizeDecoratorOperationSignal(value: string | undefined, raw
32
32
  const stringMatch = /^String\(([A-Za-z_$][\w$]*)\)$/.exec(expression);
33
33
  if (stringMatch?.[1]) {
34
34
  const identifier = stringMatch[1];
35
- const generated = generatedFromConstantName(identifier);
35
+ const generated = generatedOperationNameFromConstant(identifier);
36
36
  const normalizedCandidate = candidateOperation ? normalizedOperationName(candidateOperation) : undefined;
37
37
  if (generated) return { status: 'resolved', operationName: generated, raw: expression };
38
38
  if (normalizedCandidate && identifier === normalizedCandidate) return { status: 'resolved', operationName: identifier, raw: expression };
@@ -15,8 +15,8 @@ export function renderTraceTable(result: TraceResult): string {
15
15
  const lines = ['Step Type From To Evidence'];
16
16
  for (const e of result.edges) {
17
17
  lines.push(`${String(e.step).padEnd(5)} ${e.type.padEnd(20)} ${e.from.slice(0, 34).padEnd(35)} ${e.to.slice(0, 35).padEnd(36)} ${location(e.evidence)}`);
18
- const hint = firstHint(e.evidence);
19
- if (e.unresolvedReason && hint) lines.push(` try ${hint}`);
18
+ if (e.unresolvedReason)
19
+ lines.push(...hintLines(e.evidence).map((hint) => ` ${hint}`));
20
20
  }
21
21
  if (result.diagnostics.length > 0) lines.push('', 'Diagnostics:', ...result.diagnostics.flatMap(diagnosticLines));
22
22
  return `${lines.join('\n')}\n`;
@@ -24,13 +24,23 @@ export function renderTraceTable(result: TraceResult): string {
24
24
 
25
25
  function diagnosticLines(diagnostic: Record<string, unknown>): string[] {
26
26
  const first = `${String(diagnostic.severity ?? 'info')} ${String(diagnostic.code ?? 'diagnostic')} ${String(diagnostic.message ?? '')}`;
27
- const hint = firstHint(diagnostic);
28
- return hint ? [first, ` try ${hint}`] : [first];
27
+ return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
29
28
  }
30
29
 
31
- function firstHint(evidence: Record<string, unknown>): string | undefined {
30
+ function hintLines(evidence: Record<string, unknown>): string[] {
32
31
  const suggestions = evidence.implementationHintSuggestions;
33
- if (!Array.isArray(suggestions)) return undefined;
34
- const first = suggestions.find((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object');
35
- return typeof first?.cli === 'string' ? first.cli : undefined;
32
+ if (!Array.isArray(suggestions)) return [];
33
+ const hints = suggestions.flatMap((item) =>
34
+ isRecord(item) && typeof item.cli === 'string'
35
+ ? [item.cli]
36
+ : []);
37
+ const unique = [...new Set(hints)];
38
+ const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
39
+ if (unique.length > shown.length)
40
+ shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
41
+ return shown;
42
+ }
43
+
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
36
46
  }
@@ -1,9 +1,18 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import ts from 'typescript';
4
- import type { HandlerClassFact } from '../types.js';
4
+ import type { HandlerClassFact, HandlerMethodFact } from '../types.js';
5
+ import { generatedOperationNameFromConstant } from '../linker/operation-decorator-normalizer.js';
5
6
  import { createSourceFile } from './ts-project.js';
6
- import { normalizePath, stripQuotes } from '../utils/path-utils.js';
7
+ import { normalizePath } from '../utils/path-utils.js';
8
+
9
+ type DecoratorResolution = HandlerMethodFact['decoratorResolution'];
10
+ interface StringLookups {
11
+ identifiers: Map<string, string>;
12
+ enumMembers: Map<string, string>;
13
+ objectProperties: Map<string, string>;
14
+ }
15
+
7
16
  function line(sf: ts.SourceFile, pos: number): number {
8
17
  return sf.getLineAndCharacterOfPosition(pos).line + 1;
9
18
  }
@@ -14,11 +23,119 @@ function callName(d: ts.Decorator): string {
14
23
  const e = d.expression;
15
24
  return ts.isCallExpression(e) ? e.expression.getText() : e.getText();
16
25
  }
17
- function firstArg(d: ts.Decorator): string {
26
+ function firstArg(d: ts.Decorator): ts.Expression | undefined {
18
27
  const e = d.expression;
19
- return ts.isCallExpression(e) && e.arguments[0]
20
- ? e.arguments[0].getText()
21
- : '';
28
+ return ts.isCallExpression(e) ? e.arguments[0] : undefined;
29
+ }
30
+ function unwrapExpression(expression: ts.Expression): ts.Expression {
31
+ let current = expression;
32
+ while (
33
+ ts.isParenthesizedExpression(current)
34
+ || ts.isAsExpression(current)
35
+ || ts.isTypeAssertionExpression(current)
36
+ || ts.isSatisfiesExpression(current)
37
+ ) current = current.expression;
38
+ return current;
39
+ }
40
+ function stringValue(expression: ts.Expression | undefined): string | undefined {
41
+ if (!expression) return undefined;
42
+ const unwrapped = unwrapExpression(expression);
43
+ return ts.isStringLiteralLike(unwrapped) ? unwrapped.text : undefined;
44
+ }
45
+ function propertyName(node: ts.PropertyName): string | undefined {
46
+ if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
47
+ return undefined;
48
+ }
49
+ function collectEnumMembers(
50
+ statement: ts.EnumDeclaration,
51
+ lookups: StringLookups,
52
+ ): void {
53
+ for (const member of statement.members) {
54
+ const name = propertyName(member.name);
55
+ const value = stringValue(member.initializer);
56
+ if (name && value !== undefined)
57
+ lookups.enumMembers.set(`${statement.name.text}.${name}`, value);
58
+ }
59
+ }
60
+ function collectObjectProperties(
61
+ name: string,
62
+ initializer: ts.Expression,
63
+ lookups: StringLookups,
64
+ ): void {
65
+ const object = unwrapExpression(initializer);
66
+ if (!ts.isObjectLiteralExpression(object)) return;
67
+ for (const property of object.properties) {
68
+ if (!ts.isPropertyAssignment(property)) continue;
69
+ const key = propertyName(property.name);
70
+ const value = stringValue(property.initializer);
71
+ if (key && value !== undefined)
72
+ lookups.objectProperties.set(`${name}.${key}`, value);
73
+ }
74
+ }
75
+ function collectStringLookups(source: ts.SourceFile): StringLookups {
76
+ const lookups: StringLookups = {
77
+ identifiers: new Map(),
78
+ enumMembers: new Map(),
79
+ objectProperties: new Map(),
80
+ };
81
+ for (const statement of source.statements) {
82
+ if (ts.isEnumDeclaration(statement)) collectEnumMembers(statement, lookups);
83
+ if (!ts.isVariableStatement(statement)
84
+ || !(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
85
+ for (const declaration of statement.declarationList.declarations) {
86
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
87
+ const value = stringValue(declaration.initializer);
88
+ if (value !== undefined) lookups.identifiers.set(declaration.name.text, value);
89
+ collectObjectProperties(declaration.name.text, declaration.initializer, lookups);
90
+ }
91
+ }
92
+ return lookups;
93
+ }
94
+ function unresolved(rawExpression: string, reason: string): DecoratorResolution {
95
+ return { rawExpression, resolutionKind: 'unresolved', unresolvedReason: reason };
96
+ }
97
+ function generatedConstant(rawExpression: string): string | undefined {
98
+ const match = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/
99
+ .exec(rawExpression.trim());
100
+ return match?.[1]
101
+ ? generatedOperationNameFromConstant(match[1])
102
+ : undefined;
103
+ }
104
+ function resolveDecoratorArgument(
105
+ argument: ts.Expression | undefined,
106
+ lookups: StringLookups,
107
+ ): DecoratorResolution {
108
+ if (!argument) return unresolved('', 'decorator_argument_missing');
109
+ const rawExpression = argument.getText();
110
+ const expression = unwrapExpression(argument);
111
+ if (ts.isStringLiteralLike(expression))
112
+ return { rawExpression, resolvedValue: expression.text, resolutionKind: 'literal' };
113
+ if (ts.isIdentifier(expression)) {
114
+ const value = lookups.identifiers.get(expression.text);
115
+ return value === undefined
116
+ ? unresolved(rawExpression, 'identifier_not_resolved_to_local_const_string')
117
+ : { rawExpression, resolvedValue: value, resolutionKind: 'const_identifier' };
118
+ }
119
+ if (ts.isPropertyAccessExpression(expression)
120
+ && ts.isIdentifier(expression.expression)) {
121
+ const key = `${expression.expression.text}.${expression.name.text}`;
122
+ const enumValue = lookups.enumMembers.get(key);
123
+ if (enumValue !== undefined)
124
+ return { rawExpression, resolvedValue: enumValue, resolutionKind: 'enum_member' };
125
+ const objectValue = lookups.objectProperties.get(key);
126
+ if (objectValue !== undefined)
127
+ return { rawExpression, resolvedValue: objectValue, resolutionKind: 'const_object_property' };
128
+ }
129
+ const generatedValue = generatedConstant(rawExpression);
130
+ if (generatedValue !== undefined)
131
+ return {
132
+ rawExpression,
133
+ resolvedValue: generatedValue,
134
+ resolutionKind: 'generated_constant_name',
135
+ };
136
+ if (ts.isPropertyAccessExpression(expression))
137
+ return unresolved(rawExpression, 'property_access_not_resolved_to_local_string');
138
+ return unresolved(rawExpression, 'unsupported_decorator_expression');
22
139
  }
23
140
  export async function parseDecorators(
24
141
  repoPath: string,
@@ -26,16 +143,9 @@ export async function parseDecorators(
26
143
  ): Promise<HandlerClassFact[]> {
27
144
  const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
28
145
  const sf = createSourceFile(filePath, text);
29
- const constants = new Map<string, string>();
146
+ const lookups = collectStringLookups(sf);
30
147
  const handlers: HandlerClassFact[] = [];
31
148
  function visit(node: ts.Node): void {
32
- if (
33
- ts.isVariableDeclaration(node) &&
34
- ts.isIdentifier(node.name) &&
35
- node.initializer &&
36
- ts.isStringLiteralLike(node.initializer)
37
- )
38
- constants.set(node.name.text, node.initializer.text);
39
149
  if (ts.isClassDeclaration(node)) {
40
150
  const className = node.name?.text ?? 'AnonymousHandler';
41
151
  const hasHandler = decs(node).some((d) => callName(d) === 'Handler');
@@ -45,17 +155,13 @@ export async function parseDecorators(
45
155
  ['Func', 'Action', 'On', 'Event'].includes(callName(d))
46
156
  )
47
157
  .map((d) => {
48
- const raw = firstArg(d);
49
- const value =
50
- raw.startsWith('"') || raw.startsWith("'") || raw.startsWith('`')
51
- ? stripQuotes(raw)
52
- : (constants.get(raw) ??
53
- (raw.endsWith('.name') ? raw.split('.').at(-2) : undefined));
158
+ const decoratorResolution = resolveDecoratorArgument(firstArg(d), lookups);
54
159
  return {
55
160
  methodName: m.name.getText(),
56
161
  decoratorKind: callName(d),
57
- decoratorValue: value,
58
- decoratorRawExpression: raw,
162
+ decoratorValue: decoratorResolution.resolvedValue,
163
+ decoratorRawExpression: decoratorResolution.rawExpression,
164
+ decoratorResolution,
59
165
  sourceFile: normalizePath(filePath),
60
166
  sourceLine: line(sf, m.getStart())
61
167
  };
@@ -4,6 +4,11 @@ import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
4
4
  import type { OutboundCallFact } from '../types.js';
5
5
  import { normalizePath } from '../utils/path-utils.js';
6
6
  import { importsFor, lineOf, readSource, type ImportBinding } from './service-binding-parser-helpers.js';
7
+ import {
8
+ analyzeOperationPath,
9
+ operationPathExpression,
10
+ pathUnresolvedReason,
11
+ } from './operation-path-analysis.js';
7
12
 
8
13
  interface WrapperSpec {
9
14
  clientIndex: number;
@@ -15,12 +20,6 @@ interface WrapperSpec {
15
20
  chain: string[];
16
21
  }
17
22
 
18
- interface PathValue {
19
- value?: string;
20
- sourceKind: string;
21
- rawExpression: string;
22
- }
23
-
24
23
  export async function parseImportedWrapperCalls(
25
24
  repoPath: string,
26
25
  filePath: string,
@@ -143,11 +142,11 @@ function wrapperCallFact(
143
142
  ): OutboundCallFact | undefined {
144
143
  const client = call.arguments[spec.clientIndex];
145
144
  if (!client || !ts.isIdentifier(client) || !serviceBindings.has(client.text)) return undefined;
146
- const pathValue = resolveCallerPath(call.arguments[spec.pathIndex], call);
147
145
  const methodValue = spec.methodIndex === undefined ? spec.methodLiteral : literal(call.arguments[spec.methodIndex]);
148
146
  const method = (methodValue ?? 'POST').toUpperCase();
149
- const rawPath = pathValue.rawExpression;
150
- const operationPathExpr = pathValue.value ? normalizeOperationPath(pathValue.value) : runtimePathExpression(rawPath);
147
+ const pathAnalysis = analyzeOperationPath(call.arguments[spec.pathIndex], call, method);
148
+ const operationPathExpr = operationPathExpression(pathAnalysis);
149
+ const unresolvedReason = pathUnresolvedReason(pathAnalysis);
151
150
  return {
152
151
  callType: 'remote_action',
153
152
  serviceVariableName: client.text,
@@ -157,46 +156,29 @@ function wrapperCallFact(
157
156
  sourceFile: normalizePath(filePath),
158
157
  sourceLine: lineOf(source, call),
159
158
  confidence: operationPathExpr ? 0.85 : 0.5,
160
- unresolvedReason: pathValue.value ? undefined : 'dynamic_operation_path_identifier',
159
+ unresolvedReason,
161
160
  evidence: {
162
161
  parser: 'typescript_ast',
163
- classifier: pathValue.value ? 'imported_wrapper_literal_path' : 'imported_wrapper_dynamic_path',
162
+ classifier: importedWrapperClassifier(pathAnalysis.status),
164
163
  receiver: client.text,
165
164
  wrapperFunction: spec.chain[0],
166
165
  wrapperChain: spec.chain,
167
166
  callerSite: { sourceFile: normalizePath(filePath), sourceLine: lineOf(source, call) },
168
167
  calleeSite: { sourceFile: spec.sourceFile, sourceLine: spec.sourceLine },
169
- rawPathExpression: rawPath,
170
- missingPathIdentifier: pathValue.value ? undefined : rawPath,
171
- odataPathIntent: pathValue.value ? classifyODataPathIntent(operationPathExpr, method) : undefined,
168
+ rawPathExpression: pathAnalysis.rawExpression,
169
+ missingPathIdentifier: pathAnalysis.runtimeIdentifier,
170
+ pathAnalysis,
171
+ odataPathIntent: operationPathExpr
172
+ ? classifyODataPathIntent(operationPathExpr, method)
173
+ : undefined,
172
174
  },
173
175
  };
174
176
  }
175
177
 
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;
178
+ function importedWrapperClassifier(status: string): string {
179
+ if (status === 'static') return 'imported_wrapper_literal_path';
180
+ if (status === 'ambiguous') return 'imported_wrapper_ambiguous_path';
181
+ return 'imported_wrapper_dynamic_path';
200
182
  }
201
183
 
202
184
  function findFunction(source: ts.SourceFile, exportedName: string): { name: string; fn: ts.FunctionLikeDeclaration } | undefined {
@@ -252,11 +234,3 @@ function propertyName(name: ts.PropertyName): string | undefined {
252
234
  function literal(expr: ts.Expression | undefined): string | undefined {
253
235
  return expr && (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) ? expr.text : undefined;
254
236
  }
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
- }