@saptools/service-flow 0.1.70 → 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 (61) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +23 -6
  3. package/TECHNICAL-NOTE.md +24 -2
  4. package/dist/{chunk-GSLFY6J2.js → chunk-32WOTGTS.js} +12061 -9288
  5. package/dist/chunk-32WOTGTS.js.map +1 -0
  6. package/dist/cli.js +854 -161
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +54 -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 +446 -0
  13. package/src/cli/doctor.ts +4 -6
  14. package/src/cli.ts +35 -13
  15. package/src/config/workspace-config.ts +15 -0
  16. package/src/db/call-fact-repository.ts +6 -3
  17. package/src/db/current-fact-semantics.ts +5 -29
  18. package/src/db/event-fact-semantics.ts +546 -0
  19. package/src/db/event-site-semantics.ts +62 -0
  20. package/src/db/event-surface-invalidation.ts +38 -0
  21. package/src/db/fact-json-inventory.ts +16 -0
  22. package/src/db/fact-lifecycle.ts +70 -12
  23. package/src/db/migrations.ts +15 -1
  24. package/src/db/package-target-invalidation.ts +80 -0
  25. package/src/db/repositories.ts +28 -2
  26. package/src/db/schema-structure.ts +41 -1
  27. package/src/db/schema.ts +6 -2
  28. package/src/indexer/repository-indexer.ts +93 -10
  29. package/src/indexer/workspace-indexer.ts +13 -3
  30. package/src/linker/cross-repo-linker.ts +27 -3
  31. package/src/linker/event-environment-link.ts +211 -0
  32. package/src/linker/event-shape-candidate-linker.ts +204 -0
  33. package/src/linker/event-subscription-handler-linker.ts +220 -25
  34. package/src/linker/event-template-link.ts +40 -6
  35. package/src/linker/package-event-constant-resolver.ts +302 -0
  36. package/src/output/repository-inspection.ts +11 -0
  37. package/src/output/table-output.ts +13 -1
  38. package/src/parsers/decorator-parser.ts +9 -53
  39. package/src/parsers/environment-declarations.ts +404 -0
  40. package/src/parsers/event-call-analysis.ts +370 -0
  41. package/src/parsers/event-environment-reference.ts +242 -0
  42. package/src/parsers/event-loop-registration.ts +132 -0
  43. package/src/parsers/event-name-import-resolution.ts +243 -0
  44. package/src/parsers/event-receiver-analysis.ts +394 -0
  45. package/src/parsers/event-subscription-facts.ts +4 -0
  46. package/src/parsers/generated-constants-parser.ts +80 -14
  47. package/src/parsers/outbound-call-classifier.ts +27 -124
  48. package/src/parsers/outbound-call-parser.ts +13 -1
  49. package/src/parsers/outbound-expression-analysis.ts +2 -2
  50. package/src/parsers/stable-local-value.ts +42 -10
  51. package/src/parsers/string-constant-lookups.ts +408 -0
  52. package/src/trace/edge-target.ts +65 -0
  53. package/src/trace/event-runtime-resolution.ts +24 -3
  54. package/src/trace/event-shape-candidate-trace.ts +172 -0
  55. package/src/trace/event-subscriber-traversal.ts +90 -8
  56. package/src/trace/evidence.ts +7 -28
  57. package/src/trace/trace-scope-execution.ts +22 -30
  58. package/src/types.ts +19 -1
  59. package/src/utils/event-skeleton.ts +207 -0
  60. package/src/version.ts +1 -1
  61. package/dist/chunk-GSLFY6J2.js.map +0 -1
package/dist/index.d.ts CHANGED
@@ -1,7 +1,45 @@
1
1
  import ts from 'typescript';
2
2
 
3
+ interface SourceFileSnapshot {
4
+ repoPath: string;
5
+ filePath: string;
6
+ text: string;
7
+ sizeBytes: number;
8
+ sourceFile: () => ts.SourceFile;
9
+ }
10
+ interface RepositorySourceContext {
11
+ get: (filePath: string) => SourceFileSnapshot | undefined;
12
+ entries: () => SourceFileSnapshot[];
13
+ }
14
+
15
+ type EventEnvironmentTransform = 'toUpperCase' | 'toLowerCase';
16
+ interface EventEnvironmentReference {
17
+ status: 'resolved' | 'refused';
18
+ sourceKey: string;
19
+ environmentKey?: string;
20
+ transforms: EventEnvironmentTransform[];
21
+ sourceFile?: string;
22
+ startOffset?: number;
23
+ endOffset?: number;
24
+ reason?: string;
25
+ }
26
+
27
+ declare const EVENT_SKELETON_SCHEMA = "service-flow/event-skeleton@1";
28
+ interface EventSkeletonFact {
29
+ schema: typeof EVENT_SKELETON_SCHEMA;
30
+ status: 'complete' | 'malformed' | 'too_large';
31
+ signature: string | null;
32
+ literalSpans: string[];
33
+ holeCount: number;
34
+ sourceKeys: string[];
35
+ canonicalKeys: string[];
36
+ candidateEligible: boolean;
37
+ environmentBindings: EventEnvironmentReference[];
38
+ reason?: string;
39
+ }
40
+
3
41
  type CallType = 'remote_action' | 'remote_query' | 'remote_entity_read' | 'remote_entity_mutation' | 'remote_entity_delete' | 'remote_entity_media' | 'remote_entity_candidate' | 'local_db_query' | 'external_http' | 'async_emit' | 'async_subscribe' | 'local_service_call' | 'unknown';
4
- type EdgeType = 'REPO_HAS_SERVICE' | 'SERVICE_HAS_OPERATION' | 'OPERATION_IMPLEMENTED_BY_HANDLER' | 'HANDLER_REGISTERED_BY_SERVER' | 'HANDLER_CALLS_LOCAL_FUNCTION' | 'HANDLER_USES_SERVICE_ALIAS' | 'HANDLER_CALLS_REMOTE_OPERATION' | 'REMOTE_CALL_RESOLVES_TO_OPERATION' | 'LOCAL_CALL_RESOLVES_TO_OPERATION' | 'HANDLER_RUNS_DB_QUERY' | 'HANDLER_RUNS_REMOTE_QUERY' | 'HANDLER_ACCESSES_REMOTE_ENTITY' | 'HANDLER_CALLS_EXTERNAL_HTTP' | 'HANDLER_CALLS_TRANSPORT_METHOD' | 'HANDLER_EMITS_EVENT' | 'EVENT_CONSUMED_BY_HANDLER' | 'EVENT_SUBSCRIPTION_HANDLED_BY' | 'REPO_IMPORTS_HELPER_PACKAGE' | 'HELPER_PACKAGE_PROVIDES_HANDLER' | 'DYNAMIC_EDGE_CANDIDATE' | 'UNRESOLVED_EDGE';
42
+ type EdgeType = 'REPO_HAS_SERVICE' | 'SERVICE_HAS_OPERATION' | 'OPERATION_IMPLEMENTED_BY_HANDLER' | 'HANDLER_REGISTERED_BY_SERVER' | 'HANDLER_CALLS_LOCAL_FUNCTION' | 'HANDLER_USES_SERVICE_ALIAS' | 'HANDLER_CALLS_REMOTE_OPERATION' | 'REMOTE_CALL_RESOLVES_TO_OPERATION' | 'LOCAL_CALL_RESOLVES_TO_OPERATION' | 'HANDLER_RUNS_DB_QUERY' | 'HANDLER_RUNS_REMOTE_QUERY' | 'HANDLER_ACCESSES_REMOTE_ENTITY' | 'HANDLER_CALLS_EXTERNAL_HTTP' | 'HANDLER_CALLS_TRANSPORT_METHOD' | 'HANDLER_EMITS_EVENT' | 'EVENT_CONSUMED_BY_HANDLER' | 'EVENT_SUBSCRIPTION_HANDLED_BY' | 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER' | 'REPO_IMPORTS_HELPER_PACKAGE' | 'HELPER_PACKAGE_PROVIDES_HANDLER' | 'DYNAMIC_EDGE_CANDIDATE' | 'UNRESOLVED_EDGE';
5
43
  interface DiscoveredRepository {
6
44
  name: string;
7
45
  absolutePath: string;
@@ -149,6 +187,7 @@ interface OutboundCallFact {
149
187
  operationPathExpr?: string;
150
188
  queryEntity?: string;
151
189
  eventNameExpr?: string;
190
+ eventSkeleton?: EventSkeletonFact;
152
191
  payloadSummary?: string;
153
192
  sourceFile: string;
154
193
  sourceLine: number;
@@ -194,9 +233,20 @@ interface SymbolCallFact {
194
233
  }
195
234
  interface GeneratedConstantFact {
196
235
  name: string;
197
- value: string;
236
+ value?: string;
198
237
  sourceFile: string;
199
238
  sourceLine: number;
239
+ containerName?: string;
240
+ memberName?: string;
241
+ constantKind: 'const_identifier' | 'enum_member' | 'const_object_property';
242
+ exported: boolean;
243
+ stable: boolean;
244
+ resolutionStatus: 'resolved' | 'refused';
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
+ declarationStartOffset: number;
247
+ declarationEndOffset: number;
248
+ valueStartOffset: number;
249
+ valueEndOffset: number;
200
250
  }
201
251
  interface TraceStart {
202
252
  repo?: string;
@@ -250,18 +300,6 @@ interface ParsePackageJsonOptions {
250
300
  }
251
301
  declare function parsePackageJson(repoPath: string, options?: ParsePackageJsonOptions): Promise<PackageFacts>;
252
302
 
253
- interface SourceFileSnapshot {
254
- repoPath: string;
255
- filePath: string;
256
- text: string;
257
- sizeBytes: number;
258
- sourceFile: () => ts.SourceFile;
259
- }
260
- interface RepositorySourceContext {
261
- get: (filePath: string) => SourceFileSnapshot | undefined;
262
- entries: () => SourceFileSnapshot[];
263
- }
264
-
265
303
  declare function parseCdsFile(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<CdsServiceFact[]>;
266
304
 
267
305
  declare function parseDecorators(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<HandlerClassFact[]>;
@@ -314,6 +352,8 @@ interface LinkWorkspaceResult {
314
352
  subscriptionHandlerAmbiguousCount: number;
315
353
  subscriptionHandlerUnresolvedCount: number;
316
354
  subscriptionHandlerMissingAssociationCount: number;
355
+ eventShapeCandidateCount: number;
356
+ eventShapeCandidateOmittedCount: number;
317
357
  }
318
358
  declare function linkWorkspace(db: Db, workspaceId: number, vars?: Record<string, string>): LinkWorkspaceResult;
319
359
 
package/dist/index.js CHANGED
@@ -5,9 +5,9 @@ import {
5
5
  discoverRepositories,
6
6
  extractPlaceholders,
7
7
  linkWorkspace,
8
- normalizePath,
9
8
  parseCdsFile,
10
9
  parseDecorators,
10
+ parseGeneratedConstants,
11
11
  parseHandlerRegistrations,
12
12
  parseImplementationHint,
13
13
  parseOutboundCalls,
@@ -15,31 +15,10 @@ import {
15
15
  parseServiceBindings,
16
16
  redactText,
17
17
  redactValue,
18
- stripQuotes,
19
18
  substituteVariables,
20
19
  trace,
21
20
  traceAndCompact
22
- } from "./chunk-GSLFY6J2.js";
23
-
24
- // src/parsers/generated-constants-parser.ts
25
- import fs from "fs/promises";
26
- import path from "path";
27
- function lineOf(text, idx) {
28
- return text.slice(0, idx).split("\n").length;
29
- }
30
- async function parseGeneratedConstants(repoPath, filePath) {
31
- const text = await fs.readFile(path.join(repoPath, filePath), "utf8");
32
- return [
33
- ...text.matchAll(
34
- /(?:export\s+)?(?:const|static\s+readonly)\s+(\w+)\s*=\s*(['"])([^'"]+)\2/g
35
- )
36
- ].map((m) => ({
37
- name: m[1] ?? "constant",
38
- value: stripQuotes(m[3] ?? ""),
39
- sourceFile: normalizePath(filePath),
40
- sourceLine: lineOf(text, m.index ?? 0)
41
- }));
42
- }
21
+ } from "./chunk-32WOTGTS.js";
43
22
  export {
44
23
  applyVariables,
45
24
  compactTrace,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/parsers/generated-constants-parser.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { GeneratedConstantFact } from '../types.js';\nimport { normalizePath, stripQuotes } from '../utils/path-utils.js';\nfunction lineOf(text: string, idx: number): number {\n return text.slice(0, idx).split('\\n').length;\n}\nexport async function parseGeneratedConstants(\n repoPath: string,\n filePath: string\n): Promise<GeneratedConstantFact[]> {\n const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');\n return [\n ...text.matchAll(\n /(?:export\\s+)?(?:const|static\\s+readonly)\\s+(\\w+)\\s*=\\s*(['\"])([^'\"]+)\\2/g\n )\n ].map((m) => ({\n name: m[1] ?? 'constant',\n value: stripQuotes(m[3] ?? ''),\n sourceFile: normalizePath(filePath),\n sourceLine: lineOf(text, m.index ?? 0)\n }));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAGjB,SAAS,OAAO,MAAc,KAAqB;AACjD,SAAO,KAAK,MAAM,GAAG,GAAG,EAAE,MAAM,IAAI,EAAE;AACxC;AACA,eAAsB,wBACpB,UACA,UACkC;AAClC,QAAM,OAAO,MAAM,GAAG,SAAS,KAAK,KAAK,UAAU,QAAQ,GAAG,MAAM;AACpE,SAAO;AAAA,IACL,GAAG,KAAK;AAAA,MACN;AAAA,IACF;AAAA,EACF,EAAE,IAAI,CAAC,OAAO;AAAA,IACZ,MAAM,EAAE,CAAC,KAAK;AAAA,IACd,OAAO,YAAY,EAAE,CAAC,KAAK,EAAE;AAAA,IAC7B,YAAY,cAAc,QAAQ;AAAA,IAClC,YAAY,OAAO,MAAM,EAAE,SAAS,CAAC;AAAA,EACvC,EAAE;AACJ;","names":[]}
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.70",
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": {
@@ -0,0 +1,446 @@
1
+ import type { Db } from '../db/connection.js';
2
+
3
+ type Diagnostic = Record<string, unknown>;
4
+
5
+ const eventNameReason = `COALESCE(
6
+ json_extract(c.evidence_json,'$.eventNameUnresolvedReason'),
7
+ CASE WHEN c.unresolved_reason GLOB 'dynamic_event_name_*'
8
+ OR c.unresolved_reason GLOB 'event_name_*'
9
+ THEN c.unresolved_reason END
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`;
21
+
22
+ function workspacePredicate(alias: string): string {
23
+ return `(? IS NULL OR ${alias}.workspace_id=?)`;
24
+ }
25
+
26
+ function count(value: unknown): number {
27
+ return Number(value ?? 0);
28
+ }
29
+
30
+ function unresolvedEventNameExamples(
31
+ db: Db,
32
+ workspaceId?: number,
33
+ ): Diagnostic[] {
34
+ return db.prepare(`SELECT r.name repositoryName,
35
+ c.source_file sourceFile,c.source_line sourceLine,
36
+ c.event_name_expr eventName,${eventNameReason} reason
37
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
38
+ WHERE c.call_type='async_emit' AND ${workspacePredicate('r')}
39
+ AND ${eventNameReason} IS NOT NULL
40
+ ORDER BY r.name COLLATE BINARY,c.source_file COLLATE BINARY,
41
+ c.source_line,c.id LIMIT 5`).all(
42
+ workspaceId, workspaceId,
43
+ ) as Diagnostic[];
44
+ }
45
+
46
+ function eventNameResolutionQuality(
47
+ db: Db,
48
+ workspaceId?: number,
49
+ ): Diagnostic {
50
+ const aggregate = db.prepare(`SELECT COUNT(*) total,
51
+ SUM(CASE WHEN reason IS NOT NULL THEN 1 ELSE 0 END) unresolved
52
+ FROM (SELECT ${eventNameReason} reason
53
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
54
+ WHERE c.call_type='async_emit' AND ${workspacePredicate('r')})`).get(
55
+ workspaceId, workspaceId,
56
+ );
57
+ const reasons = db.prepare(`SELECT ${eventNameReason} reason,COUNT(*) count
58
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
59
+ WHERE c.call_type='async_emit' AND ${workspacePredicate('r')}
60
+ AND ${eventNameReason} IS NOT NULL
61
+ GROUP BY reason ORDER BY count DESC,reason COLLATE BINARY LIMIT 16`).all(
62
+ workspaceId, workspaceId,
63
+ );
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);
72
+ return {
73
+ severity: unresolved > 0 ? 'warning' : 'info',
74
+ code: 'strict_event_name_resolution_quality',
75
+ message: 'Event publication name-resolution aggregate',
76
+ publicationTotal: count(aggregate?.total),
77
+ unresolvedPublicationCount: unresolved,
78
+ reasonBuckets: reasons,
79
+ reasonBucketCount: reasonCount,
80
+ shownReasonBucketCount: reasons.length,
81
+ omittedReasonBucketCount: Math.max(0, reasonCount - reasons.length),
82
+ examples: unresolvedEventNameExamples(db, workspaceId),
83
+ exampleCount: unresolved,
84
+ };
85
+ }
86
+
87
+ function dynamicEventExamples(
88
+ db: Db,
89
+ workspaceId?: number,
90
+ ): Diagnostic[] {
91
+ return db.prepare(`SELECT r.name repositoryName,c.call_type callType,
92
+ c.source_file sourceFile,c.source_line sourceLine,
93
+ e.to_kind targetKind,e.to_id targetId,e.unresolved_reason reason
94
+ FROM graph_edges e JOIN outbound_calls c
95
+ ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER)
96
+ JOIN repositories r ON r.id=c.repo_id
97
+ WHERE c.call_type IN ('async_emit','async_subscribe')
98
+ AND ${workspacePredicate('r')}
99
+ AND (e.to_kind='event_candidate'
100
+ OR e.edge_type='DYNAMIC_EDGE_CANDIDATE')
101
+ ORDER BY r.name COLLATE BINARY,c.source_file COLLATE BINARY,
102
+ c.source_line,c.id LIMIT 5`).all(
103
+ workspaceId, workspaceId,
104
+ ) as Diagnostic[];
105
+ }
106
+
107
+ function dynamicEventQuality(
108
+ db: Db,
109
+ workspaceId?: number,
110
+ ): Diagnostic {
111
+ const row = db.prepare(`SELECT
112
+ COUNT(DISTINCT CASE WHEN e.to_kind='event_candidate'
113
+ THEN e.workspace_id || ':' || e.to_id END) eventCandidateNodeCount,
114
+ COUNT(*) dynamicEventEdgeCount,
115
+ SUM(CASE WHEN json_array_length(e.evidence_json,
116
+ '$.eventTemplateResolution.placeholders')>0 THEN 1 ELSE 0 END)
117
+ variableRecoverableCount
118
+ FROM graph_edges e JOIN outbound_calls c
119
+ ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER)
120
+ JOIN repositories r ON r.id=c.repo_id
121
+ WHERE c.call_type IN ('async_emit','async_subscribe')
122
+ AND ${workspacePredicate('r')}
123
+ AND (e.to_kind='event_candidate'
124
+ OR e.edge_type='DYNAMIC_EDGE_CANDIDATE')`).get(
125
+ workspaceId, workspaceId,
126
+ );
127
+ const total = count(row?.dynamicEventEdgeCount);
128
+ const recoverable = count(row?.variableRecoverableCount);
129
+ return {
130
+ severity: total > 0 ? 'warning' : 'info',
131
+ code: 'strict_event_dynamic_candidate_quality',
132
+ message: 'Dynamic event candidate aggregate',
133
+ eventCandidateNodeCount: count(row?.eventCandidateNodeCount),
134
+ dynamicEventEdgeCount: total,
135
+ eventCandidateCount: total,
136
+ variableRecoverableCount: recoverable,
137
+ nonVariableRecoverableCount: Math.max(0, total - recoverable),
138
+ examples: dynamicEventExamples(db, workspaceId),
139
+ exampleCount: total,
140
+ };
141
+ }
142
+
143
+ function unmatchedPublicationRows(
144
+ db: Db,
145
+ workspaceId?: number,
146
+ ): Diagnostic[] {
147
+ return db.prepare(`SELECT r.name repositoryName,c.source_file sourceFile,
148
+ c.source_line sourceLine,e.to_id eventName
149
+ FROM graph_edges e JOIN outbound_calls c
150
+ ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER)
151
+ JOIN repositories r ON r.id=c.repo_id
152
+ WHERE c.call_type='async_emit' AND e.edge_type='HANDLER_EMITS_EVENT'
153
+ AND e.to_kind='event' AND ${workspacePredicate('r')}
154
+ AND NOT EXISTS (SELECT 1 FROM graph_edges subscription
155
+ WHERE subscription.workspace_id=e.workspace_id
156
+ AND subscription.generation=e.generation
157
+ AND subscription.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
158
+ AND subscription.from_kind='event'
159
+ AND subscription.from_id=e.to_id)
160
+ ORDER BY r.name COLLATE BINARY,c.source_file COLLATE BINARY,
161
+ c.source_line,c.id LIMIT 5`).all(
162
+ workspaceId, workspaceId,
163
+ ) as Diagnostic[];
164
+ }
165
+
166
+ function unmatchedPublicationQuality(
167
+ db: Db,
168
+ workspaceId?: number,
169
+ ): Diagnostic {
170
+ const row = db.prepare(`SELECT COUNT(*) count
171
+ FROM graph_edges e JOIN outbound_calls c
172
+ ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER)
173
+ JOIN repositories r ON r.id=c.repo_id
174
+ WHERE c.call_type='async_emit' AND e.edge_type='HANDLER_EMITS_EVENT'
175
+ AND e.to_kind='event' AND ${workspacePredicate('r')}
176
+ AND NOT EXISTS (SELECT 1 FROM graph_edges subscription
177
+ WHERE subscription.workspace_id=e.workspace_id
178
+ AND subscription.generation=e.generation
179
+ AND subscription.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
180
+ AND subscription.from_kind='event'
181
+ AND subscription.from_id=e.to_id)`).get(workspaceId, workspaceId);
182
+ const total = count(row?.count);
183
+ return {
184
+ severity: total > 0 ? 'warning' : 'info',
185
+ code: 'strict_event_publication_without_subscription_quality',
186
+ message: 'Resolved event publications without a matching subscription',
187
+ unmatchedPublicationCount: total,
188
+ examples: unmatchedPublicationRows(db, workspaceId),
189
+ exampleCount: total,
190
+ };
191
+ }
192
+
193
+ function unmatchedSubscriptionRows(
194
+ db: Db,
195
+ workspaceId?: number,
196
+ ): Diagnostic[] {
197
+ return db.prepare(`SELECT COALESCE(json_extract(e.evidence_json,
198
+ '$.subscriptionConsumerRepositoryName'),
199
+ json_extract(e.evidence_json,'$.repositoryName')) repositoryName,
200
+ json_extract(e.evidence_json,'$.sourceFile') sourceFile,
201
+ json_extract(e.evidence_json,'$.sourceLine') sourceLine,
202
+ e.from_id eventName,
203
+ json_extract(c.evidence_json,'$.subscriptionLoopRegistrationStatus')
204
+ loopRegistrationStatus,
205
+ json_extract(c.evidence_json,'$.subscriptionLoopRegistrationCount')
206
+ loopRegistrationCount
207
+ FROM graph_edges e LEFT JOIN outbound_calls c
208
+ ON c.id=CAST(json_extract(e.evidence_json,'$.subscribeCallId') AS INTEGER)
209
+ WHERE e.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
210
+ AND ${workspacePredicate('e')}
211
+ AND NOT EXISTS (SELECT 1 FROM graph_edges publication
212
+ WHERE publication.workspace_id=e.workspace_id
213
+ AND publication.generation=e.generation
214
+ AND publication.edge_type='HANDLER_EMITS_EVENT'
215
+ AND publication.to_kind='event'
216
+ AND publication.to_id=e.from_id)
217
+ ORDER BY repositoryName COLLATE BINARY,sourceFile COLLATE BINARY,
218
+ sourceLine,e.id LIMIT 5`).all(
219
+ workspaceId, workspaceId,
220
+ ) as Diagnostic[];
221
+ }
222
+
223
+ function unmatchedSubscriptionQuality(
224
+ db: Db,
225
+ workspaceId?: number,
226
+ ): Diagnostic {
227
+ const row = db.prepare(`SELECT
228
+ COUNT(DISTINCT json_extract(e.evidence_json,'$.subscribeCallId'))
229
+ siteCount,
230
+ SUM(CASE
231
+ WHEN json_extract(e.evidence_json,'$.materializedLoopEventName')
232
+ IS NOT NULL THEN 1
233
+ WHEN json_extract(c.evidence_json,
234
+ '$.subscriptionLoopRegistrationStatus')='enumerated'
235
+ THEN CAST(json_extract(c.evidence_json,
236
+ '$.subscriptionLoopRegistrationCount') AS INTEGER)
237
+ WHEN json_extract(c.evidence_json,
238
+ '$.subscriptionLoopRegistrationStatus')='unresolved' THEN 0
239
+ ELSE 1 END) count,
240
+ SUM(CASE WHEN json_extract(c.evidence_json,
241
+ '$.subscriptionLoopRegistrationStatus')='unresolved'
242
+ THEN 1 ELSE 0 END) unknownMultiplicityEdgeCount
243
+ FROM graph_edges e LEFT JOIN outbound_calls c
244
+ ON c.id=CAST(json_extract(e.evidence_json,'$.subscribeCallId') AS INTEGER)
245
+ WHERE e.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
246
+ AND ${workspacePredicate('e')}
247
+ AND NOT EXISTS (SELECT 1 FROM graph_edges publication
248
+ WHERE publication.workspace_id=e.workspace_id
249
+ AND publication.generation=e.generation
250
+ AND publication.edge_type='HANDLER_EMITS_EVENT'
251
+ AND publication.to_kind='event'
252
+ AND publication.to_id=e.from_id)`).get(workspaceId, workspaceId);
253
+ const total = count(row?.count);
254
+ const siteCount = count(row?.siteCount);
255
+ const unknownSites = unknownMultiplicitySiteCount(db, workspaceId);
256
+ return {
257
+ severity: siteCount > 0 ? 'warning' : 'info',
258
+ code: 'strict_event_subscription_without_publication_quality',
259
+ message: 'Event subscriptions without a matching publication',
260
+ unmatchedSubscriptionCount: total,
261
+ unmatchedSubscriptionSiteCount: siteCount,
262
+ unknownMultiplicitySiteCount: unknownSites,
263
+ examples: unmatchedSubscriptionRows(db, workspaceId),
264
+ exampleCount: siteCount,
265
+ };
266
+ }
267
+
268
+ function unknownMultiplicitySiteCount(
269
+ db: Db,
270
+ workspaceId?: number,
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,
294
+ SUM(CASE WHEN json_extract(c.evidence_json,
295
+ '$.receiverClassification')='cap_evidence' THEN 1 ELSE 0 END) proven,
296
+ SUM(CASE WHEN json_extract(c.evidence_json,
297
+ '$.receiverClassification')='name_fallback' THEN 1 ELSE 0 END)
298
+ nameFallback,
299
+ SUM(CASE WHEN json_extract(c.evidence_json,
300
+ '$.receiverClassification')='unproven' THEN 1 ELSE 0 END) unproven
301
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
302
+ WHERE c.call_type IN ('async_emit','async_subscribe')
303
+ AND ${workspacePredicate('r')}`).get(workspaceId, workspaceId);
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
311
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
312
+ WHERE c.call_type IN ('async_emit','async_subscribe')
313
+ AND ${workspacePredicate('r')}
314
+ AND json_extract(c.evidence_json,'$.receiverClassification')
315
+ <>'cap_evidence'
316
+ GROUP BY reason ORDER BY count DESC,reason COLLATE BINARY LIMIT 16`).all(
317
+ workspaceId, workspaceId,
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);
342
+ const questionable = count(row?.nameFallback) + count(row?.unproven);
343
+ return {
344
+ severity: questionable > 0 ? 'warning' : 'info',
345
+ code: 'strict_event_receiver_classification_quality',
346
+ message: 'CAP event receiver proof aggregate',
347
+ eventTotal: count(row?.eventTotal),
348
+ proven: count(row?.proven),
349
+ nameFallback: count(row?.nameFallback),
350
+ unproven: count(row?.unproven),
351
+ questionable,
352
+ reasonBuckets: buckets,
353
+ reasonBucketCount: bucketCount,
354
+ shownReasonBucketCount: buckets.length,
355
+ omittedReasonBucketCount: Math.max(0, bucketCount - buckets.length),
356
+ examples: receiverProofExamples(db, workspaceId),
357
+ exampleCount: questionable,
358
+ };
359
+ }
360
+
361
+ function receiverProofExamples(
362
+ db: Db,
363
+ workspaceId?: number,
364
+ ): Diagnostic[] {
365
+ return db.prepare(`SELECT r.name repositoryName,c.call_type callType,
366
+ c.source_file sourceFile,c.source_line sourceLine,
367
+ json_extract(c.evidence_json,'$.receiverClassification')
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
373
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
374
+ WHERE c.call_type IN ('async_emit','async_subscribe')
375
+ AND ${workspacePredicate('r')}
376
+ AND json_extract(c.evidence_json,'$.receiverClassification')
377
+ <>'cap_evidence'
378
+ ORDER BY r.name COLLATE BINARY,c.source_file COLLATE BINARY,
379
+ c.source_line,c.id LIMIT 5`).all(
380
+ workspaceId, workspaceId,
381
+ ) as Diagnostic[];
382
+ }
383
+
384
+ function shapeEnvironmentExamples(
385
+ db: Db,
386
+ workspaceId?: number,
387
+ ): Diagnostic[] {
388
+ return db.prepare(`SELECT e.edge_type edgeType,e.from_id fromId,
389
+ e.to_kind toKind,e.to_id toId,
390
+ json_extract(e.evidence_json,
391
+ '$.eventEnvironmentResolution.status') environmentStatus,
392
+ json_extract(e.evidence_json,
393
+ '$.eventEnvironmentResolution.collisionCount') collisionCount
394
+ FROM graph_edges e WHERE ${workspacePredicate('e')}
395
+ AND (e.edge_type='EVENT_SHAPE_CANDIDATE_SUBSCRIBER'
396
+ OR json_extract(e.evidence_json,
397
+ '$.eventEnvironmentResolution.status')='ambiguous'
398
+ OR CAST(json_extract(e.evidence_json,
399
+ '$.eventEnvironmentResolution.collisionCount') AS INTEGER)>1)
400
+ ORDER BY e.edge_type COLLATE BINARY,e.from_id COLLATE BINARY,
401
+ e.to_id COLLATE BINARY,e.id LIMIT 5`).all(
402
+ workspaceId, workspaceId,
403
+ ) as Diagnostic[];
404
+ }
405
+
406
+ function shapeEnvironmentQuality(
407
+ db: Db,
408
+ workspaceId?: number,
409
+ ): Diagnostic {
410
+ const row = db.prepare(`SELECT
411
+ SUM(CASE WHEN e.edge_type='EVENT_SHAPE_CANDIDATE_SUBSCRIBER'
412
+ THEN 1 ELSE 0 END) shapeCandidates,
413
+ SUM(CASE WHEN json_extract(e.evidence_json,
414
+ '$.eventEnvironmentResolution.status')='ambiguous'
415
+ OR CAST(json_extract(e.evidence_json,
416
+ '$.eventEnvironmentResolution.collisionCount') AS INTEGER)>1
417
+ THEN 1 ELSE 0 END) environmentAmbiguities
418
+ FROM graph_edges e WHERE ${workspacePredicate('e')}`).get(
419
+ workspaceId, workspaceId,
420
+ );
421
+ const shapes = count(row?.shapeCandidates);
422
+ const ambiguous = count(row?.environmentAmbiguities);
423
+ return {
424
+ severity: shapes + ambiguous > 0 ? 'warning' : 'info',
425
+ code: 'strict_event_shape_environment_quality',
426
+ message: 'Event skeleton candidate and environment ambiguity aggregate',
427
+ skeletonCandidateCount: shapes,
428
+ environmentBindingAmbiguityCount: ambiguous,
429
+ examples: shapeEnvironmentExamples(db, workspaceId),
430
+ exampleCount: shapes + ambiguous,
431
+ };
432
+ }
433
+
434
+ export function eventSurfaceQualityDiagnostics(
435
+ db: Db,
436
+ workspaceId?: number,
437
+ ): Diagnostic[] {
438
+ return [
439
+ eventNameResolutionQuality(db, workspaceId),
440
+ dynamicEventQuality(db, workspaceId),
441
+ unmatchedPublicationQuality(db, workspaceId),
442
+ unmatchedSubscriptionQuality(db, workspaceId),
443
+ receiverProofQuality(db, workspaceId),
444
+ shapeEnvironmentQuality(db, workspaceId),
445
+ ];
446
+ }
package/src/cli/doctor.ts CHANGED
@@ -11,6 +11,9 @@ import {
11
11
  packagePendingDiagnostics,
12
12
  symbolCallQuality,
13
13
  } from './doctor-package-resolution.js';
14
+ import {
15
+ eventSurfaceQualityDiagnostics,
16
+ } from './doctor-event-quality.js';
14
17
  export { linkUpgradeWarnings } from './doctor-lifecycle.js';
15
18
 
16
19
  type Diagnostic = Record<string, unknown>;
@@ -163,7 +166,7 @@ function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOption
163
166
  externalHttpTargetQuality(db),
164
167
  odataInvocationResolutionQuality(db),
165
168
  ...jsonEvidenceQuality(db),
166
- eventReceiverQuality(db),
169
+ ...eventSurfaceQualityDiagnostics(db, options.workspaceId),
167
170
  graphDynamicFlagQuality(db),
168
171
  symbol,
169
172
  dbq,
@@ -202,11 +205,6 @@ function outboundOwnershipQuality(db: Db): Diagnostic {
202
205
  return { severity: ratio > 0.01 ? 'warning' : 'info', code: 'strict_outbound_source_ownership_quality', message: 'Outbound call source-symbol ownership aggregate', total, withoutOwnership: without, withoutOwnershipRatio: ratio, withoutOwnershipRatioThreshold: 0.01, ownerlessByType: byType, ownerlessExamples: examples };
203
206
  }
204
207
 
205
- function eventReceiverQuality(db: Db): Diagnostic {
206
- const row = db.prepare("SELECT SUM(CASE WHEN call_type IN ('async_emit','async_subscribe') THEN 1 ELSE 0 END) eventTotal, SUM(CASE WHEN call_type IN ('async_emit','async_subscribe') AND (json_extract(evidence_json,'$.receiverClassification') IS NULL OR json_extract(evidence_json,'$.receiverClassification') <> 'cap_evidence') THEN 1 ELSE 0 END) questionable FROM outbound_calls").get() as Record<string, unknown>;
207
- return { severity: Number(row.questionable ?? 0) > 0 ? 'warning' : 'info', code: 'strict_event_receiver_classification_quality', message: 'CAP event receiver classification aggregate', eventTotal: Number(row.eventTotal ?? 0), questionable: Number(row.questionable ?? 0) };
208
- }
209
-
210
208
  function graphDynamicFlagQuality(db: Db): Diagnostic {
211
209
  const row = db.prepare("SELECT COUNT(*) count FROM graph_edges WHERE status='terminal' AND is_dynamic=1").get() as Record<string, unknown>;
212
210
  return { severity: Number(row.count ?? 0) > 0 ? 'warning' : 'info', code: 'strict_graph_dynamic_flag_consistency', message: 'Graph dynamic flag consistency aggregate', dynamicTerminalEdges: Number(row.count ?? 0) };