@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.
- package/CHANGELOG.md +21 -0
- package/README.md +23 -6
- package/TECHNICAL-NOTE.md +24 -2
- package/dist/{chunk-GSLFY6J2.js → chunk-32WOTGTS.js} +12061 -9288
- package/dist/chunk-32WOTGTS.js.map +1 -0
- package/dist/cli.js +854 -161
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +54 -14
- package/dist/index.js +2 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/doctor-event-quality.ts +446 -0
- package/src/cli/doctor.ts +4 -6
- package/src/cli.ts +35 -13
- package/src/config/workspace-config.ts +15 -0
- package/src/db/call-fact-repository.ts +6 -3
- package/src/db/current-fact-semantics.ts +5 -29
- package/src/db/event-fact-semantics.ts +546 -0
- package/src/db/event-site-semantics.ts +62 -0
- package/src/db/event-surface-invalidation.ts +38 -0
- package/src/db/fact-json-inventory.ts +16 -0
- package/src/db/fact-lifecycle.ts +70 -12
- package/src/db/migrations.ts +15 -1
- package/src/db/package-target-invalidation.ts +80 -0
- package/src/db/repositories.ts +28 -2
- package/src/db/schema-structure.ts +41 -1
- package/src/db/schema.ts +6 -2
- package/src/indexer/repository-indexer.ts +93 -10
- package/src/indexer/workspace-indexer.ts +13 -3
- package/src/linker/cross-repo-linker.ts +27 -3
- package/src/linker/event-environment-link.ts +211 -0
- package/src/linker/event-shape-candidate-linker.ts +204 -0
- package/src/linker/event-subscription-handler-linker.ts +220 -25
- package/src/linker/event-template-link.ts +40 -6
- package/src/linker/package-event-constant-resolver.ts +302 -0
- package/src/output/repository-inspection.ts +11 -0
- package/src/output/table-output.ts +13 -1
- package/src/parsers/decorator-parser.ts +9 -53
- package/src/parsers/environment-declarations.ts +404 -0
- package/src/parsers/event-call-analysis.ts +370 -0
- package/src/parsers/event-environment-reference.ts +242 -0
- package/src/parsers/event-loop-registration.ts +132 -0
- package/src/parsers/event-name-import-resolution.ts +243 -0
- package/src/parsers/event-receiver-analysis.ts +394 -0
- package/src/parsers/event-subscription-facts.ts +4 -0
- package/src/parsers/generated-constants-parser.ts +80 -14
- package/src/parsers/outbound-call-classifier.ts +27 -124
- package/src/parsers/outbound-call-parser.ts +13 -1
- package/src/parsers/outbound-expression-analysis.ts +2 -2
- package/src/parsers/stable-local-value.ts +42 -10
- package/src/parsers/string-constant-lookups.ts +408 -0
- package/src/trace/edge-target.ts +65 -0
- package/src/trace/event-runtime-resolution.ts +24 -3
- package/src/trace/event-shape-candidate-trace.ts +172 -0
- package/src/trace/event-subscriber-traversal.ts +90 -8
- package/src/trace/evidence.ts +7 -28
- package/src/trace/trace-scope-execution.ts +22 -30
- package/src/types.ts +19 -1
- package/src/utils/event-skeleton.ts +207 -0
- package/src/version.ts +1 -1
- 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
|
|
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-
|
|
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":[
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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) };
|