@saptools/service-flow 0.1.64 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +67 -11
- package/TECHNICAL-NOTE.md +41 -1
- package/dist/{chunk-TBH33OYC.js → chunk-TOVX4WYH.js} +3765 -643
- package/dist/chunk-TOVX4WYH.js.map +1 -0
- package/dist/cli.js +231 -292
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +196 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/002-doctor-lifecycle.ts +66 -0
- package/src/cli/doctor.ts +14 -27
- package/src/cli.ts +130 -102
- package/src/db/000-call-fact-repository.ts +537 -0
- package/src/db/001-fact-lifecycle.ts +111 -0
- package/src/db/migrations.ts +16 -1
- package/src/db/repositories.ts +1 -315
- package/src/db/schema.ts +4 -2
- package/src/index.ts +22 -0
- package/src/linker/004-event-subscription-handler-linker.ts +300 -0
- package/src/linker/cross-repo-linker.ts +23 -2
- package/src/output/001-compact-json-output.ts +6 -0
- package/src/parsers/imported-wrapper-parser.ts +4 -0
- package/src/parsers/outbound-call-parser.ts +11 -1
- package/src/parsers/symbol-parser.ts +110 -2
- package/src/trace/010-traversal-scope.ts +188 -0
- package/src/trace/011-event-subscriber-traversal.ts +366 -0
- package/src/trace/012-trace-graph-lookups.ts +74 -0
- package/src/trace/013-trace-root-scopes.ts +279 -0
- package/src/trace/014-compact-contract.ts +347 -0
- package/src/trace/015-trace-edge-recorder.ts +336 -0
- package/src/trace/016-compact-projector.ts +697 -0
- package/src/trace/017-trace-context.ts +378 -0
- package/src/trace/018-compact-trace.ts +87 -0
- package/src/trace/019-trace-edge-semantics.ts +328 -0
- package/src/trace/020-compact-field-projection.ts +387 -0
- package/src/trace/dynamic-branches.ts +35 -13
- package/src/trace/trace-engine.ts +271 -245
- package/src/types.ts +10 -0
- package/src/version.ts +1 -1
- package/dist/chunk-TBH33OYC.js.map +0 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { Db } from '../db/connection.js';
|
|
3
|
+
import type { ContextBinding } from './008-contextual-runtime-state.js';
|
|
4
|
+
|
|
5
|
+
export interface TraversalScopeIdentity {
|
|
6
|
+
workspaceId?: number;
|
|
7
|
+
repoId?: number;
|
|
8
|
+
files?: ReadonlySet<string>;
|
|
9
|
+
symbolIds?: ReadonlySet<number>;
|
|
10
|
+
context?: ReadonlyMap<string, ContextBinding>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TraversalScopeState {
|
|
14
|
+
structuralKey: string;
|
|
15
|
+
evaluationKey: string;
|
|
16
|
+
ancestry: ReadonlySet<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type TraversalScheduleKind = 'scheduled' | 'converged' | 'cycle';
|
|
20
|
+
|
|
21
|
+
export interface TraversalScheduleDecision {
|
|
22
|
+
kind: TraversalScheduleKind;
|
|
23
|
+
state: TraversalScopeState;
|
|
24
|
+
alreadyExpanded: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function compareBinary(left: string, right: string): number {
|
|
28
|
+
const leftPoints = [...left];
|
|
29
|
+
const rightPoints = [...right];
|
|
30
|
+
const length = Math.min(leftPoints.length, rightPoints.length);
|
|
31
|
+
for (let index = 0; index < length; index += 1) {
|
|
32
|
+
const leftPoint = leftPoints[index]?.codePointAt(0) ?? 0;
|
|
33
|
+
const rightPoint = rightPoints[index]?.codePointAt(0) ?? 0;
|
|
34
|
+
if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1;
|
|
35
|
+
}
|
|
36
|
+
return leftPoints.length - rightPoints.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveTraversalWorkspaceId(
|
|
40
|
+
db: Db,
|
|
41
|
+
requestedWorkspaceId: number | undefined,
|
|
42
|
+
repoId: number | undefined,
|
|
43
|
+
): number | undefined {
|
|
44
|
+
if (requestedWorkspaceId !== undefined) return requestedWorkspaceId;
|
|
45
|
+
if (repoId !== undefined) {
|
|
46
|
+
const row = db.prepare(
|
|
47
|
+
'SELECT workspace_id workspaceId FROM repositories WHERE id=?',
|
|
48
|
+
).get(repoId) as { workspaceId?: number } | undefined;
|
|
49
|
+
return typeof row?.workspaceId === 'number' ? row.workspaceId : undefined;
|
|
50
|
+
}
|
|
51
|
+
const rows = db.prepare(`SELECT DISTINCT w.id workspaceId FROM workspaces w
|
|
52
|
+
JOIN repositories r ON r.workspace_id=w.id ORDER BY w.id LIMIT 2`).all() as
|
|
53
|
+
Array<{ workspaceId?: number }>;
|
|
54
|
+
return rows.length === 1 && typeof rows[0]?.workspaceId === 'number'
|
|
55
|
+
? rows[0].workspaceId : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function structuralScopeKey(
|
|
59
|
+
workspaceId: number | undefined,
|
|
60
|
+
repoId: number | undefined,
|
|
61
|
+
files: ReadonlySet<string> | undefined,
|
|
62
|
+
symbolIds: ReadonlySet<number> | undefined,
|
|
63
|
+
): string {
|
|
64
|
+
return JSON.stringify([
|
|
65
|
+
workspaceId ?? null,
|
|
66
|
+
repoId ?? null,
|
|
67
|
+
files ? [...files].sort(compareBinary) : null,
|
|
68
|
+
symbolIds ? [...symbolIds].sort((left, right) => left - right) : null,
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function canonicalContextFingerprint(
|
|
73
|
+
context: ReadonlyMap<string, ContextBinding> | undefined,
|
|
74
|
+
): string {
|
|
75
|
+
const entries = [...(context ?? new Map<string, ContextBinding>()).entries()]
|
|
76
|
+
.map(([name, binding]) => `${JSON.stringify(name)}:${canonicalValue(binding)}`)
|
|
77
|
+
.sort(compareBinary);
|
|
78
|
+
return createHash('sha256').update(`[${entries.join(',')}]`).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function evaluationScopeKey(
|
|
82
|
+
structuralKey: string,
|
|
83
|
+
context: ReadonlyMap<string, ContextBinding> | undefined,
|
|
84
|
+
): string {
|
|
85
|
+
return JSON.stringify([structuralKey, canonicalContextFingerprint(context)]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class TraversalScopeScheduler {
|
|
89
|
+
readonly #scheduled = new Set<string>();
|
|
90
|
+
readonly #expanded = new Set<string>();
|
|
91
|
+
readonly #structuralByEvaluation = new Map<string, string>();
|
|
92
|
+
readonly #childrenByEvaluation = new Map<string, Set<string>>();
|
|
93
|
+
|
|
94
|
+
schedule(
|
|
95
|
+
identity: TraversalScopeIdentity,
|
|
96
|
+
parent?: TraversalScopeState,
|
|
97
|
+
): TraversalScheduleDecision {
|
|
98
|
+
const state = scopeState(identity, parent);
|
|
99
|
+
this.#rememberState(state);
|
|
100
|
+
if (parent) this.#rememberState(parent);
|
|
101
|
+
const cycle = parent
|
|
102
|
+
? parent.ancestry.has(state.structuralKey)
|
|
103
|
+
|| this.#reachesStructural(state.evaluationKey, parent.structuralKey)
|
|
104
|
+
: false;
|
|
105
|
+
if (parent) this.#recordEdge(parent.evaluationKey, state.evaluationKey);
|
|
106
|
+
if (cycle)
|
|
107
|
+
return { kind: 'cycle', state, alreadyExpanded: this.#expanded.has(state.evaluationKey) };
|
|
108
|
+
if (this.#scheduled.has(state.evaluationKey))
|
|
109
|
+
return { kind: 'converged', state, alreadyExpanded: this.#expanded.has(state.evaluationKey) };
|
|
110
|
+
this.#scheduled.add(state.evaluationKey);
|
|
111
|
+
return { kind: 'scheduled', state, alreadyExpanded: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
markExpanded(state: TraversalScopeState): boolean {
|
|
115
|
+
if (this.#expanded.has(state.evaluationKey)) return false;
|
|
116
|
+
this.#expanded.add(state.evaluationKey);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#rememberState(state: TraversalScopeState): void {
|
|
121
|
+
this.#structuralByEvaluation.set(state.evaluationKey, state.structuralKey);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#recordEdge(parent: string, child: string): void {
|
|
125
|
+
const children = this.#childrenByEvaluation.get(parent) ?? new Set<string>();
|
|
126
|
+
children.add(child);
|
|
127
|
+
this.#childrenByEvaluation.set(parent, children);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#reachesStructural(start: string, targetStructuralKey: string): boolean {
|
|
131
|
+
const pending = [start];
|
|
132
|
+
const seen = new Set<string>();
|
|
133
|
+
let cursor = 0;
|
|
134
|
+
while (cursor < pending.length) {
|
|
135
|
+
const current = pending[cursor];
|
|
136
|
+
cursor += 1;
|
|
137
|
+
if (seen.has(current)) continue;
|
|
138
|
+
seen.add(current);
|
|
139
|
+
if (this.#structuralByEvaluation.get(current) === targetStructuralKey)
|
|
140
|
+
return true;
|
|
141
|
+
pending.push(...(this.#childrenByEvaluation.get(current) ?? []));
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function scopeState(
|
|
148
|
+
identity: TraversalScopeIdentity,
|
|
149
|
+
parent: TraversalScopeState | undefined,
|
|
150
|
+
): TraversalScopeState {
|
|
151
|
+
const structuralKey = structuralScopeKey(
|
|
152
|
+
identity.workspaceId, identity.repoId, identity.files, identity.symbolIds,
|
|
153
|
+
);
|
|
154
|
+
const ancestry = new Set(parent?.ancestry ?? []);
|
|
155
|
+
ancestry.add(structuralKey);
|
|
156
|
+
return {
|
|
157
|
+
structuralKey,
|
|
158
|
+
evaluationKey: evaluationScopeKey(structuralKey, identity.context),
|
|
159
|
+
ancestry,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function canonicalValue(value: unknown): string {
|
|
164
|
+
if (value === null) return 'null';
|
|
165
|
+
if (value === undefined) return 'undefined';
|
|
166
|
+
if (typeof value === 'string') return `string:${JSON.stringify(value)}`;
|
|
167
|
+
if (typeof value === 'boolean') return `boolean:${String(value)}`;
|
|
168
|
+
if (typeof value === 'number') return canonicalNumber(value);
|
|
169
|
+
if (typeof value === 'bigint') return `bigint:${String(value)}`;
|
|
170
|
+
if (Array.isArray(value))
|
|
171
|
+
return `array:[${value.map(canonicalValue).join(',')}]`;
|
|
172
|
+
if (!isRecord(value)) return `unsupported:${typeof value}`;
|
|
173
|
+
const entries = Object.entries(value).sort(([left], [right]) => compareBinary(left, right));
|
|
174
|
+
return `object:{${entries.map(([key, child]) =>
|
|
175
|
+
`${JSON.stringify(key)}:${canonicalValue(child)}`).join(',')}}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function canonicalNumber(value: number): string {
|
|
179
|
+
if (Number.isNaN(value)) return 'number:NaN';
|
|
180
|
+
if (value === Number.POSITIVE_INFINITY) return 'number:Infinity';
|
|
181
|
+
if (value === Number.NEGATIVE_INFINITY) return 'number:-Infinity';
|
|
182
|
+
if (Object.is(value, -0)) return 'number:-0';
|
|
183
|
+
return `number:${String(value)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
187
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
188
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { Db } from '../db/connection.js';
|
|
2
|
+
import {
|
|
3
|
+
type TraversalScopeScheduler,
|
|
4
|
+
type TraversalScopeState,
|
|
5
|
+
} from './010-traversal-scope.js';
|
|
6
|
+
|
|
7
|
+
export type EventSubscriberTransitionStatus =
|
|
8
|
+
| 'resolved'
|
|
9
|
+
| 'ambiguous'
|
|
10
|
+
| 'unresolved';
|
|
11
|
+
|
|
12
|
+
export interface EventSubscriberSymbolTarget {
|
|
13
|
+
symbolId: number;
|
|
14
|
+
kind: string;
|
|
15
|
+
qualifiedName: string;
|
|
16
|
+
repoId: number;
|
|
17
|
+
repoName: string;
|
|
18
|
+
packageName?: string;
|
|
19
|
+
sourceFile: string;
|
|
20
|
+
sourceLine: number;
|
|
21
|
+
endLine: number;
|
|
22
|
+
startOffset?: number;
|
|
23
|
+
endOffset?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EventSubscriberTransition {
|
|
27
|
+
graphEdgeId: number;
|
|
28
|
+
graphGeneration: number;
|
|
29
|
+
eventName: string;
|
|
30
|
+
status: EventSubscriberTransitionStatus;
|
|
31
|
+
targetKind: 'symbol' | 'symbol_reference' | 'subscription_handler';
|
|
32
|
+
targetId: string;
|
|
33
|
+
confidence: number;
|
|
34
|
+
unresolvedReason?: string;
|
|
35
|
+
reasonCode?: string;
|
|
36
|
+
subscribeCallId?: number;
|
|
37
|
+
symbolCallId?: number;
|
|
38
|
+
roleSiteMatchCount: number;
|
|
39
|
+
callRole?: string;
|
|
40
|
+
factOrigin?: string;
|
|
41
|
+
associationBasis?: string;
|
|
42
|
+
dispatchScope?: string;
|
|
43
|
+
subscriptionRepoId?: number;
|
|
44
|
+
subscriptionRepoName?: string;
|
|
45
|
+
sourceFile?: string;
|
|
46
|
+
sourceLine?: number;
|
|
47
|
+
callSiteStartOffset?: number;
|
|
48
|
+
callSiteEndOffset?: number;
|
|
49
|
+
wrapperFunction?: string;
|
|
50
|
+
resolutionStrategy?: string;
|
|
51
|
+
associationStatus?: string;
|
|
52
|
+
symbolCallResolutionStatus?: string;
|
|
53
|
+
candidateCount: number;
|
|
54
|
+
symbolCallUnresolvedReason?: string;
|
|
55
|
+
omittedSymbolCallUnresolvedReasonCharacterCount?: number;
|
|
56
|
+
handler?: EventSubscriberSymbolTarget;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EventSubscriberTransitionQuery {
|
|
60
|
+
workspaceId: number;
|
|
61
|
+
graphGeneration: number;
|
|
62
|
+
eventName: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type EventBodyExpansion =
|
|
66
|
+
| 'scheduled'
|
|
67
|
+
| 'already_scheduled'
|
|
68
|
+
| 'already_expanded'
|
|
69
|
+
| 'cycle_blocked'
|
|
70
|
+
| 'depth_limited'
|
|
71
|
+
| 'not_resolved';
|
|
72
|
+
|
|
73
|
+
export interface PlannedEventSubscriberTransition {
|
|
74
|
+
transition: EventSubscriberTransition;
|
|
75
|
+
node: Record<string, unknown>;
|
|
76
|
+
evidence: Record<string, unknown>;
|
|
77
|
+
bodyExpansion: EventBodyExpansion;
|
|
78
|
+
state?: TraversalScopeState;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function planEventSubscriberTransitions(
|
|
82
|
+
db: Db,
|
|
83
|
+
query: EventSubscriberTransitionQuery,
|
|
84
|
+
scheduler: TraversalScopeScheduler,
|
|
85
|
+
parent: TraversalScopeState,
|
|
86
|
+
depth: number,
|
|
87
|
+
maxDepth: number,
|
|
88
|
+
): PlannedEventSubscriberTransition[] {
|
|
89
|
+
return loadEventSubscriberTransitions(db, query).map((transition) => {
|
|
90
|
+
const handler = transition.handler;
|
|
91
|
+
if (!handler) return plannedTransition(transition, 'not_resolved');
|
|
92
|
+
if (depth >= maxDepth)
|
|
93
|
+
return plannedTransition(transition, 'depth_limited');
|
|
94
|
+
const state = scheduler.schedule({
|
|
95
|
+
workspaceId: query.workspaceId,
|
|
96
|
+
repoId: handler.repoId,
|
|
97
|
+
files: new Set([handler.sourceFile]),
|
|
98
|
+
symbolIds: new Set([handler.symbolId]),
|
|
99
|
+
context: new Map(),
|
|
100
|
+
}, parent);
|
|
101
|
+
const bodyExpansion: EventBodyExpansion = state.kind === 'scheduled'
|
|
102
|
+
? 'scheduled'
|
|
103
|
+
: state.kind === 'cycle' ? 'cycle_blocked'
|
|
104
|
+
: state.alreadyExpanded ? 'already_expanded' : 'already_scheduled';
|
|
105
|
+
return plannedTransition(transition, bodyExpansion, state.state);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function plannedTransition(
|
|
110
|
+
transition: EventSubscriberTransition,
|
|
111
|
+
bodyExpansion: EventBodyExpansion,
|
|
112
|
+
state?: TraversalScopeState,
|
|
113
|
+
): PlannedEventSubscriberTransition {
|
|
114
|
+
return {
|
|
115
|
+
transition,
|
|
116
|
+
node: eventSubscriberNode(transition),
|
|
117
|
+
evidence: eventTransitionEvidence(transition, bodyExpansion),
|
|
118
|
+
bodyExpansion,
|
|
119
|
+
state,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function eventSubscriberNode(
|
|
124
|
+
transition: EventSubscriberTransition,
|
|
125
|
+
): Record<string, unknown> {
|
|
126
|
+
const handler = transition.handler;
|
|
127
|
+
if (!handler) return {
|
|
128
|
+
id: `event_subscription:${transition.graphEdgeId}`,
|
|
129
|
+
kind: transition.targetKind,
|
|
130
|
+
label: `${transition.targetKind}:${transition.targetId}`,
|
|
131
|
+
graphEdgeId: transition.graphEdgeId,
|
|
132
|
+
};
|
|
133
|
+
const fileName = handler.sourceFile.split('/').at(-1) ?? handler.sourceFile;
|
|
134
|
+
return {
|
|
135
|
+
id: `symbol:${handler.symbolId}`,
|
|
136
|
+
kind: 'symbol',
|
|
137
|
+
label: `${fileName}:${handler.qualifiedName}`,
|
|
138
|
+
symbolId: handler.symbolId,
|
|
139
|
+
symbolName: handler.qualifiedName,
|
|
140
|
+
qualifiedName: handler.qualifiedName,
|
|
141
|
+
sourceFile: handler.sourceFile,
|
|
142
|
+
startLine: handler.sourceLine,
|
|
143
|
+
endLine: handler.endLine,
|
|
144
|
+
repoName: handler.repoName,
|
|
145
|
+
repoId: handler.repoId,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function eventTransitionEvidence(
|
|
150
|
+
transition: EventSubscriberTransition,
|
|
151
|
+
bodyExpansion: EventBodyExpansion,
|
|
152
|
+
): Record<string, unknown> {
|
|
153
|
+
return {
|
|
154
|
+
graphEdgeId: transition.graphEdgeId,
|
|
155
|
+
graphGeneration: transition.graphGeneration,
|
|
156
|
+
subscribeCallId: transition.subscribeCallId,
|
|
157
|
+
symbolCallId: transition.symbolCallId,
|
|
158
|
+
eventName: transition.eventName,
|
|
159
|
+
matchStrategy: 'workspace_exact_event_name',
|
|
160
|
+
dispatchCertainty: 'static_name_only',
|
|
161
|
+
associationBasis: transition.associationBasis,
|
|
162
|
+
dispatchScope: transition.dispatchScope,
|
|
163
|
+
roleSiteMatchCount: transition.roleSiteMatchCount,
|
|
164
|
+
callRole: transition.callRole,
|
|
165
|
+
factOrigin: transition.factOrigin,
|
|
166
|
+
repositoryId: transition.subscriptionRepoId,
|
|
167
|
+
repositoryName: transition.subscriptionRepoName,
|
|
168
|
+
sourceFile: transition.sourceFile,
|
|
169
|
+
sourceLine: transition.sourceLine,
|
|
170
|
+
callSiteStartOffset: transition.callSiteStartOffset,
|
|
171
|
+
callSiteEndOffset: transition.callSiteEndOffset,
|
|
172
|
+
wrapperFunction: transition.wrapperFunction,
|
|
173
|
+
handlerSymbolId: transition.handler?.symbolId,
|
|
174
|
+
handlerSourceFile: transition.handler?.sourceFile,
|
|
175
|
+
handlerSourceLine: transition.handler?.sourceLine,
|
|
176
|
+
associationStatus: transition.associationStatus ?? transition.status,
|
|
177
|
+
symbolCallResolutionStatus: transition.symbolCallResolutionStatus,
|
|
178
|
+
resolutionStatus: transition.status,
|
|
179
|
+
resolutionStrategy: transition.resolutionStrategy,
|
|
180
|
+
candidateCount: transition.candidateCount,
|
|
181
|
+
symbolCallUnresolvedReason: transition.symbolCallUnresolvedReason,
|
|
182
|
+
omittedSymbolCallUnresolvedReasonCharacterCount:
|
|
183
|
+
transition.omittedSymbolCallUnresolvedReasonCharacterCount,
|
|
184
|
+
reasonCode: transition.reasonCode,
|
|
185
|
+
bodyExpansion,
|
|
186
|
+
cycle: bodyExpansion === 'cycle_blocked' || undefined,
|
|
187
|
+
cycleReason: bodyExpansion === 'cycle_blocked'
|
|
188
|
+
? 'structural_ancestry_cycle' : undefined,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function loadEventSubscriberTransitions(
|
|
193
|
+
db: Db,
|
|
194
|
+
query: EventSubscriberTransitionQuery,
|
|
195
|
+
): EventSubscriberTransition[] {
|
|
196
|
+
const rows = db.prepare(`SELECT ge.id graphEdgeId,ge.generation graphGeneration,
|
|
197
|
+
ge.from_id eventName,ge.status,ge.to_kind targetKind,ge.to_id targetId,
|
|
198
|
+
ge.confidence,ge.unresolved_reason unresolvedReason,ge.evidence_json evidenceJson,
|
|
199
|
+
subscribe.id subscribeCallId,subscribe.repo_id subscriptionRepoId,
|
|
200
|
+
subscribe.source_file sourceFile,subscribe.source_line sourceLine,
|
|
201
|
+
subscribe.call_site_start_offset callSiteStartOffset,
|
|
202
|
+
subscribe.call_site_end_offset callSiteEndOffset,
|
|
203
|
+
subscription_repo.name subscriptionRepoName,
|
|
204
|
+
handler.id handlerSymbolId,handler.kind handlerKind,
|
|
205
|
+
handler.qualified_name handlerQualifiedName,handler.source_file handlerSourceFile,
|
|
206
|
+
handler.start_line handlerSourceLine,handler.end_line handlerEndLine,
|
|
207
|
+
handler.start_offset handlerStartOffset,handler.end_offset handlerEndOffset,
|
|
208
|
+
handler_repo.id handlerRepoId,handler_repo.name handlerRepoName,
|
|
209
|
+
handler_repo.package_name handlerPackageName
|
|
210
|
+
FROM graph_edges ge
|
|
211
|
+
LEFT JOIN outbound_calls subscribe
|
|
212
|
+
ON subscribe.id=CAST(json_extract(ge.evidence_json,'$.subscribeCallId') AS INTEGER)
|
|
213
|
+
LEFT JOIN repositories subscription_repo
|
|
214
|
+
ON subscription_repo.id=subscribe.repo_id
|
|
215
|
+
AND subscription_repo.workspace_id=ge.workspace_id
|
|
216
|
+
LEFT JOIN symbols handler
|
|
217
|
+
ON ge.to_kind='symbol' AND handler.id=CAST(ge.to_id AS INTEGER)
|
|
218
|
+
LEFT JOIN repositories handler_repo
|
|
219
|
+
ON handler_repo.id=handler.repo_id AND handler_repo.workspace_id=ge.workspace_id
|
|
220
|
+
WHERE ge.workspace_id=? AND ge.generation=?
|
|
221
|
+
AND ge.edge_type='EVENT_SUBSCRIPTION_HANDLED_BY' AND ge.from_kind='event'
|
|
222
|
+
AND ge.from_id COLLATE BINARY=? COLLATE BINARY
|
|
223
|
+
ORDER BY COALESCE(subscription_repo.name,'') COLLATE BINARY,
|
|
224
|
+
COALESCE(subscription_repo.id,0),COALESCE(subscribe.source_file,'') COLLATE BINARY,
|
|
225
|
+
subscribe.call_site_start_offset,subscribe.call_site_end_offset,ge.id`).all(
|
|
226
|
+
query.workspaceId, query.graphGeneration, query.eventName,
|
|
227
|
+
);
|
|
228
|
+
return rows.flatMap((row) => {
|
|
229
|
+
const transition = transitionFromRow(row);
|
|
230
|
+
return transition ? [transition] : [];
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function transitionFromRow(
|
|
235
|
+
row: Record<string, unknown>,
|
|
236
|
+
): EventSubscriberTransition | undefined {
|
|
237
|
+
const graphEdgeId = numberValue(row.graphEdgeId);
|
|
238
|
+
const graphGeneration = numberValue(row.graphGeneration);
|
|
239
|
+
const eventName = stringValue(row.eventName);
|
|
240
|
+
const targetId = stringValue(row.targetId);
|
|
241
|
+
if (graphEdgeId === undefined || graphGeneration === undefined
|
|
242
|
+
|| eventName === undefined || targetId === undefined) return undefined;
|
|
243
|
+
const evidence = parseEvidence(row.evidenceJson);
|
|
244
|
+
const handler = symbolTarget(row);
|
|
245
|
+
const status = transitionStatus(stringValue(row.status), handler);
|
|
246
|
+
const reasonCode = status === 'resolved'
|
|
247
|
+
? undefined
|
|
248
|
+
: stringValue(evidence.reasonCode) ?? missingTargetReason(row, handler);
|
|
249
|
+
return {
|
|
250
|
+
graphEdgeId, graphGeneration, eventName, status,
|
|
251
|
+
targetKind: targetKind(row.targetKind), targetId,
|
|
252
|
+
confidence: numberValue(row.confidence) ?? 0,
|
|
253
|
+
unresolvedReason: status === 'resolved'
|
|
254
|
+
? undefined : stringValue(row.unresolvedReason) ?? reasonCode,
|
|
255
|
+
reasonCode,
|
|
256
|
+
...associationEvidence(row, evidence),
|
|
257
|
+
handler,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function associationEvidence(
|
|
262
|
+
row: Record<string, unknown>,
|
|
263
|
+
evidence: Record<string, unknown>,
|
|
264
|
+
): Omit<EventSubscriberTransition,
|
|
265
|
+
'graphEdgeId' | 'graphGeneration' | 'eventName' | 'status' | 'targetKind'
|
|
266
|
+
| 'targetId' | 'confidence' | 'unresolvedReason' | 'reasonCode' | 'handler'> {
|
|
267
|
+
const symbolCallUnresolvedReason = stringValue(
|
|
268
|
+
evidence.symbolCallUnresolvedReason,
|
|
269
|
+
);
|
|
270
|
+
return {
|
|
271
|
+
subscribeCallId: numberValue(row.subscribeCallId),
|
|
272
|
+
symbolCallId: numberValue(evidence.symbolCallId),
|
|
273
|
+
roleSiteMatchCount: nonNegativeCount(evidence.roleSiteMatchCount),
|
|
274
|
+
callRole: stringValue(evidence.callRole),
|
|
275
|
+
factOrigin: stringValue(evidence.factOrigin),
|
|
276
|
+
associationBasis: stringValue(evidence.associationBasis),
|
|
277
|
+
dispatchScope: stringValue(evidence.dispatchScope),
|
|
278
|
+
subscriptionRepoId: numberValue(row.subscriptionRepoId),
|
|
279
|
+
subscriptionRepoName: stringValue(row.subscriptionRepoName),
|
|
280
|
+
sourceFile: stringValue(row.sourceFile),
|
|
281
|
+
sourceLine: numberValue(row.sourceLine),
|
|
282
|
+
callSiteStartOffset: numberValue(row.callSiteStartOffset),
|
|
283
|
+
callSiteEndOffset: numberValue(row.callSiteEndOffset),
|
|
284
|
+
wrapperFunction: stringValue(evidence.wrapperFunction),
|
|
285
|
+
resolutionStrategy: stringValue(evidence.resolutionStrategy),
|
|
286
|
+
associationStatus: stringValue(evidence.associationStatus),
|
|
287
|
+
symbolCallResolutionStatus: stringValue(evidence.symbolCallResolutionStatus),
|
|
288
|
+
candidateCount: nonNegativeCount(evidence.candidateCount),
|
|
289
|
+
symbolCallUnresolvedReason,
|
|
290
|
+
omittedSymbolCallUnresolvedReasonCharacterCount: symbolCallUnresolvedReason
|
|
291
|
+
? nonNegativeCount(evidence.omittedSymbolCallUnresolvedReasonCharacterCount)
|
|
292
|
+
: undefined,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function symbolTarget(
|
|
297
|
+
row: Record<string, unknown>,
|
|
298
|
+
): EventSubscriberSymbolTarget | undefined {
|
|
299
|
+
const symbolId = numberValue(row.handlerSymbolId);
|
|
300
|
+
const repoId = numberValue(row.handlerRepoId);
|
|
301
|
+
const repoName = stringValue(row.handlerRepoName);
|
|
302
|
+
const sourceFile = stringValue(row.handlerSourceFile);
|
|
303
|
+
const sourceLine = numberValue(row.handlerSourceLine);
|
|
304
|
+
const endLine = numberValue(row.handlerEndLine);
|
|
305
|
+
const kind = stringValue(row.handlerKind);
|
|
306
|
+
const qualifiedName = stringValue(row.handlerQualifiedName);
|
|
307
|
+
if (symbolId === undefined || repoId === undefined || !repoName || !sourceFile
|
|
308
|
+
|| sourceLine === undefined || endLine === undefined || !kind || !qualifiedName)
|
|
309
|
+
return undefined;
|
|
310
|
+
return {
|
|
311
|
+
symbolId, repoId, repoName, sourceFile, sourceLine, endLine, kind, qualifiedName,
|
|
312
|
+
packageName: stringValue(row.handlerPackageName),
|
|
313
|
+
startOffset: numberValue(row.handlerStartOffset),
|
|
314
|
+
endOffset: numberValue(row.handlerEndOffset),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function transitionStatus(
|
|
319
|
+
value: string | undefined,
|
|
320
|
+
handler: EventSubscriberSymbolTarget | undefined,
|
|
321
|
+
): EventSubscriberTransitionStatus {
|
|
322
|
+
if (value === 'resolved' && handler) return 'resolved';
|
|
323
|
+
return value === 'ambiguous' ? 'ambiguous' : 'unresolved';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function missingTargetReason(
|
|
327
|
+
row: Record<string, unknown>,
|
|
328
|
+
handler: EventSubscriberSymbolTarget | undefined,
|
|
329
|
+
): string | undefined {
|
|
330
|
+
return stringValue(row.status) === 'resolved' && !handler
|
|
331
|
+
? 'subscription_handler_target_missing'
|
|
332
|
+
: undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function targetKind(
|
|
336
|
+
value: unknown,
|
|
337
|
+
): EventSubscriberTransition['targetKind'] {
|
|
338
|
+
return value === 'symbol' || value === 'symbol_reference'
|
|
339
|
+
|| value === 'subscription_handler' ? value : 'subscription_handler';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseEvidence(value: unknown): Record<string, unknown> {
|
|
343
|
+
try {
|
|
344
|
+
const parsed: unknown = JSON.parse(String(value ?? '{}'));
|
|
345
|
+
return isRecord(parsed) ? parsed : {};
|
|
346
|
+
} catch {
|
|
347
|
+
return {};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function nonNegativeCount(value: unknown): number {
|
|
352
|
+
const count = numberValue(value);
|
|
353
|
+
return count === undefined ? 0 : Math.max(0, Math.floor(count));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function numberValue(value: unknown): number | undefined {
|
|
357
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function stringValue(value: unknown): string | undefined {
|
|
361
|
+
return typeof value === 'string' ? value : undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
365
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
366
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Db } from '../db/connection.js';
|
|
2
|
+
|
|
3
|
+
export interface TraceGraphEdgeRow extends Record<string, unknown> {
|
|
4
|
+
id: number;
|
|
5
|
+
edge_type: string;
|
|
6
|
+
from_id: string;
|
|
7
|
+
to_kind: string;
|
|
8
|
+
to_id: string;
|
|
9
|
+
confidence: number;
|
|
10
|
+
evidence_json: string;
|
|
11
|
+
unresolved_reason?: string;
|
|
12
|
+
status?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function graphForCalls(
|
|
16
|
+
db: Db,
|
|
17
|
+
callIds: number[],
|
|
18
|
+
): Map<number, TraceGraphEdgeRow[]> {
|
|
19
|
+
const map = new Map<number, TraceGraphEdgeRow[]>();
|
|
20
|
+
if (callIds.length === 0) return map;
|
|
21
|
+
const rows = db.prepare(`SELECT * FROM graph_edges
|
|
22
|
+
WHERE from_kind='call'
|
|
23
|
+
AND from_id IN (${callIds.map(() => '?').join(',')})
|
|
24
|
+
ORDER BY id`).all(
|
|
25
|
+
...callIds.map(String),
|
|
26
|
+
) as TraceGraphEdgeRow[];
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
const id = Number(row.from_id);
|
|
29
|
+
map.set(id, [...(map.get(id) ?? []), row]);
|
|
30
|
+
}
|
|
31
|
+
return map;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function symbolNode(
|
|
35
|
+
db: Db,
|
|
36
|
+
symbolId: number,
|
|
37
|
+
): Record<string, unknown> | undefined {
|
|
38
|
+
const row = db.prepare(`SELECT s.id symbolId,s.name symbolName,
|
|
39
|
+
s.qualified_name qualifiedName,s.source_file sourceFile,
|
|
40
|
+
s.start_line startLine,s.end_line endLine,r.name repoName,r.id repoId
|
|
41
|
+
FROM symbols s JOIN repositories r ON r.id=s.repo_id
|
|
42
|
+
WHERE s.id=?`).get(symbolId) as Record<string, unknown> | undefined;
|
|
43
|
+
if (!row) return undefined;
|
|
44
|
+
const sourceFile = String(row.sourceFile ?? '');
|
|
45
|
+
const fileName = sourceFile.split('/').at(-1) ?? sourceFile;
|
|
46
|
+
return {
|
|
47
|
+
id: `symbol:${symbolId}`,
|
|
48
|
+
kind: 'symbol',
|
|
49
|
+
label: `${fileName}:${String(row.qualifiedName ?? row.symbolName)}`,
|
|
50
|
+
...row,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function operationNode(
|
|
55
|
+
db: Db,
|
|
56
|
+
operationId: string,
|
|
57
|
+
): Record<string, unknown> | undefined {
|
|
58
|
+
const row = db.prepare(`SELECT o.id operationId,o.operation_name operationName,
|
|
59
|
+
o.operation_type operationType,o.operation_path operationPath,
|
|
60
|
+
o.source_file sourceFile,o.source_line sourceLine,s.id serviceId,
|
|
61
|
+
s.service_name serviceName,s.qualified_name qualifiedName,
|
|
62
|
+
s.service_path servicePath,r.id repoId,r.name repoName
|
|
63
|
+
FROM cds_operations o JOIN cds_services s ON s.id=o.service_id
|
|
64
|
+
JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(
|
|
65
|
+
operationId,
|
|
66
|
+
) as Record<string, unknown> | undefined;
|
|
67
|
+
if (!row) return undefined;
|
|
68
|
+
return {
|
|
69
|
+
id: `operation:${operationId}`,
|
|
70
|
+
kind: 'operation',
|
|
71
|
+
label: `${String(row.repoName)}:${String(row.servicePath)}${String(row.operationPath)}`,
|
|
72
|
+
...row,
|
|
73
|
+
};
|
|
74
|
+
}
|