@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
@@ -0,0 +1,408 @@
1
+ import ts from 'typescript';
2
+ import { stableLocalValueReference } from './stable-local-value.js';
3
+ import { lexicalIdentifierDeclaration } from './symbol-import-bindings.js';
4
+
5
+ export type StaticStringConstantKind =
6
+ | 'const_identifier'
7
+ | 'enum_member'
8
+ | 'const_object_property';
9
+
10
+ export interface StaticStringConstant {
11
+ key: string;
12
+ value: string;
13
+ kind: StaticStringConstantKind;
14
+ containerName?: string;
15
+ memberName?: string;
16
+ sourceFile: string;
17
+ declarationStartOffset: number;
18
+ declarationEndOffset: number;
19
+ valueStartOffset: number;
20
+ valueEndOffset: number;
21
+ exported: boolean;
22
+ stable: boolean;
23
+ }
24
+
25
+ export interface StaticStringRefusal {
26
+ key: string;
27
+ kind: StaticStringConstantKind;
28
+ containerName?: string;
29
+ memberName?: string;
30
+ sourceFile: string;
31
+ declarationStartOffset: number;
32
+ declarationEndOffset: number;
33
+ exported: boolean;
34
+ stable: boolean;
35
+ reason: 'event_name_constant_member_not_string'
36
+ | 'event_name_constant_container_mutable'
37
+ | 'event_name_constant_container_unsafe_reference'
38
+ | 'event_name_constant_container_unsupported_shape';
39
+ }
40
+
41
+ export interface StringConstantLookups {
42
+ identifiers: Map<string, StaticStringConstant>;
43
+ enumMembers: Map<string, StaticStringConstant>;
44
+ objectProperties: Map<string, StaticStringConstant>;
45
+ refusedMembers: Map<string, StaticStringRefusal>;
46
+ }
47
+
48
+ export type StaticStringLookupResult =
49
+ | { status: 'resolved'; constant: StaticStringConstant }
50
+ | { status: 'refused'; reason: string }
51
+ | { status: 'not_found' };
52
+
53
+ function unwrapExpression(expression: ts.Expression): ts.Expression {
54
+ if (ts.isParenthesizedExpression(expression)
55
+ || ts.isAsExpression(expression)
56
+ || ts.isSatisfiesExpression(expression)
57
+ || ts.isTypeAssertionExpression(expression))
58
+ return unwrapExpression(expression.expression);
59
+ return expression;
60
+ }
61
+
62
+ function stringValue(
63
+ expression: ts.Expression | undefined,
64
+ ): { value: string; node: ts.Expression } | undefined {
65
+ if (!expression) return undefined;
66
+ const value = unwrapExpression(expression);
67
+ return ts.isStringLiteralLike(value)
68
+ ? { value: value.text, node: value } : undefined;
69
+ }
70
+
71
+ function propertyName(name: ts.PropertyName): string | undefined {
72
+ return ts.isIdentifier(name) || ts.isStringLiteralLike(name)
73
+ || ts.isNumericLiteral(name) ? name.text : undefined;
74
+ }
75
+
76
+ function exportedNames(source: ts.SourceFile): Set<string> {
77
+ const names = new Set<string>();
78
+ for (const statement of source.statements) {
79
+ if (!ts.isExportDeclaration(statement) || statement.moduleSpecifier
80
+ || !statement.exportClause
81
+ || !ts.isNamedExports(statement.exportClause)) continue;
82
+ for (const element of statement.exportClause.elements)
83
+ names.add(element.propertyName?.text ?? element.name.text);
84
+ }
85
+ return names;
86
+ }
87
+
88
+ function hasExportModifier(node: ts.Node): boolean {
89
+ return ts.canHaveModifiers(node)
90
+ && Boolean(ts.getModifiers(node)?.some((modifier) =>
91
+ modifier.kind === ts.SyntaxKind.ExportKeyword));
92
+ }
93
+
94
+ function staticFact(
95
+ source: ts.SourceFile,
96
+ key: string,
97
+ value: { value: string; node: ts.Expression },
98
+ declaration: ts.Node,
99
+ fields: Pick<StaticStringConstant, 'kind' | 'exported' | 'stable'>
100
+ & Partial<Pick<StaticStringConstant, 'containerName' | 'memberName'>>,
101
+ ): StaticStringConstant {
102
+ return {
103
+ key,
104
+ value: value.value,
105
+ sourceFile: source.fileName,
106
+ declarationStartOffset: declaration.getStart(source),
107
+ declarationEndOffset: declaration.getEnd(),
108
+ valueStartOffset: value.node.getStart(source),
109
+ valueEndOffset: value.node.getEnd(),
110
+ ...fields,
111
+ };
112
+ }
113
+
114
+ function refusalFact(
115
+ source: ts.SourceFile,
116
+ key: string,
117
+ declaration: ts.Node,
118
+ fields: Pick<StaticStringRefusal,
119
+ 'kind' | 'exported' | 'stable' | 'reason'>
120
+ & Partial<Pick<StaticStringRefusal, 'containerName' | 'memberName'>>,
121
+ ): StaticStringRefusal {
122
+ return {
123
+ key,
124
+ sourceFile: source.fileName,
125
+ declarationStartOffset: declaration.getStart(source),
126
+ declarationEndOffset: declaration.getEnd(),
127
+ ...fields,
128
+ };
129
+ }
130
+
131
+ function collectEnum(
132
+ source: ts.SourceFile,
133
+ statement: ts.EnumDeclaration,
134
+ exports: Set<string>,
135
+ lookups: StringConstantLookups,
136
+ ): void {
137
+ const stable = stableLocalValueReference(source, statement.name);
138
+ const exported = hasExportModifier(statement)
139
+ || exports.has(statement.name.text);
140
+ for (const member of statement.members) {
141
+ const memberName = propertyName(member.name);
142
+ if (!memberName) continue;
143
+ const key = `${statement.name.text}.${memberName}`;
144
+ const value = stringValue(member.initializer);
145
+ if (!stable) lookups.refusedMembers.set(key, refusalFact(
146
+ source, key, member, {
147
+ kind: 'enum_member', containerName: statement.name.text,
148
+ memberName, exported, stable,
149
+ reason: 'event_name_constant_container_unsafe_reference',
150
+ },
151
+ ));
152
+ else if (!value) lookups.refusedMembers.set(key, refusalFact(
153
+ source, key, member, {
154
+ kind: 'enum_member', containerName: statement.name.text,
155
+ memberName, exported, stable,
156
+ reason: 'event_name_constant_member_not_string',
157
+ },
158
+ ));
159
+ else lookups.enumMembers.set(key, staticFact(
160
+ source, key, value, member, {
161
+ kind: 'enum_member',
162
+ containerName: statement.name.text,
163
+ memberName,
164
+ exported,
165
+ stable,
166
+ },
167
+ ));
168
+ }
169
+ }
170
+
171
+ function objectLiteral(
172
+ expression: ts.Expression,
173
+ ): ts.ObjectLiteralExpression | undefined {
174
+ const value = unwrapExpression(expression);
175
+ return ts.isObjectLiteralExpression(value) ? value : undefined;
176
+ }
177
+
178
+ function objectMemberName(
179
+ property: ts.ObjectLiteralElementLike,
180
+ ): string | undefined {
181
+ if (ts.isSpreadAssignment(property)) return undefined;
182
+ return property.name ? propertyName(property.name) : undefined;
183
+ }
184
+
185
+ function supportedObjectShape(object: ts.ObjectLiteralExpression): boolean {
186
+ return object.properties.every((property) =>
187
+ ts.isPropertyAssignment(property)
188
+ && propertyName(property.name) !== undefined);
189
+ }
190
+
191
+ function refuseObjectShape(
192
+ source: ts.SourceFile,
193
+ object: ts.ObjectLiteralExpression,
194
+ declaration: ts.VariableDeclaration,
195
+ exported: boolean,
196
+ lookups: StringConstantLookups,
197
+ ): void {
198
+ if (!ts.isIdentifier(declaration.name)) return;
199
+ for (const property of object.properties) {
200
+ const memberName = objectMemberName(property);
201
+ if (!memberName) continue;
202
+ const key = `${declaration.name.text}.${memberName}`;
203
+ lookups.refusedMembers.set(key, refusalFact(
204
+ source, key, property, {
205
+ kind: 'const_object_property',
206
+ containerName: declaration.name.text,
207
+ memberName,
208
+ exported,
209
+ stable: false,
210
+ reason: 'event_name_constant_container_unsupported_shape',
211
+ },
212
+ ));
213
+ }
214
+ }
215
+
216
+ function collectObject(
217
+ source: ts.SourceFile,
218
+ statement: ts.VariableStatement,
219
+ declaration: ts.VariableDeclaration,
220
+ exports: Set<string>,
221
+ lookups: StringConstantLookups,
222
+ ): void {
223
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) return;
224
+ const object = objectLiteral(declaration.initializer);
225
+ if (!object) return;
226
+ const stable = stableLocalValueReference(source, declaration.name);
227
+ const exported = hasExportModifier(statement)
228
+ || exports.has(declaration.name.text);
229
+ if (!supportedObjectShape(object)) {
230
+ refuseObjectShape(source, object, declaration, exported, lookups);
231
+ return;
232
+ }
233
+ for (const property of object.properties) {
234
+ if (ts.isPropertyAssignment(property))
235
+ collectObjectProperty(
236
+ source, property, declaration.name.text, exported, stable, lookups,
237
+ );
238
+ }
239
+ }
240
+
241
+ function collectObjectProperty(
242
+ source: ts.SourceFile,
243
+ property: ts.PropertyAssignment,
244
+ containerName: string,
245
+ exported: boolean,
246
+ stable: boolean,
247
+ lookups: StringConstantLookups,
248
+ ): void {
249
+ const memberName = propertyName(property.name);
250
+ if (!memberName) return;
251
+ const key = `${containerName}.${memberName}`;
252
+ const value = stringValue(property.initializer);
253
+ if (!stable || !value) {
254
+ lookups.refusedMembers.set(key, refusalFact(
255
+ source, key, property, {
256
+ kind: 'const_object_property', containerName, memberName,
257
+ exported, stable,
258
+ reason: stable
259
+ ? 'event_name_constant_member_not_string'
260
+ : 'event_name_constant_container_unsafe_reference',
261
+ },
262
+ ));
263
+ return;
264
+ }
265
+ lookups.objectProperties.set(key, staticFact(
266
+ source, key, value, property, {
267
+ kind: 'const_object_property', containerName, memberName,
268
+ exported, stable,
269
+ },
270
+ ));
271
+ }
272
+
273
+ function collectIdentifier(
274
+ source: ts.SourceFile,
275
+ statement: ts.VariableStatement,
276
+ declaration: ts.VariableDeclaration,
277
+ exports: Set<string>,
278
+ lookups: StringConstantLookups,
279
+ ): void {
280
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) return;
281
+ const value = stringValue(declaration.initializer);
282
+ if (!value) return;
283
+ const key = declaration.name.text;
284
+ lookups.identifiers.set(key, staticFact(
285
+ source, key, value, declaration, {
286
+ kind: 'const_identifier',
287
+ exported: hasExportModifier(statement) || exports.has(key),
288
+ stable: true,
289
+ },
290
+ ));
291
+ }
292
+
293
+ export function collectStringConstantLookups(
294
+ source: ts.SourceFile,
295
+ ): StringConstantLookups {
296
+ const lookups: StringConstantLookups = {
297
+ identifiers: new Map(),
298
+ enumMembers: new Map(),
299
+ objectProperties: new Map(),
300
+ refusedMembers: new Map(),
301
+ };
302
+ const exports = exportedNames(source);
303
+ for (const statement of source.statements) {
304
+ if (ts.isEnumDeclaration(statement))
305
+ collectEnum(source, statement, exports, lookups);
306
+ if (!ts.isVariableStatement(statement)
307
+ || (statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
308
+ for (const declaration of statement.declarationList.declarations) {
309
+ collectIdentifier(source, statement, declaration, exports, lookups);
310
+ collectObject(source, statement, declaration, exports, lookups);
311
+ }
312
+ }
313
+ return lookups;
314
+ }
315
+
316
+ export function resolveStringConstant(
317
+ expression: ts.Expression,
318
+ lookups: StringConstantLookups,
319
+ ): StaticStringLookupResult {
320
+ const value = unwrapExpression(expression);
321
+ if (ts.isIdentifier(value)) {
322
+ const constant = lookups.identifiers.get(value.text);
323
+ return constant && constantReferenceMatches(value, constant)
324
+ ? { status: 'resolved', constant } : { status: 'not_found' };
325
+ }
326
+ if (ts.isElementAccessExpression(value)
327
+ && ts.isIdentifier(value.expression)) {
328
+ const prefix = `${value.expression.text}.`;
329
+ const known = [
330
+ ...lookups.enumMembers.keys(),
331
+ ...lookups.objectProperties.keys(),
332
+ ...lookups.refusedMembers.keys(),
333
+ ].some((key) => key.startsWith(prefix));
334
+ return known && constantContainerMatches(
335
+ value.expression, lookups, prefix,
336
+ ) ? {
337
+ status: 'refused',
338
+ reason: 'event_name_constant_container_ambiguous',
339
+ } : { status: 'not_found' };
340
+ }
341
+ if (!ts.isPropertyAccessExpression(value)
342
+ || value.questionDotToken || !ts.isIdentifier(value.expression))
343
+ return { status: 'not_found' };
344
+ const key = `${value.expression.text}.${value.name.text}`;
345
+ const constant = lookups.enumMembers.get(key)
346
+ ?? lookups.objectProperties.get(key);
347
+ if (constant && constantReferenceMatches(value.expression, constant))
348
+ return { status: 'resolved', constant };
349
+ const reason = lookups.refusedMembers.get(key);
350
+ return reason && refusalReferenceMatches(value.expression, reason)
351
+ ? { status: 'refused', reason: reason.reason }
352
+ : constant || reason ? {
353
+ status: 'refused',
354
+ reason: 'event_name_constant_container_ambiguous',
355
+ } : { status: 'not_found' };
356
+ }
357
+
358
+ function declarationContains(
359
+ identifier: ts.Identifier,
360
+ startOffset: number,
361
+ endOffset: number,
362
+ ): boolean {
363
+ const declaration = lexicalIdentifierDeclaration(identifier);
364
+ if (!declaration) return false;
365
+ const owner = declaration.parent;
366
+ return owner.getSourceFile() === identifier.getSourceFile()
367
+ && owner.getStart(identifier.getSourceFile()) <= startOffset
368
+ && owner.getEnd() >= endOffset;
369
+ }
370
+
371
+ function constantReferenceMatches(
372
+ identifier: ts.Identifier,
373
+ constant: StaticStringConstant,
374
+ ): boolean {
375
+ return declarationContains(
376
+ identifier,
377
+ constant.declarationStartOffset,
378
+ constant.declarationEndOffset,
379
+ );
380
+ }
381
+
382
+ function refusalReferenceMatches(
383
+ identifier: ts.Identifier,
384
+ refusal: StaticStringRefusal,
385
+ ): boolean {
386
+ return declarationContains(
387
+ identifier,
388
+ refusal.declarationStartOffset,
389
+ refusal.declarationEndOffset,
390
+ );
391
+ }
392
+
393
+ function constantContainerMatches(
394
+ identifier: ts.Identifier,
395
+ lookups: StringConstantLookups,
396
+ prefix: string,
397
+ ): boolean {
398
+ const constants = [
399
+ ...lookups.enumMembers.values(),
400
+ ...lookups.objectProperties.values(),
401
+ ].filter((item) => item.key.startsWith(prefix));
402
+ const refusals = [...lookups.refusedMembers.values()]
403
+ .filter((item) => item.key.startsWith(prefix));
404
+ return [...constants, ...refusals].some((item) =>
405
+ declarationContains(
406
+ identifier, item.declarationStartOffset, item.declarationEndOffset,
407
+ ));
408
+ }
@@ -0,0 +1,65 @@
1
+ import type { TraceGraphRow } from './evidence.js';
2
+
3
+ function record(value: unknown): Record<string, unknown> {
4
+ return value && typeof value === 'object' && !Array.isArray(value)
5
+ ? value as Record<string, unknown> : {};
6
+ }
7
+
8
+ function text(value: unknown): string | undefined {
9
+ return typeof value === 'string' ? value : undefined;
10
+ }
11
+
12
+ function effectiveTarget(
13
+ evidence: Record<string, unknown>,
14
+ ): string | undefined {
15
+ const effective = record(evidence.effectiveResolution);
16
+ const service = text(
17
+ effective.targetServicePath ?? evidence.targetServicePath,
18
+ );
19
+ const operation = text(
20
+ effective.targetOperationPath ?? evidence.targetOperationPath,
21
+ );
22
+ return service && operation ? `${service}${operation}` : undefined;
23
+ }
24
+
25
+ function runtimeCandidateTarget(
26
+ evidence: Record<string, unknown>,
27
+ ): string | undefined {
28
+ const candidate = record(evidence.runtimeResolvedCandidate);
29
+ const service = text(candidate.servicePath);
30
+ const operation = text(candidate.operationPath);
31
+ return service && operation ? `${service}${operation}` : undefined;
32
+ }
33
+
34
+ function semanticEdgeTarget(
35
+ row: TraceGraphRow,
36
+ evidence: Record<string, unknown>,
37
+ ): string | undefined {
38
+ if (row.edge_type === 'HANDLER_RUNS_DB_QUERY')
39
+ return `Entity: ${row.to_id || 'unknown'}`;
40
+ if (row.edge_type === 'HANDLER_RUNS_REMOTE_QUERY')
41
+ return text(evidence.remoteQueryTarget)
42
+ ?? `Remote query: ${row.to_id || 'unknown'}`;
43
+ if (row.edge_type === 'HANDLER_CALLS_EXTERNAL_HTTP') {
44
+ const target = record(evidence.externalTarget);
45
+ return text(target.label) ?? `External endpoint: ${row.to_id || 'unknown'}`;
46
+ }
47
+ return row.edge_type === 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER'
48
+ ? text(evidence.eventShapeCandidateTargetLabel) : undefined;
49
+ }
50
+
51
+ export function edgeTarget(
52
+ row: TraceGraphRow,
53
+ evidence: Record<string, unknown>,
54
+ ): string {
55
+ const exact = effectiveTarget(evidence) ?? runtimeCandidateTarget(evidence);
56
+ if (exact) return exact;
57
+ const semantic = semanticEdgeTarget(row, evidence);
58
+ if (semantic) return semantic;
59
+ const service = text(evidence.servicePath);
60
+ const operation = text(evidence.operationPath);
61
+ if (service && operation) return `${service}${operation}`;
62
+ const targetOperation = text(evidence.targetOperation);
63
+ const targetRepo = text(evidence.targetRepo) ?? '';
64
+ return targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
65
+ }
@@ -14,6 +14,12 @@ import type {
14
14
  TraversalScopeState,
15
15
  } from './traversal-scope.js';
16
16
  import type { TraceGraphRow } from './evidence.js';
17
+ import {
18
+ eventMissingVariableNames,
19
+ eventTemplateVariables,
20
+ parseEventSkeletonFact,
21
+ type EventSkeletonFact,
22
+ } from '../utils/event-skeleton.js';
17
23
 
18
24
  export interface EventRuntimeResolution {
19
25
  row: TraceGraphRow;
@@ -37,6 +43,12 @@ function eventTemplate(evidence: Record<string, unknown>): string | undefined {
37
43
  ? resolution.original : undefined;
38
44
  }
39
45
 
46
+ function eventSkeleton(
47
+ evidence: Record<string, unknown>,
48
+ ): EventSkeletonFact | undefined {
49
+ return parseEventSkeletonFact(evidence.eventSkeleton);
50
+ }
51
+
40
52
  function missingReason(substitution: RuntimeSubstitution): string {
41
53
  return `Dynamic target requires runtime variable overrides: ${
42
54
  substitution.missing.join(', ')}`;
@@ -88,6 +100,10 @@ function eventEvidence(
88
100
  unresolvedReason,
89
101
  edgeType: row.edge_type,
90
102
  };
103
+ const skeleton = eventSkeleton(evidence);
104
+ const missing = eventMissingVariableNames(
105
+ skeleton, substitution.missing,
106
+ );
91
107
  return {
92
108
  ...evidence,
93
109
  runtimeSubstitutions: { eventName: substitution },
@@ -96,8 +112,10 @@ function eventEvidence(
96
112
  ),
97
113
  ...(substitution.supplied.length > 0
98
114
  ? { runtimeVariablesApplied: true } : {}),
99
- ...(substitution.missing.length > 0
100
- ? { missingRuntimeVariables: substitution.missing } : {}),
115
+ ...(missing.length > 0 ? {
116
+ missingRuntimeVariables: missing,
117
+ missingVariableCount: missing.length,
118
+ } : {}),
101
119
  effectiveResolution,
102
120
  linker: {
103
121
  status: effectiveResolution.status,
@@ -137,7 +155,10 @@ export function runtimeEventResolution(
137
155
  const template = eventTemplate(evidence);
138
156
  if (!template || row.to_kind === 'event' && variables === undefined)
139
157
  return undefined;
140
- const substitution = substituteVariables(template, variables ?? {});
158
+ const skeleton = eventSkeleton(evidence);
159
+ const substitution = substituteVariables(
160
+ template, eventTemplateVariables(skeleton, variables ?? {}),
161
+ );
141
162
  const effectiveRow = runtimeEventRow(
142
163
  row, evidence, substitution, template,
143
164
  );
@@ -0,0 +1,172 @@
1
+ import type { Db } from '../db/connection.js';
2
+ import type { TraceOptions } from '../types.js';
3
+ import {
4
+ operationNode,
5
+ symbolNode,
6
+ type TraceGraphEdgeRow,
7
+ } from './trace-graph-lookups.js';
8
+ import type { TraceGraphRow } from './evidence.js';
9
+ import {
10
+ eventMissingVariableNames,
11
+ eventTemplateVariables,
12
+ parseEventSkeletonFact,
13
+ } from '../utils/event-skeleton.js';
14
+
15
+ const defaultEventShapeCandidateCap = 5;
16
+ const maximumEventShapeCandidateCap = 50;
17
+
18
+ function candidateCap(options: TraceOptions): number {
19
+ const value = options.maxDynamicCandidates
20
+ ?? defaultEventShapeCandidateCap;
21
+ if (!Number.isSafeInteger(value) || value < 1)
22
+ return defaultEventShapeCandidateCap;
23
+ return Math.min(value, maximumEventShapeCandidateCap);
24
+ }
25
+
26
+ function withCandidateCounts(
27
+ row: TraceGraphEdgeRow,
28
+ total: number,
29
+ shown: number,
30
+ ): TraceGraphEdgeRow {
31
+ let evidence: Record<string, unknown> = {};
32
+ try {
33
+ const parsed: unknown = JSON.parse(row.evidence_json);
34
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
35
+ evidence = parsed as Record<string, unknown>;
36
+ } catch {
37
+ evidence = {};
38
+ }
39
+ return {
40
+ ...row,
41
+ evidence_json: JSON.stringify({
42
+ ...evidence,
43
+ eventShapeCandidateCount: total,
44
+ shownEventShapeCandidateCount: shown,
45
+ omittedEventShapeCandidateCount: Math.max(0, total - shown),
46
+ }),
47
+ };
48
+ }
49
+
50
+ export function visibleEventShapeRows(
51
+ rows: readonly TraceGraphEdgeRow[],
52
+ options: TraceOptions,
53
+ ): TraceGraphEdgeRow[] {
54
+ const regular = rows.filter((row) =>
55
+ row.edge_type !== 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER');
56
+ if ((options.dynamicMode ?? 'strict') !== 'candidates') return regular;
57
+ const candidates = rows.filter((row) =>
58
+ row.edge_type === 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER');
59
+ const shown = candidates.slice(0, candidateCap(options));
60
+ return [
61
+ ...regular,
62
+ ...shown.map((row) =>
63
+ withCandidateCounts(row, candidates.length, shown.length)),
64
+ ];
65
+ }
66
+
67
+ export function outboundTraceEdgeType(
68
+ call: { call_type: string },
69
+ row: { edge_type: string; to_kind: string },
70
+ ): string {
71
+ if (row.edge_type === 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER')
72
+ return 'event_shape_candidate_subscriber';
73
+ if (row.to_kind === 'operation'
74
+ && row.edge_type === 'REMOTE_CALL_RESOLVES_TO_OPERATION')
75
+ return 'remote_action';
76
+ if (row.to_kind === 'operation'
77
+ && row.edge_type === 'LOCAL_CALL_RESOLVES_TO_OPERATION')
78
+ return 'local_service_call';
79
+ return call.call_type;
80
+ }
81
+
82
+ export function outboundTraceTargetNode(
83
+ db: Db,
84
+ id: string,
85
+ row: TraceGraphRow,
86
+ ): Record<string, unknown> {
87
+ const operation = row.to_kind === 'operation'
88
+ ? operationNode(db, row.to_id) : undefined;
89
+ const candidate = row.edge_type === 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER'
90
+ ? symbolNode(db, Number(row.to_id)) : undefined;
91
+ return operation ?? candidate ?? {
92
+ id,
93
+ kind: row.to_kind,
94
+ label: row.to_kind === 'db_entity'
95
+ ? `Entity: ${row.to_id || 'unknown'}` : row.to_id,
96
+ };
97
+ }
98
+
99
+ function stringArray(value: unknown): string[] {
100
+ return Array.isArray(value)
101
+ ? value.filter((item): item is string => typeof item === 'string') : [];
102
+ }
103
+
104
+ function subscriberSkeletons(
105
+ db: Db,
106
+ workspaceId: number,
107
+ publishCallId: number,
108
+ ): unknown[] {
109
+ return db.prepare(`SELECT subscription.event_skeleton_json skeleton
110
+ FROM outbound_calls publication
111
+ JOIN repositories publication_repo ON publication_repo.id=publication.repo_id
112
+ JOIN outbound_calls subscription
113
+ ON subscription.call_type='async_subscribe'
114
+ AND subscription.event_skeleton_signature
115
+ =publication.event_skeleton_signature
116
+ JOIN repositories subscription_repo
117
+ ON subscription_repo.id=subscription.repo_id
118
+ AND subscription_repo.workspace_id=publication_repo.workspace_id
119
+ WHERE publication.id=? AND publication_repo.workspace_id=?
120
+ AND publication.event_skeleton_signature IS NOT NULL
121
+ ORDER BY subscription_repo.name COLLATE BINARY,
122
+ subscription.repo_id,subscription.source_file COLLATE BINARY,
123
+ subscription.call_site_start_offset,subscription.id`).all(
124
+ publishCallId, workspaceId,
125
+ ).map((row) => row.skeleton);
126
+ }
127
+
128
+ export function eventShapeMissingVariableEvidence(
129
+ db: Db,
130
+ workspaceId: number,
131
+ publishCallId: number,
132
+ evidence: Record<string, unknown>,
133
+ variables: Record<string, string> | undefined,
134
+ ): Record<string, unknown> {
135
+ const current = stringArray(evidence.missingRuntimeVariables);
136
+ if (current.length === 0) return evidence;
137
+ const names = new Set(current);
138
+ let matchingSubscriptions = 0;
139
+ for (const value of subscriberSkeletons(db, workspaceId, publishCallId)) {
140
+ const skeleton = parseEventSkeletonFact(value);
141
+ if (!skeleton?.candidateEligible) continue;
142
+ matchingSubscriptions += 1;
143
+ const expanded = eventTemplateVariables(skeleton, variables ?? {});
144
+ const missing = skeleton.sourceKeys.filter((key) =>
145
+ !Object.hasOwn(expanded, key));
146
+ for (const name of eventMissingVariableNames(skeleton, missing))
147
+ names.add(name);
148
+ }
149
+ const missing = [...names].sort((left, right) =>
150
+ left < right ? -1 : left > right ? 1 : 0);
151
+ return {
152
+ ...evidence,
153
+ missingRuntimeVariables: missing,
154
+ missingVariableCount: missing.length,
155
+ eventShapeMatchingSubscriptionCount: matchingSubscriptions,
156
+ };
157
+ }
158
+
159
+ export function eventShapeRuntimeEvidence(
160
+ db: Db,
161
+ workspaceId: number,
162
+ callId: number,
163
+ callType: string,
164
+ evidence: Record<string, unknown>,
165
+ variables: Record<string, string> | undefined,
166
+ ): Record<string, unknown> {
167
+ return callType === 'async_emit'
168
+ ? eventShapeMissingVariableEvidence(
169
+ db, workspaceId, callId, evidence, variables,
170
+ )
171
+ : evidence;
172
+ }