@saptools/service-flow 0.1.65 → 0.1.66

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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +67 -11
  3. package/TECHNICAL-NOTE.md +41 -1
  4. package/dist/{chunk-OONNRIDL.js → chunk-TOVX4WYH.js} +3764 -643
  5. package/dist/chunk-TOVX4WYH.js.map +1 -0
  6. package/dist/cli.js +158 -295
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +196 -1
  9. package/dist/index.js +7 -3
  10. package/dist/index.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/cli/002-doctor-lifecycle.ts +66 -0
  13. package/src/cli/doctor.ts +14 -27
  14. package/src/cli.ts +130 -102
  15. package/src/db/000-call-fact-repository.ts +537 -0
  16. package/src/db/001-fact-lifecycle.ts +111 -0
  17. package/src/db/migrations.ts +16 -1
  18. package/src/db/repositories.ts +1 -315
  19. package/src/db/schema.ts +4 -2
  20. package/src/index.ts +22 -0
  21. package/src/linker/004-event-subscription-handler-linker.ts +300 -0
  22. package/src/linker/cross-repo-linker.ts +23 -2
  23. package/src/output/001-compact-json-output.ts +6 -0
  24. package/src/parsers/imported-wrapper-parser.ts +4 -0
  25. package/src/parsers/outbound-call-parser.ts +11 -1
  26. package/src/parsers/symbol-parser.ts +7 -4
  27. package/src/trace/010-traversal-scope.ts +188 -0
  28. package/src/trace/011-event-subscriber-traversal.ts +366 -0
  29. package/src/trace/012-trace-graph-lookups.ts +74 -0
  30. package/src/trace/013-trace-root-scopes.ts +279 -0
  31. package/src/trace/014-compact-contract.ts +347 -0
  32. package/src/trace/015-trace-edge-recorder.ts +336 -0
  33. package/src/trace/016-compact-projector.ts +697 -0
  34. package/src/trace/017-trace-context.ts +378 -0
  35. package/src/trace/018-compact-trace.ts +87 -0
  36. package/src/trace/019-trace-edge-semantics.ts +328 -0
  37. package/src/trace/020-compact-field-projection.ts +387 -0
  38. package/src/trace/dynamic-branches.ts +35 -13
  39. package/src/trace/trace-engine.ts +271 -245
  40. package/src/types.ts +10 -0
  41. package/src/version.ts +1 -1
  42. package/dist/chunk-OONNRIDL.js.map +0 -1
@@ -1,6 +1,5 @@
1
1
  import type { Db } from '../db/connection.js';
2
2
  import { reposByName } from '../db/repositories.js';
3
- import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
4
3
  import type { ImplementationHint, TraceEdge, TraceOptions, TraceResult, TraceStart } from '../types.js';
5
4
  import { baseTraceEvidence, edgeTarget, runtimeNoCandidateDiagnostics, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
6
5
  import { dynamicCandidateBranches } from './dynamic-branches.js';
@@ -23,7 +22,38 @@ import {
23
22
  withSelectedHandlerProvenance,
24
23
  type SelectedHandlerEvidence,
25
24
  } from './009-selected-handler-provenance.js';
26
- import { boundCandidateLikeEvidence } from '../utils/000-bounded-projection.js';
25
+ import { schemaLifecycleDiagnostic } from '../db/001-fact-lifecycle.js';
26
+ import { TraversalScopeScheduler } from './010-traversal-scope.js';
27
+ import { planEventSubscriberTransitions } from './011-event-subscriber-traversal.js';
28
+ import {
29
+ graphForCalls,
30
+ operationNode,
31
+ symbolNode,
32
+ type TraceGraphEdgeRow as GraphRow,
33
+ } from './012-trace-graph-lookups.js';
34
+ import {
35
+ createTraceRootPlan,
36
+ enqueueCausalScope,
37
+ nextPendingRoot,
38
+ } from './013-trace-root-scopes.js';
39
+ import {
40
+ contextForSymbolCall,
41
+ knownBindingsForCalls,
42
+ knownBindingsForScope,
43
+ parseTraceEvidence as parseEvidence,
44
+ receiverFromTraceEvidence as receiverFromEvidence,
45
+ } from './017-trace-context.js';
46
+ import type { CompactTraceObserver } from './014-compact-contract.js';
47
+ import { TraceEdgeRecorder } from './015-trace-edge-recorder.js';
48
+ import {
49
+ recordCycleObservation,
50
+ recordDynamicBranchObservation,
51
+ recordEventBridgeObservation,
52
+ recordEventCycleObservation,
53
+ recordImplementationObservation,
54
+ recordLocalCallObservation,
55
+ recordOutboundObservation,
56
+ } from './019-trace-edge-semantics.js';
27
57
  import {
28
58
  ambiguousStartDiagnostic,
29
59
  selectorNotFoundDiagnostic,
@@ -54,22 +84,21 @@ interface CallRow extends Record<string, unknown> {
54
84
  call_type: string;
55
85
  confidence: number;
56
86
  source_symbol_id?: number;
57
- }
58
- interface GraphRow extends Record<string, unknown> {
59
- id: number;
60
- edge_type: string;
61
- from_id: string;
62
- to_kind: string;
63
- to_id: string;
64
- confidence: number;
65
- evidence_json: string;
66
- unresolved_reason?: string;
67
- status?: string;
87
+ workspaceId: number;
88
+ graphGeneration: number;
68
89
  }
69
90
  interface ImplementationHintOptions {
70
91
  implementationRepo?: string;
71
92
  implementationHints?: ImplementationHint[];
72
93
  }
94
+ const compactObserverKey = Symbol('service-flow.compact-trace-observer');
95
+ type ObservedTraceOptions = TraceOptions & {
96
+ [compactObserverKey]?: CompactTraceObserver;
97
+ };
98
+ function compactObserver(options: TraceOptions): CompactTraceObserver | undefined {
99
+ const observed: ObservedTraceOptions = options;
100
+ return observed[compactObserverKey];
101
+ }
73
102
  function normalizeOperation(value: string | undefined): string | undefined {
74
103
  if (!value) return undefined;
75
104
  return value.startsWith('/') ? value.slice(1) : value;
@@ -212,14 +241,11 @@ function handlerScope(db: Db, methodId: string): { repoId?: number; files: Set<s
212
241
  if (!row || typeof row.symbolId !== 'number') return undefined;
213
242
  return { repoId: row.repoId, files: new Set(row.sourceFile ? [row.sourceFile] : []), symbolId: row.symbolId };
214
243
  }
215
-
216
-
217
244
  function traceEdgeType(call: CallRow, row: GraphRow): string {
218
245
  if (row.to_kind === 'operation' && row.edge_type === 'REMOTE_CALL_RESOLVES_TO_OPERATION') return 'remote_action';
219
246
  if (row.to_kind === 'operation' && row.edge_type === 'LOCAL_CALL_RESOLVES_TO_OPERATION') return 'local_service_call';
220
247
  return String(call.call_type);
221
248
  }
222
-
223
249
  function includeCall(
224
250
  type: string,
225
251
  options: {
@@ -233,202 +259,37 @@ function includeCall(
233
259
  if (!options.includeAsync && type.startsWith('async_')) return false;
234
260
  return true;
235
261
  }
236
- function graphForCalls(db: Db, callIds: number[]): Map<number, GraphRow[]> {
237
- const map = new Map<number, GraphRow[]>();
238
- if (callIds.length === 0) return map;
239
- const rows = db
240
- .prepare(
241
- `SELECT * FROM graph_edges WHERE from_kind='call' AND from_id IN (${callIds.map(() => '?').join(',')}) ORDER BY id`,
242
- )
243
- .all(...callIds.map((id) => String(id))) as GraphRow[];
244
- for (const row of rows) {
245
- const id = Number(row.from_id);
246
- map.set(id, [...(map.get(id) ?? []), row]);
247
- }
248
- return map;
249
- }
250
- function symbolNode(db: Db, symbolId: number): Record<string, unknown> | undefined {
251
- const row = db.prepare(`SELECT s.id symbolId,s.name symbolName,s.qualified_name qualifiedName,s.source_file sourceFile,s.start_line startLine,s.end_line endLine,r.name repoName,r.id repoId FROM symbols s JOIN repositories r ON r.id=s.repo_id WHERE s.id=?`).get(symbolId) as Record<string, unknown> | undefined;
252
- if (!row) return undefined;
253
- const fileName = String(row.sourceFile ?? '').split('/').at(-1) ?? String(row.sourceFile ?? '');
254
- return { id: `symbol:${symbolId}`, kind: 'symbol', label: `${fileName}:${String(row.qualifiedName ?? row.symbolName)}`, ...row };
255
- }
256
-
257
- function operationNode(db: Db, operationId: string): Record<string, unknown> | undefined {
258
- const row = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_type operationType,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.id serviceId,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,r.id repoId,r.name repoName FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operationId) as Record<string, unknown> | undefined;
259
- if (!row) return undefined;
260
- return { id: `operation:${operationId}`, kind: 'operation', label: `${String(row.repoName)}:${String(row.servicePath)}${String(row.operationPath)}`, ...row };
261
- }
262
-
263
- function workspaceIdForCall(db: Db, callId: string): number | undefined {
264
- return (db.prepare('SELECT r.workspace_id workspaceId FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE c.id=?').get(callId) as { workspaceId?: number } | undefined)?.workspaceId;
265
- }
266
- function parseEvidence(value: unknown): Record<string, unknown> {
267
- try {
268
- const parsed = JSON.parse(String(value || '{}')) as unknown;
269
- return isEvidenceRecord(parsed) ? boundCandidateLikeEvidence(parsed) : {};
270
- } catch {
271
- return {};
272
- }
273
- }
274
-
275
- function isEvidenceRecord(value: unknown): value is Record<string, unknown> {
276
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
277
- }
278
- function receiverFromEvidence(value: unknown): string | undefined {
279
- const evidence = parseEvidence(value);
280
- return typeof evidence.receiver === 'string' ? evidence.receiver : undefined;
281
- }
282
- function hasDynamicPlaceholder(value: string | undefined): boolean {
283
- return extractPlaceholders(value).length > 0;
284
- }
285
- function enrichBinding(row: ContextBinding): ContextBinding {
286
- const effectiveServicePath = row.servicePathExpr && !hasDynamicPlaceholder(row.servicePathExpr) ? row.servicePathExpr : !row.servicePathExpr ? row.requireServicePath : undefined;
287
- const effectiveDestination = row.destinationExpr && !hasDynamicPlaceholder(row.destinationExpr) ? row.destinationExpr : !row.destinationExpr ? row.requireDestination : undefined;
288
- return { ...row, effectiveServicePath, effectiveDestination };
289
- }
290
- function knownBindingsForCalls(db: Db, calls: CallRow[]): Map<string, ContextBinding> {
291
- const map = new Map<string, ContextBinding>();
292
- for (const call of calls) {
293
- const receiver = receiverFromEvidence(call.evidence_json);
294
- const bindingId = Number(call.service_binding_id ?? 0);
295
- if (!receiver || !bindingId) continue;
296
- const row = db.prepare(`SELECT b.id,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.source_file sourceFile,b.source_line sourceLine,req.service_path requireServicePath,req.destination requireDestination
297
- FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
298
- WHERE b.id=?`).get(bindingId) as ContextBinding | undefined;
299
- if (row) map.set(receiver, enrichBinding({ ...row, bindingId, source: 'local_service_binding', calleeReceiver: receiver }));
300
- }
301
- return map;
302
- }
303
- function knownBindingsForScope(db: Db, repoId: number | undefined, symbolIds: Set<number> | undefined, files: Set<string> | undefined): Map<string, ContextBinding> {
304
- const map = new Map<string, ContextBinding>();
305
- if (repoId === undefined) return map;
306
- type BindingRow = Omit<ContextBinding, 'bindingId' | 'source' | 'calleeReceiver'> & { id?: number; symbolId?: number | null; variableName?: string };
307
- const rows = db.prepare(`SELECT b.id,b.symbol_id symbolId,b.variable_name variableName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.source_file sourceFile,b.source_line sourceLine,req.service_path requireServicePath,req.destination requireDestination
308
- FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
309
- WHERE b.repo_id=?`).all(repoId) as BindingRow[];
310
- for (const row of rows) {
311
- if (!row.variableName) continue;
312
- if (files && !files.has(String(row.sourceFile))) continue;
313
- if (symbolIds && symbolIds.size > 0 && row.symbolId != null
314
- && !symbolIds.has(Number(row.symbolId))) continue;
315
- const candidate = enrichBinding({
316
- ...row,
317
- bindingId: Number(row.id),
318
- source: 'local_service_binding',
319
- calleeReceiver: row.variableName,
320
- resolutionStatus: 'selected',
321
- });
322
- const existing = map.get(row.variableName);
323
- if (!existing) {
324
- map.set(row.variableName, candidate);
325
- continue;
326
- }
327
- const candidates = uniqueBindingCandidates([
328
- ...(existing.bindingCandidates ?? [bindingCandidateEvidence(existing)]),
329
- bindingCandidateEvidence(candidate),
330
- ]);
331
- map.set(row.variableName, {
332
- ...candidate,
333
- bindingId: undefined,
334
- source: 'ambiguous_local_service_bindings',
335
- resolutionStatus: 'ambiguous',
336
- bindingCandidates: candidates,
337
- });
338
- }
339
- return map;
340
- }
341
-
342
- function bindingCandidateEvidence(binding: ContextBinding): Record<string, unknown> {
343
- return {
344
- bindingId: binding.bindingId,
345
- sourceFile: binding.sourceFile,
346
- sourceLine: binding.sourceLine,
347
- alias: binding.alias,
348
- aliasExpr: binding.aliasExpr,
349
- destinationExpr: binding.destinationExpr,
350
- servicePathExpr: binding.servicePathExpr,
351
- };
352
- }
353
-
354
- function uniqueBindingCandidates(
355
- candidates: Array<Record<string, unknown>>,
356
- ): Array<Record<string, unknown>> {
357
- const seen = new Set<string>();
358
- return candidates.filter((candidate) => {
359
- const key = JSON.stringify(candidate);
360
- if (seen.has(key)) return false;
361
- seen.add(key);
362
- return true;
363
- });
364
- }
365
- function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, callerBindings: Map<string, ContextBinding>): Map<string, ContextBinding> {
366
- const next = new Map<string, ContextBinding>();
367
- if (callerBindings.size === 0) return next;
368
- const callEvidence = parseEvidence(symbolCall.evidence_json);
369
- const callee = db.prepare('SELECT evidence_json evidenceJson,source_file sourceFile,start_line startLine FROM symbols WHERE id=?').get(symbolCall.callee_symbol_id) as { evidenceJson?: string; sourceFile?: string; startLine?: number } | undefined;
370
- const calleeEvidence = parseEvidence(callee?.evidenceJson);
371
- const params = Array.isArray(calleeEvidence.parameters) ? calleeEvidence.parameters.filter((item): item is string => typeof item === 'string') : [];
372
- const parameterBindings = Array.isArray(calleeEvidence.parameterBindings) ? calleeEvidence.parameterBindings.filter((item): item is Record<string, unknown> => Boolean(item && typeof item === 'object' && !Array.isArray(item))) : [];
373
- const parameterPropertyAliases = Array.isArray(calleeEvidence.parameterPropertyAliases) ? calleeEvidence.parameterPropertyAliases.filter((item): item is Record<string, unknown> => Boolean(item && typeof item === 'object' && !Array.isArray(item))) : [];
374
- const args = Array.isArray(callEvidence.callArguments) ? callEvidence.callArguments as Array<Record<string, unknown>> : [];
375
- const provenance = {
376
- callerSite: { sourceFile: String(symbolCall.source_file ?? ''), sourceLine: Number(symbolCall.source_line ?? 0) },
377
- calleeSite: { sourceFile: callee?.sourceFile, sourceLine: callee?.startLine },
378
- };
379
- args.forEach((arg, index) => {
380
- const paramBinding = parameterBindings.find((binding) => binding.index === index);
381
- const param = paramBinding?.kind === 'identifier' && typeof paramBinding.name === 'string' ? paramBinding.name : params[index];
382
- if (arg.kind === 'identifier' && typeof arg.name === 'string') {
383
- const binding = callerBindings.get(arg.name);
384
- if (binding && param) next.set(param, { ...binding, ...provenance, source: 'local_symbol_argument', callerArgument: arg.name, calleeParameter: param, calleeReceiver: param });
385
- }
386
- if (arg.kind === 'object_literal' && Array.isArray(arg.properties)) {
387
- for (const prop of arg.properties as Array<Record<string, unknown>>) {
388
- if (typeof prop.property !== 'string' || typeof prop.argument !== 'string') continue;
389
- const binding = callerBindings.get(prop.argument);
390
- if (!binding) continue;
391
- const destructured = paramBinding?.kind === 'object_pattern' && Array.isArray(paramBinding.properties)
392
- ? (paramBinding.properties as Array<Record<string, unknown>>).find((item) => item.property === prop.property && typeof item.local === 'string')
393
- : undefined;
394
- if (destructured && typeof destructured.local === 'string') next.set(destructured.local, { ...binding, ...provenance, source: 'local_symbol_destructured_object_argument', callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: String(index), calleeReceiver: destructured.local });
395
- else if (param) {
396
- next.set(`${param}.${prop.property}`, { ...binding, ...provenance, source: 'local_symbol_object_argument', callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: param, calleeReceiver: `${param}.${prop.property}` });
397
- for (const alias of parameterPropertyAliases) {
398
- if (alias.parameter === param && alias.property === prop.property && typeof alias.local === 'string') next.set(alias.local, { ...binding, ...provenance, source: 'local_symbol_object_parameter_destructure', callerProperty: prop.property, callerArgument: prop.argument, calleeParameter: param, calleeObjectProperty: `${param}.${prop.property}`, calleeReceiver: alias.local, calleeLocalDestructuredIdentifier: alias.local, parameterPropertyAliasKind: alias.kind, parameterPropertyAliasLine: alias.line });
399
- }
400
- }
401
- }
402
- }
403
- if (arg.kind === 'array_literal' && Array.isArray(arg.elements) && paramBinding?.kind === 'array_pattern' && Array.isArray(paramBinding.elements)) {
404
- for (const element of arg.elements as Array<Record<string, unknown>>) {
405
- const target = (paramBinding.elements as Array<Record<string, unknown>>).find((item) => item.index === element.index);
406
- if (element.kind !== 'identifier' || typeof element.name !== 'string' || typeof target?.local !== 'string') continue;
407
- const binding = callerBindings.get(element.name);
408
- if (binding) next.set(target.local, { ...binding, ...provenance, source: 'local_symbol_destructured_array_argument', callerArgument: element.name, calleeParameter: String(index), calleeReceiver: target.local });
409
- }
410
- }
411
- });
412
- return next;
413
- }
414
262
  export function trace(
415
263
  db: Db,
416
264
  start: TraceStart,
417
265
  options: TraceOptions,
418
266
  ): TraceResult {
267
+ const observer = compactObserver(options);
268
+ const schemaLifecycle = schemaLifecycleDiagnostic(db);
269
+ if (schemaLifecycle)
270
+ return { start, nodes: [], edges: [], diagnostics: [schemaLifecycle] };
419
271
  const hintOptions = { implementationRepo: options.implementationRepo, implementationHints: options.implementationHints };
420
272
  const scope = startScope(db, start, hintOptions, options.workspaceId);
421
273
  const hasSelector = Boolean(start.repo || start.handler || start.operation
422
274
  || start.operationPath || start.servicePath);
423
275
  const diagnosticRepoId = scope.executionRepoId ?? scope.repo?.id;
276
+ const scheduler = new TraversalScopeScheduler();
277
+ const roots = createTraceRootPlan(db, scheduler, {
278
+ repoId: diagnosticRepoId, files: scope.sourceFiles,
279
+ symbolIds: scope.symbolIds, selectorMatched: scope.selectorMatched,
280
+ }, options.workspaceId, Boolean(options.includeAsync));
281
+ observer?.setWorkspaceId?.(roots.workspaceId);
282
+ if (roots.diagnostic)
283
+ return { start, nodes: [], edges: [], diagnostics: [roots.diagnostic] };
284
+ const { workspaceId, queue, pendingRoots } = roots;
424
285
  const diagnostics = loadTraceDiagnostics(
425
286
  db,
426
287
  diagnosticRepoId,
427
288
  !hasSelector,
428
- options.workspaceId,
289
+ workspaceId,
429
290
  );
430
291
  const stale = diagnosticRepoId !== undefined || !hasSelector
431
- ? db.prepare('SELECT name,graph_stale_reason reason FROM repositories WHERE graph_stale_reason IS NOT NULL AND (? IS NULL OR id=?) AND (? IS NULL OR workspace_id=?) ORDER BY name,id').all(diagnosticRepoId, diagnosticRepoId, options.workspaceId, options.workspaceId) as Array<{ name?: string; reason?: string }>
292
+ ? db.prepare('SELECT name,graph_stale_reason reason FROM repositories WHERE graph_stale_reason IS NOT NULL AND (? IS NULL OR id=?) AND (? IS NULL OR workspace_id=?) ORDER BY name,id').all(diagnosticRepoId, diagnosticRepoId, workspaceId, workspaceId) as Array<{ name?: string; reason?: string }>
432
293
  : [];
433
294
  for (const row of stale)
434
295
  prependTraceDiagnostic(diagnostics, { severity: 'warning', code: 'graph_stale', message: `Graph is stale for ${row.name ?? 'repository'}: ${row.reason ?? 'facts_changed'}. Run service-flow link.` });
@@ -438,12 +299,8 @@ export function trace(
438
299
  prependTraceDiagnostic(diagnostics, selectorNotFoundDiagnostic(start));
439
300
  const maxDepth = positiveDepth(options.depth);
440
301
  const edges: TraceEdge[] = [];
302
+ const recorder = new TraceEdgeRecorder(edges, observer);
441
303
  const nodes = new Map<string, Record<string, unknown>>();
442
- const seenEdges = new Set<number>();
443
- const queue: Array<{ repoId?: number; files?: Set<string>; symbolIds?: Set<number>; depth: number; context?: Map<string, ContextBinding> }> =
444
- scope.selectorMatched
445
- ? [{ repoId: scope.executionRepoId, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: new Map() }]
446
- : [];
447
304
  if (scope.startOperationId && scope.selectorMatched) {
448
305
  const op = operationNode(db, scope.startOperationId);
449
306
  const impl = implementationScope(db, scope.startOperationId);
@@ -472,44 +329,107 @@ export function trace(
472
329
  : { evidence: implEvidence };
473
330
  if (selected.diagnostic) prependTraceDiagnostic(diagnostics, selected.diagnostic);
474
331
  if (selected.handler) nodes.set(String(selected.handler.id), selected.handler);
475
- seenEdges.add(Number(impl.edge.id));
476
- edges.push({ step: 1, type: 'operation_implemented_by_handler', from: op?.label ? String(op.label) : `operation:${scope.startOperationId}`, to: selected.handler?.label ? String(selected.handler.label) : `${impl.edge.to_kind}:${impl.edge.to_id}`, evidence: selected.evidence, confidence: Number(impl.edge.confidence ?? 0), unresolvedReason: selected.unresolvedReason ?? (impl.edge.status === 'resolved' || startSelection.methodId ? undefined : String(impl.edge.unresolved_reason ?? impl.edge.status)) });
332
+ const unresolvedReason = selected.unresolvedReason
333
+ ?? (impl.edge.status === 'resolved' || startSelection.methodId
334
+ ? undefined : String(impl.edge.unresolved_reason ?? impl.edge.status));
335
+ const selectedScope = selectedMethodId
336
+ ? handlerScope(db, selectedMethodId) : undefined;
337
+ const edge: TraceEdge = { step: 1, type: 'operation_implemented_by_handler', from: op?.label ? String(op.label) : `operation:${scope.startOperationId}`, to: selected.handler?.label ? String(selected.handler.label) : `${impl.edge.to_kind}:${impl.edge.to_id}`, evidence: selected.evidence, confidence: Number(impl.edge.confidence ?? 0), unresolvedReason };
338
+ recordImplementationObservation(recorder, edge, {
339
+ operationId: scope.startOperationId,
340
+ handlerMethodId: selectedMethodId,
341
+ handlerSymbolId: selectedScope?.symbolId,
342
+ graphEdgeId: impl.edge.id,
343
+ persistedStatus: impl.edge.status,
344
+ persistedTargetKind: impl.edge.to_kind,
345
+ persistedTargetId: impl.edge.to_id,
346
+ effectiveStatus: startSelection.methodId
347
+ ? 'resolved' : String(impl.edge.status ?? 'unresolved'),
348
+ strategy: String(startSelection.evidence.strategy
349
+ ?? 'indexed_operation_graph'),
350
+ guided: startSelection.evidence.guided === true,
351
+ unresolvedReason, evidence: selected.evidence, site: op ?? {},
352
+ });
477
353
  }
478
354
  }
479
- const seenScopes = new Set<string>();
480
- while (queue.length > 0) {
355
+ while (queue.length > 0 || pendingRoots.length > 0) {
356
+ if ((queue[0]?.depth ?? Number.POSITIVE_INFINITY) > 1
357
+ && workspaceId !== undefined) {
358
+ const root = nextPendingRoot(pendingRoots, scheduler, workspaceId);
359
+ if (root) queue.unshift(root);
360
+ }
481
361
  const current = queue.shift();
482
362
  if (!current || current.depth > maxDepth) continue;
483
- const contextKey = [...(current.context ?? new Map<string, ContextBinding>()).keys()].sort().join(',');
484
- const key = `${current.repoId ?? '*'}:${[...(current.symbolIds ?? new Set(['*']))].sort().join(',')}:${[...(current.files ?? new Set(['*']))].sort().join(',')}:${contextKey}`;
485
- if (seenScopes.has(key)) continue;
486
- seenScopes.add(key);
363
+ if (!scheduler.markExpanded(current.state)) continue;
487
364
  const calls = db
488
365
  .prepare(
489
- `SELECT c.*,r.name repoName FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE (? IS NULL OR c.repo_id=?) AND (? IS NULL OR r.workspace_id=?) ORDER BY c.source_file,c.source_line`,
366
+ `SELECT c.*,r.name repoName,r.workspace_id workspaceId,
367
+ r.graph_generation graphGeneration
368
+ FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id
369
+ WHERE (? IS NULL OR c.repo_id=?) AND (? IS NULL OR r.workspace_id=?)
370
+ ORDER BY c.source_file COLLATE BINARY,c.call_site_start_offset,
371
+ c.call_site_end_offset,c.source_line,c.id`,
490
372
  )
491
- .all(current.repoId, current.repoId, options.workspaceId, options.workspaceId) as CallRow[];
373
+ .all(current.repoId, current.repoId, workspaceId, workspaceId) as CallRow[];
492
374
  const filtered = calls.filter(
493
375
  (c) =>
494
- (!current.symbolIds || current.symbolIds.has(Number(c.source_symbol_id))) && (!current.files || current.files.has(String(c.source_file))) &&
376
+ (current.unownedOnly ? c.source_symbol_id == null
377
+ : !current.symbolIds || current.symbolIds.has(Number(c.source_symbol_id)))
378
+ && (!current.files || current.files.has(String(c.source_file))) &&
495
379
  includeCall(String(c.call_type), options),
496
380
  );
497
- const callerBindings = new Map<string, ContextBinding>([...(current.context ?? new Map<string, ContextBinding>()), ...knownBindingsForScope(db, current.repoId, current.symbolIds, current.files), ...knownBindingsForCalls(db, filtered)]);
381
+ const callerBindings = new Map<string, ContextBinding>([...current.context, ...knownBindingsForScope(db, current.repoId, current.symbolIds, current.files), ...knownBindingsForCalls(db, filtered)]);
498
382
 
499
- if (current.symbolIds && current.symbolIds.size > 0 && current.depth < maxDepth) {
500
- const symbolRows = db.prepare(`SELECT sc.*,s.repo_id calleeRepoId,s.source_file calleeFile FROM symbol_calls sc LEFT JOIN symbols s ON s.id=sc.callee_symbol_id WHERE sc.caller_symbol_id IN (${[...current.symbolIds].map(() => '?').join(',')}) ORDER BY sc.source_file,sc.source_line`).all(...current.symbolIds) as Array<Record<string, unknown>>;
383
+ if (!current.rootObservationOnly && current.symbolIds
384
+ && current.symbolIds.size > 0 && current.depth < maxDepth) {
385
+ const symbolRows = db.prepare(`SELECT sc.*,s.repo_id calleeRepoId,
386
+ s.source_file calleeFile FROM symbol_calls sc
387
+ LEFT JOIN symbols s ON s.id=sc.callee_symbol_id
388
+ WHERE sc.call_role='ordinary_call'
389
+ AND sc.caller_symbol_id IN (${[...current.symbolIds].map(() => '?').join(',')})
390
+ ORDER BY sc.source_file COLLATE BINARY,sc.call_site_start_offset,
391
+ sc.call_site_end_offset,sc.source_line,sc.id`).all(
392
+ ...current.symbolIds,
393
+ ) as Array<Record<string, unknown>>;
501
394
  for (const symbolCall of symbolRows) {
502
395
  if (!symbolCall.callee_symbol_id) continue;
503
396
  const nextSymbols = new Set([Number(symbolCall.callee_symbol_id)]);
504
397
  const nextFiles = new Set([String(symbolCall.calleeFile)]);
505
398
  const nextRepoId = Number(symbolCall.calleeRepoId);
506
- const nextKey = `${nextRepoId}:${[...nextSymbols].join(',')}:${[...nextFiles].join(',')}`;
399
+ const nextContext = contextForSymbolCall(db, symbolCall, callerBindings);
400
+ const scheduling = scheduler.schedule({
401
+ workspaceId,
402
+ repoId: nextRepoId,
403
+ files: nextFiles,
404
+ symbolIds: nextSymbols,
405
+ context: nextContext,
406
+ }, current.state);
507
407
  const calleeNode = symbolNode(db, Number(symbolCall.callee_symbol_id));
508
408
  if (calleeNode) nodes.set(String(calleeNode.id), calleeNode);
509
409
  const evidence = { ...parseEvidence(symbolCall.evidence_json), sourceFile: symbolCall.source_file, sourceLine: symbolCall.source_line, calleeSymbolId: symbolCall.callee_symbol_id, calleeSymbolName: calleeNode?.symbolName, calleeSymbolFile: calleeNode?.sourceFile, resolutionStatus: symbolCall.status };
510
- edges.push({ step: current.depth, type: 'local_symbol_call', from: String(symbolCall.callee_expression), to: calleeNode?.label ? String(calleeNode.label) : `symbol:${String(symbolCall.callee_symbol_id)}`, evidence, confidence: Number(symbolCall.confidence ?? 0.8), unresolvedReason: String(symbolCall.status) === 'resolved' ? undefined : symbolCall.unresolved_reason ? String(symbolCall.unresolved_reason) : undefined });
511
- if (seenScopes.has(nextKey)) edges.push({ step: current.depth, type: 'cycle', from: String(symbolCall.callee_expression), to: nextKey, evidence: { cycle: true, symbolCallId: symbolCall.id }, confidence: 1, unresolvedReason: 'Cycle detected; downstream symbol already visited' });
512
- else queue.push({ repoId: nextRepoId, files: nextFiles, symbolIds: nextSymbols, depth: current.depth + 1, context: contextForSymbolCall(db, symbolCall, callerBindings) });
410
+ const unresolvedReason = String(symbolCall.status) === 'resolved'
411
+ ? undefined : symbolCall.unresolved_reason
412
+ ? String(symbolCall.unresolved_reason) : undefined;
413
+ const edge: TraceEdge = { step: current.depth, type: 'local_symbol_call', from: String(symbolCall.callee_expression), to: calleeNode?.label ? String(calleeNode.label) : `symbol:${String(symbolCall.callee_symbol_id)}`, evidence, confidence: Number(symbolCall.confidence ?? 0.8), unresolvedReason };
414
+ const target = recordLocalCallObservation(recorder, edge, {
415
+ symbolCall, evidence, unresolvedReason,
416
+ });
417
+ if (scheduling.kind === 'cycle') {
418
+ const cycleEvidence = { cycle: true,
419
+ cycleReason: 'structural_ancestry_cycle',
420
+ symbolCallId: symbolCall.id };
421
+ const cycleEdge: TraceEdge = { step: current.depth, type: 'cycle', from: String(symbolCall.callee_expression), to: scheduling.state.structuralKey, evidence: cycleEvidence, confidence: 1, unresolvedReason: 'Cycle detected in structural ancestry; downstream symbol was not expanded' };
422
+ recordCycleObservation(recorder, cycleEdge, target, {
423
+ workspaceId, repositoryId: nextRepoId, sourceFiles: nextFiles,
424
+ symbolIds: nextSymbols,
425
+ structuralKey: scheduling.state.structuralKey,
426
+ }, { symbolCallId: symbolCall.id,
427
+ symbolId: symbolCall.callee_symbol_id }, symbolCall);
428
+ }
429
+ if (scheduling.kind === 'scheduled') enqueueCausalScope(
430
+ queue, pendingRoots, { repoId: nextRepoId, files: nextFiles,
431
+ symbolIds: nextSymbols, depth: current.depth + 1,
432
+ context: nextContext, state: scheduling.state });
513
433
  }
514
434
  }
515
435
  const graph = graphForCalls(
@@ -527,18 +447,16 @@ export function trace(
527
447
  callType: call.call_type,
528
448
  });
529
449
  const persistedRowsForCall = graph.get(Number(call.id)) ?? [];
530
- const contextual = contextualRuntimeResolution(db, call, callerBindings.get(receiverFromEvidence(call.evidence_json) ?? ''), workspaceIdForCall(db, String(call.id)), persistedRowsForCall);
450
+ const contextual = contextualRuntimeResolution(db, call, callerBindings.get(receiverFromEvidence(call.evidence_json) ?? ''), call.workspaceId, persistedRowsForCall);
531
451
  const graphRows = contextual.row ? [contextual.row] : persistedRowsForCall;
532
452
  for (const row of graphRows) {
533
- if (seenEdges.has(Number(row.id))) continue;
534
- seenEdges.add(Number(row.id));
535
453
  const persistedEvidence = parseEvidence(row.evidence_json);
536
454
  const rawEvidence = baseTraceEvidence(row as TraceGraphRow, call, persistedEvidence, contextual.evidence);
537
455
  const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, {
538
456
  vars: options.vars,
539
457
  dynamicMode: options.dynamicMode ?? 'strict',
540
458
  maxDynamicCandidates: options.maxDynamicCandidates,
541
- }, workspaceIdForCall(db, String(call.id)), contextual.state);
459
+ }, call.workspaceId, contextual.state);
542
460
  const evidence = effective.evidence;
543
461
  const effectiveRow = effective.row;
544
462
  const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
@@ -549,7 +467,7 @@ export function trace(
549
467
  label: effectiveRow.to_kind === 'db_entity' ? `Entity: ${effectiveRow.to_id || 'unknown'}` : effectiveRow.to_id,
550
468
  });
551
469
  const to = edgeTarget(effectiveRow, evidence);
552
- edges.push({
470
+ const edge: TraceEdge = {
553
471
  step: current.depth,
554
472
  type: traceEdgeType(call, effectiveRow),
555
473
  from: `${call.repoName}:${call.source_file}:${call.source_line}`,
@@ -557,10 +475,68 @@ export function trace(
557
475
  evidence,
558
476
  confidence: Number(effectiveRow.confidence ?? call.confidence),
559
477
  unresolvedReason: effective.unresolvedReason,
478
+ };
479
+ const semanticWorkspaceId = workspaceId ?? call.workspaceId;
480
+ const semantic = recordOutboundObservation(recorder, edge, {
481
+ call, row: effectiveRow, evidence,
482
+ workspaceId: semanticWorkspaceId,
483
+ dynamicMode: options.dynamicMode,
484
+ unresolvedReason: effective.unresolvedReason,
560
485
  });
486
+ if (options.includeAsync && call.call_type === 'async_emit'
487
+ && effectiveRow.edge_type === 'HANDLER_EMITS_EVENT'
488
+ && typeof call.event_name_expr === 'string') {
489
+ const plans = planEventSubscriberTransitions(db, {
490
+ workspaceId: workspaceId ?? call.workspaceId,
491
+ graphGeneration: call.graphGeneration,
492
+ eventName: call.event_name_expr,
493
+ }, scheduler, current.state, current.depth, maxDepth);
494
+ for (const plan of plans) {
495
+ const nodeId = String(plan.node.id);
496
+ const targetLabel = String(plan.node.label ?? nodeId);
497
+ nodes.set(nodeId, plan.node);
498
+ const bridgeEdge: TraceEdge = {
499
+ step: current.depth,
500
+ type: 'event_name_matches_subscription_handler',
501
+ from: plan.transition.eventName,
502
+ to: targetLabel,
503
+ evidence: plan.evidence,
504
+ confidence: plan.transition.confidence,
505
+ unresolvedReason: plan.transition.unresolvedReason,
506
+ };
507
+ const handler = plan.transition.handler;
508
+ const bridgeTarget = recordEventBridgeObservation(
509
+ recorder, bridgeEdge, plan, semanticWorkspaceId, plans.length,
510
+ );
511
+ if (plan.bodyExpansion === 'cycle_blocked' && plan.state) {
512
+ const cycleEvidence = { cycle: true,
513
+ cycleReason: 'structural_ancestry_cycle',
514
+ graphEdgeId: plan.transition.graphEdgeId };
515
+ const cycleEdge: TraceEdge = { step: current.depth, type: 'cycle', from: targetLabel, to: plan.state.structuralKey, evidence: cycleEvidence, confidence: 1, unresolvedReason: 'Cycle detected across an event subscriber boundary; downstream symbol was not expanded' };
516
+ recordEventCycleObservation(recorder, cycleEdge, plan,
517
+ bridgeTarget, semanticWorkspaceId);
518
+ }
519
+ if (plan.bodyExpansion === 'scheduled' && plan.state && handler) {
520
+ const files = new Set([handler.sourceFile]);
521
+ const symbolIds = new Set([handler.symbolId]);
522
+ enqueueCausalScope(queue, pendingRoots, {
523
+ repoId: handler.repoId, files, symbolIds,
524
+ depth: current.depth + 1, context: new Map(), state: plan.state,
525
+ });
526
+ }
527
+ }
528
+ }
561
529
  if ((options.dynamicMode ?? 'strict') === 'candidates'
562
- && effectiveRow.status !== 'resolved')
563
- edges.push(...dynamicCandidateBranches(current.depth, call, evidence));
530
+ && effectiveRow.status !== 'resolved') {
531
+ for (const branch of dynamicCandidateBranches(
532
+ current.depth, call, evidence,
533
+ )) {
534
+ recordDynamicBranchObservation(
535
+ recorder, branch, call, semantic.source, evidence,
536
+ semanticWorkspaceId,
537
+ );
538
+ }
539
+ }
564
540
  if (effectiveRow.to_kind === 'operation') {
565
541
  const implementation = implementationScope(db, effectiveRow.to_id);
566
542
  const contextSelection = contextualImplementationSelection(
@@ -601,17 +577,40 @@ export function trace(
601
577
  ? String(selected.handler.label)
602
578
  : `${implementation.edge.to_kind}:${implementation.edge.to_id}`;
603
579
  if (selected.handler) nodes.set(String(selected.handler.id), selected.handler);
604
- edges.push({
580
+ const unresolvedReason = selected.unresolvedReason
581
+ ?? (implementation.edge.status === 'resolved' || contextMethodId
582
+ ? undefined
583
+ : String(implementation.edge.unresolved_reason
584
+ ?? implementation.edge.status));
585
+ const implementationTraceEdge: TraceEdge = {
605
586
  step: current.depth,
606
587
  type: 'operation_implemented_by_handler',
607
588
  from: to,
608
589
  to: implTo,
609
590
  evidence: selected.evidence,
610
591
  confidence: Number(implementation.edge.confidence ?? 0),
611
- unresolvedReason: selected.unresolvedReason
612
- ?? (implementation.edge.status === 'resolved' || contextMethodId
613
- ? undefined
614
- : String(implementation.edge.unresolved_reason ?? implementation.edge.status)),
592
+ unresolvedReason,
593
+ };
594
+ const selectedScope = selectedMethodId
595
+ ? handlerScope(db, selectedMethodId) : undefined;
596
+ recordImplementationObservation(recorder, implementationTraceEdge, {
597
+ operationId: effectiveRow.to_id,
598
+ handlerMethodId: selectedMethodId,
599
+ handlerSymbolId: selectedScope?.symbolId,
600
+ graphEdgeId: implementation.edge.id,
601
+ persistedStatus: implementation.edge.status,
602
+ persistedTargetKind: implementation.edge.to_kind,
603
+ persistedTargetId: implementation.edge.to_id,
604
+ effectiveStatus: contextMethodId
605
+ ? 'resolved' : String(implementation.edge.status),
606
+ strategy: String(contextSelection.evidence.strategy
607
+ ?? (contextMethodId ? 'contextual_implementation_selection'
608
+ : 'indexed_operation_graph')),
609
+ guided: contextSelection.evidence.guided === true,
610
+ contextual: Boolean(contextMethodId
611
+ && contextSelection.evidence.strategy
612
+ !== 'implementation_repo_hint'),
613
+ unresolvedReason, evidence: selected.evidence, site: call,
615
614
  });
616
615
  }
617
616
  if (current.depth >= maxDepth) continue;
@@ -626,25 +625,39 @@ export function trace(
626
625
  'SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?',
627
626
  )
628
627
  .get(effectiveRow.to_id)?.repoId as number | undefined);
629
- const nextKey = `${targetRepoId ?? '*'}:${[...(symbolIds ?? new Set(['*']))].sort().join(',')}:${[...files].sort().join(',')}`;
630
- if (seenScopes.has(nextKey))
631
- edges.push({
628
+ const nextContext = new Map<string, ContextBinding>();
629
+ const scheduling = scheduler.schedule({
630
+ workspaceId: workspaceId ?? call.workspaceId,
631
+ repoId: targetRepoId,
632
+ files,
633
+ symbolIds,
634
+ context: nextContext,
635
+ }, current.state);
636
+ if (scheduling.kind === 'cycle') {
637
+ const cycleEvidence = { ...evidence, cycle: true,
638
+ cycleReason: 'structural_ancestry_cycle' };
639
+ const cycleEdge: TraceEdge = {
632
640
  step: current.depth,
633
641
  type: 'cycle',
634
642
  from: to,
635
- to: nextKey,
636
- evidence: { ...evidence, cycle: true },
643
+ to: scheduling.state.structuralKey,
644
+ evidence: cycleEvidence,
637
645
  confidence: 1,
638
646
  unresolvedReason:
639
- 'Cycle detected; downstream scope already visited',
640
- });
641
- else
642
- queue.push({
643
- repoId: targetRepoId,
644
- files,
645
- symbolIds,
646
- depth: current.depth + 1,
647
- });
647
+ 'Cycle detected in structural ancestry; downstream scope was not expanded',
648
+ };
649
+ recordCycleObservation(recorder, cycleEdge, semantic.target, {
650
+ workspaceId: semanticWorkspaceId, repositoryId: targetRepoId,
651
+ sourceFiles: files, symbolIds,
652
+ structuralKey: scheduling.state.structuralKey,
653
+ }, { graphEdgeId: evidence.persistedGraphEdgeId,
654
+ outboundCallId: call.id,
655
+ operationId: effectiveRow.to_id }, call);
656
+ }
657
+ if (scheduling.kind === 'scheduled') enqueueCausalScope(
658
+ queue, pendingRoots, { repoId: targetRepoId, files, symbolIds,
659
+ depth: current.depth + 1, context: nextContext,
660
+ state: scheduling.state });
648
661
  }
649
662
  }
650
663
  }
@@ -656,3 +669,16 @@ export function trace(
656
669
  prependTraceDiagnostic(diagnostics, diagnostic);
657
670
  return { start, nodes: [...nodes.values()], edges, diagnostics };
658
671
  }
672
+
673
+ export function traceWithObserver(
674
+ db: Db,
675
+ start: TraceStart,
676
+ options: TraceOptions,
677
+ observer: CompactTraceObserver,
678
+ ): TraceResult {
679
+ const observed: ObservedTraceOptions = {
680
+ ...options,
681
+ [compactObserverKey]: observer,
682
+ };
683
+ return trace(db, start, observed);
684
+ }