@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
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';
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,7 @@ interface LinkWorkspaceResult {
314
352
  subscriptionHandlerAmbiguousCount: number;
315
353
  subscriptionHandlerUnresolvedCount: number;
316
354
  subscriptionHandlerMissingAssociationCount: number;
355
+ eventShapeCandidateCount: number;
317
356
  }
318
357
  declare function linkWorkspace(db: Db, workspaceId: number, vars?: Record<string, string>): LinkWorkspaceResult;
319
358
 
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-Z6D433R5.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.72",
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,371 @@
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
+
12
+ function workspacePredicate(alias: string): string {
13
+ return `(? IS NULL OR ${alias}.workspace_id=?)`;
14
+ }
15
+
16
+ function count(value: unknown): number {
17
+ return Number(value ?? 0);
18
+ }
19
+
20
+ function unresolvedEventNameExamples(
21
+ db: Db,
22
+ workspaceId?: number,
23
+ ): Diagnostic[] {
24
+ return db.prepare(`SELECT r.name repositoryName,
25
+ c.source_file sourceFile,c.source_line sourceLine,
26
+ c.event_name_expr eventName,${eventNameReason} reason
27
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
28
+ WHERE c.call_type='async_emit' AND ${workspacePredicate('r')}
29
+ AND ${eventNameReason} IS NOT NULL
30
+ ORDER BY r.name COLLATE BINARY,c.source_file COLLATE BINARY,
31
+ c.source_line,c.id LIMIT 5`).all(
32
+ workspaceId, workspaceId,
33
+ ) as Diagnostic[];
34
+ }
35
+
36
+ function eventNameResolutionQuality(
37
+ db: Db,
38
+ workspaceId?: number,
39
+ ): Diagnostic {
40
+ const aggregate = db.prepare(`SELECT COUNT(*) total,
41
+ SUM(CASE WHEN reason IS NOT NULL THEN 1 ELSE 0 END) unresolved
42
+ FROM (SELECT ${eventNameReason} reason
43
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
44
+ WHERE c.call_type='async_emit' AND ${workspacePredicate('r')})`).get(
45
+ workspaceId, workspaceId,
46
+ );
47
+ const reasons = db.prepare(`SELECT ${eventNameReason} reason,COUNT(*) count
48
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
49
+ WHERE c.call_type='async_emit' AND ${workspacePredicate('r')}
50
+ AND ${eventNameReason} IS NOT NULL
51
+ GROUP BY reason ORDER BY count DESC,reason COLLATE BINARY LIMIT 16`).all(
52
+ workspaceId, workspaceId,
53
+ );
54
+ const unresolved = count(aggregate?.unresolved);
55
+ return {
56
+ severity: unresolved > 0 ? 'warning' : 'info',
57
+ code: 'strict_event_name_resolution_quality',
58
+ message: 'Event publication name-resolution aggregate',
59
+ publicationTotal: count(aggregate?.total),
60
+ unresolvedPublicationCount: unresolved,
61
+ reasonBuckets: reasons,
62
+ examples: unresolvedEventNameExamples(db, workspaceId),
63
+ exampleCount: unresolved,
64
+ };
65
+ }
66
+
67
+ function dynamicEventExamples(
68
+ db: Db,
69
+ workspaceId?: number,
70
+ ): Diagnostic[] {
71
+ return db.prepare(`SELECT r.name repositoryName,c.call_type callType,
72
+ c.source_file sourceFile,c.source_line sourceLine,
73
+ e.to_kind targetKind,e.to_id targetId,e.unresolved_reason reason
74
+ FROM graph_edges e JOIN outbound_calls c
75
+ ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER)
76
+ JOIN repositories r ON r.id=c.repo_id
77
+ WHERE c.call_type IN ('async_emit','async_subscribe')
78
+ AND ${workspacePredicate('r')}
79
+ AND (e.to_kind='event_candidate'
80
+ OR e.edge_type='DYNAMIC_EDGE_CANDIDATE')
81
+ ORDER BY r.name COLLATE BINARY,c.source_file COLLATE BINARY,
82
+ c.source_line,c.id LIMIT 5`).all(
83
+ workspaceId, workspaceId,
84
+ ) as Diagnostic[];
85
+ }
86
+
87
+ function dynamicEventQuality(
88
+ db: Db,
89
+ workspaceId?: number,
90
+ ): Diagnostic {
91
+ const row = db.prepare(`SELECT
92
+ COUNT(DISTINCT CASE WHEN e.to_kind='event_candidate'
93
+ THEN e.workspace_id || ':' || e.to_id END) eventCandidateNodeCount,
94
+ COUNT(*) dynamicEventEdgeCount,
95
+ SUM(CASE WHEN json_array_length(e.evidence_json,
96
+ '$.eventTemplateResolution.placeholders')>0 THEN 1 ELSE 0 END)
97
+ variableRecoverableCount
98
+ FROM graph_edges e JOIN outbound_calls c
99
+ ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER)
100
+ JOIN repositories r ON r.id=c.repo_id
101
+ WHERE c.call_type IN ('async_emit','async_subscribe')
102
+ AND ${workspacePredicate('r')}
103
+ AND (e.to_kind='event_candidate'
104
+ OR e.edge_type='DYNAMIC_EDGE_CANDIDATE')`).get(
105
+ workspaceId, workspaceId,
106
+ );
107
+ const total = count(row?.dynamicEventEdgeCount);
108
+ const recoverable = count(row?.variableRecoverableCount);
109
+ return {
110
+ severity: total > 0 ? 'warning' : 'info',
111
+ code: 'strict_event_dynamic_candidate_quality',
112
+ message: 'Dynamic event candidate aggregate',
113
+ eventCandidateNodeCount: count(row?.eventCandidateNodeCount),
114
+ dynamicEventEdgeCount: total,
115
+ eventCandidateCount: total,
116
+ variableRecoverableCount: recoverable,
117
+ nonVariableRecoverableCount: Math.max(0, total - recoverable),
118
+ examples: dynamicEventExamples(db, workspaceId),
119
+ exampleCount: total,
120
+ };
121
+ }
122
+
123
+ function unmatchedPublicationRows(
124
+ db: Db,
125
+ workspaceId?: number,
126
+ ): Diagnostic[] {
127
+ return db.prepare(`SELECT r.name repositoryName,c.source_file sourceFile,
128
+ c.source_line sourceLine,e.to_id eventName
129
+ FROM graph_edges e JOIN outbound_calls c
130
+ ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER)
131
+ JOIN repositories r ON r.id=c.repo_id
132
+ WHERE c.call_type='async_emit' AND e.edge_type='HANDLER_EMITS_EVENT'
133
+ AND e.to_kind='event' AND ${workspacePredicate('r')}
134
+ AND NOT EXISTS (SELECT 1 FROM graph_edges subscription
135
+ WHERE subscription.workspace_id=e.workspace_id
136
+ AND subscription.generation=e.generation
137
+ AND subscription.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
138
+ AND subscription.from_kind='event'
139
+ AND subscription.from_id=e.to_id)
140
+ ORDER BY r.name COLLATE BINARY,c.source_file COLLATE BINARY,
141
+ c.source_line,c.id LIMIT 5`).all(
142
+ workspaceId, workspaceId,
143
+ ) as Diagnostic[];
144
+ }
145
+
146
+ function unmatchedPublicationQuality(
147
+ db: Db,
148
+ workspaceId?: number,
149
+ ): Diagnostic {
150
+ const row = db.prepare(`SELECT COUNT(*) count
151
+ FROM graph_edges e JOIN outbound_calls c
152
+ ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER)
153
+ JOIN repositories r ON r.id=c.repo_id
154
+ WHERE c.call_type='async_emit' AND e.edge_type='HANDLER_EMITS_EVENT'
155
+ AND e.to_kind='event' AND ${workspacePredicate('r')}
156
+ AND NOT EXISTS (SELECT 1 FROM graph_edges subscription
157
+ WHERE subscription.workspace_id=e.workspace_id
158
+ AND subscription.generation=e.generation
159
+ AND subscription.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
160
+ AND subscription.from_kind='event'
161
+ AND subscription.from_id=e.to_id)`).get(workspaceId, workspaceId);
162
+ const total = count(row?.count);
163
+ return {
164
+ severity: total > 0 ? 'warning' : 'info',
165
+ code: 'strict_event_publication_without_subscription_quality',
166
+ message: 'Resolved event publications without a matching subscription',
167
+ unmatchedPublicationCount: total,
168
+ examples: unmatchedPublicationRows(db, workspaceId),
169
+ exampleCount: total,
170
+ };
171
+ }
172
+
173
+ function unmatchedSubscriptionRows(
174
+ db: Db,
175
+ workspaceId?: number,
176
+ ): Diagnostic[] {
177
+ return db.prepare(`SELECT COALESCE(json_extract(e.evidence_json,
178
+ '$.subscriptionConsumerRepositoryName'),
179
+ json_extract(e.evidence_json,'$.repositoryName')) repositoryName,
180
+ json_extract(e.evidence_json,'$.sourceFile') sourceFile,
181
+ json_extract(e.evidence_json,'$.sourceLine') sourceLine,
182
+ e.from_id eventName,
183
+ json_extract(c.evidence_json,'$.subscriptionLoopRegistrationStatus')
184
+ loopRegistrationStatus,
185
+ json_extract(c.evidence_json,'$.subscriptionLoopRegistrationCount')
186
+ loopRegistrationCount
187
+ FROM graph_edges e LEFT JOIN outbound_calls c
188
+ ON c.id=CAST(json_extract(e.evidence_json,'$.subscribeCallId') AS INTEGER)
189
+ WHERE e.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
190
+ AND ${workspacePredicate('e')}
191
+ AND NOT EXISTS (SELECT 1 FROM graph_edges publication
192
+ WHERE publication.workspace_id=e.workspace_id
193
+ AND publication.generation=e.generation
194
+ AND publication.edge_type='HANDLER_EMITS_EVENT'
195
+ AND publication.to_kind='event'
196
+ AND publication.to_id=e.from_id)
197
+ ORDER BY repositoryName COLLATE BINARY,sourceFile COLLATE BINARY,
198
+ sourceLine,e.id LIMIT 5`).all(
199
+ workspaceId, workspaceId,
200
+ ) as Diagnostic[];
201
+ }
202
+
203
+ function unmatchedSubscriptionQuality(
204
+ db: Db,
205
+ workspaceId?: number,
206
+ ): Diagnostic {
207
+ const row = db.prepare(`SELECT COUNT(*) siteCount,
208
+ SUM(CASE
209
+ WHEN json_extract(c.evidence_json,
210
+ '$.subscriptionLoopRegistrationStatus')='enumerated'
211
+ THEN CAST(json_extract(c.evidence_json,
212
+ '$.subscriptionLoopRegistrationCount') AS INTEGER)
213
+ WHEN json_extract(c.evidence_json,
214
+ '$.subscriptionLoopRegistrationStatus')='unresolved' THEN 0
215
+ ELSE 1 END) count,
216
+ SUM(CASE WHEN json_extract(c.evidence_json,
217
+ '$.subscriptionLoopRegistrationStatus')='unresolved'
218
+ THEN 1 ELSE 0 END) unknownMultiplicitySiteCount
219
+ FROM graph_edges e LEFT JOIN outbound_calls c
220
+ ON c.id=CAST(json_extract(e.evidence_json,'$.subscribeCallId') AS INTEGER)
221
+ WHERE e.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
222
+ AND ${workspacePredicate('e')}
223
+ AND NOT EXISTS (SELECT 1 FROM graph_edges publication
224
+ WHERE publication.workspace_id=e.workspace_id
225
+ AND publication.generation=e.generation
226
+ AND publication.edge_type='HANDLER_EMITS_EVENT'
227
+ AND publication.to_kind='event'
228
+ AND publication.to_id=e.from_id)`).get(workspaceId, workspaceId);
229
+ const total = count(row?.count);
230
+ const siteCount = count(row?.siteCount);
231
+ return {
232
+ severity: siteCount > 0 ? 'warning' : 'info',
233
+ code: 'strict_event_subscription_without_publication_quality',
234
+ message: 'Event subscriptions without a matching publication',
235
+ unmatchedSubscriptionCount: total,
236
+ unmatchedSubscriptionSiteCount: siteCount,
237
+ unknownMultiplicitySiteCount: count(row?.unknownMultiplicitySiteCount),
238
+ examples: unmatchedSubscriptionRows(db, workspaceId),
239
+ exampleCount: siteCount,
240
+ };
241
+ }
242
+
243
+ function receiverProofQuality(
244
+ db: Db,
245
+ workspaceId?: number,
246
+ ): Diagnostic {
247
+ const row = db.prepare(`SELECT COUNT(*) eventTotal,
248
+ SUM(CASE WHEN json_extract(c.evidence_json,
249
+ '$.receiverClassification')='cap_evidence' THEN 1 ELSE 0 END) proven,
250
+ SUM(CASE WHEN json_extract(c.evidence_json,
251
+ '$.receiverClassification')='name_fallback' THEN 1 ELSE 0 END)
252
+ nameFallback,
253
+ SUM(CASE WHEN json_extract(c.evidence_json,
254
+ '$.receiverClassification')='unproven' THEN 1 ELSE 0 END) unproven
255
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
256
+ WHERE c.call_type IN ('async_emit','async_subscribe')
257
+ 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
265
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
266
+ WHERE c.call_type IN ('async_emit','async_subscribe')
267
+ AND ${workspacePredicate('r')}
268
+ AND json_extract(c.evidence_json,'$.receiverClassification')
269
+ <>'cap_evidence'
270
+ GROUP BY reason ORDER BY count DESC,reason COLLATE BINARY LIMIT 16`).all(
271
+ workspaceId, workspaceId,
272
+ );
273
+ const questionable = count(row?.nameFallback) + count(row?.unproven);
274
+ return {
275
+ severity: questionable > 0 ? 'warning' : 'info',
276
+ code: 'strict_event_receiver_classification_quality',
277
+ message: 'CAP event receiver proof aggregate',
278
+ eventTotal: count(row?.eventTotal),
279
+ proven: count(row?.proven),
280
+ nameFallback: count(row?.nameFallback),
281
+ unproven: count(row?.unproven),
282
+ questionable,
283
+ reasonBuckets: buckets,
284
+ examples: receiverProofExamples(db, workspaceId),
285
+ exampleCount: questionable,
286
+ };
287
+ }
288
+
289
+ function receiverProofExamples(
290
+ db: Db,
291
+ workspaceId?: number,
292
+ ): Diagnostic[] {
293
+ return db.prepare(`SELECT r.name repositoryName,c.call_type callType,
294
+ c.source_file sourceFile,c.source_line sourceLine,
295
+ json_extract(c.evidence_json,'$.receiverClassification')
296
+ receiverClassification,
297
+ c.unresolved_reason reason
298
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
299
+ WHERE c.call_type IN ('async_emit','async_subscribe')
300
+ AND ${workspacePredicate('r')}
301
+ AND json_extract(c.evidence_json,'$.receiverClassification')
302
+ <>'cap_evidence'
303
+ ORDER BY r.name COLLATE BINARY,c.source_file COLLATE BINARY,
304
+ c.source_line,c.id LIMIT 5`).all(
305
+ workspaceId, workspaceId,
306
+ ) as Diagnostic[];
307
+ }
308
+
309
+ function shapeEnvironmentExamples(
310
+ db: Db,
311
+ workspaceId?: number,
312
+ ): Diagnostic[] {
313
+ return db.prepare(`SELECT e.edge_type edgeType,e.from_id fromId,
314
+ e.to_kind toKind,e.to_id toId,
315
+ json_extract(e.evidence_json,
316
+ '$.eventEnvironmentResolution.status') environmentStatus,
317
+ json_extract(e.evidence_json,
318
+ '$.eventEnvironmentResolution.collisionCount') collisionCount
319
+ FROM graph_edges e WHERE ${workspacePredicate('e')}
320
+ AND (e.edge_type='EVENT_SHAPE_CANDIDATE_SUBSCRIBER'
321
+ OR json_extract(e.evidence_json,
322
+ '$.eventEnvironmentResolution.status')='ambiguous'
323
+ OR CAST(json_extract(e.evidence_json,
324
+ '$.eventEnvironmentResolution.collisionCount') AS INTEGER)>1)
325
+ ORDER BY e.edge_type COLLATE BINARY,e.from_id COLLATE BINARY,
326
+ e.to_id COLLATE BINARY,e.id LIMIT 5`).all(
327
+ workspaceId, workspaceId,
328
+ ) as Diagnostic[];
329
+ }
330
+
331
+ function shapeEnvironmentQuality(
332
+ db: Db,
333
+ workspaceId?: number,
334
+ ): Diagnostic {
335
+ const row = db.prepare(`SELECT
336
+ SUM(CASE WHEN e.edge_type='EVENT_SHAPE_CANDIDATE_SUBSCRIBER'
337
+ THEN 1 ELSE 0 END) shapeCandidates,
338
+ SUM(CASE WHEN json_extract(e.evidence_json,
339
+ '$.eventEnvironmentResolution.status')='ambiguous'
340
+ OR CAST(json_extract(e.evidence_json,
341
+ '$.eventEnvironmentResolution.collisionCount') AS INTEGER)>1
342
+ THEN 1 ELSE 0 END) environmentAmbiguities
343
+ FROM graph_edges e WHERE ${workspacePredicate('e')}`).get(
344
+ workspaceId, workspaceId,
345
+ );
346
+ const shapes = count(row?.shapeCandidates);
347
+ const ambiguous = count(row?.environmentAmbiguities);
348
+ return {
349
+ severity: shapes + ambiguous > 0 ? 'warning' : 'info',
350
+ code: 'strict_event_shape_environment_quality',
351
+ message: 'Event skeleton candidate and environment ambiguity aggregate',
352
+ skeletonCandidateCount: shapes,
353
+ environmentBindingAmbiguityCount: ambiguous,
354
+ examples: shapeEnvironmentExamples(db, workspaceId),
355
+ exampleCount: shapes + ambiguous,
356
+ };
357
+ }
358
+
359
+ export function eventSurfaceQualityDiagnostics(
360
+ db: Db,
361
+ workspaceId?: number,
362
+ ): Diagnostic[] {
363
+ return [
364
+ eventNameResolutionQuality(db, workspaceId),
365
+ dynamicEventQuality(db, workspaceId),
366
+ unmatchedPublicationQuality(db, workspaceId),
367
+ unmatchedSubscriptionQuality(db, workspaceId),
368
+ receiverProofQuality(db, workspaceId),
369
+ shapeEnvironmentQuality(db, workspaceId),
370
+ ];
371
+ }
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) };
package/src/cli.ts CHANGED
@@ -298,7 +298,7 @@ function registerLinkCommand(program: Command): void {
298
298
  const r = linkWorkspace(db, workspaceId);
299
299
  const upgradeWarnings = linkUpgradeWarnings(db, workspaceId);
300
300
  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\n`,
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`,
302
302
  );
303
303
  }).catch(fail),
304
304
  );
@@ -400,11 +400,12 @@ export function insertCalls(
400
400
  function outboundCallInsertStatement(db: Db): Statement {
401
401
  return db.prepare(`INSERT INTO outbound_calls(
402
402
  repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,
403
- event_name_expr,payload_summary,source_file,source_line,call_site_start_offset,
403
+ event_name_expr,event_skeleton_signature,event_skeleton_json,
404
+ payload_summary,source_file,source_line,call_site_start_offset,
404
405
  call_site_end_offset,confidence,unresolved_reason,local_service_name,
405
406
  local_service_lookup,alias_chain_json,evidence_json,external_target_kind,
406
407
  external_target_id,external_target_label,external_target_dynamic,service_binding_id
407
- ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
408
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
408
409
  }
409
410
 
410
411
  function insertOutboundCall(
@@ -424,7 +425,9 @@ function insertOutboundCall(
424
425
  stmt.run(
425
426
  repoId, sourceSymbolId,
426
427
  call.callType, call.method, call.operationPathExpr, call.queryEntity,
427
- call.eventNameExpr, call.payloadSummary, call.sourceFile, call.sourceLine,
428
+ call.eventNameExpr, call.eventSkeleton?.signature ?? null,
429
+ call.eventSkeleton ? JSON.stringify(call.eventSkeleton) : null,
430
+ call.payloadSummary, call.sourceFile, call.sourceLine,
428
431
  call.callSiteStartOffset, call.callSiteEndOffset, call.confidence,
429
432
  call.unresolvedReason,
430
433
  call.localServiceName, call.localServiceLookup,
@@ -10,9 +10,8 @@ import {
10
10
  import {
11
11
  invalidBindingFactCategories,
12
12
  } from './binding-fact-semantics.js';
13
- import {
14
- invalidSymbolFactCategories,
15
- } from './symbol-call-semantics.js';
13
+ import { invalidSymbolFactCategories } from './symbol-call-semantics.js';
14
+ import { invalidEventFactCategories } from './event-fact-semantics.js';
16
15
 
17
16
  export type PackageFactPhase = 'pre_package' | 'terminal';
18
17
 
@@ -262,7 +261,7 @@ function eventNameCategories(
262
261
  db: Db,
263
262
  workspaceId?: number,
264
263
  ): FactSemanticCategoryCount[] {
265
- const invalid = count(db, `SELECT COUNT(*) count
264
+ const invalidName = count(db, `SELECT COUNT(*) count
266
265
  FROM outbound_calls fact JOIN repositories r ON r.id=fact.repo_id
267
266
  WHERE ${currentRepositoryPredicate()}
268
267
  AND fact.call_type IN ('async_emit','async_subscribe')
@@ -278,7 +277,7 @@ function eventNameCategories(
278
277
  fact.call_site_end_offset HAVING COUNT(*)<>1
279
278
  )`, workspaceId);
280
279
  return [
281
- ...category('event_name_invalid', invalid),
280
+ ...category('event_name_invalid', invalidName),
282
281
  ...category('async_subscription_site_duplicate', duplicate),
283
282
  ];
284
283
  }
@@ -686,6 +685,7 @@ export function invalidFactSemanticCategories(
686
685
  ...callSpanCategories(db, workspaceId),
687
686
  ...duplicateSymbolCallCategories(db, workspaceId),
688
687
  ...eventNameCategories(db, workspaceId),
688
+ ...invalidEventFactCategories(db, workspaceId, phase),
689
689
  ...ownerCategories(db, workspaceId),
690
690
  ...bindingCategories(db, workspaceId),
691
691
  ...invalidBindingFactCategories(db, workspaceId),