@saptools/service-flow 0.1.70 → 0.1.72

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 (55) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -5
  3. package/TECHNICAL-NOTE.md +13 -0
  4. package/dist/{chunk-GSLFY6J2.js → chunk-Z6D433R5.js} +8696 -6476
  5. package/dist/chunk-Z6D433R5.js.map +1 -0
  6. package/dist/cli.js +685 -142
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +53 -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 +371 -0
  13. package/src/cli/doctor.ts +4 -6
  14. package/src/cli.ts +1 -1
  15. package/src/db/call-fact-repository.ts +6 -3
  16. package/src/db/current-fact-semantics.ts +5 -5
  17. package/src/db/event-fact-semantics.ts +347 -0
  18. package/src/db/event-surface-invalidation.ts +38 -0
  19. package/src/db/fact-json-inventory.ts +16 -0
  20. package/src/db/migrations.ts +12 -1
  21. package/src/db/package-target-invalidation.ts +79 -0
  22. package/src/db/repositories.ts +28 -2
  23. package/src/db/schema-structure.ts +41 -1
  24. package/src/db/schema.ts +6 -2
  25. package/src/indexer/repository-indexer.ts +45 -6
  26. package/src/linker/cross-repo-linker.ts +25 -3
  27. package/src/linker/event-environment-link.ts +211 -0
  28. package/src/linker/event-shape-candidate-linker.ts +161 -0
  29. package/src/linker/event-subscription-handler-linker.ts +123 -19
  30. package/src/linker/event-template-link.ts +40 -6
  31. package/src/linker/package-event-constant-resolver.ts +298 -0
  32. package/src/output/table-output.ts +13 -1
  33. package/src/parsers/decorator-parser.ts +9 -53
  34. package/src/parsers/environment-declarations.ts +327 -0
  35. package/src/parsers/event-call-analysis.ts +242 -0
  36. package/src/parsers/event-environment-reference.ts +231 -0
  37. package/src/parsers/event-loop-registration.ts +132 -0
  38. package/src/parsers/event-name-import-resolution.ts +243 -0
  39. package/src/parsers/event-receiver-analysis.ts +404 -0
  40. package/src/parsers/event-subscription-facts.ts +4 -0
  41. package/src/parsers/generated-constants-parser.ts +80 -14
  42. package/src/parsers/outbound-call-classifier.ts +27 -124
  43. package/src/parsers/outbound-call-parser.ts +13 -1
  44. package/src/parsers/outbound-expression-analysis.ts +2 -2
  45. package/src/parsers/stable-local-value.ts +30 -9
  46. package/src/parsers/string-constant-lookups.ts +358 -0
  47. package/src/trace/event-runtime-resolution.ts +24 -3
  48. package/src/trace/event-shape-candidate-trace.ts +172 -0
  49. package/src/trace/event-subscriber-traversal.ts +19 -7
  50. package/src/trace/evidence.ts +7 -0
  51. package/src/trace/trace-scope-execution.ts +21 -29
  52. package/src/types.ts +17 -1
  53. package/src/utils/event-skeleton.ts +207 -0
  54. package/src/version.ts +1 -1
  55. package/dist/chunk-GSLFY6J2.js.map +0 -1
@@ -0,0 +1,347 @@
1
+ import type { Db } from './connection.js';
2
+ import { ANALYZER_VERSION } from '../version.js';
3
+ import {
4
+ EVENT_ENVIRONMENT_KEY_ALLOWLIST,
5
+ parseEnvironmentDeclarationsFact,
6
+ } from '../parsers/environment-declarations.js';
7
+ import type {
8
+ EventEnvironmentReference,
9
+ } from '../parsers/event-environment-reference.js';
10
+ import { parseEventSkeletonFact } from '../utils/event-skeleton.js';
11
+ import { parsePackageImportReference } from
12
+ '../parsers/package-fact-contract.js';
13
+ import {
14
+ expectedPackageEventConstantResolution,
15
+ type PackageEventConstantResolution,
16
+ } from '../linker/package-event-constant-resolver.js';
17
+
18
+ export interface EventFactSemanticCategoryCount {
19
+ category: string;
20
+ count: number;
21
+ }
22
+
23
+ interface EventFactRow extends Record<string, unknown> {
24
+ workspaceId?: number;
25
+ eventName?: string;
26
+ skeletonSignature?: string | null;
27
+ skeletonJson?: string | null;
28
+ unresolvedReason?: string | null;
29
+ evidenceJson?: string;
30
+ }
31
+
32
+ const receiverReasons = new Set([
33
+ 'event_receiver_unproven_binding',
34
+ 'event_receiver_unproven_propagation',
35
+ 'event_receiver_not_cap_client',
36
+ ]);
37
+ const constantReasons = new Set([
38
+ 'event_name_constant_container_ambiguous',
39
+ 'event_name_constant_member_not_string',
40
+ 'event_name_constant_container_mutable',
41
+ 'event_name_constant_container_not_exported',
42
+ 'event_name_constant_resolution_pending',
43
+ ]);
44
+ const environmentKeys = new Set<string>(EVENT_ENVIRONMENT_KEY_ALLOWLIST);
45
+
46
+ function category(
47
+ name: string,
48
+ count: number,
49
+ ): EventFactSemanticCategoryCount[] {
50
+ return count > 0 ? [{ category: name, count }] : [];
51
+ }
52
+
53
+ function record(value: unknown): Record<string, unknown> | undefined {
54
+ return value && typeof value === 'object' && !Array.isArray(value)
55
+ ? value as Record<string, unknown> : undefined;
56
+ }
57
+
58
+ function jsonRecord(value: unknown): Record<string, unknown> | undefined {
59
+ if (typeof value !== 'string') return undefined;
60
+ try {
61
+ return record(JSON.parse(value) as unknown);
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ function currentEventRows(
68
+ db: Db,
69
+ workspaceId?: number,
70
+ ): EventFactRow[] {
71
+ return db.prepare(`SELECT fact.event_name_expr eventName,
72
+ r.workspace_id workspaceId,
73
+ fact.event_skeleton_signature skeletonSignature,
74
+ fact.event_skeleton_json skeletonJson,
75
+ fact.unresolved_reason unresolvedReason,
76
+ fact.evidence_json evidenceJson
77
+ FROM outbound_calls fact JOIN repositories r ON r.id=fact.repo_id
78
+ WHERE r.fact_analyzer_version=?
79
+ AND (? IS NULL OR r.workspace_id=?)
80
+ AND fact.call_type IN ('async_emit','async_subscribe')`).all(
81
+ ANALYZER_VERSION, workspaceId, workspaceId,
82
+ ) as EventFactRow[];
83
+ }
84
+
85
+ function integerField(
86
+ value: Record<string, unknown>,
87
+ key: string,
88
+ ): number | undefined {
89
+ const item = value[key];
90
+ return Number.isInteger(item) && Number(item) >= 0
91
+ ? Number(item) : undefined;
92
+ }
93
+
94
+ function packagePendingValid(
95
+ row: EventFactRow,
96
+ evidence: Record<string, unknown>,
97
+ ): boolean {
98
+ const source = evidence.eventNameConstantSourceExpression;
99
+ return evidence.eventNameUnresolvedReason
100
+ === 'event_name_constant_resolution_pending'
101
+ && evidence.eventNamePackageConstantResolution === undefined
102
+ && typeof source === 'string' && source.length > 0
103
+ && row.eventName === source;
104
+ }
105
+
106
+ function resolutionFieldsValid(
107
+ value: Record<string, unknown>,
108
+ expected: PackageEventConstantResolution,
109
+ ): boolean {
110
+ return value.status === expected.status
111
+ && value.reason === expected.reason
112
+ && integerField(value, 'candidateCount') === expected.candidateCount
113
+ && integerField(value, 'eligibleCandidateCount')
114
+ === expected.eligibleCandidateCount
115
+ && integerField(value, 'selectedCandidateCount')
116
+ === (expected.status === 'resolved' ? 1 : 0)
117
+ && value.candidateSetComplete === expected.complete
118
+ && value.resolvedModulePath === expected.modulePath
119
+ && value.targetRepositoryId === expected.targetRepoId;
120
+ }
121
+
122
+ function packageTerminalValid(
123
+ db: Db,
124
+ row: EventFactRow,
125
+ evidence: Record<string, unknown>,
126
+ ): boolean {
127
+ const binding = parsePackageImportReference(
128
+ evidence.eventNameConstantImportBinding,
129
+ );
130
+ const workspaceId = row.workspaceId;
131
+ const resolution = record(evidence.eventNamePackageConstantResolution);
132
+ if (!binding || typeof workspaceId !== 'number' || !resolution)
133
+ return false;
134
+ const expected = expectedPackageEventConstantResolution(
135
+ db, workspaceId, binding,
136
+ );
137
+ const source = evidence.eventNameConstantSourceExpression;
138
+ const expectedName = expected.value ?? source;
139
+ const expectedReason = expected.status === 'resolved'
140
+ ? undefined : expected.reason;
141
+ if (row.eventName !== expectedName
142
+ || evidence.eventNameUnresolvedReason !== expectedReason
143
+ || !resolutionFieldsValid(resolution, expected)) return false;
144
+ if (expected.status !== 'resolved') return true;
145
+ const constant = record(evidence.eventNameConstant);
146
+ return constant?.sourceKind === 'package_static_string'
147
+ && constant.sourceFile === expected.sourceFile
148
+ && constant.sourceLine === expected.sourceLine;
149
+ }
150
+
151
+ function packageConstantValid(
152
+ db: Db,
153
+ row: EventFactRow,
154
+ evidence: Record<string, unknown>,
155
+ phase: 'pre_package' | 'terminal',
156
+ ): boolean {
157
+ if (evidence.eventNameConstantImportBinding === undefined) return true;
158
+ const binding = parsePackageImportReference(
159
+ evidence.eventNameConstantImportBinding,
160
+ );
161
+ const source = evidence.eventNameConstantSourceExpression;
162
+ if (!binding || typeof source !== 'string' || source.length === 0)
163
+ return false;
164
+ if (packagePendingValid(row, evidence))
165
+ return phase === 'pre_package';
166
+ return packageTerminalValid(db, row, evidence);
167
+ }
168
+
169
+ function receiverEvidenceValid(
170
+ row: EventFactRow,
171
+ evidence: Record<string, unknown>,
172
+ ): boolean {
173
+ const classification = evidence.receiverClassification;
174
+ const proof = evidence.receiverProof;
175
+ if (!['cap_evidence', 'name_fallback', 'unproven'].includes(
176
+ String(classification),
177
+ ) || typeof proof !== 'string' || proof.length === 0
178
+ || !Array.isArray(evidence.consideredBindingSites)
179
+ || evidence.consideredBindingSites.length > 8) return false;
180
+ if (classification === 'name_fallback')
181
+ return proof === 'compatibility_name_fallback';
182
+ if (classification !== 'unproven') return true;
183
+ return typeof row.unresolvedReason === 'string'
184
+ && receiverReasons.has(row.unresolvedReason);
185
+ }
186
+
187
+ function environmentBindingValid(
188
+ binding: EventEnvironmentReference,
189
+ sourceKeys: readonly string[],
190
+ ): boolean {
191
+ if (!['resolved', 'refused'].includes(binding.status)
192
+ || !sourceKeys.includes(binding.sourceKey)
193
+ || !Array.isArray(binding.transforms)
194
+ || !binding.transforms.every((item) =>
195
+ item === 'toUpperCase' || item === 'toLowerCase')) return false;
196
+ if (binding.status === 'refused')
197
+ return typeof binding.reason === 'string' && binding.reason.length > 0;
198
+ return typeof binding.environmentKey === 'string'
199
+ && environmentKeys.has(binding.environmentKey)
200
+ && typeof binding.sourceFile === 'string' && binding.sourceFile.length > 0
201
+ && Number.isInteger(binding.startOffset)
202
+ && Number(binding.startOffset) >= 0
203
+ && Number.isInteger(binding.endOffset)
204
+ && Number(binding.endOffset) > Number(binding.startOffset);
205
+ }
206
+
207
+ function skeletonValid(
208
+ row: EventFactRow,
209
+ evidence: Record<string, unknown>,
210
+ ): boolean {
211
+ const reason = evidence.eventNameUnresolvedReason;
212
+ if (reason !== 'dynamic_event_name_identifier')
213
+ return row.skeletonSignature == null && row.skeletonJson == null;
214
+ const skeleton = parseEventSkeletonFact(row.skeletonJson);
215
+ if (!skeleton || skeleton.status !== 'complete'
216
+ || row.skeletonSignature !== skeleton.signature
217
+ || !skeleton.environmentBindings.every((binding) =>
218
+ environmentBindingValid(binding, skeleton.sourceKeys))) return false;
219
+ const keys = evidence.eventNamePlaceholderKeys;
220
+ return Array.isArray(keys)
221
+ && JSON.stringify(keys) === JSON.stringify(skeleton.sourceKeys);
222
+ }
223
+
224
+ function eventReasonValid(
225
+ row: EventFactRow,
226
+ evidence: Record<string, unknown>,
227
+ ): boolean {
228
+ const eventReason = evidence.eventNameUnresolvedReason;
229
+ if (receiverReasons.has(String(row.unresolvedReason)))
230
+ return eventReason === undefined
231
+ || eventReason === 'dynamic_event_name_identifier'
232
+ || eventReason === 'dynamic_event_name_unsupported_expression'
233
+ || constantReasons.has(String(eventReason));
234
+ if (eventReason === undefined) return row.unresolvedReason == null;
235
+ if (eventReason === 'dynamic_event_name_identifier'
236
+ || eventReason === 'dynamic_event_name_unsupported_expression'
237
+ || constantReasons.has(String(eventReason)))
238
+ return row.unresolvedReason === eventReason;
239
+ return false;
240
+ }
241
+
242
+ function invalidEventRows(
243
+ db: Db,
244
+ workspaceId?: number,
245
+ phase: 'pre_package' | 'terminal' = 'pre_package',
246
+ ): number {
247
+ return currentEventRows(db, workspaceId).filter((row) => {
248
+ if (typeof row.eventName !== 'string' || row.eventName.length === 0)
249
+ return false;
250
+ const evidence = jsonRecord(row.evidenceJson);
251
+ return !evidence
252
+ || !receiverEvidenceValid(row, evidence)
253
+ || !eventReasonValid(row, evidence)
254
+ || !packageConstantValid(db, row, evidence, phase)
255
+ || !skeletonValid(row, evidence);
256
+ }).length;
257
+ }
258
+
259
+ function invalidEnvironmentRepositories(
260
+ db: Db,
261
+ workspaceId?: number,
262
+ ): number {
263
+ const rows = db.prepare(`SELECT environment_declarations_json value
264
+ FROM repositories WHERE fact_analyzer_version=?
265
+ AND (? IS NULL OR workspace_id=?)`).all(
266
+ ANALYZER_VERSION, workspaceId, workspaceId,
267
+ );
268
+ return rows.filter((row) =>
269
+ !parseEnvironmentDeclarationsFact(row.value)).length;
270
+ }
271
+
272
+ function generatedConstantInvalid(row: Record<string, unknown>): boolean {
273
+ const kind = String(row.constantKind);
274
+ const container = row.containerName;
275
+ const member = row.memberName;
276
+ const name = row.name;
277
+ const memberShape = kind === 'const_identifier'
278
+ ? container == null && member == null
279
+ : typeof container === 'string' && container.length > 0
280
+ && typeof member === 'string' && member.length > 0
281
+ && name === `${container}.${member}`;
282
+ const resolved = row.resolutionStatus === 'resolved';
283
+ const refused = row.resolutionStatus === 'refused';
284
+ return !['const_identifier', 'enum_member', 'const_object_property']
285
+ .includes(kind)
286
+ || typeof name !== 'string' || name.length === 0
287
+ || (!resolved && !refused)
288
+ || (resolved && (typeof row.value !== 'string'
289
+ || row.value.length === 0 || row.unresolvedReason != null))
290
+ || (refused && (row.value != null
291
+ || !constantReasons.has(String(row.unresolvedReason))))
292
+ || typeof row.sourceFile !== 'string' || row.sourceFile.length === 0
293
+ || !Number.isInteger(row.sourceLine) || Number(row.sourceLine) < 1
294
+ || ![0, 1].includes(Number(row.exported))
295
+ || ![0, 1].includes(Number(row.stable))
296
+ || !validConstantOffsets(row) || !memberShape;
297
+ }
298
+
299
+ function validConstantOffsets(row: Record<string, unknown>): boolean {
300
+ const start = row.declarationStartOffset;
301
+ const end = row.declarationEndOffset;
302
+ const valueStart = row.valueStartOffset;
303
+ const valueEnd = row.valueEndOffset;
304
+ return Number.isInteger(start) && Number(start) >= 0
305
+ && Number.isInteger(end) && Number(end) > Number(start)
306
+ && Number.isInteger(valueStart) && Number(valueStart) >= Number(start)
307
+ && Number.isInteger(valueEnd) && Number(valueEnd) > Number(valueStart)
308
+ && Number(valueEnd) <= Number(end);
309
+ }
310
+
311
+ function invalidGeneratedConstants(
312
+ db: Db,
313
+ workspaceId?: number,
314
+ ): number {
315
+ const rows = db.prepare(`SELECT fact.source_file sourceFile,
316
+ fact.source_line sourceLine,fact.name,fact.container_name containerName,
317
+ fact.member_name memberName,fact.value,
318
+ fact.constant_kind constantKind,fact.exported,fact.stable,
319
+ fact.resolution_status resolutionStatus,
320
+ fact.unresolved_reason unresolvedReason,
321
+ fact.declaration_start_offset declarationStartOffset,
322
+ fact.declaration_end_offset declarationEndOffset,
323
+ fact.value_start_offset valueStartOffset,
324
+ fact.value_end_offset valueEndOffset
325
+ FROM generated_constants fact
326
+ JOIN repositories r ON r.id=fact.repo_id
327
+ WHERE r.fact_analyzer_version=?
328
+ AND (? IS NULL OR r.workspace_id=?)`).all(
329
+ ANALYZER_VERSION, workspaceId, workspaceId,
330
+ );
331
+ return rows.filter(generatedConstantInvalid).length;
332
+ }
333
+
334
+ export function invalidEventFactCategories(
335
+ db: Db,
336
+ workspaceId?: number,
337
+ phase: 'pre_package' | 'terminal' = 'pre_package',
338
+ ): EventFactSemanticCategoryCount[] {
339
+ return [
340
+ ...category('event_fact_semantics_invalid',
341
+ invalidEventRows(db, workspaceId, phase)),
342
+ ...category('repository_environment_declarations_invalid',
343
+ invalidEnvironmentRepositories(db, workspaceId)),
344
+ ...category('generated_constant_fact_invalid',
345
+ invalidGeneratedConstants(db, workspaceId)),
346
+ ];
347
+ }
@@ -0,0 +1,38 @@
1
+ import type { Db } from './connection.js';
2
+ import type { OutboundCallFact } from '../types.js';
3
+
4
+ function priorEventFactCount(db: Db, repoId: number): number {
5
+ const row = db.prepare(`SELECT COUNT(*) count FROM outbound_calls
6
+ WHERE repo_id=? AND call_type IN ('async_emit','async_subscribe')`).get(
7
+ repoId,
8
+ );
9
+ return Number(row?.count ?? 0);
10
+ }
11
+
12
+ function repositoryEnvironment(
13
+ db: Db,
14
+ repoId: number,
15
+ ): string | null {
16
+ const row = db.prepare(`SELECT environment_declarations_json value
17
+ FROM repositories WHERE id=?`).get(repoId);
18
+ return typeof row?.value === 'string' ? row.value : null;
19
+ }
20
+
21
+ export function invalidateEventSurfaceFacts(
22
+ db: Db,
23
+ repoId: number,
24
+ calls: readonly OutboundCallFact[],
25
+ nextEnvironmentJson: string,
26
+ ): void {
27
+ const hasNewEvents = calls.some((call) =>
28
+ call.callType === 'async_emit' || call.callType === 'async_subscribe');
29
+ const environmentChanged = repositoryEnvironment(db, repoId)
30
+ !== nextEnvironmentJson;
31
+ if (!hasNewEvents && priorEventFactCount(db, repoId) === 0
32
+ && !environmentChanged) return;
33
+ db.prepare(`UPDATE repositories SET
34
+ graph_stale_reason='event_surface_facts_changed',
35
+ graph_stale_at=datetime('now')
36
+ WHERE workspace_id=(SELECT workspace_id FROM repositories WHERE id=?)`)
37
+ .run(repoId);
38
+ }
@@ -36,6 +36,14 @@ export const LINK_FACT_JSON_INVENTORY: readonly FactJsonInventoryItem[] = [
36
36
  consumers: ['package-import-symbol-resolver'],
37
37
  repositoryJoin: '',
38
38
  },
39
+ {
40
+ table: 'repositories',
41
+ column: 'environment_declarations_json',
42
+ nullable: false,
43
+ shape: 'object',
44
+ consumers: ['event-environment-resolver'],
45
+ repositoryJoin: '',
46
+ },
39
47
  {
40
48
  table: 'handler_methods',
41
49
  column: 'decorator_resolution_json',
@@ -77,6 +85,14 @@ export const LINK_FACT_JSON_INVENTORY: readonly FactJsonInventoryItem[] = [
77
85
  consumers: ['cross-repository-linker', 'trace-engine'],
78
86
  repositoryJoin: directRepositoryJoin,
79
87
  },
88
+ {
89
+ table: 'outbound_calls',
90
+ column: 'event_skeleton_json',
91
+ nullable: true,
92
+ shape: 'object',
93
+ consumers: ['event-shape-linker', 'event-runtime-resolver'],
94
+ repositoryJoin: directRepositoryJoin,
95
+ },
80
96
  {
81
97
  table: 'service_bindings',
82
98
  column: 'placeholders_json',
@@ -1,6 +1,6 @@
1
1
  import type { Db } from './connection.js';
2
2
  import { schemaIndexesSql, schemaTablesSql } from './schema.js';
3
- export const CURRENT_SCHEMA_VERSION = 13;
3
+ export const CURRENT_SCHEMA_VERSION = 14;
4
4
  const columns: Record<string, Array<{ name: string; ddl: string }>> = {
5
5
  handler_methods: [
6
6
  { name: 'decorator_resolution_json', ddl: "ALTER TABLE handler_methods ADD COLUMN decorator_resolution_json TEXT NOT NULL DEFAULT '{}'" },
@@ -20,6 +20,7 @@ const columns: Record<string, Array<{ name: string; ddl: string }>> = {
20
20
  { name: 'graph_stale_at', ddl: 'ALTER TABLE repositories ADD COLUMN graph_stale_at TEXT' },
21
21
  { name: 'fact_analyzer_version', ddl: "ALTER TABLE repositories ADD COLUMN fact_analyzer_version TEXT DEFAULT 'legacy'" },
22
22
  { name: 'package_public_surface_json', ddl: 'ALTER TABLE repositories ADD COLUMN package_public_surface_json TEXT' },
23
+ { name: 'environment_declarations_json', ddl: 'ALTER TABLE repositories ADD COLUMN environment_declarations_json TEXT' },
23
24
  ],
24
25
  graph_edges: [
25
26
  { name: 'status', ddl: "ALTER TABLE graph_edges ADD COLUMN status TEXT NOT NULL DEFAULT 'unresolved'" },
@@ -60,6 +61,8 @@ const columns: Record<string, Array<{ name: string; ddl: string }>> = {
60
61
  { name: 'external_target_dynamic', ddl: 'ALTER TABLE outbound_calls ADD COLUMN external_target_dynamic INTEGER NOT NULL DEFAULT 0' },
61
62
  { name: 'call_site_start_offset', ddl: 'ALTER TABLE outbound_calls ADD COLUMN call_site_start_offset INTEGER' },
62
63
  { name: 'call_site_end_offset', ddl: 'ALTER TABLE outbound_calls ADD COLUMN call_site_end_offset INTEGER' },
64
+ { name: 'event_skeleton_signature', ddl: 'ALTER TABLE outbound_calls ADD COLUMN event_skeleton_signature TEXT' },
65
+ { name: 'event_skeleton_json', ddl: 'ALTER TABLE outbound_calls ADD COLUMN event_skeleton_json TEXT' },
63
66
  ],
64
67
  symbol_calls: [
65
68
  { name: 'call_site_start_offset', ddl: 'ALTER TABLE symbol_calls ADD COLUMN call_site_start_offset INTEGER' },
@@ -104,6 +107,13 @@ function markFactProvenanceMigrationStale(db: Db, priorVersion: number): void {
104
107
  graph_stale_at=COALESCE(graph_stale_at,datetime('now'))
105
108
  WHERE index_status='indexed' OR last_indexed_at IS NOT NULL`).run();
106
109
  }
110
+ function markEventSurfaceMigrationStale(db: Db, priorVersion: number): void {
111
+ if (priorVersion >= 14) return;
112
+ db.prepare(`UPDATE repositories
113
+ SET graph_stale_reason='schema_v14_event_surface_requires_reindex',
114
+ graph_stale_at=COALESCE(graph_stale_at,datetime('now'))
115
+ WHERE index_status='indexed' OR last_indexed_at IS NOT NULL`).run();
116
+ }
107
117
  export function migrate(db: Db): void {
108
118
  db.transaction(() => {
109
119
  const version = userVersion(db);
@@ -114,6 +124,7 @@ export function migrate(db: Db): void {
114
124
  normalizeLegacyStatus(db, version);
115
125
  markCallSiteMigrationStale(db, version);
116
126
  markFactProvenanceMigrationStale(db, version);
127
+ markEventSurfaceMigrationStale(db, version);
117
128
  const violations = db.pragma('foreign_key_check');
118
129
  if (violations.length > 0) throw new Error('SQLite foreign_key_check failed during migration');
119
130
  db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
@@ -10,6 +10,13 @@ interface PackageCallRow {
10
10
  evidence: Record<string, unknown>;
11
11
  }
12
12
 
13
+ interface PackageEventCallRow {
14
+ id: number;
15
+ repoId: number;
16
+ unresolvedReason?: string | null;
17
+ evidence: Record<string, unknown>;
18
+ }
19
+
13
20
  export interface PackageInvalidationBatch {
14
21
  publishingRepoIds: ReadonlySet<number>;
15
22
  affectedCallerRepoIds: Set<number>;
@@ -44,6 +51,14 @@ function packageName(evidence: Record<string, unknown>): string | undefined {
44
51
  ?.requestedPackageName ?? undefined;
45
52
  }
46
53
 
54
+ function eventPackageName(
55
+ evidence: Record<string, unknown>,
56
+ ): string | undefined {
57
+ return parsePackageImportReference(
58
+ evidence.eventNameConstantImportBinding,
59
+ )?.requestedPackageName ?? undefined;
60
+ }
61
+
47
62
  function packageCallEvidenceValid(
48
63
  evidence: Record<string, unknown>,
49
64
  ): boolean {
@@ -94,6 +109,67 @@ function pendingEvidence(evidence: Record<string, unknown>): string {
94
109
  });
95
110
  }
96
111
 
112
+ function currentEventCalls(
113
+ db: Db,
114
+ workspaceId: number,
115
+ targetRepoId: number,
116
+ ): PackageEventCallRow[] {
117
+ const rows = db.prepare(`SELECT c.id,c.repo_id repoId,
118
+ c.unresolved_reason unresolvedReason,c.evidence_json evidenceJson
119
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
120
+ WHERE r.workspace_id=? AND r.id<>? AND r.fact_analyzer_version=?
121
+ AND json_extract(c.evidence_json,
122
+ '$.eventNameConstantImportBinding.moduleKind')='package'
123
+ ORDER BY c.id`).all(workspaceId, targetRepoId, ANALYZER_VERSION);
124
+ return rows.flatMap((row) => {
125
+ const evidence = parsedEvidence(row.evidenceJson);
126
+ return evidence && eventPackageName(evidence)
127
+ && typeof row.id === 'number' && typeof row.repoId === 'number'
128
+ ? [{
129
+ id: row.id, repoId: row.repoId, evidence,
130
+ unresolvedReason: typeof row.unresolvedReason === 'string'
131
+ ? row.unresolvedReason : null,
132
+ }] : [];
133
+ });
134
+ }
135
+
136
+ function pendingEventEvidence(
137
+ evidence: Record<string, unknown>,
138
+ ): string {
139
+ const parser = { ...evidence };
140
+ delete parser.eventNameConstant;
141
+ delete parser.eventNamePackageConstantResolution;
142
+ parser.eventNameUnresolvedReason = 'event_name_constant_resolution_pending';
143
+ parser.eventNameStatus = 'dynamic';
144
+ parser.eventNameSourceKind = 'dynamic_expression';
145
+ parser.eventNamePlaceholderKeys = [];
146
+ return JSON.stringify(parser);
147
+ }
148
+
149
+ function resetPackageEventCalls(
150
+ db: Db,
151
+ workspaceId: number,
152
+ targetRepoId: number,
153
+ names: ReadonlySet<string>,
154
+ batch: PackageInvalidationBatch,
155
+ ): boolean {
156
+ const update = db.prepare(`UPDATE outbound_calls SET event_name_expr=?,
157
+ unresolved_reason=?,evidence_json=? WHERE id=?`);
158
+ let matched = false;
159
+ for (const call of currentEventCalls(db, workspaceId, targetRepoId)) {
160
+ if (!names.has(eventPackageName(call.evidence) ?? '')) continue;
161
+ const source = call.evidence.eventNameConstantSourceExpression;
162
+ if (typeof source !== 'string' || source.length === 0)
163
+ throw new Error('invalid_current_package_event_constant_evidence');
164
+ const reason = call.evidence.receiverClassification === 'unproven'
165
+ ? call.unresolvedReason : 'event_name_constant_resolution_pending';
166
+ update.run(source, reason, pendingEventEvidence(call.evidence), call.id);
167
+ batch.affectedCallerRepoIds.add(call.repoId);
168
+ matched = true;
169
+ }
170
+ return matched;
171
+ }
172
+
97
173
  function targetWorkspace(
98
174
  db: Db,
99
175
  repoId: number,
@@ -142,6 +218,9 @@ export function invalidatePackageTargetFacts(
142
218
  batch.affectedCallerRepoIds.add(call.repoId);
143
219
  matched = true;
144
220
  }
221
+ matched = resetPackageEventCalls(
222
+ db, target.workspaceId, targetRepoId, names, batch,
223
+ ) || matched;
145
224
  if (matched || packageIdentityChanged(
146
225
  target.packageName, newPackageName,
147
226
  )) batch.affectedWorkspaceIds.add(target.workspaceId);
@@ -7,6 +7,7 @@ import type {
7
7
  HandlerRegistrationFact,
8
8
  ServiceBindingFact,
9
9
  ExecutableSymbolFact,
10
+ GeneratedConstantFact,
10
11
  } from '../types.js';
11
12
  import {
12
13
  selectCallOwner,
@@ -16,6 +17,9 @@ import {
16
17
  PreparedRepositorySnapshotError,
17
18
  type PreparedSnapshotFailureCode,
18
19
  } from './index-publication-failure.js';
20
+ import {
21
+ emptyEnvironmentDeclarations,
22
+ } from '../parsers/environment-declarations.js';
19
23
  export interface RepoRow {
20
24
  id: number;
21
25
  name: string;
@@ -92,8 +96,8 @@ export function upsertRepository(
92
96
  db.prepare(
93
97
  `INSERT INTO repositories(workspace_id,name,absolute_path,relative_path,
94
98
  package_name,package_version,dependencies_json,
95
- package_public_surface_json,kind,is_git_repo)
96
- VALUES(?,?,?,?,?,?,?,?,?,?)
99
+ package_public_surface_json,environment_declarations_json,kind,is_git_repo)
100
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
97
101
  ON CONFLICT(workspace_id,absolute_path) DO UPDATE SET
98
102
  name=excluded.name,relative_path=excluded.relative_path,
99
103
  package_name=excluded.package_name,
@@ -108,6 +112,7 @@ export function upsertRepository(
108
112
  r.packageVersion,
109
113
  JSON.stringify(r.dependencies ?? {}),
110
114
  initialPackagePublicSurface(r.packageName),
115
+ JSON.stringify(emptyEnvironmentDeclarations()),
111
116
  r.kind ?? 'unknown',
112
117
  r.isGitRepo ? 1 : 0,
113
118
  );
@@ -152,6 +157,7 @@ export function clearRepoFacts(db: Db, repoId: number): void {
152
157
  'symbol_calls',
153
158
  'handler_registrations',
154
159
  'service_bindings',
160
+ 'generated_constants',
155
161
  'symbols',
156
162
  'diagnostics',
157
163
  'files',
@@ -159,6 +165,26 @@ export function clearRepoFacts(db: Db, repoId: number): void {
159
165
  db.prepare(`DELETE FROM ${t} WHERE repo_id=?`).run(repoId);
160
166
  db.prepare('DELETE FROM search_index WHERE repo=?').run(String(repoId));
161
167
  }
168
+ export function insertGeneratedConstants(
169
+ db: Db,
170
+ repoId: number,
171
+ rows: GeneratedConstantFact[],
172
+ ): void {
173
+ const stmt = db.prepare(`INSERT INTO generated_constants(
174
+ repo_id,source_file,source_line,name,container_name,member_name,value,
175
+ constant_kind,exported,stable,resolution_status,unresolved_reason,
176
+ declaration_start_offset,declaration_end_offset,
177
+ value_start_offset,value_end_offset
178
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
179
+ for (const row of rows) stmt.run(
180
+ repoId, row.sourceFile, row.sourceLine, row.name,
181
+ row.containerName ?? null, row.memberName ?? null, row.value ?? null,
182
+ row.constantKind, row.exported ? 1 : 0, row.stable ? 1 : 0,
183
+ row.resolutionStatus, row.unresolvedReason ?? null,
184
+ row.declarationStartOffset, row.declarationEndOffset,
185
+ row.valueStartOffset, row.valueEndOffset,
186
+ );
187
+ }
162
188
  export function insertRequires(
163
189
  db: Db,
164
190
  repoId: number,