@saptools/service-flow 0.1.72 → 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 (41) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +19 -6
  3. package/TECHNICAL-NOTE.md +11 -2
  4. package/dist/{chunk-Z6D433R5.js → chunk-32WOTGTS.js} +6714 -6161
  5. package/dist/chunk-32WOTGTS.js.map +1 -0
  6. package/dist/cli.js +189 -39
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/doctor-event-quality.ts +90 -15
  12. package/src/cli.ts +35 -13
  13. package/src/config/workspace-config.ts +15 -0
  14. package/src/db/current-fact-semantics.ts +2 -26
  15. package/src/db/event-fact-semantics.ts +260 -61
  16. package/src/db/event-site-semantics.ts +62 -0
  17. package/src/db/fact-lifecycle.ts +70 -12
  18. package/src/db/migrations.ts +4 -1
  19. package/src/db/package-target-invalidation.ts +4 -3
  20. package/src/db/schema.ts +1 -1
  21. package/src/indexer/repository-indexer.ts +50 -6
  22. package/src/indexer/workspace-indexer.ts +13 -3
  23. package/src/linker/cross-repo-linker.ts +4 -2
  24. package/src/linker/event-shape-candidate-linker.ts +63 -20
  25. package/src/linker/event-subscription-handler-linker.ts +121 -30
  26. package/src/linker/package-event-constant-resolver.ts +22 -18
  27. package/src/output/repository-inspection.ts +11 -0
  28. package/src/parsers/environment-declarations.ts +104 -27
  29. package/src/parsers/event-call-analysis.ts +163 -35
  30. package/src/parsers/event-environment-reference.ts +18 -7
  31. package/src/parsers/event-receiver-analysis.ts +17 -27
  32. package/src/parsers/outbound-call-classifier.ts +1 -1
  33. package/src/parsers/stable-local-value.ts +12 -1
  34. package/src/parsers/string-constant-lookups.ts +78 -28
  35. package/src/trace/edge-target.ts +65 -0
  36. package/src/trace/event-subscriber-traversal.ts +71 -1
  37. package/src/trace/evidence.ts +0 -28
  38. package/src/trace/trace-scope-execution.ts +1 -1
  39. package/src/types.ts +3 -1
  40. package/src/version.ts +1 -1
  41. package/dist/chunk-Z6D433R5.js.map +0 -1
@@ -161,9 +161,10 @@ function resetPackageEventCalls(
161
161
  const source = call.evidence.eventNameConstantSourceExpression;
162
162
  if (typeof source !== 'string' || source.length === 0)
163
163
  throw new Error('invalid_current_package_event_constant_evidence');
164
- const reason = call.evidence.receiverClassification === 'unproven'
165
- ? call.unresolvedReason : 'event_name_constant_resolution_pending';
166
- update.run(source, reason, pendingEventEvidence(call.evidence), call.id);
164
+ update.run(
165
+ source, 'event_name_constant_resolution_pending',
166
+ pendingEventEvidence(call.evidence), call.id,
167
+ );
167
168
  batch.affectedCallerRepoIds.add(call.repoId);
168
169
  matched = true;
169
170
  }
package/src/db/schema.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export const schemaTablesSql = `
2
2
  CREATE TABLE IF NOT EXISTS workspaces (id INTEGER PRIMARY KEY, root_path TEXT UNIQUE NOT NULL, db_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
3
- CREATE TABLE IF NOT EXISTS repositories (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, name TEXT NOT NULL, absolute_path TEXT NOT NULL, relative_path TEXT NOT NULL, package_name TEXT, package_version TEXT, dependencies_json TEXT DEFAULT '{}', package_public_surface_json TEXT, environment_declarations_json TEXT DEFAULT '{"schema":"service-flow/environment-declarations@1","status":"not_applicable","reason":null,"recordCap":32,"total":0,"shown":0,"omitted":0,"declarations":[]}', kind TEXT NOT NULL, is_git_repo INTEGER NOT NULL, last_indexed_at TEXT, index_status TEXT DEFAULT 'pending', error_count INTEGER DEFAULT 0, fingerprint TEXT, fact_generation INTEGER NOT NULL DEFAULT 0, graph_generation INTEGER NOT NULL DEFAULT 0, graph_stale_reason TEXT, graph_stale_at TEXT, fact_analyzer_version TEXT, UNIQUE(workspace_id, absolute_path), FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
3
+ CREATE TABLE IF NOT EXISTS repositories (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, name TEXT NOT NULL, absolute_path TEXT NOT NULL, relative_path TEXT NOT NULL, package_name TEXT, package_version TEXT, dependencies_json TEXT DEFAULT '{}', package_public_surface_json TEXT, environment_declarations_json TEXT DEFAULT '{"schema":"service-flow/environment-declarations@1","allowedKeys":["SHARD_CODE"],"status":"not_applicable","reason":null,"recordCap":32,"total":0,"shown":0,"omitted":0,"declarations":[]}', kind TEXT NOT NULL, is_git_repo INTEGER NOT NULL, last_indexed_at TEXT, index_status TEXT DEFAULT 'pending', error_count INTEGER DEFAULT 0, fingerprint TEXT, fact_generation INTEGER NOT NULL DEFAULT 0, graph_generation INTEGER NOT NULL DEFAULT 0, graph_stale_reason TEXT, graph_stale_at TEXT, fact_analyzer_version TEXT, UNIQUE(workspace_id, absolute_path), FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
4
4
  CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, relative_path TEXT NOT NULL, extension TEXT NOT NULL, sha256 TEXT NOT NULL, size_bytes INTEGER NOT NULL, last_indexed_at TEXT NOT NULL, UNIQUE(repo_id, relative_path), FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
5
5
  CREATE TABLE IF NOT EXISTS cds_requires (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, alias TEXT NOT NULL, kind TEXT, model TEXT, destination TEXT, service_path TEXT, request_timeout INTEGER, raw_json TEXT NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
6
6
  CREATE TABLE IF NOT EXISTS cds_services (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, namespace TEXT, service_name TEXT NOT NULL, qualified_name TEXT NOT NULL, service_path TEXT NOT NULL, is_extend INTEGER NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, extension_local_ref TEXT, extension_imported_symbol TEXT, extension_local_alias TEXT, extension_module_specifier TEXT, extension_import_kind TEXT, extension_base_service_id INTEGER, extension_base_status TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(extension_base_service_id) REFERENCES cds_services(id) ON DELETE SET NULL);
@@ -56,6 +56,7 @@ import {
56
56
  import {
57
57
  loadRepositorySourceContext,
58
58
  type RepositorySourceContext,
59
+ type SourceFileSnapshot,
59
60
  type SourceContextInstrumentation,
60
61
  } from '../parsers/ts-project.js';
61
62
  import {
@@ -128,6 +129,7 @@ export async function prepareRepositoryIndex(
128
129
  repo: RepoRow,
129
130
  force: boolean,
130
131
  instrumentation?: SourceContextInstrumentation,
132
+ eventEnvironmentKeys?: readonly string[],
131
133
  ): Promise<PreparedRepositoryIndex> {
132
134
  const sourceFiles = await findSourceFiles(repo.absolute_path);
133
135
  const packageSnapshot = await loadPackageJsonSnapshot(repo.absolute_path, {
@@ -138,11 +140,17 @@ export async function prepareRepositoryIndex(
138
140
  const sources = await loadRepositorySourceContext(
139
141
  repo.absolute_path, sourceFiles, instrumentation,
140
142
  );
143
+ const environmentDeclarations = collectEnvironmentDeclarations(
144
+ sources, eventEnvironmentKeys,
145
+ );
141
146
  const fingerprint = repositoryFingerprint(
142
- sources, packageFacts, packageSnapshot.rawText,
147
+ sources, packageFacts, packageSnapshot.rawText, environmentDeclarations,
143
148
  );
144
149
  if (!force && repo.fingerprint === fingerprint) return { repo, fileCount: 0, diagnosticCount: 0, skipped: true };
145
- const parsedFacts = await parseAllSourceFacts(repo.absolute_path, sources);
150
+ const parsedFacts = await parseAllSourceFacts(
151
+ repo.absolute_path, sources, environmentDeclarations,
152
+ eventEnvironmentKeys,
153
+ );
146
154
  const packageSurface = analyzeRepositoryPackageSurface(
147
155
  packageFacts, packageSnapshot.manifest, sources,
148
156
  );
@@ -157,7 +165,7 @@ export async function prepareRepositoryIndex(
157
165
  kind: await classifyRepository(repo.absolute_path, packageFacts),
158
166
  parsed,
159
167
  packagePublicSurface: packageSurface.surface,
160
- environmentDeclarations: collectEnvironmentDeclarations(sources),
168
+ environmentDeclarations,
161
169
  fileCount: sourceFiles.length,
162
170
  diagnosticCount: parsed.handlers.filter((handler) =>
163
171
  handler.hasHandlerDecorator
@@ -269,11 +277,18 @@ export function recordIndexFailure(db: Db, repoId: number, error: unknown): void
269
277
  async function parseAllSourceFacts(
270
278
  root: string,
271
279
  sources: RepositorySourceContext,
280
+ environmentDeclarations: EnvironmentDeclarationsFact,
281
+ eventEnvironmentKeys?: readonly string[],
272
282
  ): Promise<ParsedFacts> {
273
283
  const facts: ParsedFacts = { services: [], handlers: [], registrations: [], bindings: [], calls: [], symbols: [], symbolCalls: [], generatedConstants: [], fileRecords: [] };
274
284
  for (const snapshot of sources.entries()) {
275
285
  const file = snapshot.filePath;
276
- facts.fileRecords.push({ relativePath: normalizePath(file), extension: path.extname(file), sha256: sha256Text(snapshot.text), sizeBytes: snapshot.sizeBytes });
286
+ facts.fileRecords.push({
287
+ relativePath: normalizePath(file),
288
+ extension: path.extname(file),
289
+ sha256: sourceFactHash(snapshot, environmentDeclarations),
290
+ sizeBytes: snapshot.sizeBytes,
291
+ });
277
292
  if (file.endsWith('.cds')) facts.services.push(...(await parseCdsFile(root, file, sources)));
278
293
  if (/\.[jt]s$/.test(file)) {
279
294
  const source = snapshot.sourceFile();
@@ -283,7 +298,9 @@ async function parseAllSourceFacts(
283
298
  sources, source, file,
284
299
  ),
285
300
  eventEnvironmentReferenceResolver:
286
- createEventEnvironmentReferenceResolver(sources, source, file),
301
+ createEventEnvironmentReferenceResolver(
302
+ sources, source, file, eventEnvironmentKeys,
303
+ ),
287
304
  });
288
305
  facts.handlers.push(...(await parseDecorators(root, file, sources)));
289
306
  facts.registrations.push(...(await parseHandlerRegistrations(root, file, sources)));
@@ -334,6 +351,7 @@ function repositoryFingerprint(
334
351
  sources: RepositorySourceContext,
335
352
  facts: PackageFacts,
336
353
  packageJsonText: string,
354
+ environmentDeclarations: EnvironmentDeclarationsFact,
337
355
  ): string {
338
356
  const normalizedFacts = {
339
357
  analyzerVersion: ANALYZER_VERSION,
@@ -343,10 +361,36 @@ function repositoryFingerprint(
343
361
  cdsRequires: [...facts.cdsRequires].sort((a, b) => a.alias.localeCompare(b.alias)),
344
362
  scripts: Object.fromEntries(Object.entries(facts.scripts).sort()),
345
363
  includeTests: false,
364
+ eventEnvironmentKeys: environmentDeclarations.allowedKeys,
346
365
  packageJsonHash: sha256Text(packageJsonText),
347
366
  };
348
367
  const entries: string[] = [`facts:${JSON.stringify(normalizedFacts)}`];
349
368
  for (const snapshot of sources.entries())
350
- entries.push(`${snapshot.filePath}:${sha256Text(snapshot.text)}`);
369
+ entries.push(
370
+ `${snapshot.filePath}:${
371
+ sourceFactHash(snapshot, environmentDeclarations)}`,
372
+ );
351
373
  return sha256Text(entries.join('\n'));
352
374
  }
375
+
376
+ function sourceFactHash(
377
+ snapshot: SourceFileSnapshot,
378
+ environmentDeclarations: EnvironmentDeclarationsFact,
379
+ ): string {
380
+ if (!isEnvironmentFactInput(snapshot.filePath))
381
+ return sha256Text(snapshot.text);
382
+ const declarations = environmentDeclarations.declarations.filter(
383
+ (item) => item.sourceFile === snapshot.filePath,
384
+ );
385
+ return sha256Text(JSON.stringify({
386
+ allowedKeys: environmentDeclarations.allowedKeys,
387
+ declarations,
388
+ }));
389
+ }
390
+
391
+ function isEnvironmentFactInput(filePath: string): boolean {
392
+ const name = filePath.split('/').at(-1);
393
+ return ['nodemon.json', '.env', 'mta.yaml', 'manifest.yml'].includes(
394
+ name ?? '',
395
+ );
396
+ }
@@ -104,7 +104,12 @@ export interface IndexWorkspaceSummary {
104
104
  export async function indexWorkspace(
105
105
  db: Db,
106
106
  workspaceId: number,
107
- options: { repo?: string; force: boolean; injectDerivedMaterializationFailure?: boolean },
107
+ options: {
108
+ repo?: string;
109
+ force: boolean;
110
+ eventEnvironmentKeys?: readonly string[];
111
+ injectDerivedMaterializationFailure?: boolean;
112
+ },
108
113
  ): Promise<IndexWorkspaceSummary> {
109
114
  const repos = selectedRepositories(db, workspaceId, options.repo);
110
115
  const runId = claimIndexRun(db, workspaceId, repos.length);
@@ -112,7 +117,9 @@ export async function indexWorkspace(
112
117
  fileCount: 0, diagnosticCount: 0, skippedCount: 0, rows: [],
113
118
  };
114
119
  try {
115
- await prepareRepositories(repos, options.force, state);
120
+ await prepareRepositories(
121
+ repos, options.force, state, options.eventEnvironmentKeys,
122
+ );
116
123
  return publishPreparedWorkspaceRows(
117
124
  db, workspaceId, runId, state.rows, options,
118
125
  );
@@ -168,10 +175,13 @@ async function prepareRepositories(
168
175
  repos: readonly IndexRepository[],
169
176
  force: boolean,
170
177
  state: PreparationState,
178
+ eventEnvironmentKeys?: readonly string[],
171
179
  ): Promise<void> {
172
180
  for (const repo of repos) {
173
181
  state.activeRepoId = repo.id;
174
- const result = await prepareRepositoryIndex(repo, force);
182
+ const result = await prepareRepositoryIndex(
183
+ repo, force, undefined, eventEnvironmentKeys,
184
+ );
175
185
  state.rows.push(result);
176
186
  state.fileCount += result.fileCount;
177
187
  state.diagnosticCount += result.diagnosticCount;
@@ -27,6 +27,7 @@ export interface LinkWorkspaceResult {
27
27
  subscriptionHandlerUnresolvedCount: number;
28
28
  subscriptionHandlerMissingAssociationCount: number;
29
29
  eventShapeCandidateCount: number;
30
+ eventShapeCandidateOmittedCount: number;
30
31
  }
31
32
  export function linkWorkspace(db: Db, workspaceId: number, vars: Record<string, string> = {}): LinkWorkspaceResult {
32
33
  return db.transaction(() => {
@@ -46,7 +47,7 @@ export function linkWorkspace(db: Db, workspaceId: number, vars: Record<string,
46
47
  const impl = linkCanonicalImplementations(db, workspaceId, generation);
47
48
  const callSummary = linkCalls(db, workspaceId, vars, generation);
48
49
  db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
49
- return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount + subscriptions.edgeCount + eventShapes.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount, subscriptionHandlerResolvedCount: subscriptions.resolvedCount, subscriptionHandlerAmbiguousCount: subscriptions.ambiguousCount, subscriptionHandlerUnresolvedCount: subscriptions.unresolvedCount, subscriptionHandlerMissingAssociationCount: subscriptions.missingAssociationCount, eventShapeCandidateCount: eventShapes.edgeCount };
50
+ return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount + subscriptions.edgeCount + eventShapes.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount, subscriptionHandlerResolvedCount: subscriptions.resolvedCount, subscriptionHandlerAmbiguousCount: subscriptions.ambiguousCount, subscriptionHandlerUnresolvedCount: subscriptions.unresolvedCount, subscriptionHandlerMissingAssociationCount: subscriptions.missingAssociationCount, eventShapeCandidateCount: eventShapes.edgeCount, eventShapeCandidateOmittedCount: eventShapes.omittedCount };
50
51
  });
51
52
  }
52
53
  function nextGraphGeneration(db: Db, workspaceId: number): number {
@@ -63,7 +64,8 @@ type CallLinkSummary = Omit<LinkWorkspaceResult,
63
64
  | 'subscriptionHandlerAmbiguousCount'
64
65
  | 'subscriptionHandlerUnresolvedCount'
65
66
  | 'subscriptionHandlerMissingAssociationCount'
66
- | 'eventShapeCandidateCount'>;
67
+ | 'eventShapeCandidateCount'
68
+ | 'eventShapeCandidateOmittedCount'>;
67
69
 
68
70
  function linkCalls(db: Db, workspaceId: number, vars: Record<string, string>, generation: number): CallLinkSummary {
69
71
  let edgeCount = 0;
@@ -1,16 +1,21 @@
1
1
  import type { Db } from '../db/connection.js';
2
2
  import { parseEventSkeletonFact } from '../utils/event-skeleton.js';
3
+ import { boundCandidateLikeEvidence } from '../utils/bounded-projection.js';
3
4
 
4
5
  export interface EventShapeCandidateLinkSummary {
5
6
  edgeCount: number;
7
+ omittedCount: number;
6
8
  }
7
9
 
10
+ const EVENT_SHAPE_LINK_CAP_PER_EMIT = 100;
11
+
8
12
  interface EventShapeRow extends Record<string, unknown> {
9
13
  id: number;
10
14
  repoId: number;
11
15
  repoName: string;
12
16
  signature: string;
13
17
  skeletonJson: string;
18
+ evidenceJson: string;
14
19
  }
15
20
 
16
21
  interface SubscriberAssociation extends Record<string, unknown> {
@@ -20,6 +25,7 @@ interface SubscriberAssociation extends Record<string, unknown> {
20
25
  targetId: string;
21
26
  status: string;
22
27
  evidenceJson: string;
28
+ targetLabel?: string | null;
23
29
  }
24
30
 
25
31
  function eventRows(
@@ -29,7 +35,7 @@ function eventRows(
29
35
  ): EventShapeRow[] {
30
36
  return db.prepare(`SELECT c.id,c.repo_id repoId,r.name repoName,
31
37
  c.event_skeleton_signature signature,
32
- c.event_skeleton_json skeletonJson
38
+ c.event_skeleton_json skeletonJson,c.evidence_json evidenceJson
33
39
  FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
34
40
  WHERE r.workspace_id=? AND c.call_type=?
35
41
  AND c.event_skeleton_signature IS NOT NULL
@@ -46,15 +52,19 @@ function associations(
46
52
  workspaceId: number,
47
53
  generation: number,
48
54
  ): SubscriberAssociation[] {
49
- return db.prepare(`SELECT id graphEdgeId,
50
- CAST(json_extract(evidence_json,'$.subscribeCallId') AS INTEGER)
55
+ return db.prepare(`SELECT edge.id graphEdgeId,
56
+ CAST(json_extract(edge.evidence_json,'$.subscribeCallId') AS INTEGER)
51
57
  subscribeCallId,
52
- to_kind targetKind,to_id targetId,status,evidence_json evidenceJson
53
- FROM graph_edges
54
- WHERE workspace_id=? AND generation=?
55
- AND edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
56
- AND to_kind='symbol'
57
- ORDER BY subscribeCallId,id`).all(
58
+ edge.to_kind targetKind,edge.to_id targetId,edge.status,
59
+ edge.evidence_json evidenceJson,
60
+ target.source_file || ':' || target.qualified_name targetLabel
61
+ FROM graph_edges edge
62
+ LEFT JOIN symbols target ON edge.to_kind='symbol'
63
+ AND target.id=CAST(edge.to_id AS INTEGER)
64
+ WHERE edge.workspace_id=? AND edge.generation=?
65
+ AND edge.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY'
66
+ AND edge.to_kind='symbol'
67
+ ORDER BY subscribeCallId,edge.id`).all(
58
68
  workspaceId, generation,
59
69
  ) as unknown as SubscriberAssociation[];
60
70
  }
@@ -85,8 +95,11 @@ function candidateEvidence(
85
95
  emit: EventShapeRow,
86
96
  subscribe: EventShapeRow,
87
97
  association: SubscriberAssociation,
98
+ total: number,
99
+ shown: number,
88
100
  ): Record<string, unknown> {
89
101
  const evidence = parsedEvidence(association.evidenceJson);
102
+ const parser = parsedEvidence(emit.evidenceJson);
90
103
  return {
91
104
  publishCallId: emit.id,
92
105
  subscribeCallId: subscribe.id,
@@ -100,7 +113,12 @@ function candidateEvidence(
100
113
  subscriptionConsumerRepositoryName:
101
114
  evidence.subscriptionConsumerRepositoryName,
102
115
  handlerSymbolId: Number(association.targetId),
116
+ eventShapeCandidateTargetLabel: association.targetLabel,
103
117
  associationGraphEdgeId: association.graphEdgeId,
118
+ outboundEvidence: boundCandidateLikeEvidence(parser),
119
+ eventShapeLinkCandidateCount: total,
120
+ shownEventShapeLinkCandidateCount: shown,
121
+ omittedEventShapeLinkCandidateCount: Math.max(0, total - shown),
104
122
  };
105
123
  }
106
124
 
@@ -111,6 +129,8 @@ function insertCandidate(
111
129
  emit: EventShapeRow,
112
130
  subscribe: EventShapeRow,
113
131
  association: SubscriberAssociation,
132
+ total: number,
133
+ shown: number,
114
134
  ): void {
115
135
  db.prepare(`INSERT INTO graph_edges(
116
136
  workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
@@ -125,7 +145,7 @@ function insertCandidate(
125
145
  association.targetId,
126
146
  0.3,
127
147
  JSON.stringify(candidateEvidence(
128
- emit, subscribe, association,
148
+ emit, subscribe, association, total, shown,
129
149
  )),
130
150
  1,
131
151
  'event_skeleton_equivalent_non_authoritative',
@@ -133,6 +153,24 @@ function insertCandidate(
133
153
  );
134
154
  }
135
155
 
156
+ interface ShapeCandidate {
157
+ subscription: EventShapeRow;
158
+ association: SubscriberAssociation;
159
+ }
160
+
161
+ function candidatesForEmit(
162
+ emit: EventShapeRow,
163
+ subscriptions: readonly EventShapeRow[],
164
+ bySubscription: ReadonlyMap<number, SubscriberAssociation[]>,
165
+ ): ShapeCandidate[] {
166
+ return subscriptions.flatMap((subscription) =>
167
+ !candidateEligible(emit, subscription) ? []
168
+ : (bySubscription.get(subscription.id) ?? []).map((association) => ({
169
+ subscription,
170
+ association,
171
+ })));
172
+ }
173
+
136
174
  export function linkEventShapeCandidates(
137
175
  db: Db,
138
176
  workspaceId: number,
@@ -147,15 +185,20 @@ export function linkEventShapeCandidates(
147
185
  association,
148
186
  ]);
149
187
  let edgeCount = 0;
150
- for (const emit of emits)
151
- for (const subscription of subscriptions) {
152
- if (!candidateEligible(emit, subscription)) continue;
153
- for (const association of bySubscription.get(subscription.id) ?? []) {
154
- insertCandidate(
155
- db, workspaceId, generation, emit, subscription, association,
156
- );
157
- edgeCount += 1;
158
- }
188
+ let omittedCount = 0;
189
+ for (const emit of emits) {
190
+ const candidates = candidatesForEmit(
191
+ emit, subscriptions, bySubscription,
192
+ );
193
+ const shown = candidates.slice(0, EVENT_SHAPE_LINK_CAP_PER_EMIT);
194
+ for (const candidate of shown) {
195
+ insertCandidate(
196
+ db, workspaceId, generation, emit, candidate.subscription,
197
+ candidate.association, candidates.length, shown.length,
198
+ );
199
+ edgeCount += 1;
159
200
  }
160
- return { edgeCount };
201
+ omittedCount += Math.max(0, candidates.length - shown.length);
202
+ }
203
+ return { edgeCount, omittedCount };
161
204
  }
@@ -33,6 +33,7 @@ interface SubscriptionRow {
33
33
  packageName?: string | null;
34
34
  environmentJson?: string | null;
35
35
  eventSkeletonJson?: string | null;
36
+ evidenceJson?: string | null;
36
37
  }
37
38
 
38
39
  interface HandlerCallRow {
@@ -73,6 +74,7 @@ function subscriptionRows(db: Db, workspaceId: number): SubscriptionRow[] {
73
74
  c.call_site_start_offset startOffset,c.call_site_end_offset endOffset,
74
75
  c.confidence,c.unresolved_reason unresolvedReason,
75
76
  c.event_skeleton_json eventSkeletonJson,
77
+ c.evidence_json evidenceJson,
76
78
  r.package_name packageName,
77
79
  r.environment_declarations_json environmentJson
78
80
  FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
@@ -220,6 +222,7 @@ function evidenceFor(
220
222
  association: HandlerAssociation,
221
223
  event: LinkedEventTemplate,
222
224
  environment?: SubscriptionEnvironmentTarget,
225
+ loopValue?: string,
223
226
  ): Record<string, unknown> {
224
227
  const call: Partial<HandlerCallRow> = association.call ?? {};
225
228
  const symbolCallReason = boundedSymbolCallReason(call.unresolvedReason);
@@ -228,8 +231,25 @@ function evidenceFor(
228
231
  const resolutionStatus = event.isDynamic
229
232
  ? 'unresolved'
230
233
  : environmentAmbiguous ? 'ambiguous' : association.status;
234
+ return {
235
+ ...eventDispatchEvidence(subscription, event, environment, loopValue),
236
+ ...handlerAssociationEvidence(
237
+ subscription, association, call, resolutionStatus,
238
+ environmentAmbiguous, environment,
239
+ ),
240
+ ...symbolCallReason,
241
+ };
242
+ }
243
+
244
+ function eventDispatchEvidence(
245
+ subscription: SubscriptionRow,
246
+ event: LinkedEventTemplate,
247
+ environment?: SubscriptionEnvironmentTarget,
248
+ loopValue?: string,
249
+ ): Record<string, unknown> {
231
250
  return {
232
251
  eventName: subscription.eventName,
252
+ materializedLoopEventName: loopValue,
233
253
  ...(eventSkeletonEvidence(subscription.eventSkeletonJson)),
234
254
  ...(event.substitution.placeholders.length > 0 ? {
235
255
  effectiveEventName: event.targetId,
@@ -238,10 +258,6 @@ function evidenceFor(
238
258
  associationBasis: 'exact_subscription_call_span',
239
259
  dispatchScope: 'workspace_event_name_only',
240
260
  subscribeCallId: subscription.id,
241
- symbolCallId: call.id,
242
- roleSiteMatchCount: association.matchCount,
243
- callRole: association.matchCount > 0 ? 'event_subscribe_handler' : undefined,
244
- factOrigin: association.factOrigin ?? call.factOrigin,
245
261
  repositoryId: subscription.repoId,
246
262
  repositoryName: subscription.repoName,
247
263
  subscriptionConsumerRepositoryId: environment?.consumerRepoId,
@@ -256,6 +272,23 @@ function evidenceFor(
256
272
  collisionCount: environment?.collisionCount,
257
273
  },
258
274
  }),
275
+ };
276
+ }
277
+
278
+ function handlerAssociationEvidence(
279
+ subscription: SubscriptionRow,
280
+ association: HandlerAssociation,
281
+ call: Partial<HandlerCallRow>,
282
+ resolutionStatus: string,
283
+ environmentAmbiguous: boolean,
284
+ environment?: SubscriptionEnvironmentTarget,
285
+ ): Record<string, unknown> {
286
+ return {
287
+ symbolCallId: call.id,
288
+ roleSiteMatchCount: association.matchCount,
289
+ callRole: association.matchCount > 0
290
+ ? 'event_subscribe_handler' : undefined,
291
+ factOrigin: association.factOrigin ?? call.factOrigin,
259
292
  sourceFile: subscription.sourceFile,
260
293
  sourceLine: subscription.sourceLine,
261
294
  callSiteStartOffset: subscription.startOffset,
@@ -273,7 +306,6 @@ function evidenceFor(
273
306
  reasonCode: environmentAmbiguous
274
307
  ? environment?.resolution.reason ?? 'event_environment_value_collision'
275
308
  : association.reasonCode,
276
- ...symbolCallReason,
277
309
  };
278
310
  }
279
311
 
@@ -304,20 +336,13 @@ function insertAssociationEdge(
304
336
  association: HandlerAssociation,
305
337
  event: LinkedEventTemplate,
306
338
  environment?: SubscriptionEnvironmentTarget,
339
+ loopValue?: string,
307
340
  ): void {
308
- const environmentAmbiguous = environment?.resolution.status === 'ambiguous'
309
- || Number(environment?.collisionCount ?? 1) > 1;
310
- const status = event.isDynamic
311
- ? 'unresolved'
312
- : environmentAmbiguous ? 'ambiguous' : association.status;
313
- const reason = event.isDynamic
314
- ? event.substitution.missing.length > 0
315
- ? 'event_template_variables_missing'
316
- : event.unresolvedReason ?? 'event_name_unsupported_constant_expression'
317
- : environmentAmbiguous
318
- ? environment?.resolution.reason
319
- ?? 'event_environment_value_collision'
320
- : association.reasonCode ?? association.call?.unresolvedReason ?? null;
341
+ const ambiguous = environmentTargetAmbiguous(environment);
342
+ const status = associationEdgeStatus(event, association, ambiguous);
343
+ const reason = associationEdgeReason(
344
+ event, association, environment, ambiguous,
345
+ );
321
346
  db.prepare(`INSERT INTO graph_edges(
322
347
  workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
323
348
  confidence,evidence_json,is_dynamic,unresolved_reason,generation
@@ -331,7 +356,7 @@ function insertAssociationEdge(
331
356
  association.toId,
332
357
  association.call?.confidence ?? subscription.confidence,
333
358
  JSON.stringify(evidenceFor(
334
- subscription, association, event, environment,
359
+ subscription, association, event, environment, loopValue,
335
360
  )),
336
361
  event.isDynamic ? 1 : 0,
337
362
  reason,
@@ -339,6 +364,66 @@ function insertAssociationEdge(
339
364
  );
340
365
  }
341
366
 
367
+ function environmentTargetAmbiguous(
368
+ environment?: SubscriptionEnvironmentTarget,
369
+ ): boolean {
370
+ return environment?.resolution.status === 'ambiguous'
371
+ || Number(environment?.collisionCount ?? 1) > 1;
372
+ }
373
+
374
+ function associationEdgeStatus(
375
+ event: LinkedEventTemplate,
376
+ association: HandlerAssociation,
377
+ environmentAmbiguous: boolean,
378
+ ): 'resolved' | 'ambiguous' | 'unresolved' {
379
+ if (event.isDynamic) return 'unresolved';
380
+ return environmentAmbiguous ? 'ambiguous' : association.status;
381
+ }
382
+
383
+ function associationEdgeReason(
384
+ event: LinkedEventTemplate,
385
+ association: HandlerAssociation,
386
+ environment: SubscriptionEnvironmentTarget | undefined,
387
+ environmentAmbiguous: boolean,
388
+ ): string | null {
389
+ if (event.isDynamic)
390
+ return event.substitution.missing.length > 0
391
+ ? 'event_template_variables_missing'
392
+ : event.unresolvedReason ?? 'event_name_unsupported_constant_expression';
393
+ if (environmentAmbiguous)
394
+ return environment?.resolution.reason
395
+ ?? 'event_environment_value_collision';
396
+ return association.reasonCode ?? association.call?.unresolvedReason ?? null;
397
+ }
398
+
399
+ function parsedEvidenceRecord(
400
+ value: string,
401
+ ): Record<string, unknown> | undefined {
402
+ try {
403
+ const parsed: unknown = JSON.parse(value);
404
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
405
+ ? parsed as Record<string, unknown> : undefined;
406
+ } catch {
407
+ return undefined;
408
+ }
409
+ }
410
+
411
+ function enumeratedLoopValues(
412
+ evidenceJson: string | null | undefined,
413
+ ): string[] | undefined {
414
+ if (!evidenceJson) return undefined;
415
+ const evidence = parsedEvidenceRecord(evidenceJson);
416
+ if (!evidence
417
+ || evidence.subscriptionLoopRegistrationStatus !== 'enumerated')
418
+ return undefined;
419
+ const values = evidence.subscriptionLoopValues;
420
+ if (!Array.isArray(values)
421
+ || !values.every((item): item is string => typeof item === 'string'))
422
+ return undefined;
423
+ return evidence.subscriptionLoopRegistrationCount === values.length
424
+ && evidence.omittedSubscriptionLoopValueCount === 0 ? values : undefined;
425
+ }
426
+
342
427
  function linkedSubscriptionEvents(
343
428
  db: Db,
344
429
  workspaceId: number,
@@ -347,24 +432,30 @@ function linkedSubscriptionEvents(
347
432
  ): Array<{
348
433
  event: LinkedEventTemplate;
349
434
  environment: SubscriptionEnvironmentTarget;
435
+ loopValue?: string;
350
436
  }> {
351
437
  const skeleton = parseEventSkeletonFact(subscription.eventSkeletonJson);
352
- return subscriptionEnvironmentTargets(
438
+ const environments = subscriptionEnvironmentTargets(
353
439
  db,
354
440
  workspaceId,
355
441
  subscription.packageName ?? undefined,
356
442
  subscription.eventSkeletonJson,
357
443
  subscription.environmentJson,
358
444
  variables,
359
- ).map((environment) => ({
360
- environment,
361
- event: linkEventTemplate(
362
- subscription.eventName,
363
- environment.resolution.variables,
364
- subscription.unresolvedReason ?? undefined,
365
- skeleton,
366
- ),
367
- }));
445
+ );
446
+ const loopValues = enumeratedLoopValues(subscription.evidenceJson);
447
+ const templates = loopValues ?? [subscription.eventName];
448
+ return environments.flatMap((environment) =>
449
+ templates.map((template) => ({
450
+ environment,
451
+ loopValue: loopValues ? template : undefined,
452
+ event: linkEventTemplate(
453
+ template,
454
+ environment.resolution.variables,
455
+ loopValues ? undefined : subscription.unresolvedReason ?? undefined,
456
+ loopValues ? undefined : skeleton,
457
+ ),
458
+ })));
368
459
  }
369
460
 
370
461
  function targetStatus(
@@ -414,7 +505,7 @@ export function linkEventSubscriptionHandlers(
414
505
  )) {
415
506
  insertAssociationEdge(
416
507
  db, workspaceId, generation, subscription, association,
417
- target.event, target.environment,
508
+ target.event, target.environment, target.loopValue,
418
509
  );
419
510
  incrementSummary(
420
511
  summary,