@saptools/service-flow 0.1.51 → 0.1.53

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +16 -14
  3. package/TECHNICAL-NOTE.md +18 -6
  4. package/dist/{chunk-YZJKE5UX.js → chunk-LFH7C46B.js} +4290 -1261
  5. package/dist/chunk-LFH7C46B.js.map +1 -0
  6. package/dist/cli.js +435 -515
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +45 -7
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/000-clean.ts +82 -0
  12. package/src/cli/001-doctor-projection.ts +140 -0
  13. package/src/cli/doctor.ts +20 -3
  14. package/src/cli.ts +75 -57
  15. package/src/db/connection.ts +1 -1
  16. package/src/db/migrations.ts +5 -3
  17. package/src/db/repositories.ts +130 -24
  18. package/src/db/schema.ts +7 -2
  19. package/src/indexer/repository-indexer.ts +57 -29
  20. package/src/indexer/workspace-indexer.ts +84 -6
  21. package/src/linker/000-implementation-candidates.ts +641 -0
  22. package/src/linker/001-implementation-evidence-projection.ts +119 -0
  23. package/src/linker/002-call-evidence.ts +226 -0
  24. package/src/linker/cross-repo-linker.ts +27 -441
  25. package/src/linker/dynamic-edge-resolver.ts +35 -0
  26. package/src/linker/external-http-target.ts +24 -3
  27. package/src/linker/helper-package-linker.ts +18 -2
  28. package/src/linker/service-resolver.ts +45 -2
  29. package/src/output/doctor-output.ts +13 -4
  30. package/src/output/table-output.ts +33 -5
  31. package/src/parsers/cds-parser.ts +8 -2
  32. package/src/parsers/decorator-parser.ts +382 -48
  33. package/src/parsers/handler-registration-parser.ts +38 -21
  34. package/src/parsers/imported-wrapper-parser.ts +18 -5
  35. package/src/parsers/outbound-call-parser.ts +25 -8
  36. package/src/parsers/package-json-parser.ts +36 -11
  37. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  38. package/src/parsers/service-binding-parser.ts +6 -3
  39. package/src/parsers/symbol-parser.ts +13 -3
  40. package/src/parsers/ts-project.ts +54 -0
  41. package/src/trace/000-dynamic-target-types.ts +96 -0
  42. package/src/trace/001-dynamic-identity.ts +308 -0
  43. package/src/trace/002-trace-diagnostics.ts +54 -0
  44. package/src/trace/003-dynamic-references.ts +283 -0
  45. package/src/trace/004-dynamic-candidate-sources.ts +155 -0
  46. package/src/trace/005-implementation-selection.ts +187 -0
  47. package/src/trace/006-contextual-projection.ts +30 -0
  48. package/src/trace/007-implementation-start-diagnostic.ts +61 -0
  49. package/src/trace/dynamic-targets.ts +582 -306
  50. package/src/trace/evidence.ts +331 -46
  51. package/src/trace/implementation-hints.ts +148 -8
  52. package/src/trace/selectors.ts +551 -3
  53. package/src/trace/trace-engine.ts +129 -135
  54. package/src/types.ts +27 -1
  55. package/src/utils/000-bounded-projection.ts +161 -0
  56. package/dist/chunk-YZJKE5UX.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import type { Db } from '../db/connection.js';
2
+ import { canonicalImplementationEvidence } from './000-implementation-candidates.js';
2
3
  export interface OperationTarget {
3
4
  operationId: number;
4
5
  repoName: string;
@@ -209,8 +210,11 @@ function ownershipReason(db: Db, candidate: OperationTarget, callerRepoId: numbe
209
210
  if (row?.repoId === callerRepoId) return { candidate, reason: 'resolved_implementation_handler_repo_matches_caller' };
210
211
  }
211
212
  if (edge?.evidence_json) {
212
- const evidence = JSON.parse(edge.evidence_json) as { candidates?: Array<{ accepted?: boolean; handlerPackage?: { id?: number }; applicationPackage?: { id?: number } }> };
213
- const hit = evidence.candidates?.find((item) => item.accepted && (Number(item.handlerPackage?.id) === callerRepoId || Number(item.applicationPackage?.id) === callerRepoId));
213
+ const stored = parsedRecord(edge.evidence_json);
214
+ const evidence = canonicalImplementationEvidence(db, candidate.operationId) ?? stored;
215
+ const hit = implementationEvidenceCandidates(evidence).find((item) =>
216
+ item.accepted && (item.handlerRepoId === callerRepoId
217
+ || item.applicationRepoId === callerRepoId));
214
218
  if (hit) return { candidate, reason: edge.status === 'ambiguous' ? 'ambiguous_implementation_candidate_repo_matches_caller' : 'registration_package_matches_caller' };
215
219
  }
216
220
  const dep = db.prepare("SELECT 1 FROM graph_edges WHERE edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND status='resolved' AND from_kind='repo' AND from_id=? AND to_id=?").get(String(callerRepoId), String(candidate.repoId));
@@ -218,6 +222,45 @@ function ownershipReason(db: Db, candidate: OperationTarget, callerRepoId: numbe
218
222
  return undefined;
219
223
  }
220
224
 
225
+ function implementationEvidenceCandidates(
226
+ evidence: Record<string, unknown>,
227
+ ): Array<{ accepted: boolean; handlerRepoId?: number; applicationRepoId?: number }> {
228
+ const candidates = evidence.candidates;
229
+ if (!Array.isArray(candidates)) return [];
230
+ return candidates.flatMap((candidate) => {
231
+ if (!isRecord(candidate)) return [];
232
+ const row = candidate;
233
+ const handler = recordValue(row.handlerPackage);
234
+ const application = recordValue(row.applicationPackage);
235
+ return [{
236
+ accepted: row.accepted === true,
237
+ handlerRepoId: numberValue(handler.id),
238
+ applicationRepoId: numberValue(application.id),
239
+ }];
240
+ });
241
+ }
242
+
243
+ function recordValue(value: unknown): Record<string, unknown> {
244
+ return isRecord(value) ? value : {};
245
+ }
246
+
247
+ function parsedRecord(value: string): Record<string, unknown> {
248
+ try {
249
+ const parsed: unknown = JSON.parse(value);
250
+ return recordValue(parsed);
251
+ } catch {
252
+ return {};
253
+ }
254
+ }
255
+
256
+ function isRecord(value: unknown): value is Record<string, unknown> {
257
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
258
+ }
259
+
260
+ function numberValue(value: unknown): number | undefined {
261
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
262
+ }
263
+
221
264
  function matchesLocalRepo(db: Db, operationId: number, repoId: number | undefined): boolean {
222
265
  if (repoId === undefined) return true;
223
266
  const row = db.prepare('SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?').get(operationId) as { repoId?: number } | undefined;
@@ -56,8 +56,11 @@ function compactMessage(diagnostic: Diagnostic): string {
56
56
 
57
57
  function suggestedHintLines(diagnostic: Diagnostic): string[] {
58
58
  const direct = cliHints(diagnostic.suggestedHints);
59
- if (direct.length > 0) return cappedHints(direct);
60
- return cappedHints(cliHintsFromSuggestions(diagnostic.implementationHintSuggestions));
59
+ const omitted = numericValue(diagnostic.omittedImplementationHintSuggestionCount);
60
+ if (direct.length > 0) return cappedHints(direct, omitted);
61
+ return cappedHints(
62
+ cliHintsFromSuggestions(diagnostic.implementationHintSuggestions), omitted,
63
+ );
61
64
  }
62
65
 
63
66
  function cliHints(value: unknown): string[] {
@@ -76,13 +79,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
76
79
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
77
80
  }
78
81
 
79
- function cappedHints(hints: string[]): string[] {
82
+ function cappedHints(hints: string[], omitted: number): string[] {
80
83
  const unique = [...new Set(hints)];
81
84
  const shown = unique.slice(0, 3);
82
- if (unique.length > shown.length) shown.push(`... ${unique.length - shown.length} more hint(s) available in --format json`);
85
+ const remaining = Math.max(0, unique.length - shown.length) + omitted;
86
+ if (remaining > 0)
87
+ shown.push(`... ${remaining} additional hint(s) omitted; use a scoped --implementation-hint`);
83
88
  return shown;
84
89
  }
85
90
 
91
+ function numericValue(value: unknown): number {
92
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
93
+ }
94
+
86
95
  function cleanDoctorMessage(): string {
87
96
  return `${pc.green('No diagnostics recorded')}\n`;
88
97
  }
@@ -24,7 +24,30 @@ 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
- return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
27
+ const details = diagnosticDetailLines(diagnostic);
28
+ return [first, ...[...details, ...hintLines(diagnostic)]
29
+ .map((hint) => ` ${hint}`)];
30
+ }
31
+
32
+ function diagnosticDetailLines(diagnostic: Record<string, unknown>): string[] {
33
+ const lines: string[] = [];
34
+ if (diagnostic.sourceFile || diagnostic.sourceLine)
35
+ lines.push(`at ${String(diagnostic.sourceFile ?? '')}:${String(diagnostic.sourceLine ?? '')}`);
36
+ const unsupported = stringList(diagnostic.unsupportedDecoratorNames);
37
+ const observed = stringList(diagnostic.observedDecoratorNames);
38
+ if (unsupported.length > 0)
39
+ lines.push(`unsupported decorators: ${unsupported.join(', ')}`);
40
+ else if (observed.length > 0)
41
+ lines.push(`observed decorators: ${observed.join(', ')}`);
42
+ if (typeof diagnostic.remediation === 'string')
43
+ lines.push(`hint: ${diagnostic.remediation}`);
44
+ return lines;
45
+ }
46
+
47
+ function stringList(value: unknown): string[] {
48
+ return Array.isArray(value)
49
+ ? value.filter((item): item is string => typeof item === 'string')
50
+ : [];
28
51
  }
29
52
 
30
53
  function hintLines(evidence: Record<string, unknown>): string[] {
@@ -37,8 +60,10 @@ function hintLines(evidence: Record<string, unknown>): string[] {
37
60
  : []);
38
61
  const unique = [...new Set(hints)];
39
62
  const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
40
- if (unique.length > shown.length)
41
- shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
63
+ const omitted = numberValue(evidence.omittedImplementationHintSuggestionCount);
64
+ const remaining = Math.max(0, unique.length - shown.length) + omitted;
65
+ if (remaining > 0)
66
+ shown.push(`... ${remaining} additional hint(s) omitted; use a scoped --implementation-hint`);
42
67
  return [...dynamicLines, ...shown];
43
68
  }
44
69
 
@@ -50,9 +75,12 @@ function dynamicHintLines(evidence: Record<string, unknown>): string[] {
50
75
  if (count === 0) return [];
51
76
  const shown = numberValue(exploration.shownCandidateCount);
52
77
  const omitted = numberValue(exploration.omittedCandidateCount);
53
- const lines = [`candidates: ${shown} shown, ${omitted} omitted`];
78
+ const rejected = numberValue(exploration.rejectedCandidateCount);
79
+ const lines = [
80
+ `viable candidates: ${shown} shown, ${omitted} omitted; rejected: ${rejected}`,
81
+ ];
54
82
  lines.push(...varSetHints(exploration.suggestedVarSets));
55
- if (omitted > 0 || shown < count)
83
+ if (omitted > 0 || rejected > 0 || shown < count)
56
84
  lines.push('use --dynamic-mode candidates --max-dynamic-candidates 20 to explore candidate branches');
57
85
  return lines;
58
86
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import type { CdsOperationFact, CdsServiceFact } from '../types.js';
4
4
  import { ensureLeadingSlash, normalizePath } from '../utils/path-utils.js';
5
+ import type { RepositorySourceContext } from './ts-project.js';
5
6
 
6
7
  function lineOf(text: string, index: number): number {
7
8
  return text.slice(0, index).split('\n').length;
@@ -136,9 +137,14 @@ function operationsFromBody(text: string, maskedBody: string, bodyOffset: number
136
137
  }));
137
138
  }
138
139
 
139
- export async function parseCdsFile(repoPath: string, filePath: string): Promise<CdsServiceFact[]> {
140
+ export async function parseCdsFile(
141
+ repoPath: string,
142
+ filePath: string,
143
+ context?: RepositorySourceContext,
144
+ ): Promise<CdsServiceFact[]> {
140
145
  const absolute = path.join(repoPath, filePath);
141
- const text = await fs.readFile(absolute, 'utf8');
146
+ const text = context?.get(filePath)?.text
147
+ ?? await fs.readFile(absolute, 'utf8');
142
148
  const masked = maskCommentsAndStrings(text);
143
149
  const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
144
150
  const services: CdsServiceFact[] = [];
@@ -1,17 +1,65 @@
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, HandlerMethodFact } from '../types.js';
4
+ import type {
5
+ HandlerClassFact,
6
+ HandlerLifecycleEvent,
7
+ HandlerLifecyclePhase,
8
+ HandlerMethodFact,
9
+ HandlerMethodKind,
10
+ } from '../types.js';
5
11
  import { generatedOperationNameFromConstant } from '../linker/operation-decorator-normalizer.js';
6
- import { createSourceFile } from './ts-project.js';
12
+ import {
13
+ createSourceFile,
14
+ type RepositorySourceContext,
15
+ } from './ts-project.js';
7
16
  import { normalizePath } from '../utils/path-utils.js';
8
17
 
9
18
  type DecoratorResolution = HandlerMethodFact['decoratorResolution'];
19
+ type ResolvedArgumentKind = Exclude<
20
+ DecoratorResolution['resolutionKind'],
21
+ 'lifecycle_implicit' | 'unresolved'
22
+ >;
10
23
  interface StringLookups {
11
24
  identifiers: Map<string, string>;
12
25
  enumMembers: Map<string, string>;
13
26
  objectProperties: Map<string, string>;
27
+ capDecoratorNames: Map<string, string>;
28
+ capDecoratorNamespaces: Set<string>;
14
29
  }
30
+ interface LifecycleMetadata {
31
+ phase: HandlerLifecyclePhase;
32
+ event: HandlerLifecycleEvent;
33
+ }
34
+ interface MethodClassification {
35
+ handlerKind: HandlerMethodKind;
36
+ executable: boolean;
37
+ lifecyclePhase?: HandlerLifecyclePhase;
38
+ lifecycleEvent?: HandlerLifecycleEvent;
39
+ }
40
+ interface ParsedMethodDecorator {
41
+ classification: MethodClassification;
42
+ resolution: DecoratorResolution;
43
+ decoratorKind: string;
44
+ importedKind?: string;
45
+ }
46
+
47
+ const OPERATION_DECORATORS = new Set(['Action', 'Func', 'On']);
48
+ const EVENT_DECORATORS = new Set(['Event']);
49
+ const LIFECYCLE_DECORATORS = new Map<string, LifecycleMetadata>([
50
+ ['BeforeCreate', { phase: 'before', event: 'CREATE' }],
51
+ ['OnCreate', { phase: 'on', event: 'CREATE' }],
52
+ ['AfterCreate', { phase: 'after', event: 'CREATE' }],
53
+ ['BeforeRead', { phase: 'before', event: 'READ' }],
54
+ ['OnRead', { phase: 'on', event: 'READ' }],
55
+ ['AfterRead', { phase: 'after', event: 'READ' }],
56
+ ['BeforeUpdate', { phase: 'before', event: 'UPDATE' }],
57
+ ['OnUpdate', { phase: 'on', event: 'UPDATE' }],
58
+ ['AfterUpdate', { phase: 'after', event: 'UPDATE' }],
59
+ ['BeforeDelete', { phase: 'before', event: 'DELETE' }],
60
+ ['OnDelete', { phase: 'on', event: 'DELETE' }],
61
+ ['AfterDelete', { phase: 'after', event: 'DELETE' }],
62
+ ]);
15
63
 
16
64
  function line(sf: ts.SourceFile, pos: number): number {
17
65
  return sf.getLineAndCharacterOfPosition(pos).line + 1;
@@ -27,6 +75,15 @@ function firstArg(d: ts.Decorator): ts.Expression | undefined {
27
75
  const e = d.expression;
28
76
  return ts.isCallExpression(e) ? e.arguments[0] : undefined;
29
77
  }
78
+ function decoratorArguments(d: ts.Decorator): readonly ts.Expression[] | undefined {
79
+ return ts.isCallExpression(d.expression) ? d.expression.arguments : undefined;
80
+ }
81
+ function methodName(name: ts.PropertyName): string {
82
+ return ts.isIdentifier(name) || ts.isStringLiteralLike(name)
83
+ || ts.isNumericLiteral(name)
84
+ ? name.text
85
+ : name.getText();
86
+ }
30
87
  function unwrapExpression(expression: ts.Expression): ts.Expression {
31
88
  let current = expression;
32
89
  while (
@@ -77,8 +134,11 @@ function collectStringLookups(source: ts.SourceFile): StringLookups {
77
134
  identifiers: new Map(),
78
135
  enumMembers: new Map(),
79
136
  objectProperties: new Map(),
137
+ capDecoratorNames: new Map(),
138
+ capDecoratorNamespaces: new Set(),
80
139
  };
81
140
  for (const statement of source.statements) {
141
+ collectCapDecoratorImports(statement, lookups);
82
142
  if (ts.isEnumDeclaration(statement)) collectEnumMembers(statement, lookups);
83
143
  if (!ts.isVariableStatement(statement)
84
144
  || !(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
@@ -91,8 +151,62 @@ function collectStringLookups(source: ts.SourceFile): StringLookups {
91
151
  }
92
152
  return lookups;
93
153
  }
94
- function unresolved(rawExpression: string, reason: string): DecoratorResolution {
95
- return { rawExpression, resolutionKind: 'unresolved', unresolvedReason: reason };
154
+ function collectCapDecoratorImports(
155
+ statement: ts.Statement,
156
+ lookups: StringLookups,
157
+ ): void {
158
+ if (!ts.isImportDeclaration(statement)
159
+ || !ts.isStringLiteral(statement.moduleSpecifier)
160
+ || statement.moduleSpecifier.text !== 'cds-routing-handlers') return;
161
+ const clause = statement.importClause;
162
+ if (!clause || clause.isTypeOnly) return;
163
+ const bindings = clause.namedBindings;
164
+ if (bindings && ts.isNamedImports(bindings)) {
165
+ for (const element of bindings.elements) {
166
+ if (element.isTypeOnly) continue;
167
+ lookups.capDecoratorNames.set(
168
+ element.name.text,
169
+ element.propertyName?.text ?? element.name.text,
170
+ );
171
+ }
172
+ }
173
+ if (bindings && ts.isNamespaceImport(bindings))
174
+ lookups.capDecoratorNamespaces.add(bindings.name.text);
175
+ }
176
+ function capDecoratorName(
177
+ decorator: ts.Decorator,
178
+ lookups: StringLookups,
179
+ ): string | undefined {
180
+ const expression = ts.isCallExpression(decorator.expression)
181
+ ? decorator.expression.expression
182
+ : decorator.expression;
183
+ if (ts.isIdentifier(expression))
184
+ return lookups.capDecoratorNames.get(expression.text);
185
+ if (ts.isPropertyAccessExpression(expression)
186
+ && ts.isIdentifier(expression.expression)
187
+ && lookups.capDecoratorNamespaces.has(expression.expression.text))
188
+ return expression.name.text;
189
+ return undefined;
190
+ }
191
+ function unresolved(
192
+ rawExpression: string,
193
+ reason: string,
194
+ argumentExpression?: string,
195
+ ): DecoratorResolution {
196
+ return {
197
+ rawExpression,
198
+ argumentExpression,
199
+ resolutionKind: 'unresolved',
200
+ unresolvedReason: reason,
201
+ };
202
+ }
203
+ function resolved(
204
+ rawExpression: string,
205
+ argumentExpression: string,
206
+ resolvedValue: string,
207
+ resolutionKind: ResolvedArgumentKind,
208
+ ): DecoratorResolution {
209
+ return { rawExpression, argumentExpression, resolvedValue, resolutionKind };
96
210
  }
97
211
  function generatedConstant(rawExpression: string): string | undefined {
98
212
  const match = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/
@@ -104,76 +218,296 @@ function generatedConstant(rawExpression: string): string | undefined {
104
218
  function resolveDecoratorArgument(
105
219
  argument: ts.Expression | undefined,
106
220
  lookups: StringLookups,
221
+ rawExpression: string,
107
222
  ): DecoratorResolution {
108
- if (!argument) return unresolved('', 'decorator_argument_missing');
109
- const rawExpression = argument.getText();
223
+ if (!argument) return unresolved(rawExpression, 'decorator_argument_missing');
224
+ const argumentExpression = argument.getText();
110
225
  const expression = unwrapExpression(argument);
111
226
  if (ts.isStringLiteralLike(expression))
112
- return { rawExpression, resolvedValue: expression.text, resolutionKind: 'literal' };
227
+ return resolved(rawExpression, argumentExpression, expression.text, 'literal');
113
228
  if (ts.isIdentifier(expression)) {
114
229
  const value = lookups.identifiers.get(expression.text);
115
230
  return value === undefined
116
- ? unresolved(rawExpression, 'identifier_not_resolved_to_local_const_string')
117
- : { rawExpression, resolvedValue: value, resolutionKind: 'const_identifier' };
231
+ ? unresolved(rawExpression, 'identifier_not_resolved_to_local_const_string', argumentExpression)
232
+ : resolved(rawExpression, argumentExpression, value, 'const_identifier');
118
233
  }
119
234
  if (ts.isPropertyAccessExpression(expression)
120
235
  && ts.isIdentifier(expression.expression)) {
121
236
  const key = `${expression.expression.text}.${expression.name.text}`;
122
237
  const enumValue = lookups.enumMembers.get(key);
123
238
  if (enumValue !== undefined)
124
- return { rawExpression, resolvedValue: enumValue, resolutionKind: 'enum_member' };
239
+ return resolved(rawExpression, argumentExpression, enumValue, 'enum_member');
125
240
  const objectValue = lookups.objectProperties.get(key);
126
241
  if (objectValue !== undefined)
127
- return { rawExpression, resolvedValue: objectValue, resolutionKind: 'const_object_property' };
242
+ return resolved(rawExpression, argumentExpression, objectValue, 'const_object_property');
128
243
  }
129
- const generatedValue = generatedConstant(rawExpression);
244
+ const generatedValue = generatedConstant(argumentExpression);
130
245
  if (generatedValue !== undefined)
131
- return {
246
+ return resolved(
132
247
  rawExpression,
133
- resolvedValue: generatedValue,
134
- resolutionKind: 'generated_constant_name',
135
- };
248
+ argumentExpression,
249
+ generatedValue,
250
+ 'generated_constant_name',
251
+ );
136
252
  if (ts.isPropertyAccessExpression(expression))
137
- return unresolved(rawExpression, 'property_access_not_resolved_to_local_string');
138
- return unresolved(rawExpression, 'unsupported_decorator_expression');
253
+ return unresolved(
254
+ rawExpression,
255
+ 'property_access_not_resolved_to_local_string',
256
+ argumentExpression,
257
+ );
258
+ return unresolved(rawExpression, 'unsupported_decorator_expression', argumentExpression);
259
+ }
260
+ function classificationFor(name: string): MethodClassification | undefined {
261
+ if (OPERATION_DECORATORS.has(name))
262
+ return { handlerKind: 'operation', executable: true };
263
+ if (EVENT_DECORATORS.has(name))
264
+ return { handlerKind: 'event', executable: true };
265
+ const lifecycle = LIFECYCLE_DECORATORS.get(name);
266
+ if (!lifecycle) return undefined;
267
+ return {
268
+ handlerKind: 'entity_lifecycle',
269
+ executable: true,
270
+ lifecyclePhase: lifecycle.phase,
271
+ lifecycleEvent: lifecycle.event,
272
+ };
273
+ }
274
+ function lifecycleLikePhase(name: string): HandlerLifecyclePhase | undefined {
275
+ if (/^On[A-Z]/.test(name)) return 'on';
276
+ if (/^Before[A-Z]/.test(name)) return 'before';
277
+ if (/^After[A-Z]/.test(name)) return 'after';
278
+ return undefined;
279
+ }
280
+ function withClassification(
281
+ resolution: DecoratorResolution,
282
+ classification: MethodClassification,
283
+ ): DecoratorResolution {
284
+ return {
285
+ ...resolution,
286
+ handlerKind: classification.handlerKind,
287
+ executable: classification.executable,
288
+ lifecyclePhase: classification.lifecyclePhase,
289
+ lifecycleEvent: classification.lifecycleEvent,
290
+ };
291
+ }
292
+ function withDecoratorEvidence(
293
+ resolution: DecoratorResolution,
294
+ decorator: ts.Decorator,
295
+ resolvedDecoratorKind: string | undefined,
296
+ ): DecoratorResolution {
297
+ return {
298
+ ...resolution,
299
+ decoratorExpression: decorator.expression.getText(),
300
+ resolvedDecoratorKind,
301
+ decoratorImportSource: resolvedDecoratorKind
302
+ ? 'cds-routing-handlers'
303
+ : undefined,
304
+ };
305
+ }
306
+ function lifecycleDecoratorResolution(
307
+ decorator: ts.Decorator,
308
+ lookups: StringLookups,
309
+ classification: MethodClassification,
310
+ ): { classification: MethodClassification; resolution: DecoratorResolution } {
311
+ const rawExpression = decorator.expression.getText();
312
+ const args = decoratorArguments(decorator);
313
+ if (args?.length === 0)
314
+ return {
315
+ classification,
316
+ resolution: withClassification({
317
+ rawExpression,
318
+ resolutionKind: 'lifecycle_implicit',
319
+ }, classification),
320
+ };
321
+ const resolution = args?.length === 1
322
+ ? resolveDecoratorArgument(args[0], lookups, rawExpression)
323
+ : unresolved(rawExpression, args ? 'unsupported_lifecycle_argument_count' : 'lifecycle_decorator_call_required');
324
+ const unsupported = { ...classification, handlerKind: 'unsupported_lifecycle', executable: false } as const;
325
+ const unsupportedResolution = args?.length === 1
326
+ ? { ...resolution, unresolvedReason: 'lifecycle_decorator_arguments_not_supported' }
327
+ : resolution;
328
+ return {
329
+ classification: unsupported,
330
+ resolution: withClassification(unsupportedResolution, unsupported),
331
+ };
332
+ }
333
+ function unsupportedLifecycleResolution(
334
+ decorator: ts.Decorator,
335
+ phase: HandlerLifecyclePhase,
336
+ ): { classification: MethodClassification; resolution: DecoratorResolution } {
337
+ const classification: MethodClassification = {
338
+ handlerKind: 'unsupported_lifecycle',
339
+ executable: false,
340
+ lifecyclePhase: phase,
341
+ };
342
+ const resolution = unresolved(
343
+ decorator.expression.getText(),
344
+ 'lifecycle_decorator_not_allowlisted',
345
+ );
346
+ return { classification, resolution: withClassification(resolution, classification) };
347
+ }
348
+ function unsupportedDecoratorResolution(
349
+ decorator: ts.Decorator,
350
+ reason: string,
351
+ phase?: HandlerLifecyclePhase,
352
+ ): { classification: MethodClassification; resolution: DecoratorResolution } {
353
+ const classification: MethodClassification = {
354
+ handlerKind: phase ? 'unsupported_lifecycle' : 'unsupported_decorator',
355
+ executable: false,
356
+ lifecyclePhase: phase,
357
+ };
358
+ return {
359
+ classification,
360
+ resolution: withClassification(
361
+ unresolved(decorator.expression.getText(), reason),
362
+ classification,
363
+ ),
364
+ };
365
+ }
366
+ function parseMethodDecorator(
367
+ decorator: ts.Decorator,
368
+ lookups: StringLookups,
369
+ handlerClass: boolean,
370
+ allowLifecycle: boolean,
371
+ ): ParsedMethodDecorator | undefined {
372
+ const decoratorKind = callName(decorator);
373
+ const importedKind = capDecoratorName(decorator, lookups);
374
+ const resolvedKind = importedKind ?? decoratorKind;
375
+ const base = classificationFor(resolvedKind);
376
+ const phase = lifecycleLikePhase(resolvedKind);
377
+ const isLifecycle = base?.handlerKind === 'entity_lifecycle' || Boolean(phase && !base);
378
+ if (!handlerClass && isLifecycle) return undefined;
379
+ const parsed = isLifecycle && (!allowLifecycle || !importedKind)
380
+ ? unsupportedDecoratorResolution(
381
+ decorator, 'lifecycle_decorator_import_not_supported', phase,
382
+ )
383
+ : base?.handlerKind === 'entity_lifecycle'
384
+ ? lifecycleDecoratorResolution(decorator, lookups, base)
385
+ : phase && !base
386
+ ? unsupportedLifecycleResolution(decorator, phase)
387
+ : !base && handlerClass
388
+ ? unsupportedDecoratorResolution(decorator, 'decorator_not_allowlisted')
389
+ : {
390
+ classification: base,
391
+ resolution: resolveDecoratorArgument(
392
+ firstArg(decorator),
393
+ lookups,
394
+ firstArg(decorator)?.getText() ?? decorator.expression.getText(),
395
+ ),
396
+ };
397
+ if (!parsed.classification) return undefined;
398
+ return {
399
+ classification: parsed.classification,
400
+ resolution: parsed.resolution,
401
+ decoratorKind,
402
+ importedKind,
403
+ };
404
+ }
405
+ function methodDecoratorFact(
406
+ method: ts.MethodDeclaration,
407
+ decorator: ts.Decorator,
408
+ lookups: StringLookups,
409
+ source: ts.SourceFile,
410
+ filePath: string,
411
+ handlerClass: boolean,
412
+ allowLifecycle: boolean,
413
+ ): HandlerMethodFact | undefined {
414
+ const parsed = parseMethodDecorator(decorator, lookups, handlerClass, allowLifecycle);
415
+ if (!parsed) return undefined;
416
+ const classification = method.body
417
+ ? parsed.classification
418
+ : { ...parsed.classification, executable: false };
419
+ const resolution = method.body
420
+ ? parsed.resolution
421
+ : { ...parsed.resolution, unresolvedReason: 'handler_method_body_missing' };
422
+ const argumentExpression = firstArg(decorator)?.getText();
423
+ return {
424
+ methodName: methodName(method.name),
425
+ decoratorKind: parsed.decoratorKind,
426
+ decoratorValue: parsed.resolution.resolvedValue,
427
+ decoratorRawExpression: argumentExpression ?? decorator.expression.getText(),
428
+ handlerKind: classification.handlerKind,
429
+ executable: classification.executable,
430
+ lifecyclePhase: classification.lifecyclePhase,
431
+ lifecycleEvent: classification.lifecycleEvent,
432
+ decoratorResolution: withDecoratorEvidence(
433
+ withClassification(resolution, classification),
434
+ decorator,
435
+ parsed.importedKind,
436
+ ),
437
+ sourceFile: normalizePath(filePath),
438
+ sourceLine: line(source, method.getStart()),
439
+ };
440
+ }
441
+ function unique(values: string[]): string[] {
442
+ return [...new Set(values)];
443
+ }
444
+ function parseClassMethods(
445
+ node: ts.ClassDeclaration,
446
+ lookups: StringLookups,
447
+ source: ts.SourceFile,
448
+ filePath: string,
449
+ handlerClass: boolean,
450
+ allowLifecycle: boolean,
451
+ ): Pick<HandlerClassFact, 'methods' | 'observedDecoratorNames' | 'unsupportedDecoratorNames'> {
452
+ const methods: HandlerMethodFact[] = [];
453
+ const observed: string[] = [];
454
+ const unsupported: string[] = [];
455
+ for (const method of node.members.filter(ts.isMethodDeclaration)) {
456
+ for (const decorator of decs(method)) {
457
+ observed.push(callName(decorator));
458
+ const fact = methodDecoratorFact(
459
+ method, decorator, lookups, source, filePath, handlerClass, allowLifecycle,
460
+ );
461
+ if (fact) methods.push(fact);
462
+ if (!fact?.executable) unsupported.push(callName(decorator));
463
+ }
464
+ }
465
+ return {
466
+ methods,
467
+ observedDecoratorNames: unique(observed),
468
+ unsupportedDecoratorNames: unique(unsupported),
469
+ };
470
+ }
471
+ function parseHandlerClass(
472
+ node: ts.ClassDeclaration,
473
+ lookups: StringLookups,
474
+ source: ts.SourceFile,
475
+ filePath: string,
476
+ ): HandlerClassFact | undefined {
477
+ const classDecoratorNames = unique(decs(node).map(callName));
478
+ const classDecorators = decs(node);
479
+ const hasImportedHandler = classDecorators.some((decorator) =>
480
+ capDecoratorName(decorator, lookups) === 'Handler');
481
+ const hasHandlerDecorator = hasImportedHandler
482
+ || classDecoratorNames.includes('Handler');
483
+ const parsed = parseClassMethods(
484
+ node, lookups, source, filePath, hasHandlerDecorator, hasImportedHandler,
485
+ );
486
+ if (!hasHandlerDecorator && parsed.methods.length === 0) return undefined;
487
+ return {
488
+ className: node.name?.text ?? 'AnonymousHandler',
489
+ sourceFile: normalizePath(filePath),
490
+ sourceLine: line(source, node.getStart()),
491
+ ...parsed,
492
+ hasHandlerDecorator,
493
+ classDecoratorNames,
494
+ };
139
495
  }
140
496
  export async function parseDecorators(
141
497
  repoPath: string,
142
- filePath: string
498
+ filePath: string,
499
+ context?: RepositorySourceContext,
143
500
  ): Promise<HandlerClassFact[]> {
144
- const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
145
- const sf = createSourceFile(filePath, text);
501
+ const snapshot = context?.get(filePath);
502
+ const text = snapshot?.text
503
+ ?? await fs.readFile(path.join(repoPath, filePath), 'utf8');
504
+ const sf = snapshot?.sourceFile() ?? createSourceFile(filePath, text);
146
505
  const lookups = collectStringLookups(sf);
147
506
  const handlers: HandlerClassFact[] = [];
148
507
  function visit(node: ts.Node): void {
149
508
  if (ts.isClassDeclaration(node)) {
150
- const className = node.name?.text ?? 'AnonymousHandler';
151
- const hasHandler = decs(node).some((d) => callName(d) === 'Handler');
152
- const methods = node.members.filter(ts.isMethodDeclaration).flatMap((m) =>
153
- decs(m)
154
- .filter((d) =>
155
- ['Func', 'Action', 'On', 'Event'].includes(callName(d))
156
- )
157
- .map((d) => {
158
- const decoratorResolution = resolveDecoratorArgument(firstArg(d), lookups);
159
- return {
160
- methodName: m.name.getText(),
161
- decoratorKind: callName(d),
162
- decoratorValue: decoratorResolution.resolvedValue,
163
- decoratorRawExpression: decoratorResolution.rawExpression,
164
- decoratorResolution,
165
- sourceFile: normalizePath(filePath),
166
- sourceLine: line(sf, m.getStart())
167
- };
168
- })
169
- );
170
- if (hasHandler || methods.length > 0)
171
- handlers.push({
172
- className,
173
- sourceFile: normalizePath(filePath),
174
- sourceLine: line(sf, node.getStart()),
175
- methods
176
- });
509
+ const handler = parseHandlerClass(node, lookups, sf, filePath);
510
+ if (handler) handlers.push(handler);
177
511
  }
178
512
  ts.forEachChild(node, visit);
179
513
  }