@saptools/service-flow 0.1.51 → 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 (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +10 -5
  3. package/TECHNICAL-NOTE.md +8 -4
  4. package/dist/{chunk-YZJKE5UX.js → chunk-PTLDSHRC.js} +2412 -553
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +280 -506
  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.ts +75 -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/indexer/repository-indexer.ts +57 -29
  18. package/src/indexer/workspace-indexer.ts +84 -6
  19. package/src/linker/cross-repo-linker.ts +13 -1
  20. package/src/output/table-output.ts +29 -3
  21. package/src/parsers/cds-parser.ts +8 -2
  22. package/src/parsers/decorator-parser.ts +382 -48
  23. package/src/parsers/handler-registration-parser.ts +38 -21
  24. package/src/parsers/imported-wrapper-parser.ts +18 -5
  25. package/src/parsers/outbound-call-parser.ts +25 -8
  26. package/src/parsers/package-json-parser.ts +36 -11
  27. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  28. package/src/parsers/service-binding-parser.ts +6 -3
  29. package/src/parsers/symbol-parser.ts +13 -3
  30. package/src/parsers/ts-project.ts +54 -0
  31. package/src/trace/000-dynamic-target-types.ts +84 -0
  32. package/src/trace/001-dynamic-identity.ts +280 -0
  33. package/src/trace/002-trace-diagnostics.ts +54 -0
  34. package/src/trace/003-dynamic-references.ts +82 -0
  35. package/src/trace/dynamic-targets.ts +459 -229
  36. package/src/trace/evidence.ts +305 -46
  37. package/src/trace/selectors.ts +483 -0
  38. package/src/trace/trace-engine.ts +90 -83
  39. package/src/types.ts +27 -1
  40. package/dist/chunk-YZJKE5UX.js.map +0 -1
@@ -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
  }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import ts from 'typescript';
5
5
  import type { HandlerRegistrationFact } from '../types.js';
6
6
  import { normalizePath } from '../utils/path-utils.js';
7
+ import type { RepositorySourceContext } from './ts-project.js';
7
8
 
8
9
  interface ImportEvidence { importedName: string; source: string }
9
10
  interface ClassEvidence { className: string; importSource?: string }
@@ -26,16 +27,26 @@ function importSourceFor(identifier: string, imports: Map<string, ImportEvidence
26
27
  return evidence ? `${evidence.source}#${evidence.importedName}` : undefined;
27
28
  }
28
29
 
29
- export async function parseHandlerRegistrations(repoPath: string, filePath: string): Promise<HandlerRegistrationFact[]> {
30
+ export async function parseHandlerRegistrations(
31
+ repoPath: string,
32
+ filePath: string,
33
+ context?: RepositorySourceContext,
34
+ ): Promise<HandlerRegistrationFact[]> {
30
35
  const absolutePath = path.join(repoPath, filePath);
31
- const text = await fs.readFile(absolutePath, 'utf8');
32
- const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
36
+ const snapshot = context?.get(filePath);
37
+ const text = snapshot?.text ?? await fs.readFile(absolutePath, 'utf8');
38
+ const sourceFile = snapshot?.sourceFile() ?? ts.createSourceFile(
39
+ filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS,
40
+ );
33
41
  const imports = collectImports(sourceFile);
34
- const localArrays = collectLocalArrays(sourceFile, imports, new Map(), repoPath, filePath);
42
+ const localArrays = collectLocalArrays(
43
+ sourceFile, imports, new Map(), repoPath, filePath, context,
44
+ );
35
45
  const out: HandlerRegistrationFact[] = [];
36
-
37
46
  function emitFromExpression(expression: ts.Expression, call: ts.CallExpression): void {
38
- const classes = resolveArrayExpression(expression, localArrays, imports, repoPath, filePath, new Set());
47
+ const classes = resolveArrayExpression(
48
+ expression, localArrays, imports, repoPath, filePath, new Set(), context,
49
+ );
39
50
  for (const cls of classes) {
40
51
  out.push({
41
52
  className: cls.className,
@@ -55,7 +66,6 @@ export async function parseHandlerRegistrations(repoPath: string, filePath: stri
55
66
  });
56
67
  }
57
68
  }
58
-
59
69
  function visit(node: ts.Node): void {
60
70
  if (ts.isCallExpression(node) && isRegistrationCall(node)) {
61
71
  const handlerExpr = handlerExpression(node, sourceFile);
@@ -98,45 +108,47 @@ function collectImports(sourceFile: ts.SourceFile): Map<string, ImportEvidence>
98
108
  }
99
109
  return imports;
100
110
  }
101
- function collectLocalArrays(sourceFile: ts.SourceFile, imports: Map<string, ImportEvidence>, seed: Map<string, ClassEvidence[]>, repoPath = '', fromFile = ''): Map<string, ClassEvidence[]> {
111
+ function collectLocalArrays(sourceFile: ts.SourceFile, imports: Map<string, ImportEvidence>, seed: Map<string, ClassEvidence[]>, repoPath = '', fromFile = '', context?: RepositorySourceContext): Map<string, ClassEvidence[]> {
102
112
  const arrays = new Map(seed);
103
113
  for (const statement of sourceFile.statements) {
104
114
  if (ts.isVariableStatement(statement)) {
105
115
  for (const decl of statement.declarationList.declarations) {
106
116
  if (ts.isIdentifier(decl.name) && decl.initializer && ts.isArrayLiteralExpression(decl.initializer)) {
107
- arrays.set(decl.name.text, resolveArrayLiteral(decl.initializer, arrays, imports, repoPath, fromFile, new Set()));
117
+ arrays.set(decl.name.text, resolveArrayLiteral(
118
+ decl.initializer, arrays, imports, repoPath, fromFile, new Set(), context,
119
+ ));
108
120
  }
109
121
  }
110
122
  }
111
123
  }
112
124
  return arrays;
113
125
  }
114
- function resolveArrayExpression(expr: ts.Expression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>): ClassEvidence[] {
115
- if (ts.isArrayLiteralExpression(expr)) return resolveArrayLiteral(expr, arrays, imports, repoPath, fromFile, seen);
126
+ function resolveArrayExpression(expr: ts.Expression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>, context?: RepositorySourceContext): ClassEvidence[] {
127
+ if (ts.isArrayLiteralExpression(expr)) return resolveArrayLiteral(expr, arrays, imports, repoPath, fromFile, seen, context);
116
128
  if (ts.isIdentifier(expr)) {
117
129
  const local = arrays.get(expr.text);
118
130
  if (local) return local;
119
131
  const evidence = imports.get(expr.text);
120
- if (evidence && isRelative(evidence.source)) return resolveImportedArray(repoPath, fromFile, evidence, seen);
132
+ if (evidence && isRelative(evidence.source)) return resolveImportedArray(repoPath, fromFile, evidence, seen, context);
121
133
  if (evidence) return [{ className: evidence.importedName === 'default' ? expr.text : evidence.importedName, importSource: `${evidence.source}#${evidence.importedName}` }];
122
134
  }
123
135
  return [];
124
136
  }
125
- function resolveArrayLiteral(array: ts.ArrayLiteralExpression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>): ClassEvidence[] {
137
+ function resolveArrayLiteral(array: ts.ArrayLiteralExpression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>, context?: RepositorySourceContext): ClassEvidence[] {
126
138
  const out: ClassEvidence[] = [];
127
139
  for (const element of array.elements) {
128
- if (ts.isSpreadElement(element)) out.push(...resolveArrayExpression(element.expression, arrays, imports, repoPath, fromFile, seen));
140
+ if (ts.isSpreadElement(element)) out.push(...resolveArrayExpression(element.expression, arrays, imports, repoPath, fromFile, seen, context));
129
141
  else if (ts.isIdentifier(element)) out.push({ className: element.text, importSource: importSourceFor(element.text, imports) });
130
142
  }
131
143
  return out;
132
144
  }
133
- function resolveImportedArray(repoPath: string, fromFile: string, evidence: ImportEvidence, seen: Set<string>): ClassEvidence[] {
145
+ function resolveImportedArray(repoPath: string, fromFile: string, evidence: ImportEvidence, seen: Set<string>, context?: RepositorySourceContext): ClassEvidence[] {
134
146
  const moduleFile = resolveRelativeModule(repoPath, fromFile, evidence.source);
135
147
  if (!moduleFile) return [];
136
148
  const key = `${moduleFile}:${evidence.importedName}`;
137
149
  if (seen.has(key) || seen.size > MAX_EXPORT_DEPTH) return [];
138
150
  seen.add(key);
139
- const exports = readExports(repoPath, moduleFile, seen);
151
+ const exports = readExports(repoPath, moduleFile, seen, context);
140
152
  if (evidence.importedName === 'default') return exports.defaultArray ?? [];
141
153
  return exports.arrays.get(evidence.importedName) ?? exports.arrays.get(exports.aliases.get(evidence.importedName) ?? evidence.importedName) ?? [];
142
154
  }
@@ -150,13 +162,16 @@ function resolveRelativeModule(repoPath: string, fromFile: string, specifier: st
150
162
  }
151
163
  return undefined;
152
164
  }
153
- function readExports(repoPath: string, filePath: string, seen: Set<string>): FileExports {
165
+ function readExports(repoPath: string, filePath: string, seen: Set<string>, context?: RepositorySourceContext): FileExports {
154
166
  const absolute = path.join(repoPath, filePath);
155
167
  let text: string;
156
- try { text = fsSync.readFileSync(absolute, 'utf8'); } catch { return { arrays: new Map(), aliases: new Map() }; }
157
- const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
168
+ const snapshot = context?.get(filePath);
169
+ try { text = snapshot?.text ?? fsSync.readFileSync(absolute, 'utf8'); } catch { return { arrays: new Map(), aliases: new Map() }; }
170
+ const sourceFile = snapshot?.sourceFile() ?? ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
158
171
  const imports = collectImports(sourceFile);
159
- const arrays = collectLocalArrays(sourceFile, imports, new Map(), repoPath, filePath);
172
+ const arrays = collectLocalArrays(
173
+ sourceFile, imports, new Map(), repoPath, filePath, context,
174
+ );
160
175
  const aliases = new Map<string, string>();
161
176
  let defaultArray: ClassEvidence[] | undefined;
162
177
  for (const statement of sourceFile.statements) {
@@ -167,7 +182,9 @@ function readExports(repoPath: string, filePath: string, seen: Set<string>): Fil
167
182
  const local = element.propertyName?.text ?? element.name.text;
168
183
  aliases.set(element.name.text, local);
169
184
  if (module && isRelative(module)) {
170
- const imported = resolveImportedArray(repoPath, filePath, { source: module, importedName: local }, seen);
185
+ const imported = resolveImportedArray(
186
+ repoPath, filePath, { source: module, importedName: local }, seen, context,
187
+ );
171
188
  if (imported.length > 0) arrays.set(element.name.text, imported);
172
189
  }
173
190
  }