@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
package/dist/index.d.ts CHANGED
@@ -242,7 +242,7 @@ interface GeneratedConstantFact {
242
242
  exported: boolean;
243
243
  stable: boolean;
244
244
  resolutionStatus: 'resolved' | 'refused';
245
- unresolvedReason?: 'event_name_constant_member_not_string' | 'event_name_constant_container_mutable';
245
+ unresolvedReason?: 'event_name_constant_member_not_string' | 'event_name_constant_container_mutable' | 'event_name_constant_container_unsafe_reference' | 'event_name_constant_container_unsupported_shape';
246
246
  declarationStartOffset: number;
247
247
  declarationEndOffset: number;
248
248
  valueStartOffset: number;
@@ -353,6 +353,7 @@ interface LinkWorkspaceResult {
353
353
  subscriptionHandlerUnresolvedCount: number;
354
354
  subscriptionHandlerMissingAssociationCount: number;
355
355
  eventShapeCandidateCount: number;
356
+ eventShapeCandidateOmittedCount: number;
356
357
  }
357
358
  declare function linkWorkspace(db: Db, workspaceId: number, vars?: Record<string, string>): LinkWorkspaceResult;
358
359
 
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  substituteVariables,
19
19
  trace,
20
20
  traceAndCompact
21
- } from "./chunk-Z6D433R5.js";
21
+ } from "./chunk-32WOTGTS.js";
22
22
  export {
23
23
  applyVariables,
24
24
  compactTrace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.72",
3
+ "version": "0.1.73",
4
4
  "description": "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -8,6 +8,16 @@ const eventNameReason = `COALESCE(
8
8
  OR c.unresolved_reason GLOB 'event_name_*'
9
9
  THEN c.unresolved_reason END
10
10
  )`;
11
+ const receiverReason = `CASE
12
+ WHEN json_extract(c.evidence_json,
13
+ '$.receiverClassification')='name_fallback'
14
+ THEN COALESCE(json_extract(c.evidence_json,
15
+ '$.receiverFallbackRefusedReason'),'name_fallback')
16
+ WHEN json_extract(c.evidence_json,
17
+ '$.receiverClassification')='unproven'
18
+ THEN COALESCE(json_extract(c.evidence_json,
19
+ '$.receiverUnresolvedReason'),'unproven')
20
+ ELSE 'missing' END`;
11
21
 
12
22
  function workspacePredicate(alias: string): string {
13
23
  return `(? IS NULL OR ${alias}.workspace_id=?)`;
@@ -52,6 +62,13 @@ function eventNameResolutionQuality(
52
62
  workspaceId, workspaceId,
53
63
  );
54
64
  const unresolved = count(aggregate?.unresolved);
65
+ const reasonCount = count(db.prepare(`SELECT COUNT(DISTINCT reason) count
66
+ FROM (SELECT ${eventNameReason} reason
67
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
68
+ WHERE c.call_type='async_emit' AND ${workspacePredicate('r')}
69
+ AND ${eventNameReason} IS NOT NULL)`).get(
70
+ workspaceId, workspaceId,
71
+ )?.count);
55
72
  return {
56
73
  severity: unresolved > 0 ? 'warning' : 'info',
57
74
  code: 'strict_event_name_resolution_quality',
@@ -59,6 +76,9 @@ function eventNameResolutionQuality(
59
76
  publicationTotal: count(aggregate?.total),
60
77
  unresolvedPublicationCount: unresolved,
61
78
  reasonBuckets: reasons,
79
+ reasonBucketCount: reasonCount,
80
+ shownReasonBucketCount: reasons.length,
81
+ omittedReasonBucketCount: Math.max(0, reasonCount - reasons.length),
62
82
  examples: unresolvedEventNameExamples(db, workspaceId),
63
83
  exampleCount: unresolved,
64
84
  };
@@ -204,8 +224,12 @@ function unmatchedSubscriptionQuality(
204
224
  db: Db,
205
225
  workspaceId?: number,
206
226
  ): Diagnostic {
207
- const row = db.prepare(`SELECT COUNT(*) siteCount,
227
+ const row = db.prepare(`SELECT
228
+ COUNT(DISTINCT json_extract(e.evidence_json,'$.subscribeCallId'))
229
+ siteCount,
208
230
  SUM(CASE
231
+ WHEN json_extract(e.evidence_json,'$.materializedLoopEventName')
232
+ IS NOT NULL THEN 1
209
233
  WHEN json_extract(c.evidence_json,
210
234
  '$.subscriptionLoopRegistrationStatus')='enumerated'
211
235
  THEN CAST(json_extract(c.evidence_json,
@@ -215,7 +239,7 @@ function unmatchedSubscriptionQuality(
215
239
  ELSE 1 END) count,
216
240
  SUM(CASE WHEN json_extract(c.evidence_json,
217
241
  '$.subscriptionLoopRegistrationStatus')='unresolved'
218
- THEN 1 ELSE 0 END) unknownMultiplicitySiteCount
242
+ THEN 1 ELSE 0 END) unknownMultiplicityEdgeCount
219
243
  FROM graph_edges e LEFT JOIN outbound_calls c
220
244
  ON c.id=CAST(json_extract(e.evidence_json,'$.subscribeCallId') AS INTEGER)
221
245
  WHERE e.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
@@ -228,23 +252,45 @@ function unmatchedSubscriptionQuality(
228
252
  AND publication.to_id=e.from_id)`).get(workspaceId, workspaceId);
229
253
  const total = count(row?.count);
230
254
  const siteCount = count(row?.siteCount);
255
+ const unknownSites = unknownMultiplicitySiteCount(db, workspaceId);
231
256
  return {
232
257
  severity: siteCount > 0 ? 'warning' : 'info',
233
258
  code: 'strict_event_subscription_without_publication_quality',
234
259
  message: 'Event subscriptions without a matching publication',
235
260
  unmatchedSubscriptionCount: total,
236
261
  unmatchedSubscriptionSiteCount: siteCount,
237
- unknownMultiplicitySiteCount: count(row?.unknownMultiplicitySiteCount),
262
+ unknownMultiplicitySiteCount: unknownSites,
238
263
  examples: unmatchedSubscriptionRows(db, workspaceId),
239
264
  exampleCount: siteCount,
240
265
  };
241
266
  }
242
267
 
243
- function receiverProofQuality(
268
+ function unknownMultiplicitySiteCount(
244
269
  db: Db,
245
270
  workspaceId?: number,
246
- ): Diagnostic {
247
- const row = db.prepare(`SELECT COUNT(*) eventTotal,
271
+ ): number {
272
+ const row = db.prepare(`SELECT COUNT(DISTINCT
273
+ json_extract(e.evidence_json,'$.subscribeCallId')) count
274
+ FROM graph_edges e LEFT JOIN outbound_calls c
275
+ ON c.id=CAST(json_extract(e.evidence_json,'$.subscribeCallId') AS INTEGER)
276
+ WHERE e.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
277
+ AND ${workspacePredicate('e')}
278
+ AND json_extract(c.evidence_json,
279
+ '$.subscriptionLoopRegistrationStatus')='unresolved'
280
+ AND NOT EXISTS (SELECT 1 FROM graph_edges publication
281
+ WHERE publication.workspace_id=e.workspace_id
282
+ AND publication.generation=e.generation
283
+ AND publication.edge_type='HANDLER_EMITS_EVENT'
284
+ AND publication.to_kind='event'
285
+ AND publication.to_id=e.from_id)`).get(workspaceId, workspaceId);
286
+ return count(row?.count);
287
+ }
288
+
289
+ function receiverProofAggregate(
290
+ db: Db,
291
+ workspaceId?: number,
292
+ ): Record<string, unknown> | undefined {
293
+ return db.prepare(`SELECT COUNT(*) eventTotal,
248
294
  SUM(CASE WHEN json_extract(c.evidence_json,
249
295
  '$.receiverClassification')='cap_evidence' THEN 1 ELSE 0 END) proven,
250
296
  SUM(CASE WHEN json_extract(c.evidence_json,
@@ -255,13 +301,13 @@ function receiverProofQuality(
255
301
  FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
256
302
  WHERE c.call_type IN ('async_emit','async_subscribe')
257
303
  AND ${workspacePredicate('r')}`).get(workspaceId, workspaceId);
258
- const buckets = db.prepare(`SELECT CASE
259
- WHEN json_extract(c.evidence_json,
260
- '$.receiverClassification')='name_fallback' THEN 'name_fallback'
261
- WHEN json_extract(c.evidence_json,
262
- '$.receiverClassification')='unproven'
263
- THEN COALESCE(c.unresolved_reason,'unproven')
264
- ELSE 'missing' END reason,COUNT(*) count
304
+ }
305
+
306
+ function receiverReasonBuckets(
307
+ db: Db,
308
+ workspaceId?: number,
309
+ ): Array<Record<string, unknown>> {
310
+ return db.prepare(`SELECT ${receiverReason} reason,COUNT(*) count
265
311
  FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
266
312
  WHERE c.call_type IN ('async_emit','async_subscribe')
267
313
  AND ${workspacePredicate('r')}
@@ -270,6 +316,29 @@ function receiverProofQuality(
270
316
  GROUP BY reason ORDER BY count DESC,reason COLLATE BINARY LIMIT 16`).all(
271
317
  workspaceId, workspaceId,
272
318
  );
319
+ }
320
+
321
+ function receiverReasonBucketCount(
322
+ db: Db,
323
+ workspaceId?: number,
324
+ ): number {
325
+ const row = db.prepare(`SELECT COUNT(DISTINCT reason) count
326
+ FROM (SELECT ${receiverReason} reason
327
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
328
+ WHERE c.call_type IN ('async_emit','async_subscribe')
329
+ AND ${workspacePredicate('r')}
330
+ AND json_extract(c.evidence_json,'$.receiverClassification')
331
+ <>'cap_evidence')`).get(workspaceId, workspaceId);
332
+ return count(row?.count);
333
+ }
334
+
335
+ function receiverProofQuality(
336
+ db: Db,
337
+ workspaceId?: number,
338
+ ): Diagnostic {
339
+ const row = receiverProofAggregate(db, workspaceId);
340
+ const buckets = receiverReasonBuckets(db, workspaceId);
341
+ const bucketCount = receiverReasonBucketCount(db, workspaceId);
273
342
  const questionable = count(row?.nameFallback) + count(row?.unproven);
274
343
  return {
275
344
  severity: questionable > 0 ? 'warning' : 'info',
@@ -281,6 +350,9 @@ function receiverProofQuality(
281
350
  unproven: count(row?.unproven),
282
351
  questionable,
283
352
  reasonBuckets: buckets,
353
+ reasonBucketCount: bucketCount,
354
+ shownReasonBucketCount: buckets.length,
355
+ omittedReasonBucketCount: Math.max(0, bucketCount - buckets.length),
284
356
  examples: receiverProofExamples(db, workspaceId),
285
357
  exampleCount: questionable,
286
358
  };
@@ -293,8 +365,11 @@ function receiverProofExamples(
293
365
  return db.prepare(`SELECT r.name repositoryName,c.call_type callType,
294
366
  c.source_file sourceFile,c.source_line sourceLine,
295
367
  json_extract(c.evidence_json,'$.receiverClassification')
296
- receiverClassification,
297
- c.unresolved_reason reason
368
+ receiverClassification,
369
+ COALESCE(json_extract(c.evidence_json,'$.receiverUnresolvedReason'),
370
+ json_extract(c.evidence_json,'$.receiverFallbackRefusedReason')) reason,
371
+ json_array_length(json_extract(c.evidence_json,
372
+ '$.consideredBindingSites')) consideredBindingSiteCount
298
373
  FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
299
374
  WHERE c.call_type IN ('async_emit','async_subscribe')
300
375
  AND ${workspacePredicate('r')}
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  createWorkspaceConfig,
5
5
  loadWorkspaceConfig,
6
6
  saveWorkspaceConfig,
7
+ type WorkspaceConfig,
7
8
  } from './config/workspace-config.js';
8
9
  import { openDatabase, openReadOnlyDatabase, type Db } from './db/connection.js';
9
10
  import {
@@ -20,6 +21,7 @@ import { classifyRepository } from './discovery/classify-repository.js';
20
21
  import { indexWorkspace } from './indexer/workspace-indexer.js';
21
22
  import { linkWorkspace } from './linker/cross-repo-linker.js';
22
23
  import { doctorDiagnostics, linkUpgradeWarnings } from './cli/doctor.js';
24
+ import { factLifecycleDiagnostic } from './db/fact-lifecycle.js';
23
25
  import { trace } from './trace/trace-engine.js';
24
26
  import { compactTrace } from './trace/compact-trace.js';
25
27
  import {
@@ -33,6 +35,9 @@ import { renderDoctorDiagnostics } from './output/doctor-output.js';
33
35
  import { renderMermaid } from './output/mermaid-output.js';
34
36
  import { createStdoutWriter } from './output/stdout-policy.js';
35
37
  import { renderCompactJson } from './output/compact-json-output.js';
38
+ import {
39
+ projectRepositoryInspection,
40
+ } from './output/repository-inspection.js';
36
41
  import { VERSION } from './version.js';
37
42
  import type {
38
43
  DynamicMode,
@@ -122,6 +127,7 @@ async function withWorkspace<T>(
122
127
  db: ReturnType<typeof openDatabase>,
123
128
  workspaceId: number,
124
129
  rootPath: string,
130
+ config: WorkspaceConfig,
125
131
  ) => Promise<T> | T,
126
132
  ): Promise<T> {
127
133
  const config = await loadWorkspaceConfig(workspace);
@@ -130,7 +136,7 @@ async function withWorkspace<T>(
130
136
  const row = getWorkspace(db, config.rootPath);
131
137
  const workspaceId =
132
138
  row?.id ?? upsertWorkspace(db, config.rootPath, config.dbPath);
133
- return await fn(db, workspaceId, config.rootPath);
139
+ return await fn(db, workspaceId, config.rootPath, config);
134
140
  } finally {
135
141
  db.close();
136
142
  }
@@ -275,10 +281,11 @@ function registerIndexCommand(program: Command): void {
275
281
  .option('--force')
276
282
  .action(
277
283
  (opts: { workspace?: string; repo?: string; force?: boolean }) =>
278
- void withWorkspace(opts.workspace, async (db, workspaceId) => {
284
+ void withWorkspace(opts.workspace, async (db, workspaceId, _root, config) => {
279
285
  const r = await indexWorkspace(db, workspaceId, {
280
286
  repo: opts.repo,
281
287
  force: Boolean(opts.force),
288
+ eventEnvironmentKeys: config.eventEnvironmentKeys,
282
289
  });
283
290
  const outcome = indexCommandOutcome(r);
284
291
  writeStdout(outcome.stdout);
@@ -298,7 +305,7 @@ function registerLinkCommand(program: Command): void {
298
305
  const r = linkWorkspace(db, workspaceId);
299
306
  const upgradeWarnings = linkUpgradeWarnings(db, workspaceId);
300
307
  writeStdout(
301
- `${upgradeWarnings.length ? `Warnings: ${upgradeWarnings.map((item) => String(item.code)).join(', ')}. Run service-flow doctor --strict for remediation.\n` : ''}Linked ${r.edgeCount} edges: ${r.remoteResolvedCount} remote operation calls resolved, ${r.localResolvedCount} local operation calls resolved, ${r.unresolvedCount} unresolved operation calls, ${r.ambiguousCount} ambiguous operation calls, ${r.dynamicCount} dynamic operation calls, ${r.terminalCount} terminal call edges, ${r.dependencyResolvedCount} dependency resolved, ${r.dependencyAmbiguousCount} dependency ambiguous, ${r.implementationResolvedCount} implementation resolved, ${r.implementationAmbiguousCount} implementation ambiguous, ${r.implementationUnresolvedCount} implementation unresolved, ${r.subscriptionHandlerResolvedCount} subscription handlers resolved, ${r.subscriptionHandlerAmbiguousCount} subscription handlers ambiguous, ${r.subscriptionHandlerUnresolvedCount} subscription handlers unresolved, ${r.subscriptionHandlerMissingAssociationCount} subscription handler associations missing, ${r.eventShapeCandidateCount} event shape candidates\n`,
308
+ `${upgradeWarnings.length ? `Warnings: ${upgradeWarnings.map((item) => String(item.code)).join(', ')}. Run service-flow doctor --strict for remediation.\n` : ''}Linked ${r.edgeCount} edges: ${r.remoteResolvedCount} remote operation calls resolved, ${r.localResolvedCount} local operation calls resolved, ${r.unresolvedCount} unresolved operation calls, ${r.ambiguousCount} ambiguous operation calls, ${r.dynamicCount} dynamic operation calls, ${r.terminalCount} terminal call edges, ${r.dependencyResolvedCount} dependency resolved, ${r.dependencyAmbiguousCount} dependency ambiguous, ${r.implementationResolvedCount} implementation resolved, ${r.implementationAmbiguousCount} implementation ambiguous, ${r.implementationUnresolvedCount} implementation unresolved, ${r.subscriptionHandlerResolvedCount} subscription handlers resolved, ${r.subscriptionHandlerAmbiguousCount} subscription handlers ambiguous, ${r.subscriptionHandlerUnresolvedCount} subscription handlers unresolved, ${r.subscriptionHandlerMissingAssociationCount} subscription handler associations missing, ${r.eventShapeCandidateCount} event shape candidates, ${r.eventShapeCandidateOmittedCount} event shape candidates omitted by the link cap\n`,
302
309
  );
303
310
  }).catch(fail),
304
311
  );
@@ -329,22 +336,23 @@ function registerTraceCommand(program: Command): void {
329
336
  function listRepositoriesCommand(
330
337
  opts: { workspace?: string },
331
338
  ): Promise<void> {
332
- return withReadOnlyWorkspace(opts.workspace, (db, workspaceId) =>
333
- writeStdout(
334
- renderJson(
335
- listRepositories(db, workspaceId).map((repo) => ({
336
- name: repo.name,
337
- kind: repo.kind,
338
- packageName: repo.package_name,
339
- })),
340
- ),
339
+ return withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
340
+ if (writeLifecycleBlock(db, workspaceId)) return;
341
+ writeStdout(renderJson(
342
+ listRepositories(db, workspaceId).map((repo) => ({
343
+ name: repo.name,
344
+ kind: repo.kind,
345
+ packageName: repo.package_name,
346
+ })),
341
347
  ));
348
+ });
342
349
  }
343
350
 
344
351
  function listServicesCommand(
345
352
  opts: { workspace?: string; repo?: string },
346
353
  ): Promise<void> {
347
354
  return withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
355
+ if (writeLifecycleBlock(db, workspaceId)) return;
348
356
  const selection = opts.repo
349
357
  ? selectRepository(db, opts.repo, workspaceId) : {};
350
358
  if (selection.diagnostic) {
@@ -363,6 +371,7 @@ function listOperationsCommand(
363
371
  opts: { workspace?: string; repo?: string; service?: string },
364
372
  ): Promise<void> {
365
373
  return withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
374
+ if (writeLifecycleBlock(db, workspaceId)) return;
366
375
  const selection = opts.repo
367
376
  ? selectRepository(db, opts.repo, workspaceId) : {};
368
377
  if (selection.diagnostic) {
@@ -383,6 +392,7 @@ function listCallsCommand(
383
392
  opts: { workspace?: string; repo?: string; operation?: string },
384
393
  ): Promise<void> {
385
394
  return withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
395
+ if (writeLifecycleBlock(db, workspaceId)) return;
386
396
  const selection = opts.repo
387
397
  ? selectRepository(db, opts.repo, workspaceId) : {};
388
398
  if (selection.diagnostic) {
@@ -460,9 +470,12 @@ function inspectRepositoryCommand(
460
470
  opts: { workspace?: string },
461
471
  ): Promise<void> {
462
472
  return withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
473
+ if (writeLifecycleBlock(db, workspaceId)) return;
463
474
  const selection = selectRepository(db, name, workspaceId);
464
475
  writeStdout(renderJson(
465
- selection.repo ?? selection.diagnostic ?? { error: 'repo not found' },
476
+ selection.repo
477
+ ? projectRepositoryInspection(selection.repo)
478
+ : selection.diagnostic ?? { error: 'repo not found' },
466
479
  ));
467
480
  });
468
481
  }
@@ -472,6 +485,7 @@ function inspectOperationCommand(
472
485
  opts: { workspace?: string },
473
486
  ): Promise<void> {
474
487
  return withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
488
+ if (writeLifecycleBlock(db, workspaceId)) return;
475
489
  const rows = db.prepare(
476
490
  'SELECT o.* FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (o.operation_name=? OR o.operation_path=?)',
477
491
  ).all(workspaceId, selector, selector);
@@ -479,6 +493,14 @@ function inspectOperationCommand(
479
493
  });
480
494
  }
481
495
 
496
+ function writeLifecycleBlock(db: Db, workspaceId: number): boolean {
497
+ const diagnostic = factLifecycleDiagnostic(db, workspaceId);
498
+ if (!diagnostic) return false;
499
+ writeStdout(renderJson([diagnostic]));
500
+ process.exitCode = 1;
501
+ return true;
502
+ }
503
+
482
504
  function registerInspectCommands(program: Command): void {
483
505
  const inspect = program.command('inspect');
484
506
  inspect
@@ -7,10 +7,24 @@ import {
7
7
  DEFAULT_DB_FILE,
8
8
  DEFAULT_IGNORES,
9
9
  } from './defaults.js';
10
+ import {
11
+ DEFAULT_EVENT_ENVIRONMENT_KEYS,
12
+ EVENT_ENVIRONMENT_KEY_CAP,
13
+ normalizeEventEnvironmentKeys,
14
+ validEventEnvironmentKey,
15
+ } from '../parsers/environment-declarations.js';
16
+ const environmentKeys = z.array(
17
+ z.string().refine(validEventEnvironmentKey),
18
+ ).min(1).max(EVENT_ENVIRONMENT_KEY_CAP).transform(
19
+ normalizeEventEnvironmentKeys,
20
+ );
10
21
  const schema = z.object({
11
22
  rootPath: z.string(),
12
23
  dbPath: z.string(),
13
24
  ignore: z.array(z.string()),
25
+ eventEnvironmentKeys: environmentKeys.default(
26
+ [...DEFAULT_EVENT_ENVIRONMENT_KEYS],
27
+ ),
14
28
  createdAt: z.string(),
15
29
  updatedAt: z.string(),
16
30
  });
@@ -55,6 +69,7 @@ export function createWorkspaceConfig(
55
69
  rootPath: root,
56
70
  dbPath: path.resolve(dbPath ?? defaultDbPath(root)),
57
71
  ignore,
72
+ eventEnvironmentKeys: [...DEFAULT_EVENT_ENVIRONMENT_KEYS],
58
73
  createdAt: now,
59
74
  updatedAt: now,
60
75
  };
@@ -12,6 +12,7 @@ import {
12
12
  } from './binding-fact-semantics.js';
13
13
  import { invalidSymbolFactCategories } from './symbol-call-semantics.js';
14
14
  import { invalidEventFactCategories } from './event-fact-semantics.js';
15
+ import { eventSiteCategories } from './event-site-semantics.js';
15
16
 
16
17
  export type PackageFactPhase = 'pre_package' | 'terminal';
17
18
 
@@ -257,31 +258,6 @@ function duplicateSymbolCallCategories(
257
258
  return category('symbol_call_site_duplicate', duplicate);
258
259
  }
259
260
 
260
- function eventNameCategories(
261
- db: Db,
262
- workspaceId?: number,
263
- ): FactSemanticCategoryCount[] {
264
- const invalidName = count(db, `SELECT COUNT(*) count
265
- FROM outbound_calls fact JOIN repositories r ON r.id=fact.repo_id
266
- WHERE ${currentRepositoryPredicate()}
267
- AND fact.call_type IN ('async_emit','async_subscribe')
268
- AND (typeof(fact.event_name_expr)<>'text'
269
- OR length(fact.event_name_expr)=0)`, workspaceId);
270
- const duplicate = count(db, `SELECT COUNT(*) count FROM (
271
- SELECT fact.repo_id,fact.source_file,fact.call_site_start_offset,
272
- fact.call_site_end_offset,COUNT(*) duplicate_count
273
- FROM outbound_calls fact JOIN repositories r ON r.id=fact.repo_id
274
- WHERE ${currentRepositoryPredicate()}
275
- AND fact.call_type='async_subscribe'
276
- GROUP BY fact.repo_id,fact.source_file,fact.call_site_start_offset,
277
- fact.call_site_end_offset HAVING COUNT(*)<>1
278
- )`, workspaceId);
279
- return [
280
- ...category('event_name_invalid', invalidName),
281
- ...category('async_subscription_site_duplicate', duplicate),
282
- ];
283
- }
284
-
285
261
  function outboundOwnerCount(db: Db, workspaceId?: number): number {
286
262
  return count(db, `WITH eligible AS (
287
263
  SELECT fact.id fact_id,s.id symbol_id,
@@ -684,7 +660,7 @@ export function invalidFactSemanticCategories(
684
660
  ...operationCategories(db, workspaceId),
685
661
  ...callSpanCategories(db, workspaceId),
686
662
  ...duplicateSymbolCallCategories(db, workspaceId),
687
- ...eventNameCategories(db, workspaceId),
663
+ ...eventSiteCategories(db, workspaceId),
688
664
  ...invalidEventFactCategories(db, workspaceId, phase),
689
665
  ...ownerCategories(db, workspaceId),
690
666
  ...bindingCategories(db, workspaceId),