@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.
- package/CHANGELOG.md +21 -0
- package/README.md +23 -6
- package/TECHNICAL-NOTE.md +24 -2
- package/dist/{chunk-GSLFY6J2.js → chunk-32WOTGTS.js} +12061 -9288
- package/dist/chunk-32WOTGTS.js.map +1 -0
- package/dist/cli.js +854 -161
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +54 -14
- package/dist/index.js +2 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/doctor-event-quality.ts +446 -0
- package/src/cli/doctor.ts +4 -6
- package/src/cli.ts +35 -13
- package/src/config/workspace-config.ts +15 -0
- package/src/db/call-fact-repository.ts +6 -3
- package/src/db/current-fact-semantics.ts +5 -29
- package/src/db/event-fact-semantics.ts +546 -0
- package/src/db/event-site-semantics.ts +62 -0
- package/src/db/event-surface-invalidation.ts +38 -0
- package/src/db/fact-json-inventory.ts +16 -0
- package/src/db/fact-lifecycle.ts +70 -12
- package/src/db/migrations.ts +15 -1
- package/src/db/package-target-invalidation.ts +80 -0
- package/src/db/repositories.ts +28 -2
- package/src/db/schema-structure.ts +41 -1
- package/src/db/schema.ts +6 -2
- package/src/indexer/repository-indexer.ts +93 -10
- package/src/indexer/workspace-indexer.ts +13 -3
- package/src/linker/cross-repo-linker.ts +27 -3
- package/src/linker/event-environment-link.ts +211 -0
- package/src/linker/event-shape-candidate-linker.ts +204 -0
- package/src/linker/event-subscription-handler-linker.ts +220 -25
- package/src/linker/event-template-link.ts +40 -6
- package/src/linker/package-event-constant-resolver.ts +302 -0
- package/src/output/repository-inspection.ts +11 -0
- package/src/output/table-output.ts +13 -1
- package/src/parsers/decorator-parser.ts +9 -53
- package/src/parsers/environment-declarations.ts +404 -0
- package/src/parsers/event-call-analysis.ts +370 -0
- package/src/parsers/event-environment-reference.ts +242 -0
- package/src/parsers/event-loop-registration.ts +132 -0
- package/src/parsers/event-name-import-resolution.ts +243 -0
- package/src/parsers/event-receiver-analysis.ts +394 -0
- package/src/parsers/event-subscription-facts.ts +4 -0
- package/src/parsers/generated-constants-parser.ts +80 -14
- package/src/parsers/outbound-call-classifier.ts +27 -124
- package/src/parsers/outbound-call-parser.ts +13 -1
- package/src/parsers/outbound-expression-analysis.ts +2 -2
- package/src/parsers/stable-local-value.ts +42 -10
- package/src/parsers/string-constant-lookups.ts +408 -0
- package/src/trace/edge-target.ts +65 -0
- package/src/trace/event-runtime-resolution.ts +24 -3
- package/src/trace/event-shape-candidate-trace.ts +172 -0
- package/src/trace/event-subscriber-traversal.ts +90 -8
- package/src/trace/evidence.ts +7 -28
- package/src/trace/trace-scope-execution.ts +22 -30
- package/src/types.ts +19 -1
- package/src/utils/event-skeleton.ts +207 -0
- package/src/version.ts +1 -1
- 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
|
+
}
|