@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +19 -6
  3. package/TECHNICAL-NOTE.md +11 -2
  4. package/dist/{chunk-Z6D433R5.js → chunk-32WOTGTS.js} +6714 -6161
  5. package/dist/chunk-32WOTGTS.js.map +1 -0
  6. package/dist/cli.js +189 -39
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/doctor-event-quality.ts +90 -15
  12. package/src/cli.ts +35 -13
  13. package/src/config/workspace-config.ts +15 -0
  14. package/src/db/current-fact-semantics.ts +2 -26
  15. package/src/db/event-fact-semantics.ts +260 -61
  16. package/src/db/event-site-semantics.ts +62 -0
  17. package/src/db/fact-lifecycle.ts +70 -12
  18. package/src/db/migrations.ts +4 -1
  19. package/src/db/package-target-invalidation.ts +4 -3
  20. package/src/db/schema.ts +1 -1
  21. package/src/indexer/repository-indexer.ts +50 -6
  22. package/src/indexer/workspace-indexer.ts +13 -3
  23. package/src/linker/cross-repo-linker.ts +4 -2
  24. package/src/linker/event-shape-candidate-linker.ts +63 -20
  25. package/src/linker/event-subscription-handler-linker.ts +121 -30
  26. package/src/linker/package-event-constant-resolver.ts +22 -18
  27. package/src/output/repository-inspection.ts +11 -0
  28. package/src/parsers/environment-declarations.ts +104 -27
  29. package/src/parsers/event-call-analysis.ts +163 -35
  30. package/src/parsers/event-environment-reference.ts +18 -7
  31. package/src/parsers/event-receiver-analysis.ts +17 -27
  32. package/src/parsers/outbound-call-classifier.ts +1 -1
  33. package/src/parsers/stable-local-value.ts +12 -1
  34. package/src/parsers/string-constant-lookups.ts +78 -28
  35. package/src/trace/edge-target.ts +65 -0
  36. package/src/trace/event-subscriber-traversal.ts +71 -1
  37. package/src/trace/evidence.ts +0 -28
  38. package/src/trace/trace-scope-execution.ts +1 -1
  39. package/src/types.ts +3 -1
  40. package/src/version.ts +1 -1
  41. 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
- 'event_name_constant_container_mutable', fields,
177
+ constantRefusalReason(row), fields,
183
178
  );
184
179
  if (row.resolutionStatus !== 'resolved'
185
180
  || typeof row.value !== 'string') return failure(
186
- String(row.unresolvedReason) === 'event_name_constant_container_mutable'
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 = receiverReason(call) ?? resolution.reason ?? null;
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 EVENT_ENVIRONMENT_KEY_ALLOWLIST = ['SHARD_CODE'] as 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 EVENT_ENVIRONMENT_KEY_ALLOWLIST.flatMap((key) => {
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') return jsonDeclarations(filePath, text);
194
- if (name === '.env') return dotenvDeclarations(filePath, text);
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(filePath, text, 'env_declaration_manifest');
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 || typeof item.key !== 'string'
215
- || !allowedKeys.has(item.key)
216
- || typeof item.value !== 'string' || item.value.length === 0
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(snapshot.filePath, snapshot.text))
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 = EVENT_ENVIRONMENT_KEY_ALLOWLIST.some((key) =>
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(): EnvironmentDeclarationsFact {
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
- const local = resolveStringConstant(expression, context.constants);
77
- const lookup = local.status === 'not_found'
78
- ? context.importedConstant?.(expression) ?? local
79
- : local;
80
- if (lookup.status === 'resolved') return {
81
- eventName: lookup.constant.value,
82
- resolved: {
83
- status: 'static',
84
- sourceKind: lookup.constant.kind,
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' && state.eventName === 'error';
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, compatibilityNames),
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 || !ts.isTemplateExpression(expression)) return undefined;
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
  };