@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
@@ -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
+ }
@@ -8,6 +8,10 @@ import {
8
8
  type TraversalScopeScheduler,
9
9
  type TraversalScopeState,
10
10
  } from './traversal-scope.js';
11
+ import {
12
+ eventTemplateVariables,
13
+ parseEventSkeletonFact,
14
+ } from '../utils/event-skeleton.js';
11
15
 
12
16
  export type EventSubscriberTransitionStatus =
13
17
  | 'resolved'
@@ -96,7 +100,8 @@ export function planEventSubscriberTransitions(
96
100
  ): PlannedEventSubscriberTransition[] {
97
101
  return loadEventSubscriberTransitions(db, query).map((transition) => {
98
102
  const handler = transition.handler;
99
- if (!handler) return plannedTransition(transition, 'not_resolved');
103
+ if (transition.status !== 'resolved' || !handler)
104
+ return plannedTransition(transition, 'not_resolved');
100
105
  if (depth >= maxDepth)
101
106
  return plannedTransition(transition, 'depth_limited');
102
107
  const state = scheduler.schedule({
@@ -245,13 +250,15 @@ function eventRowForQuery(
245
250
  row: Record<string, unknown>,
246
251
  query: EventSubscriberTransitionQuery,
247
252
  ): Record<string, unknown> | undefined {
253
+ const exact = exactPersistedEventRow(row, query);
254
+ if (exact) return exact;
248
255
  const variables = query.vars;
249
- if (variables === undefined) return exactPersistedEventRow(row, query);
256
+ if (variables === undefined) return undefined;
250
257
  const evidence = parseEvidence(row.evidenceJson);
251
258
  const resolution = isRecord(evidence.eventTemplateResolution)
252
259
  ? evidence.eventTemplateResolution : {};
253
260
  if (typeof resolution.original !== 'string')
254
- return exactPersistedEventRow(row, query);
261
+ return undefined;
255
262
  return runtimeMatchedEventRow(
256
263
  row, query, evidence, resolution.original, variables,
257
264
  );
@@ -272,7 +279,10 @@ function runtimeMatchedEventRow(
272
279
  template: string,
273
280
  variables: Record<string, string>,
274
281
  ): Record<string, unknown> | undefined {
275
- const substitution = substituteVariables(template, variables);
282
+ const skeleton = parseEventSkeletonFact(evidence.eventSkeleton);
283
+ const substitution = substituteVariables(
284
+ template, eventTemplateVariables(skeleton, variables),
285
+ );
276
286
  if (substitution.missing.length > 0
277
287
  || substitution.effective !== query.eventName) return undefined;
278
288
  const resolvedAssociation = evidence.associationStatus === 'resolved';
@@ -295,8 +305,7 @@ export function eventSubscriberMissingVariables(
295
305
  db: Db,
296
306
  query: EventSubscriberTransitionQuery,
297
307
  ): string[] {
298
- if (query.vars === undefined) return [];
299
- const variables = query.vars;
308
+ const variables = query.vars ?? {};
300
309
  const rows = db.prepare(`SELECT ge.from_id eventName,
301
310
  ge.evidence_json evidenceJson FROM graph_edges ge
302
311
  WHERE ge.workspace_id=? AND ge.generation=?
@@ -311,7 +320,10 @@ export function eventSubscriberMissingVariables(
311
320
  const template = typeof resolution.original === 'string'
312
321
  ? resolution.original : undefined;
313
322
  if (!template) return [];
314
- const substitution = substituteVariables(template, variables);
323
+ const skeleton = parseEventSkeletonFact(evidence.eventSkeleton);
324
+ const substitution = substituteVariables(
325
+ template, eventTemplateVariables(skeleton, variables),
326
+ );
315
327
  if (substitution.missing.length === 0
316
328
  || matchRuntimeTemplate(
317
329
  substitution.effective, query.eventName,
@@ -262,6 +262,8 @@ function runtimeDiagnosticTotals(
262
262
  const effective = parseObject(edge.evidence.effectiveResolution);
263
263
  if (!['dynamic', 'unresolved', 'ambiguous'].includes(String(effective.status ?? '')))
264
264
  continue;
265
+ for (const key of stringArray(edge.evidence.missingRuntimeVariables))
266
+ missing.add(key);
265
267
  const substitutions = edge.evidence.runtimeSubstitutions;
266
268
  if (!substitutions || typeof substitutions !== 'object' || Array.isArray(substitutions)) continue;
267
269
  for (const value of Object.values(substitutions as Record<string, RuntimeSubstitution>))
@@ -648,6 +650,11 @@ function stringValue(value: unknown): string | undefined {
648
650
  return typeof value === 'string' ? value : undefined;
649
651
  }
650
652
 
653
+ function stringArray(value: unknown): string[] {
654
+ return Array.isArray(value)
655
+ ? value.filter((item): item is string => typeof item === 'string') : [];
656
+ }
657
+
651
658
  function numeric(value: unknown): number {
652
659
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
653
660
  }
@@ -20,7 +20,6 @@ import {
20
20
  import type { PlannedEventSubscriberTransition } from './event-subscriber-traversal.js';
21
21
  import {
22
22
  graphForCalls,
23
- operationNode,
24
23
  symbolNode,
25
24
  type TraceGraphEdgeRow,
26
25
  } from './trace-graph-lookups.js';
@@ -51,6 +50,12 @@ import type { ImplementationHintOptions } from './trace-implementation-scope.js'
51
50
  import { processOperationTarget } from './trace-operation-execution.js';
52
51
  import { runtimeEventResolution, runtimeEventSubscriberPlans } from './event-runtime-resolution.js';
53
52
  import { planLocalCallExpansion } from './local-call-expansion.js';
53
+ import {
54
+ eventShapeRuntimeEvidence,
55
+ outboundTraceEdgeType,
56
+ outboundTraceTargetNode,
57
+ visibleEventShapeRows,
58
+ } from './event-shape-candidate-trace.js';
54
59
 
55
60
  export interface CallRow extends Record<string, unknown> {
56
61
  id: number;
@@ -90,16 +95,6 @@ export interface TraceExecutionRuntime {
90
95
  recorder: TraceEdgeRecorder;
91
96
  }
92
97
 
93
- function traceEdgeType(call: CallRow, row: TraceGraphRow): string {
94
- if (row.to_kind === 'operation'
95
- && row.edge_type === 'REMOTE_CALL_RESOLVES_TO_OPERATION')
96
- return 'remote_action';
97
- if (row.to_kind === 'operation'
98
- && row.edge_type === 'LOCAL_CALL_RESOLVES_TO_OPERATION')
99
- return 'local_service_call';
100
- return String(call.call_type);
101
- }
102
-
103
98
  function includeCall(type: string, options: TraceOptions): boolean {
104
99
  if (!options.includeDb && type === 'local_db_query') return false;
105
100
  if (!options.includeExternal && type === 'external_http') return false;
@@ -339,7 +334,9 @@ function processOutboundCall(
339
334
  const contextual = contextualRuntimeResolution(
340
335
  runtime.db, call, bindings.get(receiver), call.workspaceId, persistedRows,
341
336
  );
342
- const rows = contextual.row ? [contextual.row] : persistedRows;
337
+ const rows = contextual.row
338
+ ? [contextual.row]
339
+ : visibleEventShapeRows(persistedRows, runtime.options);
343
340
  for (const row of rows)
344
341
  processOutboundRow(runtime, current, call, { ...row }, contextual);
345
342
  }
@@ -385,16 +382,23 @@ function recordEffectiveOutbound(
385
382
  ): EffectiveOutbound {
386
383
  const persisted = parseTraceEvidence(row.evidence_json);
387
384
  const raw = baseTraceEvidence(row, call, persisted, contextual.evidence);
388
- const effective = runtimeEventResolution(row, raw, runtime.options.vars)
385
+ const resolved = runtimeEventResolution(row, raw, runtime.options.vars)
389
386
  ?? runtimeResolution(runtime.db, row, raw, {
390
387
  vars: runtime.options.vars,
391
388
  dynamicMode: runtime.options.dynamicMode ?? 'strict',
392
389
  maxDynamicCandidates: runtime.options.maxDynamicCandidates,
393
390
  }, call.workspaceId, contextual.state);
391
+ const effective = {
392
+ ...resolved,
393
+ evidence: eventShapeRuntimeEvidence(
394
+ runtime.db, call.workspaceId, call.id, call.call_type,
395
+ resolved.evidence, runtime.options.vars,
396
+ ),
397
+ };
394
398
  const target = `${effective.row.to_kind}:${effective.row.to_id}`;
395
- const operation = effective.row.to_kind === 'operation'
396
- ? operationNode(runtime.db, effective.row.to_id) : undefined;
397
- runtime.nodes.set(target, operation ?? targetNode(target, effective.row));
399
+ runtime.nodes.set(
400
+ target, outboundTraceTargetNode(runtime.db, target, effective.row),
401
+ );
398
402
  const to = edgeTarget(effective.row, effective.evidence);
399
403
  const edge = outboundTraceEdge(
400
404
  current.depth, call, effective.row, to,
@@ -418,18 +422,6 @@ function recordEffectiveOutbound(
418
422
  };
419
423
  }
420
424
 
421
- function targetNode(
422
- id: string,
423
- row: TraceGraphRow,
424
- ): Record<string, unknown> {
425
- return {
426
- id,
427
- kind: row.to_kind,
428
- label: row.to_kind === 'db_entity'
429
- ? `Entity: ${row.to_id || 'unknown'}` : row.to_id,
430
- };
431
- }
432
-
433
425
  function outboundTraceEdge(
434
426
  depth: number,
435
427
  call: CallRow,
@@ -440,7 +432,7 @@ function outboundTraceEdge(
440
432
  ): TraceEdge {
441
433
  return {
442
434
  step: depth,
443
- type: traceEdgeType(call, row),
435
+ type: outboundTraceEdgeType(call, row),
444
436
  from: `${call.repoName}:${call.source_file}:${call.source_line}`,
445
437
  to,
446
438
  evidence,
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { EventSkeletonFact } from './utils/event-skeleton.js';
2
+
1
3
  export type RepoKind =
2
4
  | 'cap-service'
3
5
  | 'cap-db-model'
@@ -36,6 +38,7 @@ export type EdgeType =
36
38
  | 'HANDLER_EMITS_EVENT'
37
39
  | 'EVENT_CONSUMED_BY_HANDLER'
38
40
  | 'EVENT_SUBSCRIPTION_HANDLED_BY'
41
+ | 'EVENT_SHAPE_CANDIDATE_SUBSCRIBER'
39
42
  | 'REPO_IMPORTS_HELPER_PACKAGE'
40
43
  | 'HELPER_PACKAGE_PROVIDES_HANDLER'
41
44
  | 'DYNAMIC_EDGE_CANDIDATE'
@@ -229,6 +232,7 @@ export interface OutboundCallFact {
229
232
  operationPathExpr?: string;
230
233
  queryEntity?: string;
231
234
  eventNameExpr?: string;
235
+ eventSkeleton?: EventSkeletonFact;
232
236
  payloadSummary?: string;
233
237
  sourceFile: string;
234
238
  sourceLine: number;
@@ -272,9 +276,21 @@ export interface SymbolCallFact {
272
276
  }
273
277
  export interface GeneratedConstantFact {
274
278
  name: string;
275
- value: string;
279
+ value?: string;
276
280
  sourceFile: string;
277
281
  sourceLine: number;
282
+ containerName?: string;
283
+ memberName?: string;
284
+ constantKind: 'const_identifier' | 'enum_member' | 'const_object_property';
285
+ exported: boolean;
286
+ stable: boolean;
287
+ resolutionStatus: 'resolved' | 'refused';
288
+ unresolvedReason?: 'event_name_constant_member_not_string'
289
+ | 'event_name_constant_container_mutable';
290
+ declarationStartOffset: number;
291
+ declarationEndOffset: number;
292
+ valueStartOffset: number;
293
+ valueEndOffset: number;
278
294
  }
279
295
  export interface TraceStart {
280
296
  repo?: string;
@@ -0,0 +1,207 @@
1
+ import { sha256Text } from './hashing.js';
2
+ import { scanPlaceholderStructure } from './placeholders.js';
3
+ import type {
4
+ EventEnvironmentReference,
5
+ } from '../parsers/event-environment-reference.js';
6
+
7
+ export const EVENT_SKELETON_SCHEMA = 'service-flow/event-skeleton@1';
8
+ export const EVENT_SKELETON_LITERAL_THRESHOLD = 8;
9
+ export const EVENT_SKELETON_TEXT_LIMIT = 512;
10
+
11
+ export interface EventSkeletonFact {
12
+ schema: typeof EVENT_SKELETON_SCHEMA;
13
+ status: 'complete' | 'malformed' | 'too_large';
14
+ signature: string | null;
15
+ literalSpans: string[];
16
+ holeCount: number;
17
+ sourceKeys: string[];
18
+ canonicalKeys: string[];
19
+ candidateEligible: boolean;
20
+ environmentBindings: EventEnvironmentReference[];
21
+ reason?: string;
22
+ }
23
+
24
+ function record(value: unknown): Record<string, unknown> | undefined {
25
+ return value && typeof value === 'object' && !Array.isArray(value)
26
+ ? value as Record<string, unknown> : undefined;
27
+ }
28
+
29
+ function parseJson(value: unknown): unknown {
30
+ if (typeof value !== 'string') return value;
31
+ try {
32
+ return JSON.parse(value) as unknown;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ function stringArray(value: unknown): string[] | undefined {
39
+ return Array.isArray(value) && value.every((item) =>
40
+ typeof item === 'string')
41
+ ? value as string[] : undefined;
42
+ }
43
+
44
+ function canonicalKey(signature: string, index: number): string {
45
+ return `event.${signature.slice(0, 16)}.hole${index + 1}`;
46
+ }
47
+
48
+ function skeletonSignature(
49
+ literalSpans: readonly string[],
50
+ holeCount: number,
51
+ ): string {
52
+ return sha256Text(JSON.stringify({ literalSpans, holeCount }));
53
+ }
54
+
55
+ function completeSkeleton(
56
+ template: string,
57
+ ): EventSkeletonFact {
58
+ const scan = scanPlaceholderStructure(template);
59
+ if (scan.status === 'malformed') return {
60
+ schema: EVENT_SKELETON_SCHEMA,
61
+ status: 'malformed',
62
+ signature: null,
63
+ literalSpans: [],
64
+ holeCount: 0,
65
+ sourceKeys: [],
66
+ canonicalKeys: [],
67
+ candidateEligible: false,
68
+ environmentBindings: [],
69
+ reason: scan.reason,
70
+ };
71
+ const literals: string[] = [];
72
+ let cursor = 0;
73
+ for (const span of scan.spans) {
74
+ literals.push(template.slice(cursor, span.start));
75
+ cursor = span.end;
76
+ }
77
+ literals.push(template.slice(cursor));
78
+ const signature = skeletonSignature(literals, scan.spans.length);
79
+ return {
80
+ schema: EVENT_SKELETON_SCHEMA,
81
+ status: 'complete',
82
+ signature,
83
+ literalSpans: literals,
84
+ holeCount: scan.spans.length,
85
+ sourceKeys: scan.spans.map((span) => span.key.trim()),
86
+ canonicalKeys: scan.spans.map((_, index) =>
87
+ canonicalKey(signature, index)),
88
+ candidateEligible: scan.spans.length > 0
89
+ && literals.some((literal) =>
90
+ literal.length >= EVENT_SKELETON_LITERAL_THRESHOLD),
91
+ environmentBindings: [],
92
+ };
93
+ }
94
+
95
+ function validCompleteSkeleton(
96
+ item: Record<string, unknown>,
97
+ literals: string[],
98
+ sourceKeys: string[],
99
+ canonicalKeys: string[],
100
+ ): boolean {
101
+ const holes = item.holeCount;
102
+ const signature = item.signature;
103
+ if (!Number.isInteger(holes) || Number(holes) < 1
104
+ || typeof signature !== 'string'
105
+ || !/^[a-f0-9]{64}$/.test(signature)) return false;
106
+ const count = Number(holes);
107
+ return literals.length === count + 1
108
+ && sourceKeys.length === count
109
+ && canonicalKeys.length === count
110
+ && sourceKeys.every((key) => key.length > 0 && key === key.trim())
111
+ && canonicalKeys.every((key, index) =>
112
+ key === canonicalKey(signature, index))
113
+ && new Set(canonicalKeys).size === canonicalKeys.length
114
+ && signature === skeletonSignature(literals, count)
115
+ && item.candidateEligible === literals.some((literal) =>
116
+ literal.length >= EVENT_SKELETON_LITERAL_THRESHOLD)
117
+ && item.reason === undefined;
118
+ }
119
+
120
+ function validClosedSkeleton(
121
+ item: Record<string, unknown>,
122
+ literals: string[],
123
+ sourceKeys: string[],
124
+ canonicalKeys: string[],
125
+ ): boolean {
126
+ const reason = item.reason;
127
+ if (item.signature !== null || item.holeCount !== 0
128
+ || item.candidateEligible !== false
129
+ || literals.length > 0 || sourceKeys.length > 0
130
+ || canonicalKeys.length > 0
131
+ || !Array.isArray(item.environmentBindings)
132
+ || item.environmentBindings.length > 0) return false;
133
+ if (item.status === 'too_large')
134
+ return reason === 'event_skeleton_text_limit_exceeded';
135
+ return item.status === 'malformed'
136
+ && typeof reason === 'string' && reason.length > 0;
137
+ }
138
+
139
+ export function parseEventSkeletonFact(
140
+ value: unknown,
141
+ ): EventSkeletonFact | undefined {
142
+ const item = record(parseJson(value));
143
+ if (!item || item.schema !== EVENT_SKELETON_SCHEMA
144
+ || !['complete', 'malformed', 'too_large'].includes(String(item.status))
145
+ || !Array.isArray(item.environmentBindings)) return undefined;
146
+ const literals = stringArray(item.literalSpans);
147
+ const sourceKeys = stringArray(item.sourceKeys);
148
+ const canonicalKeys = stringArray(item.canonicalKeys);
149
+ if (!literals || !sourceKeys || !canonicalKeys) return undefined;
150
+ const valid = item.status === 'complete'
151
+ ? validCompleteSkeleton(item, literals, sourceKeys, canonicalKeys)
152
+ : validClosedSkeleton(item, literals, sourceKeys, canonicalKeys);
153
+ return valid ? item as unknown as EventSkeletonFact : undefined;
154
+ }
155
+
156
+ export function deriveEventSkeleton(
157
+ template: string | undefined,
158
+ ): EventSkeletonFact | undefined {
159
+ if (!template || !template.includes('${')) return undefined;
160
+ if (template.length <= EVENT_SKELETON_TEXT_LIMIT)
161
+ return completeSkeleton(template);
162
+ return {
163
+ schema: EVENT_SKELETON_SCHEMA,
164
+ status: 'too_large',
165
+ signature: null,
166
+ literalSpans: [],
167
+ holeCount: 0,
168
+ sourceKeys: [],
169
+ canonicalKeys: [],
170
+ candidateEligible: false,
171
+ environmentBindings: [],
172
+ reason: 'event_skeleton_text_limit_exceeded',
173
+ };
174
+ }
175
+
176
+ export function eventTemplateVariables(
177
+ skeleton: EventSkeletonFact | undefined,
178
+ variables: Record<string, string>,
179
+ ): Record<string, string> {
180
+ if (!skeleton || skeleton.status !== 'complete') return variables;
181
+ const expanded = { ...variables };
182
+ for (let index = 0; index < skeleton.sourceKeys.length; index += 1) {
183
+ const sourceKey = skeleton.sourceKeys[index];
184
+ const canonicalKeyValue = skeleton.canonicalKeys[index];
185
+ if (!sourceKey || !canonicalKeyValue
186
+ || Object.hasOwn(expanded, sourceKey)
187
+ || !Object.hasOwn(expanded, canonicalKeyValue)) continue;
188
+ expanded[sourceKey] = expanded[canonicalKeyValue] ?? '';
189
+ }
190
+ return expanded;
191
+ }
192
+
193
+ export function eventMissingVariableNames(
194
+ skeleton: EventSkeletonFact | undefined,
195
+ missingSourceKeys: readonly string[],
196
+ ): string[] {
197
+ const names = new Set(missingSourceKeys);
198
+ if (skeleton?.status === 'complete')
199
+ for (let index = 0; index < skeleton.sourceKeys.length; index += 1) {
200
+ const sourceKey = skeleton.sourceKeys[index];
201
+ const canonical = skeleton.canonicalKeys[index];
202
+ if (sourceKey && canonical && names.has(sourceKey))
203
+ names.add(canonical);
204
+ }
205
+ return [...names].sort((left, right) =>
206
+ left < right ? -1 : left > right ? 1 : 0);
207
+ }
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import packageJson from '../package.json' with { type: 'json' };
2
2
 
3
3
  export const VERSION = packageJson.version;
4
- export const ANALYZER_VERSION = '0.1.69-facts.1';
4
+ export const ANALYZER_VERSION = '0.1.71-facts.1';