@saptools/service-flow 0.1.50 → 0.1.52

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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -3
  3. package/TECHNICAL-NOTE.md +10 -1
  4. package/dist/{chunk-52OUS3MO.js → chunk-PTLDSHRC.js} +2663 -363
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +318 -510
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +59 -17
  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.ts +97 -57
  13. package/src/db/connection.ts +1 -1
  14. package/src/db/migrations.ts +5 -3
  15. package/src/db/repositories.ts +120 -22
  16. package/src/db/schema.ts +7 -2
  17. package/src/index.ts +1 -1
  18. package/src/indexer/repository-indexer.ts +57 -29
  19. package/src/indexer/workspace-indexer.ts +84 -6
  20. package/src/linker/cross-repo-linker.ts +22 -2
  21. package/src/linker/service-resolver.ts +15 -4
  22. package/src/output/table-output.ts +56 -3
  23. package/src/parsers/cds-parser.ts +8 -2
  24. package/src/parsers/decorator-parser.ts +382 -48
  25. package/src/parsers/handler-registration-parser.ts +38 -21
  26. package/src/parsers/imported-wrapper-parser.ts +18 -5
  27. package/src/parsers/outbound-call-parser.ts +25 -8
  28. package/src/parsers/package-json-parser.ts +36 -11
  29. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  30. package/src/parsers/service-binding-parser.ts +6 -3
  31. package/src/parsers/symbol-parser.ts +13 -3
  32. package/src/parsers/ts-project.ts +54 -0
  33. package/src/trace/000-dynamic-target-types.ts +84 -0
  34. package/src/trace/001-dynamic-identity.ts +280 -0
  35. package/src/trace/002-trace-diagnostics.ts +54 -0
  36. package/src/trace/003-dynamic-references.ts +82 -0
  37. package/src/trace/dynamic-branches.ts +45 -0
  38. package/src/trace/dynamic-targets.ts +654 -0
  39. package/src/trace/evidence.ts +391 -22
  40. package/src/trace/selectors.ts +483 -0
  41. package/src/trace/trace-engine.ts +101 -94
  42. package/src/types.ts +39 -1
  43. package/dist/chunk-52OUS3MO.js.map +0 -1
@@ -24,12 +24,36 @@ 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[] {
54
+ const dynamicLines = dynamicHintLines(evidence);
31
55
  const suggestions = evidence.implementationHintSuggestions;
32
- if (!Array.isArray(suggestions)) return [];
56
+ if (!Array.isArray(suggestions)) return dynamicLines;
33
57
  const hints = suggestions.flatMap((item) =>
34
58
  isRecord(item) && typeof item.cli === 'string'
35
59
  ? [item.cli]
@@ -38,9 +62,38 @@ function hintLines(evidence: Record<string, unknown>): string[] {
38
62
  const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
39
63
  if (unique.length > shown.length)
40
64
  shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
41
- return shown;
65
+ return [...dynamicLines, ...shown];
66
+ }
67
+
68
+ function dynamicHintLines(evidence: Record<string, unknown>): string[] {
69
+ const exploration = isRecord(evidence.dynamicTargetExploration)
70
+ ? evidence.dynamicTargetExploration
71
+ : evidence;
72
+ const count = numberValue(exploration.candidateCount);
73
+ if (count === 0) return [];
74
+ const shown = numberValue(exploration.shownCandidateCount);
75
+ const omitted = numberValue(exploration.omittedCandidateCount);
76
+ const rejected = numberValue(exploration.rejectedCandidateCount);
77
+ const lines = [
78
+ `viable candidates: ${shown} shown, ${omitted} omitted; rejected: ${rejected}`,
79
+ ];
80
+ lines.push(...varSetHints(exploration.suggestedVarSets));
81
+ if (omitted > 0 || rejected > 0 || shown < count)
82
+ lines.push('use --dynamic-mode candidates --max-dynamic-candidates 20 to explore candidate branches');
83
+ return lines;
84
+ }
85
+
86
+ function varSetHints(value: unknown): string[] {
87
+ if (!Array.isArray(value)) return [];
88
+ const hints = value.flatMap((item) =>
89
+ isRecord(item) && typeof item.cli === 'string' ? [`try ${item.cli}`] : []);
90
+ return [...new Set(hints)].slice(0, 3);
42
91
  }
43
92
 
44
93
  function isRecord(value: unknown): value is Record<string, unknown> {
45
94
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
46
95
  }
96
+
97
+ function numberValue(value: unknown): number {
98
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
99
+ }
@@ -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
  }