@saptools/service-flow 0.1.70 → 0.1.73

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 (61) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +23 -6
  3. package/TECHNICAL-NOTE.md +24 -2
  4. package/dist/{chunk-GSLFY6J2.js → chunk-32WOTGTS.js} +12061 -9288
  5. package/dist/chunk-32WOTGTS.js.map +1 -0
  6. package/dist/cli.js +854 -161
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +54 -14
  9. package/dist/index.js +2 -23
  10. package/dist/index.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/cli/doctor-event-quality.ts +446 -0
  13. package/src/cli/doctor.ts +4 -6
  14. package/src/cli.ts +35 -13
  15. package/src/config/workspace-config.ts +15 -0
  16. package/src/db/call-fact-repository.ts +6 -3
  17. package/src/db/current-fact-semantics.ts +5 -29
  18. package/src/db/event-fact-semantics.ts +546 -0
  19. package/src/db/event-site-semantics.ts +62 -0
  20. package/src/db/event-surface-invalidation.ts +38 -0
  21. package/src/db/fact-json-inventory.ts +16 -0
  22. package/src/db/fact-lifecycle.ts +70 -12
  23. package/src/db/migrations.ts +15 -1
  24. package/src/db/package-target-invalidation.ts +80 -0
  25. package/src/db/repositories.ts +28 -2
  26. package/src/db/schema-structure.ts +41 -1
  27. package/src/db/schema.ts +6 -2
  28. package/src/indexer/repository-indexer.ts +93 -10
  29. package/src/indexer/workspace-indexer.ts +13 -3
  30. package/src/linker/cross-repo-linker.ts +27 -3
  31. package/src/linker/event-environment-link.ts +211 -0
  32. package/src/linker/event-shape-candidate-linker.ts +204 -0
  33. package/src/linker/event-subscription-handler-linker.ts +220 -25
  34. package/src/linker/event-template-link.ts +40 -6
  35. package/src/linker/package-event-constant-resolver.ts +302 -0
  36. package/src/output/repository-inspection.ts +11 -0
  37. package/src/output/table-output.ts +13 -1
  38. package/src/parsers/decorator-parser.ts +9 -53
  39. package/src/parsers/environment-declarations.ts +404 -0
  40. package/src/parsers/event-call-analysis.ts +370 -0
  41. package/src/parsers/event-environment-reference.ts +242 -0
  42. package/src/parsers/event-loop-registration.ts +132 -0
  43. package/src/parsers/event-name-import-resolution.ts +243 -0
  44. package/src/parsers/event-receiver-analysis.ts +394 -0
  45. package/src/parsers/event-subscription-facts.ts +4 -0
  46. package/src/parsers/generated-constants-parser.ts +80 -14
  47. package/src/parsers/outbound-call-classifier.ts +27 -124
  48. package/src/parsers/outbound-call-parser.ts +13 -1
  49. package/src/parsers/outbound-expression-analysis.ts +2 -2
  50. package/src/parsers/stable-local-value.ts +42 -10
  51. package/src/parsers/string-constant-lookups.ts +408 -0
  52. package/src/trace/edge-target.ts +65 -0
  53. package/src/trace/event-runtime-resolution.ts +24 -3
  54. package/src/trace/event-shape-candidate-trace.ts +172 -0
  55. package/src/trace/event-subscriber-traversal.ts +90 -8
  56. package/src/trace/evidence.ts +7 -28
  57. package/src/trace/trace-scope-execution.ts +22 -30
  58. package/src/types.ts +19 -1
  59. package/src/utils/event-skeleton.ts +207 -0
  60. package/src/version.ts +1 -1
  61. package/dist/chunk-GSLFY6J2.js.map +0 -1
@@ -0,0 +1,370 @@
1
+ import ts from 'typescript';
2
+ import type { OutboundCallFact } from '../types.js';
3
+ import {
4
+ CDS_LIFECYCLE_EVENTS,
5
+ resolveExpression,
6
+ type ExpressionResolution,
7
+ } from './outbound-expression-analysis.js';
8
+ import {
9
+ createEventReceiverIndex,
10
+ proveEventReceiver,
11
+ type EventReceiverIndex,
12
+ type EventReceiverProof,
13
+ } from './event-receiver-analysis.js';
14
+ import {
15
+ collectStringConstantLookups,
16
+ resolveStringConstant,
17
+ type StaticStringConstant,
18
+ type StaticStringLookupResult,
19
+ type StringConstantLookups,
20
+ } from './string-constant-lookups.js';
21
+ import type {
22
+ ImportedEventNameResult,
23
+ ImportedEventNameResolver,
24
+ } from './event-name-import-resolution.js';
25
+ import type {
26
+ SymbolImportReference,
27
+ } from './symbol-import-bindings.js';
28
+ import {
29
+ deriveEventSkeleton,
30
+ type EventSkeletonFact,
31
+ } from '../utils/event-skeleton.js';
32
+ import type {
33
+ EventEnvironmentReferenceResolver,
34
+ } from './event-environment-reference.js';
35
+
36
+ type EventFact = Pick<OutboundCallFact,
37
+ 'callType' | 'serviceVariableName' | 'eventNameExpr'
38
+ | 'eventSkeleton' | 'confidence' | 'unresolvedReason'>;
39
+
40
+ export interface EventCallAnalysisContext {
41
+ source: ts.SourceFile;
42
+ receivers: EventReceiverIndex;
43
+ constants: StringConstantLookups;
44
+ importedConstant?: ImportedEventNameResolver;
45
+ environmentReference?: EventEnvironmentReferenceResolver;
46
+ }
47
+
48
+ export type EventCallAnalysis =
49
+ | { status: 'classified'; fact: EventFact; evidence: Record<string, unknown> }
50
+ | { status: 'excluded' }
51
+ | { status: 'unclassified' };
52
+
53
+ interface EventNameState {
54
+ eventName: string;
55
+ resolved: ExpressionResolution;
56
+ unresolvedReason?: string;
57
+ constant?: StaticStringConstant;
58
+ foldedConstants?: StaticStringConstant[];
59
+ packageImportReference?: SymbolImportReference;
60
+ }
61
+
62
+ const CAP_CRUD_EVENTS = new Set(['READ', 'CREATE', 'UPDATE', 'DELETE']);
63
+ const KNOWN_NON_CAP_EVENT_RECEIVERS = new Set([
64
+ 'io', 'socket', 'writeStream', 'file', 'win', 'app',
65
+ ]);
66
+
67
+ function eventNameReason(
68
+ resolved: ExpressionResolution,
69
+ ): string | undefined {
70
+ if (resolved.status === 'static') return undefined;
71
+ return resolved.value !== undefined && resolved.placeholderKeys.length > 0
72
+ ? 'dynamic_event_name_identifier'
73
+ : 'dynamic_event_name_unsupported_expression';
74
+ }
75
+
76
+ function stringConstantLookup(
77
+ expression: ts.Expression,
78
+ context: EventCallAnalysisContext,
79
+ ): ImportedEventNameResult | StaticStringLookupResult {
80
+ const local = resolveStringConstant(expression, context.constants);
81
+ return local.status === 'not_found'
82
+ ? context.importedConstant?.(expression) ?? local : local;
83
+ }
84
+
85
+ function foldedTemplateState(
86
+ expression: ts.TemplateExpression,
87
+ context: EventCallAnalysisContext,
88
+ ): EventNameState | undefined {
89
+ let eventName = expression.head.text;
90
+ const placeholderKeys: string[] = [];
91
+ const constants: StaticStringConstant[] = [];
92
+ for (const span of expression.templateSpans) {
93
+ const key = span.expression.getText(expression.getSourceFile()).trim();
94
+ const lookup = stringConstantLookup(span.expression, context);
95
+ if (lookup.status === 'resolved') {
96
+ eventName += lookup.constant.value;
97
+ constants.push(lookup.constant);
98
+ } else {
99
+ eventName += `\${${key}}`;
100
+ placeholderKeys.push(key);
101
+ }
102
+ eventName += span.literal.text;
103
+ }
104
+ if (constants.length === 0) return undefined;
105
+ const empty = eventName.length === 0;
106
+ return {
107
+ eventName: empty ? expression.getText(expression.getSourceFile()) : eventName,
108
+ resolved: {
109
+ status: empty ? 'dynamic'
110
+ : placeholderKeys.length > 0 ? 'dynamic' : 'static',
111
+ sourceKind: 'template_with_substitutions',
112
+ value: empty ? undefined : eventName,
113
+ rawExpression: expression.getText(expression.getSourceFile()),
114
+ placeholderKeys,
115
+ evidence: ['event_name_template_static_holes_folded'],
116
+ },
117
+ unresolvedReason: empty
118
+ ? 'event_name_constant_value_empty'
119
+ : placeholderKeys.length > 0
120
+ ? 'dynamic_event_name_identifier' : undefined,
121
+ foldedConstants: constants,
122
+ };
123
+ }
124
+
125
+ function resolvedConstantState(
126
+ expression: ts.Expression,
127
+ constant: StaticStringConstant,
128
+ ): EventNameState {
129
+ const empty = constant.value.length === 0;
130
+ return {
131
+ eventName: empty ? expression.getText(expression.getSourceFile())
132
+ : constant.value,
133
+ resolved: {
134
+ status: empty ? 'dynamic' : 'static',
135
+ sourceKind: constant.kind,
136
+ value: empty ? undefined : constant.value,
137
+ rawExpression: expression.getText(expression.getSourceFile()),
138
+ placeholderKeys: [],
139
+ evidence: [`event_name_${constant.kind}`],
140
+ },
141
+ unresolvedReason: empty ? 'event_name_constant_value_empty' : undefined,
142
+ constant,
143
+ };
144
+ }
145
+
146
+ function refusedConstantState(
147
+ expression: ts.Expression,
148
+ lookup: ImportedEventNameResult | StaticStringLookupResult,
149
+ ): EventNameState | undefined {
150
+ if (lookup.status !== 'refused') return undefined;
151
+ const raw = expression.getText(expression.getSourceFile());
152
+ return {
153
+ eventName: raw,
154
+ resolved: {
155
+ status: 'dynamic',
156
+ sourceKind: 'dynamic_expression',
157
+ rawExpression: raw,
158
+ placeholderKeys: [],
159
+ evidence: [lookup.reason],
160
+ },
161
+ unresolvedReason: lookup.reason,
162
+ packageImportReference: packageReference(lookup),
163
+ };
164
+ }
165
+
166
+ function eventNameState(
167
+ node: ts.CallExpression,
168
+ context: EventCallAnalysisContext,
169
+ ): EventNameState | undefined {
170
+ const expression = node.arguments[0];
171
+ if (expression) {
172
+ if (ts.isTemplateExpression(expression)) {
173
+ const folded = foldedTemplateState(expression, context);
174
+ if (folded) return folded;
175
+ }
176
+ const lookup = stringConstantLookup(expression, context);
177
+ if (lookup.status === 'resolved')
178
+ return resolvedConstantState(expression, lookup.constant);
179
+ const refused = refusedConstantState(expression, lookup);
180
+ if (refused) return refused;
181
+ }
182
+ const resolved = resolveExpression(expression, node, 'operation_path');
183
+ const eventName = resolved.value
184
+ ?? resolved.rawExpression
185
+ ?? expression?.getText(node.getSourceFile());
186
+ return eventName ? {
187
+ eventName,
188
+ resolved,
189
+ unresolvedReason: eventNameReason(resolved),
190
+ } : undefined;
191
+ }
192
+
193
+ function packageReference(
194
+ value: ImportedEventNameResult | StaticStringLookupResult,
195
+ ): SymbolImportReference | undefined {
196
+ return 'packageImportReference' in value
197
+ ? value.packageImportReference : undefined;
198
+ }
199
+
200
+ function eventConfidence(
201
+ receiver: EventReceiverProof,
202
+ eventReason: string | undefined,
203
+ ): number {
204
+ const receiverConfidence = receiver.receiverClassification === 'unproven'
205
+ ? 0.2 : receiver.receiverClassification === 'name_fallback' ? 0.5 : 0.8;
206
+ const nameConfidence = !eventReason
207
+ ? 0.8 : eventReason === 'dynamic_event_name_identifier' ? 0.6 : 0.3;
208
+ return Math.min(receiverConfidence, nameConfidence);
209
+ }
210
+
211
+ function excludedEvent(
212
+ method: string,
213
+ receiver: EventReceiverProof,
214
+ state: EventNameState,
215
+ ): boolean {
216
+ if (state.resolved.status !== 'static') return false;
217
+ if (receiver.effectiveReceiver === 'cds'
218
+ && CDS_LIFECYCLE_EVENTS.has(state.eventName)) return true;
219
+ return method === 'on'
220
+ && (state.eventName === 'error'
221
+ || (CAP_CRUD_EVENTS.has(state.eventName)
222
+ && capCrudReceiver(receiver)));
223
+ }
224
+
225
+ function capCrudReceiver(receiver: EventReceiverProof): boolean {
226
+ const name = receiver.rootReceiver ?? receiver.effectiveReceiver;
227
+ return name === 'this'
228
+ || ['cds', 'srv', 'service', 'serviceClient'].includes(name);
229
+ }
230
+
231
+ function receiverRootName(expression: ts.Expression): string | undefined {
232
+ if (ts.isIdentifier(expression)) return expression.text;
233
+ if (ts.isPropertyAccessExpression(expression)
234
+ || ts.isElementAccessExpression(expression))
235
+ return receiverRootName(expression.expression);
236
+ if (ts.isCallExpression(expression))
237
+ return receiverRootName(expression.expression);
238
+ return undefined;
239
+ }
240
+
241
+ function pipeReceiver(expression: ts.Expression): boolean {
242
+ return ts.isCallExpression(expression)
243
+ && ts.isPropertyAccessExpression(expression.expression)
244
+ && expression.expression.name.text === 'pipe';
245
+ }
246
+
247
+ function knownNonCapReceiver(
248
+ expression: ts.Expression,
249
+ receiver: EventReceiverProof,
250
+ ): boolean {
251
+ if (receiver.receiverClassification !== 'unproven') return false;
252
+ const root = receiverRootName(expression);
253
+ return Boolean(root && KNOWN_NON_CAP_EVENT_RECEIVERS.has(root))
254
+ || pipeReceiver(expression);
255
+ }
256
+
257
+ function eventEvidence(
258
+ method: string,
259
+ receiver: EventReceiverProof,
260
+ state: EventNameState,
261
+ ): Record<string, unknown> {
262
+ return {
263
+ receiver: receiver.receiver,
264
+ rootReceiver: receiver.rootReceiver,
265
+ classifier: method === 'on'
266
+ ? 'cap_service_event_subscription' : 'cap_service_event_emit',
267
+ receiverClassification: receiver.receiverClassification,
268
+ receiverProof: receiver.receiverProof,
269
+ receiverUnresolvedReason: receiver.unresolvedReason,
270
+ receiverFallbackRefusedReason: receiver.fallbackRefusedReason,
271
+ consideredBindingSites: receiver.consideredBindingSites,
272
+ eventNameUnresolvedReason: state.unresolvedReason,
273
+ eventNameConstantImportBinding: state.packageImportReference,
274
+ eventNameConstantSourceExpression: state.packageImportReference
275
+ ? state.eventName : undefined,
276
+ ...(state.constant ? {
277
+ eventNameConstant: {
278
+ sourceKind: state.constant.kind,
279
+ sourceFile: state.constant.sourceFile,
280
+ declarationStartOffset: state.constant.declarationStartOffset,
281
+ declarationEndOffset: state.constant.declarationEndOffset,
282
+ },
283
+ } : {}),
284
+ ...(state.foldedConstants ? {
285
+ eventNameTemplateFoldedConstants: state.foldedConstants.slice(0, 8)
286
+ .map((constant) => ({
287
+ sourceKind: constant.kind,
288
+ sourceFile: constant.sourceFile,
289
+ declarationStartOffset: constant.declarationStartOffset,
290
+ declarationEndOffset: constant.declarationEndOffset,
291
+ })),
292
+ eventNameTemplateFoldedConstantCount: state.foldedConstants.length,
293
+ } : {}),
294
+ ...(state.resolved.status === 'static' ? {} : {
295
+ eventNameStatus: state.resolved.status,
296
+ eventNameSourceKind: state.resolved.sourceKind,
297
+ eventNamePlaceholderKeys: state.resolved.placeholderKeys,
298
+ }),
299
+ };
300
+ }
301
+
302
+ export function createEventCallAnalysisContext(
303
+ source: ts.SourceFile,
304
+ importedConstant?: ImportedEventNameResolver,
305
+ environmentReference?: EventEnvironmentReferenceResolver,
306
+ ): EventCallAnalysisContext {
307
+ return {
308
+ source,
309
+ receivers: createEventReceiverIndex(source),
310
+ constants: collectStringConstantLookups(source),
311
+ importedConstant,
312
+ environmentReference,
313
+ };
314
+ }
315
+
316
+ function eventSkeleton(
317
+ node: ts.CallExpression,
318
+ eventName: string,
319
+ resolver: EventEnvironmentReferenceResolver | undefined,
320
+ ): EventSkeletonFact | undefined {
321
+ const expression = node.arguments[0];
322
+ if (!expression) return undefined;
323
+ if (!ts.isTemplateExpression(expression))
324
+ return eventName.includes('${')
325
+ ? deriveEventSkeleton(eventName) : undefined;
326
+ const skeleton = deriveEventSkeleton(eventName);
327
+ if (!skeleton || !resolver) return skeleton;
328
+ return {
329
+ ...skeleton,
330
+ environmentBindings: expression.templateSpans.flatMap((span) => {
331
+ const reference = resolver(span.expression);
332
+ return reference ? [{
333
+ ...reference,
334
+ sourceKey: span.expression.getText(node.getSourceFile()).trim(),
335
+ }] : [];
336
+ }),
337
+ };
338
+ }
339
+
340
+ export function analyzeEventCall(
341
+ node: ts.CallExpression,
342
+ expression: ts.PropertyAccessExpression,
343
+ context: EventCallAnalysisContext,
344
+ ): EventCallAnalysis {
345
+ const method = expression.name.text;
346
+ const state = eventNameState(node, context);
347
+ if (!state) return { status: 'unclassified' };
348
+ const receiver = proveEventReceiver(
349
+ expression.expression, node, context.receivers,
350
+ );
351
+ if (receiver.unresolvedReason === 'event_receiver_not_cap_client'
352
+ || receiver.receiverProof === 'binding_not_found'
353
+ || knownNonCapReceiver(expression.expression, receiver))
354
+ return { status: 'excluded' };
355
+ if (excludedEvent(method, receiver, state)) return { status: 'excluded' };
356
+ return {
357
+ status: 'classified',
358
+ fact: {
359
+ callType: method === 'on' ? 'async_subscribe' : 'async_emit',
360
+ serviceVariableName: receiver.effectiveReceiver,
361
+ eventNameExpr: state.eventName,
362
+ eventSkeleton: eventSkeleton(
363
+ node, state.eventName, context.environmentReference,
364
+ ),
365
+ confidence: eventConfidence(receiver, state.unresolvedReason),
366
+ unresolvedReason: state.unresolvedReason,
367
+ },
368
+ evidence: eventEvidence(method, receiver, state),
369
+ };
370
+ }
@@ -0,0 +1,242 @@
1
+ import { posix } from 'node:path';
2
+ import ts from 'typescript';
3
+ import { normalizePath } from '../utils/path-utils.js';
4
+ import { resolveBinding } from './query-entity-resolution.js';
5
+ import {
6
+ collectSymbolImportBindings,
7
+ lexicalIdentifierDeclaration,
8
+ type SymbolImportBinding,
9
+ } from './symbol-import-bindings.js';
10
+ import type { RepositorySourceContext } from './ts-project.js';
11
+ import {
12
+ DEFAULT_EVENT_ENVIRONMENT_KEYS,
13
+ normalizeEventEnvironmentKeys,
14
+ } from
15
+ './environment-declarations.js';
16
+
17
+ export type EventEnvironmentTransform = 'toUpperCase' | 'toLowerCase';
18
+
19
+ export interface EventEnvironmentReference {
20
+ status: 'resolved' | 'refused';
21
+ sourceKey: string;
22
+ environmentKey?: string;
23
+ transforms: EventEnvironmentTransform[];
24
+ sourceFile?: string;
25
+ startOffset?: number;
26
+ endOffset?: number;
27
+ reason?: string;
28
+ }
29
+
30
+ export type EventEnvironmentReferenceResolver = (
31
+ expression: ts.Expression,
32
+ ) => EventEnvironmentReference | undefined;
33
+
34
+ interface ResolutionContext {
35
+ sources: RepositorySourceContext;
36
+ source: ts.SourceFile;
37
+ sourceFile: string;
38
+ depth: number;
39
+ seen: Set<string>;
40
+ allowedKeys: ReadonlySet<string>;
41
+ }
42
+
43
+ const maxEnvironmentReferenceDepth = 6;
44
+ function unwrapExpression(expression: ts.Expression): ts.Expression {
45
+ if (ts.isParenthesizedExpression(expression)
46
+ || ts.isAsExpression(expression)
47
+ || ts.isSatisfiesExpression(expression)
48
+ || ts.isTypeAssertionExpression(expression)
49
+ || ts.isNonNullExpression(expression))
50
+ return unwrapExpression(expression.expression);
51
+ return expression;
52
+ }
53
+
54
+ function processEnvironmentKey(
55
+ expression: ts.Expression,
56
+ ): string | undefined {
57
+ if (!ts.isPropertyAccessExpression(expression)
58
+ || !ts.isPropertyAccessExpression(expression.expression)
59
+ || !ts.isIdentifier(expression.expression.expression)
60
+ || expression.expression.expression.text !== 'process'
61
+ || expression.expression.name.text !== 'env'
62
+ || lexicalIdentifierDeclaration(expression.expression.expression))
63
+ return undefined;
64
+ return expression.name.text;
65
+ }
66
+
67
+ function relativeCandidates(
68
+ callerFile: string,
69
+ specifier: string,
70
+ ): string[] {
71
+ const base = normalizePath(posix.normalize(
72
+ posix.join(posix.dirname(callerFile), specifier),
73
+ ));
74
+ return /\.[jt]s$/.test(base)
75
+ ? [base]
76
+ : [`${base}.ts`, `${base}.js`, `${base}/index.ts`, `${base}/index.js`];
77
+ }
78
+
79
+ function bindingFor(
80
+ identifier: ts.Identifier,
81
+ bindings: readonly SymbolImportBinding[],
82
+ ): SymbolImportBinding | undefined {
83
+ const declaration = lexicalIdentifierDeclaration(identifier);
84
+ if (!declaration) return undefined;
85
+ const start = declaration.getStart(identifier.getSourceFile());
86
+ const end = declaration.getEnd();
87
+ const matches = bindings.filter((binding) =>
88
+ binding.localName === identifier.text
89
+ && binding.bindingSiteStartOffset === start
90
+ && binding.bindingSiteEndOffset === end);
91
+ return matches.length === 1 ? matches[0] : undefined;
92
+ }
93
+
94
+ function exportedVariableInitializer(
95
+ source: ts.SourceFile,
96
+ name: string,
97
+ ): ts.Expression | undefined {
98
+ const exportNames = new Set(source.statements.flatMap((statement) =>
99
+ ts.isExportDeclaration(statement) && !statement.moduleSpecifier
100
+ && statement.exportClause && ts.isNamedExports(statement.exportClause)
101
+ ? statement.exportClause.elements.map((element) =>
102
+ element.propertyName?.text ?? element.name.text)
103
+ : []));
104
+ for (const statement of source.statements) {
105
+ if (!ts.isVariableStatement(statement)) continue;
106
+ const exported = ts.canHaveModifiers(statement)
107
+ && ts.getModifiers(statement)?.some((modifier) =>
108
+ modifier.kind === ts.SyntaxKind.ExportKeyword);
109
+ for (const declaration of statement.declarationList.declarations)
110
+ if (ts.isIdentifier(declaration.name)
111
+ && declaration.name.text === name
112
+ && (exported || exportNames.has(name))
113
+ && (statement.declarationList.flags & ts.NodeFlags.Const) !== 0)
114
+ return declaration.initializer;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function importedIdentifier(
120
+ identifier: ts.Identifier,
121
+ context: ResolutionContext,
122
+ ): { source: ts.SourceFile; sourceFile: string;
123
+ initializer: ts.Expression } | undefined {
124
+ const binding = bindingFor(
125
+ identifier, collectSymbolImportBindings(context.source),
126
+ );
127
+ if (!binding || binding.typeOnly || binding.moduleKind !== 'relative'
128
+ || !binding.importedName) return undefined;
129
+ const candidates = relativeCandidates(
130
+ context.sourceFile, binding.rawModuleSpecifier,
131
+ ).flatMap((filePath) => {
132
+ const snapshot = context.sources.get(filePath);
133
+ return snapshot ? [{ sourceFile: filePath, source: snapshot.sourceFile() }]
134
+ : [];
135
+ });
136
+ if (candidates.length !== 1 || !candidates[0]) return undefined;
137
+ const initializer = exportedVariableInitializer(
138
+ candidates[0].source, binding.importedName,
139
+ );
140
+ return initializer ? { ...candidates[0], initializer } : undefined;
141
+ }
142
+
143
+ function transformedReference(
144
+ expression: ts.CallExpression,
145
+ context: ResolutionContext,
146
+ ): EventEnvironmentReference | undefined {
147
+ if (!ts.isPropertyAccessExpression(expression.expression)) return undefined;
148
+ const method = expression.expression.name.text;
149
+ const base = resolveEnvironmentReference(
150
+ expression.expression.expression, context,
151
+ );
152
+ if (!base) return undefined;
153
+ if (expression.arguments.length !== 0
154
+ || !['toUpperCase', 'toLowerCase'].includes(method)) return {
155
+ ...base,
156
+ status: 'refused',
157
+ reason: 'event_environment_transform_unsupported',
158
+ };
159
+ return base.status === 'resolved'
160
+ ? {
161
+ ...base,
162
+ transforms: [
163
+ ...base.transforms, method as EventEnvironmentTransform,
164
+ ],
165
+ }
166
+ : base;
167
+ }
168
+
169
+ function identifierReference(
170
+ identifier: ts.Identifier,
171
+ context: ResolutionContext,
172
+ ): EventEnvironmentReference | undefined {
173
+ const local = resolveBinding(identifier, identifier);
174
+ if (local.declaration && local.initializer && local.immutable)
175
+ return resolveEnvironmentReference(local.initializer, context);
176
+ const binding = bindingFor(
177
+ identifier, collectSymbolImportBindings(context.source),
178
+ );
179
+ if (binding?.moduleKind === 'package') return {
180
+ status: 'refused',
181
+ sourceKey: identifier.getText(context.source),
182
+ transforms: [],
183
+ reason: 'event_environment_package_import_unsupported',
184
+ };
185
+ const imported = importedIdentifier(identifier, context);
186
+ if (!imported) return undefined;
187
+ return resolveEnvironmentReference(imported.initializer, {
188
+ ...context,
189
+ source: imported.source,
190
+ sourceFile: imported.sourceFile,
191
+ depth: context.depth + 1,
192
+ });
193
+ }
194
+
195
+ function resolveEnvironmentReference(
196
+ expression: ts.Expression,
197
+ context: ResolutionContext,
198
+ ): EventEnvironmentReference | undefined {
199
+ const sourceKey = expression.getText(context.source);
200
+ if (context.depth >= maxEnvironmentReferenceDepth
201
+ || context.seen.has(`${context.sourceFile}\0${sourceKey}`)) return {
202
+ status: 'refused', sourceKey, transforms: [],
203
+ reason: 'event_environment_reference_ambiguous',
204
+ };
205
+ const seen = new Set(context.seen).add(
206
+ `${context.sourceFile}\0${sourceKey}`,
207
+ );
208
+ const next = { ...context, depth: context.depth + 1, seen };
209
+ const value = unwrapExpression(expression);
210
+ const key = processEnvironmentKey(value);
211
+ if (key && context.allowedKeys.has(key)) return {
212
+ status: 'resolved', sourceKey, environmentKey: key, transforms: [],
213
+ sourceFile: context.sourceFile,
214
+ startOffset: value.getStart(context.source), endOffset: value.getEnd(),
215
+ };
216
+ if (key) return undefined;
217
+ if (ts.isCallExpression(value))
218
+ return transformedReference(value, next);
219
+ return ts.isIdentifier(value)
220
+ ? identifierReference(value, next) : undefined;
221
+ }
222
+
223
+ export function createEventEnvironmentReferenceResolver(
224
+ sources: RepositorySourceContext,
225
+ source: ts.SourceFile,
226
+ sourceFile: string,
227
+ configuredKeys: readonly string[] = DEFAULT_EVENT_ENVIRONMENT_KEYS,
228
+ ): EventEnvironmentReferenceResolver {
229
+ const allowedKeys = new Set(normalizeEventEnvironmentKeys(configuredKeys));
230
+ return (expression) => resolveEnvironmentReference(expression, {
231
+ sources, source, sourceFile, depth: 0, seen: new Set(), allowedKeys,
232
+ });
233
+ }
234
+
235
+ export function applyEventEnvironmentTransforms(
236
+ value: string,
237
+ transforms: readonly EventEnvironmentTransform[],
238
+ ): string {
239
+ return transforms.reduce((current, transform) =>
240
+ transform === 'toUpperCase'
241
+ ? current.toUpperCase() : current.toLowerCase(), value);
242
+ }