@saptools/service-flow 0.1.72 → 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 +8 -0
- package/README.md +19 -6
- package/TECHNICAL-NOTE.md +11 -2
- package/dist/{chunk-Z6D433R5.js → chunk-32WOTGTS.js} +6714 -6161
- package/dist/chunk-32WOTGTS.js.map +1 -0
- package/dist/cli.js +189 -39
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor-event-quality.ts +90 -15
- package/src/cli.ts +35 -13
- package/src/config/workspace-config.ts +15 -0
- package/src/db/current-fact-semantics.ts +2 -26
- package/src/db/event-fact-semantics.ts +260 -61
- package/src/db/event-site-semantics.ts +62 -0
- package/src/db/fact-lifecycle.ts +70 -12
- package/src/db/migrations.ts +4 -1
- package/src/db/package-target-invalidation.ts +4 -3
- package/src/db/schema.ts +1 -1
- package/src/indexer/repository-indexer.ts +50 -6
- package/src/indexer/workspace-indexer.ts +13 -3
- package/src/linker/cross-repo-linker.ts +4 -2
- package/src/linker/event-shape-candidate-linker.ts +63 -20
- package/src/linker/event-subscription-handler-linker.ts +121 -30
- package/src/linker/package-event-constant-resolver.ts +22 -18
- package/src/output/repository-inspection.ts +11 -0
- package/src/parsers/environment-declarations.ts +104 -27
- package/src/parsers/event-call-analysis.ts +163 -35
- package/src/parsers/event-environment-reference.ts +18 -7
- package/src/parsers/event-receiver-analysis.ts +17 -27
- package/src/parsers/outbound-call-classifier.ts +1 -1
- package/src/parsers/stable-local-value.ts +12 -1
- package/src/parsers/string-constant-lookups.ts +78 -28
- package/src/trace/edge-target.ts +65 -0
- package/src/trace/event-subscriber-traversal.ts +71 -1
- package/src/trace/evidence.ts +0 -28
- package/src/trace/trace-scope-execution.ts +1 -1
- package/src/types.ts +3 -1
- package/src/version.ts +1 -1
- package/dist/chunk-Z6D433R5.js.map +0 -1
|
@@ -20,7 +20,6 @@ interface EventConstantCall {
|
|
|
20
20
|
id: number;
|
|
21
21
|
binding: SymbolImportReference;
|
|
22
22
|
evidence: Record<string, unknown>;
|
|
23
|
-
unresolvedReason?: string | null;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
interface PackageRepository {
|
|
@@ -56,8 +55,7 @@ function jsonRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
function callRows(db: Db, workspaceId: number): EventConstantCall[] {
|
|
59
|
-
const rows = db.prepare(`SELECT c.id,c.evidence_json evidenceJson
|
|
60
|
-
c.unresolved_reason unresolvedReason
|
|
58
|
+
const rows = db.prepare(`SELECT c.id,c.evidence_json evidenceJson
|
|
61
59
|
FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
|
|
62
60
|
WHERE r.workspace_id=?
|
|
63
61
|
AND c.call_type IN ('async_emit','async_subscribe')
|
|
@@ -69,11 +67,8 @@ function callRows(db: Db, workspaceId: number): EventConstantCall[] {
|
|
|
69
67
|
const binding = parsePackageImportReference(
|
|
70
68
|
evidence?.eventNameConstantImportBinding,
|
|
71
69
|
);
|
|
72
|
-
return typeof row.id === 'number' && evidence && binding
|
|
73
|
-
id: row.id, evidence, binding
|
|
74
|
-
unresolvedReason: typeof row.unresolvedReason === 'string'
|
|
75
|
-
? row.unresolvedReason : null,
|
|
76
|
-
}] : [];
|
|
70
|
+
return typeof row.id === 'number' && evidence && binding
|
|
71
|
+
? [{ id: row.id, evidence, binding }] : [];
|
|
77
72
|
});
|
|
78
73
|
}
|
|
79
74
|
|
|
@@ -179,15 +174,16 @@ function resolveRows(
|
|
|
179
174
|
'event_name_constant_container_not_exported', fields,
|
|
180
175
|
);
|
|
181
176
|
if (Number(row.stable) !== 1) return failure(
|
|
182
|
-
|
|
177
|
+
constantRefusalReason(row), fields,
|
|
183
178
|
);
|
|
184
179
|
if (row.resolutionStatus !== 'resolved'
|
|
185
180
|
|| typeof row.value !== 'string') return failure(
|
|
186
|
-
|
|
187
|
-
? 'event_name_constant_container_mutable'
|
|
188
|
-
: 'event_name_constant_member_not_string',
|
|
181
|
+
constantRefusalReason(row),
|
|
189
182
|
fields,
|
|
190
183
|
);
|
|
184
|
+
if (row.value.length === 0) return failure(
|
|
185
|
+
'event_name_constant_value_empty', fields,
|
|
186
|
+
);
|
|
191
187
|
return {
|
|
192
188
|
...fields,
|
|
193
189
|
status: 'resolved',
|
|
@@ -198,6 +194,19 @@ function resolveRows(
|
|
|
198
194
|
};
|
|
199
195
|
}
|
|
200
196
|
|
|
197
|
+
function constantRefusalReason(row: Record<string, unknown>): string {
|
|
198
|
+
const reason = String(row.unresolvedReason ?? '');
|
|
199
|
+
if ([
|
|
200
|
+
'event_name_constant_container_mutable',
|
|
201
|
+
'event_name_constant_container_unsafe_reference',
|
|
202
|
+
'event_name_constant_container_unsupported_shape',
|
|
203
|
+
'event_name_constant_member_not_string',
|
|
204
|
+
].includes(reason)) return reason;
|
|
205
|
+
return Number(row.stable) === 1
|
|
206
|
+
? 'event_name_constant_member_not_string'
|
|
207
|
+
: 'event_name_constant_container_unsafe_reference';
|
|
208
|
+
}
|
|
209
|
+
|
|
201
210
|
function resolveCall(
|
|
202
211
|
db: Db,
|
|
203
212
|
workspaceId: number,
|
|
@@ -234,11 +243,6 @@ export function expectedPackageEventConstantResolution(
|
|
|
234
243
|
});
|
|
235
244
|
}
|
|
236
245
|
|
|
237
|
-
function receiverReason(call: EventConstantCall): string | null {
|
|
238
|
-
return call.evidence.receiverClassification === 'unproven'
|
|
239
|
-
? call.unresolvedReason ?? 'event_receiver_unproven_binding' : null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
246
|
function resolutionEvidence(
|
|
243
247
|
call: EventConstantCall,
|
|
244
248
|
resolution: PackageEventConstantResolution,
|
|
@@ -284,7 +288,7 @@ export function linkPackageEventConstants(
|
|
|
284
288
|
unresolved_reason=?,evidence_json=? WHERE id=?`);
|
|
285
289
|
for (const call of callRows(db, workspaceId)) {
|
|
286
290
|
const resolution = resolveCall(db, workspaceId, call);
|
|
287
|
-
const reason =
|
|
291
|
+
const reason = resolution.reason ?? null;
|
|
288
292
|
const sourceExpression = call.evidence.eventNameConstantSourceExpression;
|
|
289
293
|
update.run(
|
|
290
294
|
resolution.value ?? String(sourceExpression ?? ''),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RepoRow } from '../db/repositories.js';
|
|
2
|
+
|
|
3
|
+
export function projectRepositoryInspection(
|
|
4
|
+
repository: RepoRow,
|
|
5
|
+
): Record<string, unknown> {
|
|
6
|
+
return Object.fromEntries(
|
|
7
|
+
Object.entries(repository).filter(
|
|
8
|
+
([key]) => key !== 'environment_declarations_json',
|
|
9
|
+
),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -3,10 +3,12 @@ import type { RepositorySourceContext } from './ts-project.js';
|
|
|
3
3
|
export const ENVIRONMENT_DECLARATIONS_SCHEMA =
|
|
4
4
|
'service-flow/environment-declarations@1';
|
|
5
5
|
export const ENVIRONMENT_DECLARATION_RECORD_CAP = 32;
|
|
6
|
-
export const
|
|
6
|
+
export const EVENT_ENVIRONMENT_KEY_CAP = 16;
|
|
7
|
+
export const DEFAULT_EVENT_ENVIRONMENT_KEYS = ['SHARD_CODE'] as const;
|
|
8
|
+
export const EVENT_ENVIRONMENT_KEY_ALLOWLIST =
|
|
9
|
+
DEFAULT_EVENT_ENVIRONMENT_KEYS;
|
|
7
10
|
|
|
8
|
-
export type EventEnvironmentKey =
|
|
9
|
-
typeof EVENT_ENVIRONMENT_KEY_ALLOWLIST[number];
|
|
11
|
+
export type EventEnvironmentKey = string;
|
|
10
12
|
export type EnvironmentDeclarationProvenance =
|
|
11
13
|
| 'env_declaration_manifest'
|
|
12
14
|
| 'env_declaration_mta'
|
|
@@ -24,6 +26,7 @@ export interface EnvironmentDeclaration {
|
|
|
24
26
|
|
|
25
27
|
export interface EnvironmentDeclarationsFact {
|
|
26
28
|
schema: typeof ENVIRONMENT_DECLARATIONS_SCHEMA;
|
|
29
|
+
allowedKeys: string[];
|
|
27
30
|
status: 'complete' | 'ambiguous' | 'not_applicable' | 'incomplete';
|
|
28
31
|
reason: string | null;
|
|
29
32
|
recordCap: typeof ENVIRONMENT_DECLARATION_RECORD_CAP;
|
|
@@ -33,7 +36,6 @@ export interface EnvironmentDeclarationsFact {
|
|
|
33
36
|
declarations: EnvironmentDeclaration[];
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
const allowedKeys = new Set<string>(EVENT_ENVIRONMENT_KEY_ALLOWLIST);
|
|
37
39
|
const allowedProvenance = new Set<EnvironmentDeclarationProvenance>([
|
|
38
40
|
'env_declaration_manifest',
|
|
39
41
|
'env_declaration_mta',
|
|
@@ -42,6 +44,22 @@ const allowedProvenance = new Set<EnvironmentDeclarationProvenance>([
|
|
|
42
44
|
]);
|
|
43
45
|
const environmentValueLimit = 512;
|
|
44
46
|
const dynamicEnvironmentValue = /\$\{|\$\(|~\{|\(\(/;
|
|
47
|
+
const environmentKeyGrammar = /^[A-Z_][A-Z0-9_]{0,63}$/;
|
|
48
|
+
|
|
49
|
+
export function validEventEnvironmentKey(value: string): boolean {
|
|
50
|
+
return environmentKeyGrammar.test(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeEventEnvironmentKeys(
|
|
54
|
+
values: readonly string[] = DEFAULT_EVENT_ENVIRONMENT_KEYS,
|
|
55
|
+
): string[] {
|
|
56
|
+
const unique = [...new Set(values)].sort((left, right) =>
|
|
57
|
+
left < right ? -1 : left > right ? 1 : 0);
|
|
58
|
+
if (unique.length === 0 || unique.length > EVENT_ENVIRONMENT_KEY_CAP
|
|
59
|
+
|| !unique.every(validEventEnvironmentKey))
|
|
60
|
+
throw new Error('invalid_event_environment_keys');
|
|
61
|
+
return unique;
|
|
62
|
+
}
|
|
45
63
|
|
|
46
64
|
function hasControlCharacter(value: string): boolean {
|
|
47
65
|
for (const character of value) {
|
|
@@ -80,6 +98,7 @@ function declaration(
|
|
|
80
98
|
provenance: EnvironmentDeclarationProvenance,
|
|
81
99
|
sourceFile: string,
|
|
82
100
|
startOffset: number,
|
|
101
|
+
allowedKeys: ReadonlySet<string>,
|
|
83
102
|
): EnvironmentDeclaration[] {
|
|
84
103
|
if (!allowedKeys.has(key) || typeof value !== 'string'
|
|
85
104
|
|| !value || value.length > environmentValueLimit
|
|
@@ -107,6 +126,8 @@ function valueOffset(
|
|
|
107
126
|
function jsonDeclarations(
|
|
108
127
|
filePath: string,
|
|
109
128
|
text: string,
|
|
129
|
+
keys: readonly string[],
|
|
130
|
+
allowedKeys: ReadonlySet<string>,
|
|
110
131
|
): EnvironmentDeclaration[] {
|
|
111
132
|
let parsed: unknown;
|
|
112
133
|
try {
|
|
@@ -118,13 +139,14 @@ function jsonDeclarations(
|
|
|
118
139
|
return [];
|
|
119
140
|
const env = (parsed as Record<string, unknown>).env;
|
|
120
141
|
if (!env || typeof env !== 'object' || Array.isArray(env)) return [];
|
|
121
|
-
return
|
|
142
|
+
return keys.flatMap((key) => {
|
|
122
143
|
const value = (env as Record<string, unknown>)[key];
|
|
123
144
|
const keyOffset = text.indexOf(`"${key}"`);
|
|
124
145
|
const offset = typeof value === 'string'
|
|
125
146
|
? valueOffset(text, value, keyOffset + key.length + 2) : keyOffset;
|
|
126
147
|
return declaration(
|
|
127
148
|
key, value, 'env_declaration_dev', filePath, Math.max(0, offset),
|
|
149
|
+
allowedKeys,
|
|
128
150
|
);
|
|
129
151
|
});
|
|
130
152
|
}
|
|
@@ -132,6 +154,7 @@ function jsonDeclarations(
|
|
|
132
154
|
function dotenvDeclarations(
|
|
133
155
|
filePath: string,
|
|
134
156
|
text: string,
|
|
157
|
+
allowedKeys: ReadonlySet<string>,
|
|
135
158
|
): EnvironmentDeclaration[] {
|
|
136
159
|
const values: EnvironmentDeclaration[] = [];
|
|
137
160
|
let offset = 0;
|
|
@@ -144,6 +167,7 @@ function dotenvDeclarations(
|
|
|
144
167
|
values.push(...declaration(
|
|
145
168
|
match[1], value, 'env_declaration_dotenv', filePath,
|
|
146
169
|
offset + valueOffset(line, value, line.indexOf('=') + 1),
|
|
170
|
+
allowedKeys,
|
|
147
171
|
));
|
|
148
172
|
offset += line.length + 1;
|
|
149
173
|
}
|
|
@@ -158,6 +182,7 @@ function yamlDeclarations(
|
|
|
158
182
|
filePath: string,
|
|
159
183
|
text: string,
|
|
160
184
|
provenance: EnvironmentDeclarationProvenance,
|
|
185
|
+
allowedKeys: ReadonlySet<string>,
|
|
161
186
|
): EnvironmentDeclaration[] {
|
|
162
187
|
const values: EnvironmentDeclaration[] = [];
|
|
163
188
|
let envIndent: number | undefined;
|
|
@@ -177,6 +202,7 @@ function yamlDeclarations(
|
|
|
177
202
|
values.push(...declaration(
|
|
178
203
|
match[1], value, provenance, filePath,
|
|
179
204
|
offset + valueOffset(line, value, line.indexOf(':') + 1),
|
|
205
|
+
allowedKeys,
|
|
180
206
|
));
|
|
181
207
|
}
|
|
182
208
|
}
|
|
@@ -188,14 +214,20 @@ function yamlDeclarations(
|
|
|
188
214
|
function snapshotDeclarations(
|
|
189
215
|
filePath: string,
|
|
190
216
|
text: string,
|
|
217
|
+
keys: readonly string[],
|
|
218
|
+
allowedKeys: ReadonlySet<string>,
|
|
191
219
|
): EnvironmentDeclaration[] {
|
|
192
220
|
const name = filePath.split('/').at(-1);
|
|
193
|
-
if (name === 'nodemon.json')
|
|
194
|
-
|
|
221
|
+
if (name === 'nodemon.json')
|
|
222
|
+
return jsonDeclarations(filePath, text, keys, allowedKeys);
|
|
223
|
+
if (name === '.env')
|
|
224
|
+
return dotenvDeclarations(filePath, text, allowedKeys);
|
|
195
225
|
if (name === 'manifest.yml')
|
|
196
|
-
return yamlDeclarations(
|
|
226
|
+
return yamlDeclarations(
|
|
227
|
+
filePath, text, 'env_declaration_manifest', allowedKeys,
|
|
228
|
+
);
|
|
197
229
|
return name === 'mta.yaml'
|
|
198
|
-
? yamlDeclarations(filePath, text, 'env_declaration_mta') : [];
|
|
230
|
+
? yamlDeclarations(filePath, text, 'env_declaration_mta', allowedKeys) : [];
|
|
199
231
|
}
|
|
200
232
|
|
|
201
233
|
function compareDeclaration(
|
|
@@ -207,24 +239,43 @@ function compareDeclaration(
|
|
|
207
239
|
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
208
240
|
}
|
|
209
241
|
|
|
242
|
+
function declarationIdentityValid(
|
|
243
|
+
item: Record<string, unknown>,
|
|
244
|
+
allowedKeys: ReadonlySet<string>,
|
|
245
|
+
): boolean {
|
|
246
|
+
return typeof item.key === 'string'
|
|
247
|
+
&& allowedKeys.has(item.key)
|
|
248
|
+
&& typeof item.provenance === 'string'
|
|
249
|
+
&& allowedProvenance.has(
|
|
250
|
+
item.provenance as EnvironmentDeclarationProvenance,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function declarationValueValid(item: Record<string, unknown>): boolean {
|
|
255
|
+
return typeof item.value === 'string'
|
|
256
|
+
&& item.value.length > 0
|
|
257
|
+
&& item.value.length <= environmentValueLimit
|
|
258
|
+
&& !hasControlCharacter(item.value)
|
|
259
|
+
&& !dynamicEnvironmentValue.test(item.value);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function declarationLocationValid(item: Record<string, unknown>): boolean {
|
|
263
|
+
return typeof item.sourceFile === 'string'
|
|
264
|
+
&& item.sourceFile.length > 0
|
|
265
|
+
&& Number.isInteger(item.startOffset)
|
|
266
|
+
&& Number(item.startOffset) >= 0
|
|
267
|
+
&& Number.isInteger(item.endOffset)
|
|
268
|
+
&& Number(item.endOffset) > Number(item.startOffset);
|
|
269
|
+
}
|
|
270
|
+
|
|
210
271
|
function parsedDeclaration(
|
|
211
272
|
value: unknown,
|
|
273
|
+
allowedKeys: ReadonlySet<string>,
|
|
212
274
|
): EnvironmentDeclaration | undefined {
|
|
213
275
|
const item = record(value);
|
|
214
|
-
if (!item ||
|
|
215
|
-
|| !
|
|
216
|
-
||
|
|
217
|
-
|| item.value.length > environmentValueLimit
|
|
218
|
-
|| hasControlCharacter(item.value)
|
|
219
|
-
|| dynamicEnvironmentValue.test(item.value)
|
|
220
|
-
|| typeof item.provenance !== 'string'
|
|
221
|
-
|| !allowedProvenance.has(
|
|
222
|
-
item.provenance as EnvironmentDeclarationProvenance,
|
|
223
|
-
)
|
|
224
|
-
|| typeof item.sourceFile !== 'string' || item.sourceFile.length === 0
|
|
225
|
-
|| !Number.isInteger(item.startOffset) || Number(item.startOffset) < 0
|
|
226
|
-
|| !Number.isInteger(item.endOffset)
|
|
227
|
-
|| Number(item.endOffset) <= Number(item.startOffset)) return undefined;
|
|
276
|
+
if (!item || !declarationIdentityValid(item, allowedKeys)
|
|
277
|
+
|| !declarationValueValid(item)
|
|
278
|
+
|| !declarationLocationValid(item)) return undefined;
|
|
228
279
|
return item as unknown as EnvironmentDeclaration;
|
|
229
280
|
}
|
|
230
281
|
|
|
@@ -265,14 +316,31 @@ function statusValid(
|
|
|
265
316
|
&& Number(item.omitted) > 0;
|
|
266
317
|
}
|
|
267
318
|
|
|
319
|
+
function parsedAllowedKeys(value: unknown): string[] | undefined {
|
|
320
|
+
if (!Array.isArray(value)
|
|
321
|
+
|| !value.every((item): item is string => typeof item === 'string'))
|
|
322
|
+
return undefined;
|
|
323
|
+
try {
|
|
324
|
+
const normalized = normalizeEventEnvironmentKeys(value);
|
|
325
|
+
return normalized.length === value.length
|
|
326
|
+
&& normalized.every((item, index) => item === value[index])
|
|
327
|
+
? normalized : undefined;
|
|
328
|
+
} catch {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
268
333
|
export function parseEnvironmentDeclarationsFact(
|
|
269
334
|
value: unknown,
|
|
270
335
|
): EnvironmentDeclarationsFact | undefined {
|
|
271
336
|
const item = record(parseJson(value));
|
|
272
337
|
if (!item || item.schema !== ENVIRONMENT_DECLARATIONS_SCHEMA
|
|
273
338
|
|| !Array.isArray(item.declarations)) return undefined;
|
|
339
|
+
const keys = parsedAllowedKeys(item.allowedKeys);
|
|
340
|
+
if (!keys) return undefined;
|
|
341
|
+
const allowedKeys = new Set(keys);
|
|
274
342
|
const declarations = item.declarations.flatMap((entry) => {
|
|
275
|
-
const parsed = parsedDeclaration(entry);
|
|
343
|
+
const parsed = parsedDeclaration(entry, allowedKeys);
|
|
276
344
|
return parsed ? [parsed] : [];
|
|
277
345
|
});
|
|
278
346
|
if (declarations.length !== item.declarations.length
|
|
@@ -286,16 +354,22 @@ export function parseEnvironmentDeclarationsFact(
|
|
|
286
354
|
|
|
287
355
|
export function collectEnvironmentDeclarations(
|
|
288
356
|
sources: RepositorySourceContext,
|
|
357
|
+
configuredKeys: readonly string[] = DEFAULT_EVENT_ENVIRONMENT_KEYS,
|
|
289
358
|
): EnvironmentDeclarationsFact {
|
|
359
|
+
const keys = normalizeEventEnvironmentKeys(configuredKeys);
|
|
360
|
+
const allowedKeys = new Set(keys);
|
|
290
361
|
const all = sources.entries().flatMap((snapshot) =>
|
|
291
|
-
snapshotDeclarations(
|
|
362
|
+
snapshotDeclarations(
|
|
363
|
+
snapshot.filePath, snapshot.text, keys, allowedKeys,
|
|
364
|
+
))
|
|
292
365
|
.sort(compareDeclaration);
|
|
293
366
|
const values = new Set(all.map((item) => `${item.key}\0${item.value}`));
|
|
294
|
-
const ambiguous =
|
|
367
|
+
const ambiguous = keys.some((key) =>
|
|
295
368
|
[...values].filter((value) => value.startsWith(`${key}\0`)).length > 1);
|
|
296
369
|
const declarations = all.slice(0, ENVIRONMENT_DECLARATION_RECORD_CAP);
|
|
297
370
|
return {
|
|
298
371
|
schema: ENVIRONMENT_DECLARATIONS_SCHEMA,
|
|
372
|
+
allowedKeys: keys,
|
|
299
373
|
status: ambiguous
|
|
300
374
|
? 'ambiguous'
|
|
301
375
|
: all.length > ENVIRONMENT_DECLARATION_RECORD_CAP
|
|
@@ -313,9 +387,12 @@ export function collectEnvironmentDeclarations(
|
|
|
313
387
|
};
|
|
314
388
|
}
|
|
315
389
|
|
|
316
|
-
export function emptyEnvironmentDeclarations(
|
|
390
|
+
export function emptyEnvironmentDeclarations(
|
|
391
|
+
configuredKeys: readonly string[] = DEFAULT_EVENT_ENVIRONMENT_KEYS,
|
|
392
|
+
): EnvironmentDeclarationsFact {
|
|
317
393
|
return {
|
|
318
394
|
schema: ENVIRONMENT_DECLARATIONS_SCHEMA,
|
|
395
|
+
allowedKeys: normalizeEventEnvironmentKeys(configuredKeys),
|
|
319
396
|
status: 'not_applicable',
|
|
320
397
|
reason: null,
|
|
321
398
|
recordCap: ENVIRONMENT_DECLARATION_RECORD_CAP,
|
|
@@ -55,9 +55,15 @@ interface EventNameState {
|
|
|
55
55
|
resolved: ExpressionResolution;
|
|
56
56
|
unresolvedReason?: string;
|
|
57
57
|
constant?: StaticStringConstant;
|
|
58
|
+
foldedConstants?: StaticStringConstant[];
|
|
58
59
|
packageImportReference?: SymbolImportReference;
|
|
59
60
|
}
|
|
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
|
+
|
|
61
67
|
function eventNameReason(
|
|
62
68
|
resolved: ExpressionResolution,
|
|
63
69
|
): string | undefined {
|
|
@@ -67,40 +73,111 @@ function eventNameReason(
|
|
|
67
73
|
: 'dynamic_event_name_unsupported_expression';
|
|
68
74
|
}
|
|
69
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
|
+
|
|
70
166
|
function eventNameState(
|
|
71
167
|
node: ts.CallExpression,
|
|
72
168
|
context: EventCallAnalysisContext,
|
|
73
169
|
): EventNameState | undefined {
|
|
74
170
|
const expression = node.arguments[0];
|
|
75
171
|
if (expression) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
value: lookup.constant.value,
|
|
86
|
-
rawExpression: expression.getText(node.getSourceFile()),
|
|
87
|
-
placeholderKeys: [],
|
|
88
|
-
evidence: [`event_name_${lookup.constant.kind}`],
|
|
89
|
-
},
|
|
90
|
-
constant: lookup.constant,
|
|
91
|
-
};
|
|
92
|
-
if (lookup.status === 'refused') return {
|
|
93
|
-
eventName: expression.getText(node.getSourceFile()),
|
|
94
|
-
resolved: {
|
|
95
|
-
status: 'dynamic',
|
|
96
|
-
sourceKind: 'dynamic_expression',
|
|
97
|
-
rawExpression: expression.getText(node.getSourceFile()),
|
|
98
|
-
placeholderKeys: [],
|
|
99
|
-
evidence: [lookup.reason],
|
|
100
|
-
},
|
|
101
|
-
unresolvedReason: lookup.reason,
|
|
102
|
-
packageImportReference: packageReference(lookup),
|
|
103
|
-
};
|
|
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;
|
|
104
181
|
}
|
|
105
182
|
const resolved = resolveExpression(expression, node, 'operation_path');
|
|
106
183
|
const eventName = resolved.value
|
|
@@ -139,7 +216,42 @@ function excludedEvent(
|
|
|
139
216
|
if (state.resolved.status !== 'static') return false;
|
|
140
217
|
if (receiver.effectiveReceiver === 'cds'
|
|
141
218
|
&& CDS_LIFECYCLE_EVENTS.has(state.eventName)) return true;
|
|
142
|
-
return method === 'on'
|
|
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);
|
|
143
255
|
}
|
|
144
256
|
|
|
145
257
|
function eventEvidence(
|
|
@@ -154,6 +266,8 @@ function eventEvidence(
|
|
|
154
266
|
? 'cap_service_event_subscription' : 'cap_service_event_emit',
|
|
155
267
|
receiverClassification: receiver.receiverClassification,
|
|
156
268
|
receiverProof: receiver.receiverProof,
|
|
269
|
+
receiverUnresolvedReason: receiver.unresolvedReason,
|
|
270
|
+
receiverFallbackRefusedReason: receiver.fallbackRefusedReason,
|
|
157
271
|
consideredBindingSites: receiver.consideredBindingSites,
|
|
158
272
|
eventNameUnresolvedReason: state.unresolvedReason,
|
|
159
273
|
eventNameConstantImportBinding: state.packageImportReference,
|
|
@@ -167,6 +281,16 @@ function eventEvidence(
|
|
|
167
281
|
declarationEndOffset: state.constant.declarationEndOffset,
|
|
168
282
|
},
|
|
169
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
|
+
} : {}),
|
|
170
294
|
...(state.resolved.status === 'static' ? {} : {
|
|
171
295
|
eventNameStatus: state.resolved.status,
|
|
172
296
|
eventNameSourceKind: state.resolved.sourceKind,
|
|
@@ -177,13 +301,12 @@ function eventEvidence(
|
|
|
177
301
|
|
|
178
302
|
export function createEventCallAnalysisContext(
|
|
179
303
|
source: ts.SourceFile,
|
|
180
|
-
compatibilityNames: Set<string>,
|
|
181
304
|
importedConstant?: ImportedEventNameResolver,
|
|
182
305
|
environmentReference?: EventEnvironmentReferenceResolver,
|
|
183
306
|
): EventCallAnalysisContext {
|
|
184
307
|
return {
|
|
185
308
|
source,
|
|
186
|
-
receivers: createEventReceiverIndex(source
|
|
309
|
+
receivers: createEventReceiverIndex(source),
|
|
187
310
|
constants: collectStringConstantLookups(source),
|
|
188
311
|
importedConstant,
|
|
189
312
|
environmentReference,
|
|
@@ -196,7 +319,10 @@ function eventSkeleton(
|
|
|
196
319
|
resolver: EventEnvironmentReferenceResolver | undefined,
|
|
197
320
|
): EventSkeletonFact | undefined {
|
|
198
321
|
const expression = node.arguments[0];
|
|
199
|
-
if (!expression
|
|
322
|
+
if (!expression) return undefined;
|
|
323
|
+
if (!ts.isTemplateExpression(expression))
|
|
324
|
+
return eventName.includes('${')
|
|
325
|
+
? deriveEventSkeleton(eventName) : undefined;
|
|
200
326
|
const skeleton = deriveEventSkeleton(eventName);
|
|
201
327
|
if (!skeleton || !resolver) return skeleton;
|
|
202
328
|
return {
|
|
@@ -222,9 +348,11 @@ export function analyzeEventCall(
|
|
|
222
348
|
const receiver = proveEventReceiver(
|
|
223
349
|
expression.expression, node, context.receivers,
|
|
224
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' };
|
|
225
355
|
if (excludedEvent(method, receiver, state)) return { status: 'excluded' };
|
|
226
|
-
const unresolvedReason = receiver.unresolvedReason
|
|
227
|
-
?? state.unresolvedReason;
|
|
228
356
|
return {
|
|
229
357
|
status: 'classified',
|
|
230
358
|
fact: {
|
|
@@ -235,7 +363,7 @@ export function analyzeEventCall(
|
|
|
235
363
|
node, state.eventName, context.environmentReference,
|
|
236
364
|
),
|
|
237
365
|
confidence: eventConfidence(receiver, state.unresolvedReason),
|
|
238
|
-
unresolvedReason,
|
|
366
|
+
unresolvedReason: state.unresolvedReason,
|
|
239
367
|
},
|
|
240
368
|
evidence: eventEvidence(method, receiver, state),
|
|
241
369
|
};
|