@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
@@ -1,7 +1,6 @@
1
1
  import type { Db } from './connection.js';
2
2
  import { ANALYZER_VERSION } from '../version.js';
3
3
  import {
4
- EVENT_ENVIRONMENT_KEY_ALLOWLIST,
5
4
  parseEnvironmentDeclarationsFact,
6
5
  } from '../parsers/environment-declarations.js';
7
6
  import type {
@@ -21,12 +20,28 @@ export interface EventFactSemanticCategoryCount {
21
20
  }
22
21
 
23
22
  interface EventFactRow extends Record<string, unknown> {
23
+ id?: number;
24
+ repoId?: number;
25
+ repositoryName?: string;
24
26
  workspaceId?: number;
27
+ sourceFile?: string;
28
+ sourceLine?: number;
25
29
  eventName?: string;
26
30
  skeletonSignature?: string | null;
27
31
  skeletonJson?: string | null;
28
32
  unresolvedReason?: string | null;
29
33
  evidenceJson?: string;
34
+ environmentJson?: string | null;
35
+ }
36
+
37
+ export interface InvalidEventFactExample {
38
+ category: string;
39
+ repositoryId: number;
40
+ repositoryName: string;
41
+ sourceFile?: string;
42
+ sourceLine?: number;
43
+ factId?: number;
44
+ failingPredicate: string;
30
45
  }
31
46
 
32
47
  const receiverReasons = new Set([
@@ -38,11 +53,12 @@ const constantReasons = new Set([
38
53
  'event_name_constant_container_ambiguous',
39
54
  'event_name_constant_member_not_string',
40
55
  'event_name_constant_container_mutable',
56
+ 'event_name_constant_container_unsafe_reference',
57
+ 'event_name_constant_container_unsupported_shape',
41
58
  'event_name_constant_container_not_exported',
42
59
  'event_name_constant_resolution_pending',
60
+ 'event_name_constant_value_empty',
43
61
  ]);
44
- const environmentKeys = new Set<string>(EVENT_ENVIRONMENT_KEY_ALLOWLIST);
45
-
46
62
  function category(
47
63
  name: string,
48
64
  count: number,
@@ -68,12 +84,15 @@ function currentEventRows(
68
84
  db: Db,
69
85
  workspaceId?: number,
70
86
  ): EventFactRow[] {
71
- return db.prepare(`SELECT fact.event_name_expr eventName,
87
+ return db.prepare(`SELECT fact.id,fact.repo_id repoId,r.name repositoryName,
88
+ fact.source_file sourceFile,fact.source_line sourceLine,
89
+ fact.event_name_expr eventName,
72
90
  r.workspace_id workspaceId,
73
91
  fact.event_skeleton_signature skeletonSignature,
74
92
  fact.event_skeleton_json skeletonJson,
75
93
  fact.unresolved_reason unresolvedReason,
76
- fact.evidence_json evidenceJson
94
+ fact.evidence_json evidenceJson,
95
+ r.environment_declarations_json environmentJson
77
96
  FROM outbound_calls fact JOIN repositories r ON r.id=fact.repo_id
78
97
  WHERE r.fact_analyzer_version=?
79
98
  AND (? IS NULL OR r.workspace_id=?)
@@ -82,6 +101,25 @@ function currentEventRows(
82
101
  ) as EventFactRow[];
83
102
  }
84
103
 
104
+ function eventInvalidPredicate(
105
+ db: Db,
106
+ row: EventFactRow,
107
+ phase: 'pre_package' | 'terminal',
108
+ ): string | undefined {
109
+ if (typeof row.eventName !== 'string' || row.eventName.length === 0)
110
+ return 'event_name_non_empty';
111
+ const evidence = jsonRecord(row.evidenceJson);
112
+ if (!evidence) return 'event_evidence_valid_json_object';
113
+ if (!receiverEvidenceValid(evidence))
114
+ return 'event_receiver_evidence_consistent';
115
+ if (!eventReasonValid(row, evidence))
116
+ return 'event_name_reason_axis_consistent';
117
+ if (!packageConstantValid(db, row, evidence, phase))
118
+ return 'event_package_constant_resolution_consistent';
119
+ return skeletonValid(row, evidence)
120
+ ? undefined : 'event_skeleton_complete_and_signed';
121
+ }
122
+
85
123
  function integerField(
86
124
  value: Record<string, unknown>,
87
125
  key: string,
@@ -167,37 +205,70 @@ function packageConstantValid(
167
205
  }
168
206
 
169
207
  function receiverEvidenceValid(
170
- row: EventFactRow,
171
208
  evidence: Record<string, unknown>,
172
209
  ): boolean {
173
210
  const classification = evidence.receiverClassification;
174
211
  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;
212
+ if (!receiverEvidenceShapeValid(classification, proof, evidence))
213
+ return false;
180
214
  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);
215
+ return fallbackReceiverEvidenceValid(proof, evidence);
216
+ if (classification !== 'unproven')
217
+ return evidence.receiverUnresolvedReason === undefined
218
+ && evidence.receiverFallbackRefusedReason === undefined;
219
+ return evidence.receiverFallbackRefusedReason === undefined
220
+ && typeof evidence.receiverUnresolvedReason === 'string'
221
+ && receiverReasons.has(evidence.receiverUnresolvedReason);
222
+ }
223
+
224
+ function receiverEvidenceShapeValid(
225
+ classification: unknown,
226
+ proof: unknown,
227
+ evidence: Record<string, unknown>,
228
+ ): boolean {
229
+ const sites = evidence.consideredBindingSites;
230
+ return ['cap_evidence', 'name_fallback', 'unproven'].includes(
231
+ String(classification),
232
+ ) && typeof proof === 'string' && proof.length > 0
233
+ && Array.isArray(sites) && sites.length <= 8;
234
+ }
235
+
236
+ function fallbackReceiverEvidenceValid(
237
+ proof: unknown,
238
+ evidence: Record<string, unknown>,
239
+ ): boolean {
240
+ const reason = evidence.receiverFallbackRefusedReason;
241
+ return proof === 'compatibility_name_fallback'
242
+ && evidence.receiverUnresolvedReason === undefined
243
+ && typeof reason === 'string' && reason.length > 0;
185
244
  }
186
245
 
187
246
  function environmentBindingValid(
188
247
  binding: EventEnvironmentReference,
189
248
  sourceKeys: readonly string[],
249
+ allowedKeys: ReadonlySet<string>,
190
250
  ): boolean {
191
251
  if (!['resolved', 'refused'].includes(binding.status)
192
252
  || !sourceKeys.includes(binding.sourceKey)
193
- || !Array.isArray(binding.transforms)
194
- || !binding.transforms.every((item) =>
195
- item === 'toUpperCase' || item === 'toLowerCase')) return false;
253
+ || !environmentTransformsValid(binding.transforms)) return false;
196
254
  if (binding.status === 'refused')
197
255
  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
256
+ return resolvedEnvironmentBindingValid(binding, allowedKeys);
257
+ }
258
+
259
+ function environmentTransformsValid(value: unknown): boolean {
260
+ return Array.isArray(value) && value.every((item) =>
261
+ item === 'toUpperCase' || item === 'toLowerCase');
262
+ }
263
+
264
+ function resolvedEnvironmentBindingValid(
265
+ binding: EventEnvironmentReference,
266
+ allowedKeys: ReadonlySet<string>,
267
+ ): boolean {
268
+ const key = binding.environmentKey;
269
+ const file = binding.sourceFile;
270
+ return typeof key === 'string' && allowedKeys.has(key)
271
+ && typeof file === 'string' && file.length > 0
201
272
  && Number.isInteger(binding.startOffset)
202
273
  && Number(binding.startOffset) >= 0
203
274
  && Number.isInteger(binding.endOffset)
@@ -212,10 +283,14 @@ function skeletonValid(
212
283
  if (reason !== 'dynamic_event_name_identifier')
213
284
  return row.skeletonSignature == null && row.skeletonJson == null;
214
285
  const skeleton = parseEventSkeletonFact(row.skeletonJson);
286
+ const environment = parseEnvironmentDeclarationsFact(row.environmentJson);
215
287
  if (!skeleton || skeleton.status !== 'complete'
288
+ || !environment
216
289
  || row.skeletonSignature !== skeleton.signature
217
290
  || !skeleton.environmentBindings.every((binding) =>
218
- environmentBindingValid(binding, skeleton.sourceKeys))) return false;
291
+ environmentBindingValid(
292
+ binding, skeleton.sourceKeys, new Set(environment.allowedKeys),
293
+ ))) return false;
219
294
  const keys = evidence.eventNamePlaceholderKeys;
220
295
  return Array.isArray(keys)
221
296
  && JSON.stringify(keys) === JSON.stringify(skeleton.sourceKeys);
@@ -226,11 +301,6 @@ function eventReasonValid(
226
301
  evidence: Record<string, unknown>,
227
302
  ): boolean {
228
303
  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
304
  if (eventReason === undefined) return row.unresolvedReason == null;
235
305
  if (eventReason === 'dynamic_event_name_identifier'
236
306
  || eventReason === 'dynamic_event_name_unsupported_expression'
@@ -244,16 +314,8 @@ function invalidEventRows(
244
314
  workspaceId?: number,
245
315
  phase: 'pre_package' | 'terminal' = 'pre_package',
246
316
  ): 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;
317
+ return currentEventRows(db, workspaceId).filter((row) =>
318
+ eventInvalidPredicate(db, row, phase) !== undefined).length;
257
319
  }
258
320
 
259
321
  function invalidEnvironmentRepositories(
@@ -269,31 +331,59 @@ function invalidEnvironmentRepositories(
269
331
  !parseEnvironmentDeclarationsFact(row.value)).length;
270
332
  }
271
333
 
272
- function generatedConstantInvalid(row: Record<string, unknown>): boolean {
334
+ function generatedConstantInvalidPredicate(
335
+ row: Record<string, unknown>,
336
+ ): string | undefined {
273
337
  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}`;
338
+ if (!['const_identifier', 'enum_member', 'const_object_property']
339
+ .includes(kind)) return 'generated_constant_kind_known';
340
+ if (typeof row.name !== 'string' || row.name.length === 0)
341
+ return 'generated_constant_name_non_empty';
342
+ const status = generatedConstantStatusInvalid(row);
343
+ if (status) return status;
344
+ const source = generatedConstantSourceInvalid(row);
345
+ if (source) return source;
346
+ if (!validConstantOffsets(row)) return 'generated_constant_offsets_valid';
347
+ return generatedConstantMemberShapeValid(row, kind)
348
+ ? undefined : 'generated_constant_member_shape_valid';
349
+ }
350
+
351
+ function generatedConstantStatusInvalid(
352
+ row: Record<string, unknown>,
353
+ ): string | undefined {
282
354
  const resolved = row.resolutionStatus === 'resolved';
283
355
  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;
356
+ if (!resolved && !refused) return 'generated_constant_status_known';
357
+ if (resolved && (typeof row.value !== 'string'
358
+ || row.unresolvedReason != null))
359
+ return 'generated_constant_resolved_value_consistent';
360
+ return refused && (row.value != null
361
+ || !constantReasons.has(String(row.unresolvedReason)))
362
+ ? 'generated_constant_refusal_consistent' : undefined;
363
+ }
364
+
365
+ function generatedConstantSourceInvalid(
366
+ row: Record<string, unknown>,
367
+ ): string | undefined {
368
+ if (typeof row.sourceFile !== 'string' || row.sourceFile.length === 0
369
+ || !Number.isInteger(row.sourceLine) || Number(row.sourceLine) < 1)
370
+ return 'generated_constant_source_location_valid';
371
+ return [0, 1].includes(Number(row.exported))
372
+ && [0, 1].includes(Number(row.stable))
373
+ ? undefined : 'generated_constant_flags_boolean';
374
+ }
375
+
376
+ function generatedConstantMemberShapeValid(
377
+ row: Record<string, unknown>,
378
+ kind: string,
379
+ ): boolean {
380
+ if (kind === 'const_identifier')
381
+ return row.containerName == null && row.memberName == null;
382
+ const container = row.containerName;
383
+ const member = row.memberName;
384
+ return typeof container === 'string' && container.length > 0
385
+ && typeof member === 'string' && member.length > 0
386
+ && row.name === `${container}.${member}`;
297
387
  }
298
388
 
299
389
  function validConstantOffsets(row: Record<string, unknown>): boolean {
@@ -312,7 +402,17 @@ function invalidGeneratedConstants(
312
402
  db: Db,
313
403
  workspaceId?: number,
314
404
  ): number {
315
- const rows = db.prepare(`SELECT fact.source_file sourceFile,
405
+ const rows = generatedConstantRows(db, workspaceId);
406
+ return rows.filter((row) =>
407
+ generatedConstantInvalidPredicate(row) !== undefined).length;
408
+ }
409
+
410
+ function generatedConstantRows(
411
+ db: Db,
412
+ workspaceId?: number,
413
+ ): Array<Record<string, unknown>> {
414
+ return db.prepare(`SELECT fact.id,fact.repo_id repoId,
415
+ r.name repositoryName,fact.source_file sourceFile,
316
416
  fact.source_line sourceLine,fact.name,fact.container_name containerName,
317
417
  fact.member_name memberName,fact.value,
318
418
  fact.constant_kind constantKind,fact.exported,fact.stable,
@@ -328,7 +428,106 @@ function invalidGeneratedConstants(
328
428
  AND (? IS NULL OR r.workspace_id=?)`).all(
329
429
  ANALYZER_VERSION, workspaceId, workspaceId,
330
430
  );
331
- return rows.filter(generatedConstantInvalid).length;
431
+ }
432
+
433
+ function eventFactExamples(
434
+ db: Db,
435
+ workspaceId: number | undefined,
436
+ phase: 'pre_package' | 'terminal',
437
+ ): InvalidEventFactExample[] {
438
+ return currentEventRows(db, workspaceId).flatMap((row) => {
439
+ const predicate = eventInvalidPredicate(db, row, phase);
440
+ return predicate && typeof row.repoId === 'number'
441
+ && typeof row.repositoryName === 'string'
442
+ ? [{
443
+ category: 'event_fact_semantics_invalid',
444
+ repositoryId: row.repoId,
445
+ repositoryName: row.repositoryName,
446
+ sourceFile: row.sourceFile,
447
+ sourceLine: row.sourceLine,
448
+ factId: row.id,
449
+ failingPredicate: predicate,
450
+ }] : [];
451
+ });
452
+ }
453
+
454
+ function generatedConstantExamples(
455
+ db: Db,
456
+ workspaceId?: number,
457
+ ): InvalidEventFactExample[] {
458
+ return generatedConstantRows(db, workspaceId).flatMap((row) => {
459
+ const predicate = generatedConstantInvalidPredicate(row);
460
+ return predicate && typeof row.repoId === 'number'
461
+ && typeof row.repositoryName === 'string'
462
+ ? [{
463
+ category: 'generated_constant_fact_invalid',
464
+ repositoryId: row.repoId,
465
+ repositoryName: row.repositoryName,
466
+ sourceFile: typeof row.sourceFile === 'string'
467
+ ? row.sourceFile : undefined,
468
+ sourceLine: typeof row.sourceLine === 'number'
469
+ ? row.sourceLine : undefined,
470
+ factId: typeof row.id === 'number' ? row.id : undefined,
471
+ failingPredicate: predicate,
472
+ }] : [];
473
+ });
474
+ }
475
+
476
+ function environmentExamples(
477
+ db: Db,
478
+ workspaceId?: number,
479
+ ): InvalidEventFactExample[] {
480
+ const rows = db.prepare(`SELECT id repositoryId,name repositoryName,
481
+ environment_declarations_json value FROM repositories
482
+ WHERE fact_analyzer_version=?
483
+ AND (? IS NULL OR workspace_id=?)
484
+ ORDER BY name COLLATE BINARY,id`).all(
485
+ ANALYZER_VERSION, workspaceId, workspaceId,
486
+ );
487
+ return rows.flatMap((row) =>
488
+ !parseEnvironmentDeclarationsFact(row.value)
489
+ && typeof row.repositoryId === 'number'
490
+ && typeof row.repositoryName === 'string'
491
+ ? [{
492
+ category: 'repository_environment_declarations_invalid',
493
+ repositoryId: row.repositoryId,
494
+ repositoryName: row.repositoryName,
495
+ failingPredicate: 'environment_declarations_fact_valid',
496
+ }] : []);
497
+ }
498
+
499
+ function compareExample(
500
+ left: InvalidEventFactExample,
501
+ right: InvalidEventFactExample,
502
+ ): number {
503
+ const leftKey = `${left.repositoryName}\0${left.sourceFile ?? ''}\0${
504
+ left.sourceLine ?? 0}\0${left.category}\0${left.factId ?? 0}`;
505
+ const rightKey = `${right.repositoryName}\0${right.sourceFile ?? ''}\0${
506
+ right.sourceLine ?? 0}\0${right.category}\0${right.factId ?? 0}`;
507
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
508
+ }
509
+
510
+ export function invalidEventFactExamples(
511
+ db: Db,
512
+ workspaceId?: number,
513
+ phase: 'pre_package' | 'terminal' = 'pre_package',
514
+ limit = 5,
515
+ ): {
516
+ total: number;
517
+ affectedRepositoryCount: number;
518
+ examples: InvalidEventFactExample[];
519
+ } {
520
+ const all = [
521
+ ...eventFactExamples(db, workspaceId, phase),
522
+ ...generatedConstantExamples(db, workspaceId),
523
+ ...environmentExamples(db, workspaceId),
524
+ ].sort(compareExample);
525
+ return {
526
+ total: all.length,
527
+ affectedRepositoryCount:
528
+ new Set(all.map((item) => item.repositoryId)).size,
529
+ examples: all.slice(0, Math.max(0, limit)),
530
+ };
332
531
  }
333
532
 
334
533
  export function invalidEventFactCategories(
@@ -0,0 +1,62 @@
1
+ import type { Db } from './connection.js';
2
+ import { ANALYZER_VERSION } from '../version.js';
3
+
4
+ export interface EventSiteCategoryCount {
5
+ category: string;
6
+ count: number;
7
+ }
8
+
9
+ function count(
10
+ db: Db,
11
+ sql: string,
12
+ workspaceId?: number,
13
+ ): number {
14
+ const row = db.prepare(sql).get(
15
+ ANALYZER_VERSION, workspaceId, workspaceId,
16
+ );
17
+ return Number(row?.count ?? 0);
18
+ }
19
+
20
+ function category(
21
+ name: string,
22
+ value: number,
23
+ ): EventSiteCategoryCount[] {
24
+ return value > 0 ? [{ category: name, count: value }] : [];
25
+ }
26
+
27
+ function duplicateSiteCount(
28
+ db: Db,
29
+ workspaceId: number | undefined,
30
+ callType: 'async_emit' | 'async_subscribe',
31
+ ): number {
32
+ return count(db, `SELECT COUNT(*) count FROM (
33
+ SELECT fact.repo_id,fact.source_file,fact.call_site_start_offset,
34
+ fact.call_site_end_offset,COUNT(*) duplicate_count
35
+ FROM outbound_calls fact JOIN repositories r ON r.id=fact.repo_id
36
+ WHERE r.fact_analyzer_version=?
37
+ AND (? IS NULL OR r.workspace_id=?)
38
+ AND fact.call_type='${callType}'
39
+ GROUP BY fact.repo_id,fact.source_file,fact.call_site_start_offset,
40
+ fact.call_site_end_offset HAVING COUNT(*)<>1
41
+ )`, workspaceId);
42
+ }
43
+
44
+ export function eventSiteCategories(
45
+ db: Db,
46
+ workspaceId?: number,
47
+ ): EventSiteCategoryCount[] {
48
+ const invalidName = count(db, `SELECT COUNT(*) count
49
+ FROM outbound_calls fact JOIN repositories r ON r.id=fact.repo_id
50
+ WHERE r.fact_analyzer_version=?
51
+ AND (? IS NULL OR r.workspace_id=?)
52
+ AND fact.call_type IN ('async_emit','async_subscribe')
53
+ AND (typeof(fact.event_name_expr)<>'text'
54
+ OR length(fact.event_name_expr)=0)`, workspaceId);
55
+ return [
56
+ ...category('event_name_invalid', invalidName),
57
+ ...category('async_subscription_site_duplicate',
58
+ duplicateSiteCount(db, workspaceId, 'async_subscribe')),
59
+ ...category('async_emit_site_duplicate',
60
+ duplicateSiteCount(db, workspaceId, 'async_emit')),
61
+ ];
62
+ }
@@ -14,6 +14,7 @@ import {
14
14
  type SchemaStructureCategoryCount,
15
15
  } from './schema-structure.js';
16
16
  import { ANALYZER_VERSION } from '../version.js';
17
+ import { invalidEventFactExamples } from './event-fact-semantics.js';
17
18
 
18
19
  export type FactLifecycleCode =
19
20
  | 'schema_upgrade_required'
@@ -27,11 +28,8 @@ export interface FactLifecycleDiagnostic extends Record<string, unknown> {
27
28
  remediation: string;
28
29
  }
29
30
 
30
- const remediation = [
31
- 'service-flow index --workspace /workspace --force',
32
- 'service-flow link --workspace /workspace --force',
33
- ].join('\n');
34
31
  const CATEGORY_LIMIT = 24;
32
+ const EXAMPLE_LIMIT = 5;
35
33
 
36
34
  function count(db: Db, sql: string, ...params: unknown[]): number {
37
35
  const row = db.prepare(sql).get(...params);
@@ -51,12 +49,13 @@ export function factLifecycleDiagnostic(
51
49
  workspaceId?: number,
52
50
  phase: PackageFactPhase = 'pre_package',
53
51
  ): FactLifecycleDiagnostic | undefined {
54
- return schemaLifecycleDiagnostic(db)
52
+ return schemaLifecycleDiagnostic(db, workspaceId)
55
53
  ?? currentFactLifecycleDiagnostic(db, workspaceId, phase);
56
54
  }
57
55
 
58
56
  export function schemaLifecycleDiagnostic(
59
57
  db: Db,
58
+ workspaceId?: number,
60
59
  ): FactLifecycleDiagnostic | undefined {
61
60
  const currentSchema = schemaVersion(db);
62
61
  if (currentSchema > CURRENT_SCHEMA_VERSION) return {
@@ -71,13 +70,15 @@ export function schemaLifecycleDiagnostic(
71
70
  severity: 'error',
72
71
  code: 'schema_upgrade_required',
73
72
  message: `Database schema ${currentSchema} must be upgraded to ${CURRENT_SCHEMA_VERSION} before this command can read current call-site facts.`,
74
- remediation,
73
+ remediation: staleRemediation(db, workspaceId),
75
74
  currentSchemaVersion: currentSchema,
76
75
  requiredSchemaVersion: CURRENT_SCHEMA_VERSION,
77
76
  };
78
77
  const structureCategories = invalidSchemaStructureCategories(db);
79
78
  if (structureCategories.length > 0)
80
- return reindexDiagnostic(0, structureCategories);
79
+ return reindexDiagnostic(
80
+ db, workspaceId, 0, structureCategories, 'pre_package', false,
81
+ );
81
82
  return undefined;
82
83
  }
83
84
 
@@ -88,15 +89,17 @@ export function currentFactLifecycleDiagnostic(
88
89
  ): FactLifecycleDiagnostic | undefined {
89
90
  const staleRepositories = oldAnalyzerCount(db, workspaceId);
90
91
  if (staleRepositories > 0)
91
- return reindexDiagnostic(staleRepositories, []);
92
+ return reindexDiagnostic(
93
+ db, workspaceId, staleRepositories, [], phase,
94
+ );
92
95
  const jsonCategories = invalidFactJsonCategories(db, workspaceId);
93
96
  if (jsonCategories.length > 0)
94
- return reindexDiagnostic(0, jsonCategories);
97
+ return reindexDiagnostic(db, workspaceId, 0, jsonCategories, phase);
95
98
  const semanticCategories = invalidFactSemanticCategories(
96
99
  db, workspaceId, phase,
97
100
  );
98
101
  if (semanticCategories.length === 0) return undefined;
99
- return reindexDiagnostic(0, semanticCategories);
102
+ return reindexDiagnostic(db, workspaceId, 0, semanticCategories, phase);
100
103
  }
101
104
 
102
105
  type InvalidFactCategory =
@@ -105,26 +108,81 @@ type InvalidFactCategory =
105
108
  | SchemaStructureCategoryCount;
106
109
 
107
110
  function reindexDiagnostic(
111
+ db: Db,
112
+ workspaceId: number | undefined,
108
113
  staleRepositories: number,
109
114
  categories: InvalidFactCategory[],
115
+ phase: PackageFactPhase,
116
+ examplesAllowed = true,
110
117
  ): FactLifecycleDiagnostic {
111
118
  const invalidFacts = categories.reduce((sum, item) => sum + item.count, 0);
112
119
  const shown = categories.slice(0, CATEGORY_LIMIT);
120
+ const eventExamples = examplesAllowed
121
+ ? invalidEventFactExamples(db, workspaceId, phase, EXAMPLE_LIMIT)
122
+ : { total: 0, affectedRepositoryCount: 0, examples: [] };
123
+ const examples = invalidFacts > 0 ? eventExamples.examples : [];
113
124
  return {
114
125
  severity: 'error',
115
126
  code: 'reindex_required',
116
- message: 'Current facts are stale or fail bounded semantic integrity checks; force index and link before tracing or rebuilding graph edges.',
117
- remediation,
127
+ message: invalidFacts > 0
128
+ ? 'Current facts fail bounded semantic integrity checks; inspect the offending rows before rebuilding graph edges.'
129
+ : 'Current facts are stale; force index and link before tracing or rebuilding graph edges.',
130
+ remediation: invalidFacts > 0
131
+ ? invalidFactRemediation(db, workspaceId)
132
+ : staleRemediation(db, workspaceId),
118
133
  staleRepositoryCount: staleRepositories,
119
134
  invalidCallFactCount: invalidFacts,
120
135
  invalidFactCategories: shown,
121
136
  invalidFactCategoryCount: categories.length,
122
137
  shownInvalidFactCategoryCount: shown.length,
123
138
  omittedInvalidFactCategoryCount: categories.length - shown.length,
139
+ affectedRepositoryCount: eventExamples.affectedRepositoryCount,
140
+ workspaceRepositoryCount: repositoryCount(db, workspaceId),
141
+ invalidFactExamples: examples,
142
+ invalidFactExampleCount: eventExamples.total,
143
+ shownInvalidFactExampleCount: examples.length,
144
+ omittedInvalidFactExampleCount:
145
+ Math.max(0, eventExamples.total - examples.length),
124
146
  requiredAnalyzerVersion: ANALYZER_VERSION,
125
147
  };
126
148
  }
127
149
 
150
+ function workspacePath(db: Db, workspaceId?: number): string | undefined {
151
+ if (workspaceId === undefined) return undefined;
152
+ const row = db.prepare(
153
+ 'SELECT root_path rootPath FROM workspaces WHERE id=?',
154
+ ).get(workspaceId);
155
+ return typeof row?.rootPath === 'string' ? row.rootPath : undefined;
156
+ }
157
+
158
+ function shellQuote(value: string): string {
159
+ return `'${value.replaceAll("'", "'\\''")}'`;
160
+ }
161
+
162
+ function workspaceArgument(db: Db, workspaceId?: number): string {
163
+ const root = workspacePath(db, workspaceId);
164
+ return root ? ` --workspace ${shellQuote(root)}` : '';
165
+ }
166
+
167
+ function staleRemediation(db: Db, workspaceId?: number): string {
168
+ const workspace = workspaceArgument(db, workspaceId);
169
+ return [
170
+ `service-flow index${workspace} --force`,
171
+ `service-flow link${workspace} --force`,
172
+ ].join('\n');
173
+ }
174
+
175
+ function invalidFactRemediation(db: Db, workspaceId?: number): string {
176
+ return `service-flow doctor${workspaceArgument(
177
+ db, workspaceId,
178
+ )} --strict --detail`;
179
+ }
180
+
181
+ function repositoryCount(db: Db, workspaceId?: number): number {
182
+ return count(db, `SELECT COUNT(*) count FROM repositories
183
+ WHERE (? IS NULL OR workspace_id=?)`, workspaceId, workspaceId);
184
+ }
185
+
128
186
  export function assertWorkspaceLinkable(
129
187
  db: Db,
130
188
  workspaceId: number,
@@ -20,7 +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
+ { name: 'environment_declarations_json', ddl: `ALTER TABLE repositories ADD COLUMN environment_declarations_json TEXT DEFAULT '{"schema":"service-flow/environment-declarations@1","allowedKeys":["SHARD_CODE"],"status":"not_applicable","reason":null,"recordCap":32,"total":0,"shown":0,"omitted":0,"declarations":[]}'` },
24
24
  ],
25
25
  graph_edges: [
26
26
  { name: 'status', ddl: "ALTER TABLE graph_edges ADD COLUMN status TEXT NOT NULL DEFAULT 'unresolved'" },
@@ -109,6 +109,9 @@ function markFactProvenanceMigrationStale(db: Db, priorVersion: number): void {
109
109
  }
110
110
  function markEventSurfaceMigrationStale(db: Db, priorVersion: number): void {
111
111
  if (priorVersion >= 14) return;
112
+ db.prepare(
113
+ 'UPDATE repositories SET environment_declarations_json=NULL',
114
+ ).run();
112
115
  db.prepare(`UPDATE repositories
113
116
  SET graph_stale_reason='schema_v14_event_surface_requires_reindex',
114
117
  graph_stale_at=COALESCE(graph_stale_at,datetime('now'))