@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
@@ -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'
@@ -60,9 +64,23 @@ export interface EventSubscriberTransition {
60
64
  omittedSymbolCallUnresolvedReasonCharacterCount?: number;
61
65
  matchStrategy?: string;
62
66
  dispatchCertainty?: string;
67
+ subscriptionConsumerRepoId?: number;
68
+ subscriptionConsumerRepoName?: string;
69
+ dispatchProvenances?: EventDispatchProvenance[];
70
+ dispatchProvenanceCount?: number;
71
+ shownDispatchProvenanceCount?: number;
72
+ omittedDispatchProvenanceCount?: number;
63
73
  handler?: EventSubscriberSymbolTarget;
64
74
  }
65
75
 
76
+ export interface EventDispatchProvenance {
77
+ graphEdgeId: number;
78
+ matchStrategy: string;
79
+ dispatchCertainty: string;
80
+ consumerRepoId?: number;
81
+ consumerRepoName?: string;
82
+ }
83
+
66
84
  export interface EventSubscriberTransitionQuery {
67
85
  workspaceId: number;
68
86
  graphGeneration: number;
@@ -96,7 +114,8 @@ export function planEventSubscriberTransitions(
96
114
  ): PlannedEventSubscriberTransition[] {
97
115
  return loadEventSubscriberTransitions(db, query).map((transition) => {
98
116
  const handler = transition.handler;
99
- if (!handler) return plannedTransition(transition, 'not_resolved');
117
+ if (transition.status !== 'resolved' || !handler)
118
+ return plannedTransition(transition, 'not_resolved');
100
119
  if (depth >= maxDepth)
101
120
  return plannedTransition(transition, 'depth_limited');
102
121
  const state = scheduler.schedule({
@@ -166,6 +185,10 @@ export function eventTransitionEvidence(
166
185
  eventName: transition.eventName,
167
186
  matchStrategy: transition.matchStrategy ?? 'workspace_exact_event_name',
168
187
  dispatchCertainty: transition.dispatchCertainty ?? 'static_name_only',
188
+ dispatchProvenances: transition.dispatchProvenances,
189
+ dispatchProvenanceCount: transition.dispatchProvenanceCount,
190
+ shownDispatchProvenanceCount: transition.shownDispatchProvenanceCount,
191
+ omittedDispatchProvenanceCount: transition.omittedDispatchProvenanceCount,
169
192
  associationBasis: transition.associationBasis,
170
193
  dispatchScope: transition.dispatchScope,
171
194
  roleSiteMatchCount: transition.roleSiteMatchCount,
@@ -234,24 +257,72 @@ export function loadEventSubscriberTransitions(
234
257
  subscribe.call_site_start_offset,subscribe.call_site_end_offset,ge.id`).all(
235
258
  query.workspaceId, query.graphGeneration,
236
259
  );
237
- return rows.flatMap((raw) => {
260
+ const transitions = rows.flatMap((raw) => {
238
261
  const row = eventRowForQuery(raw, query);
239
262
  const transition = row ? transitionFromRow(row) : undefined;
240
263
  return transition ? [transition] : [];
241
264
  });
265
+ return deduplicateDispatchProvenance(transitions);
266
+ }
267
+
268
+ const dispatchProvenanceLimit = 5;
269
+
270
+ function transitionIdentity(value: EventSubscriberTransition): string {
271
+ const subscription = value.subscribeCallId ?? `edge:${value.graphEdgeId}`;
272
+ return `${value.eventName}\0${subscription}\0${value.targetKind}\0${
273
+ value.targetId}\0${value.status}`;
274
+ }
275
+
276
+ function dispatchProvenance(
277
+ value: EventSubscriberTransition,
278
+ ): EventDispatchProvenance {
279
+ return {
280
+ graphEdgeId: value.graphEdgeId,
281
+ matchStrategy: value.matchStrategy ?? 'workspace_exact_event_name',
282
+ dispatchCertainty: value.dispatchCertainty ?? 'static_name_only',
283
+ consumerRepoId: value.subscriptionConsumerRepoId,
284
+ consumerRepoName: value.subscriptionConsumerRepoName,
285
+ };
286
+ }
287
+
288
+ function deduplicateDispatchProvenance(
289
+ values: EventSubscriberTransition[],
290
+ ): EventSubscriberTransition[] {
291
+ const groups = new Map<string, EventSubscriberTransition[]>();
292
+ for (const value of values) {
293
+ const key = transitionIdentity(value);
294
+ groups.set(key, [...(groups.get(key) ?? []), value]);
295
+ }
296
+ return [...groups.values()].map((group) => {
297
+ const first = group[0];
298
+ if (!first || group.length === 1) return first;
299
+ const provenances = group.map(dispatchProvenance).slice(
300
+ 0, dispatchProvenanceLimit,
301
+ );
302
+ return {
303
+ ...first,
304
+ dispatchProvenances: provenances,
305
+ dispatchProvenanceCount: group.length,
306
+ shownDispatchProvenanceCount: provenances.length,
307
+ omittedDispatchProvenanceCount: group.length - provenances.length,
308
+ };
309
+ }).filter((value): value is EventSubscriberTransition =>
310
+ value !== undefined);
242
311
  }
243
312
 
244
313
  function eventRowForQuery(
245
314
  row: Record<string, unknown>,
246
315
  query: EventSubscriberTransitionQuery,
247
316
  ): Record<string, unknown> | undefined {
317
+ const exact = exactPersistedEventRow(row, query);
318
+ if (exact) return exact;
248
319
  const variables = query.vars;
249
- if (variables === undefined) return exactPersistedEventRow(row, query);
320
+ if (variables === undefined) return undefined;
250
321
  const evidence = parseEvidence(row.evidenceJson);
251
322
  const resolution = isRecord(evidence.eventTemplateResolution)
252
323
  ? evidence.eventTemplateResolution : {};
253
324
  if (typeof resolution.original !== 'string')
254
- return exactPersistedEventRow(row, query);
325
+ return undefined;
255
326
  return runtimeMatchedEventRow(
256
327
  row, query, evidence, resolution.original, variables,
257
328
  );
@@ -272,7 +343,10 @@ function runtimeMatchedEventRow(
272
343
  template: string,
273
344
  variables: Record<string, string>,
274
345
  ): Record<string, unknown> | undefined {
275
- const substitution = substituteVariables(template, variables);
346
+ const skeleton = parseEventSkeletonFact(evidence.eventSkeleton);
347
+ const substitution = substituteVariables(
348
+ template, eventTemplateVariables(skeleton, variables),
349
+ );
276
350
  if (substitution.missing.length > 0
277
351
  || substitution.effective !== query.eventName) return undefined;
278
352
  const resolvedAssociation = evidence.associationStatus === 'resolved';
@@ -295,8 +369,7 @@ export function eventSubscriberMissingVariables(
295
369
  db: Db,
296
370
  query: EventSubscriberTransitionQuery,
297
371
  ): string[] {
298
- if (query.vars === undefined) return [];
299
- const variables = query.vars;
372
+ const variables = query.vars ?? {};
300
373
  const rows = db.prepare(`SELECT ge.from_id eventName,
301
374
  ge.evidence_json evidenceJson FROM graph_edges ge
302
375
  WHERE ge.workspace_id=? AND ge.generation=?
@@ -311,7 +384,10 @@ export function eventSubscriberMissingVariables(
311
384
  const template = typeof resolution.original === 'string'
312
385
  ? resolution.original : undefined;
313
386
  if (!template) return [];
314
- const substitution = substituteVariables(template, variables);
387
+ const skeleton = parseEventSkeletonFact(evidence.eventSkeleton);
388
+ const substitution = substituteVariables(
389
+ template, eventTemplateVariables(skeleton, variables),
390
+ );
315
391
  if (substitution.missing.length === 0
316
392
  || matchRuntimeTemplate(
317
393
  substitution.effective, query.eventName,
@@ -382,6 +458,12 @@ function associationEvidence(
382
458
  : undefined,
383
459
  matchStrategy: stringValue(evidence.matchStrategy),
384
460
  dispatchCertainty: stringValue(evidence.dispatchCertainty),
461
+ subscriptionConsumerRepoId: numberValue(
462
+ evidence.subscriptionConsumerRepositoryId,
463
+ ),
464
+ subscriptionConsumerRepoName: stringValue(
465
+ evidence.subscriptionConsumerRepositoryName,
466
+ ),
385
467
  };
386
468
  }
387
469
 
@@ -23,13 +23,6 @@ export interface TraceGraphRow extends Record<string, unknown> {
23
23
  status?: string;
24
24
  }
25
25
 
26
- interface Candidate {
27
- servicePath?: string;
28
- operationPath?: string;
29
- repoName?: string;
30
- operationName?: string;
31
- score?: number;
32
- }
33
26
  interface RuntimeDiagnosticTotals {
34
27
  missing: Set<string>;
35
28
  candidateCount: number;
@@ -262,6 +255,8 @@ function runtimeDiagnosticTotals(
262
255
  const effective = parseObject(edge.evidence.effectiveResolution);
263
256
  if (!['dynamic', 'unresolved', 'ambiguous'].includes(String(effective.status ?? '')))
264
257
  continue;
258
+ for (const key of stringArray(edge.evidence.missingRuntimeVariables))
259
+ missing.add(key);
265
260
  const substitutions = edge.evidence.runtimeSubstitutions;
266
261
  if (!substitutions || typeof substitutions !== 'object' || Array.isArray(substitutions)) continue;
267
262
  for (const value of Object.values(substitutions as Record<string, RuntimeSubstitution>))
@@ -340,27 +335,6 @@ export function runtimeNoCandidateDiagnostics(
340
335
  });
341
336
  }
342
337
 
343
- export function edgeTarget(row: TraceGraphRow, evidence: Record<string, unknown>): string {
344
- const effective = parseObject(evidence.effectiveResolution);
345
- const targetServicePath = stringValue(effective.targetServicePath ?? evidence.targetServicePath);
346
- const targetOperationPath = stringValue(effective.targetOperationPath ?? evidence.targetOperationPath);
347
- if (targetServicePath && targetOperationPath) return `${targetServicePath}${targetOperationPath}`;
348
- const runtimeCandidate = evidence.runtimeResolvedCandidate as Candidate | undefined;
349
- if (runtimeCandidate?.servicePath && runtimeCandidate.operationPath) return `${runtimeCandidate.servicePath}${runtimeCandidate.operationPath}`;
350
- const servicePath = stringValue(evidence.servicePath);
351
- const operationPath = stringValue(evidence.operationPath);
352
- const targetOperation = stringValue(evidence.targetOperation);
353
- const targetRepo = stringValue(evidence.targetRepo) ?? '';
354
- if (row.edge_type === 'HANDLER_RUNS_DB_QUERY') return `Entity: ${row.to_id || 'unknown'}`;
355
- if (row.edge_type === 'HANDLER_RUNS_REMOTE_QUERY') return stringValue(evidence.remoteQueryTarget) ?? `Remote query: ${row.to_id || 'unknown'}`;
356
- if (row.edge_type === 'HANDLER_CALLS_EXTERNAL_HTTP') {
357
- const target = parseObject(evidence.externalTarget);
358
- return stringValue(target.label) ?? `External endpoint: ${row.to_id || 'unknown'}`;
359
- }
360
- if (servicePath && operationPath) return `${servicePath}${operationPath}`;
361
- return targetOperation ? `${targetRepo}:${targetOperation}` : row.to_id;
362
- }
363
-
364
338
  function persistedResolution(row: TraceGraphRow): Record<string, unknown> {
365
339
  return {
366
340
  status: row.status,
@@ -648,6 +622,11 @@ function stringValue(value: unknown): string | undefined {
648
622
  return typeof value === 'string' ? value : undefined;
649
623
  }
650
624
 
625
+ function stringArray(value: unknown): string[] {
626
+ return Array.isArray(value)
627
+ ? value.filter((item): item is string => typeof item === 'string') : [];
628
+ }
629
+
651
630
  function numeric(value: unknown): number {
652
631
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
653
632
  }
@@ -2,10 +2,10 @@ import type { Db } from '../db/connection.js';
2
2
  import type { TraceEdge, TraceOptions } from '../types.js';
3
3
  import {
4
4
  baseTraceEvidence,
5
- edgeTarget,
6
5
  runtimeResolution,
7
6
  type TraceGraphRow,
8
7
  } from './evidence.js';
8
+ import { edgeTarget } from './edge-target.js';
9
9
  import { dynamicCandidateBranches } from './dynamic-branches.js';
10
10
  import {
11
11
  contextualRuntimeResolution,
@@ -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,23 @@ 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
+ | 'event_name_constant_container_unsafe_reference'
291
+ | 'event_name_constant_container_unsupported_shape';
292
+ declarationStartOffset: number;
293
+ declarationEndOffset: number;
294
+ valueStartOffset: number;
295
+ valueEndOffset: number;
278
296
  }
279
297
  export interface TraceStart {
280
298
  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.73-facts.1';