@saptools/service-flow 0.1.67 → 0.1.69
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 +20 -0
- package/README.md +27 -9
- package/TECHNICAL-NOTE.md +36 -0
- package/dist/chunk-3N3B5KHV.js +19596 -0
- package/dist/chunk-3N3B5KHV.js.map +1 -0
- package/dist/cli.js +2645 -521
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +67 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/001-index-summary.ts +22 -0
- package/src/cli/003-doctor-package-resolution.ts +68 -0
- package/src/cli/doctor.ts +25 -14
- package/src/cli.ts +151 -87
- package/src/db/000-call-fact-repository.ts +499 -340
- package/src/db/001-fact-lifecycle.ts +60 -30
- package/src/db/002-fact-json-inventory.ts +169 -0
- package/src/db/003-current-fact-semantics.ts +699 -0
- package/src/db/004-package-target-invalidation.ts +183 -0
- package/src/db/005-schema-structure.ts +201 -0
- package/src/db/006-relative-symbol-resolution.ts +464 -0
- package/src/db/007-package-fact-semantics.ts +573 -0
- package/src/db/008-relative-fact-semantics.ts +210 -0
- package/src/db/009-binding-fact-semantics.ts +352 -0
- package/src/db/010-package-symbol-surface-semantics.ts +320 -0
- package/src/db/011-symbol-call-semantics.ts +144 -0
- package/src/db/012-binding-reference-proof.ts +268 -0
- package/src/db/013-index-publication-failure.ts +91 -0
- package/src/db/014-binding-helper-provenance.ts +17 -0
- package/src/db/migrations.ts +16 -3
- package/src/db/repositories.ts +130 -6
- package/src/db/schema.ts +4 -2
- package/src/index.ts +12 -0
- package/src/indexer/cds-extension-resolver.ts +27 -3
- package/src/indexer/repository-indexer.ts +135 -13
- package/src/indexer/workspace-indexer.ts +237 -34
- package/src/linker/003-package-import-symbol-resolver.ts +363 -131
- package/src/linker/004-event-subscription-handler-linker.ts +34 -11
- package/src/linker/005-odata-path-structure.ts +371 -0
- package/src/linker/006-event-template-link.ts +72 -0
- package/src/linker/007-call-edge-insertion.ts +568 -0
- package/src/linker/cross-repo-linker.ts +4 -166
- package/src/linker/odata-path-normalizer.ts +273 -180
- package/src/linker/service-resolver.ts +197 -77
- package/src/parsers/000-direct-query-execution.ts +11 -0
- package/src/parsers/002-symbol-import-bindings.ts +516 -0
- package/src/parsers/003-package-public-surface.ts +661 -0
- package/src/parsers/004-fact-identity.ts +108 -0
- package/src/parsers/005-event-subscription-facts.ts +281 -0
- package/src/parsers/006-binding-identity.ts +348 -0
- package/src/parsers/007-source-fact-reconciliation.ts +105 -0
- package/src/parsers/008-package-surface-publication.ts +82 -0
- package/src/parsers/009-symbol-call-facts.ts +528 -0
- package/src/parsers/010-package-public-surface-analysis.ts +352 -0
- package/src/parsers/011-binding-lexical-scope.ts +583 -0
- package/src/parsers/012-package-fact-contract.ts +306 -0
- package/src/parsers/013-executable-body-eligibility.ts +35 -0
- package/src/parsers/014-service-binding-helper-flow.ts +306 -0
- package/src/parsers/015-service-binding-collector.ts +693 -0
- package/src/parsers/016-local-symbol-reference.ts +261 -0
- package/src/parsers/017-symbol-derived-contexts.ts +268 -0
- package/src/parsers/018-package-commonjs-syntax.ts +142 -0
- package/src/parsers/019-binding-assignment-targets.ts +76 -0
- package/src/parsers/020-stable-local-value.ts +217 -0
- package/src/parsers/021-binding-visibility.ts +168 -0
- package/src/parsers/022-outbound-expression-analysis.ts +700 -0
- package/src/parsers/023-outbound-call-classifier.ts +692 -0
- package/src/parsers/operation-path-analysis.ts +6 -1
- package/src/parsers/outbound-call-parser.ts +162 -512
- package/src/parsers/package-json-parser.ts +45 -3
- package/src/parsers/service-binding-parser-helpers.ts +86 -15
- package/src/parsers/service-binding-parser.ts +147 -597
- package/src/parsers/symbol-parser.ts +513 -352
- package/src/trace/002-trace-diagnostics.ts +36 -1
- package/src/trace/007-implementation-start-diagnostic.ts +1 -0
- package/src/trace/011-event-subscriber-traversal.ts +100 -8
- package/src/trace/013-trace-root-scopes.ts +2 -3
- package/src/trace/014-compact-contract.ts +6 -0
- package/src/trace/015-trace-edge-recorder.ts +61 -4
- package/src/trace/016-compact-projector.ts +15 -17
- package/src/trace/019-trace-edge-semantics.ts +6 -10
- package/src/trace/020-compact-field-projection.ts +122 -38
- package/src/trace/021-compact-decision-normalization.ts +171 -0
- package/src/trace/022-trace-fact-preflight.ts +21 -0
- package/src/trace/023-nested-event-scopes.ts +23 -0
- package/src/trace/024-compact-observation-decision.ts +81 -0
- package/src/trace/025-trace-implementation-scope.ts +123 -0
- package/src/trace/026-trace-start-scope.ts +336 -0
- package/src/trace/027-trace-scope-execution.ts +566 -0
- package/src/trace/028-trace-operation-execution.ts +336 -0
- package/src/trace/029-trace-start-implementation.ts +172 -0
- package/src/trace/030-event-runtime-resolution.ts +151 -0
- package/src/trace/031-local-call-expansion.ts +37 -0
- package/src/trace/implementation-hints.ts +9 -6
- package/src/trace/selectors.ts +1 -0
- package/src/trace/trace-engine.ts +122 -624
- package/src/types.ts +57 -0
- package/src/utils/001-placeholders.ts +188 -10
- package/src/version.ts +1 -1
- package/dist/chunk-ZQABU7MR.js +0 -12151
- package/dist/chunk-ZQABU7MR.js.map +0 -1
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import type { Db } from '../db/connection.js';
|
|
2
|
+
import {
|
|
3
|
+
ambiguousPathCandidates,
|
|
4
|
+
linkedCallEvidence,
|
|
5
|
+
objectJson,
|
|
6
|
+
objectValue,
|
|
7
|
+
} from './002-call-evidence.js';
|
|
8
|
+
import { applyVariables } from './dynamic-edge-resolver.js';
|
|
9
|
+
import { insertEventCallEdge } from './006-event-template-link.js';
|
|
10
|
+
import { externalHttpTarget } from './external-http-target.js';
|
|
11
|
+
import {
|
|
12
|
+
classifyODataPathIntent,
|
|
13
|
+
normalizeODataOperationInvocationPath,
|
|
14
|
+
type ODataPathIntent,
|
|
15
|
+
} from './odata-path-normalizer.js';
|
|
16
|
+
import { buildRemoteQueryTarget } from './remote-query-target.js';
|
|
17
|
+
import {
|
|
18
|
+
resolveOperation,
|
|
19
|
+
type OperationResolution,
|
|
20
|
+
} from './service-resolver.js';
|
|
21
|
+
|
|
22
|
+
export interface CallEdgeInsertionResult {
|
|
23
|
+
status: string;
|
|
24
|
+
callType: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CallPathContext {
|
|
28
|
+
intent: ODataPathIntent;
|
|
29
|
+
entityQueryIntent: boolean;
|
|
30
|
+
normalized: ReturnType<typeof normalizeODataOperationInvocationPath>;
|
|
31
|
+
operation?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CallEdgeContext extends CallPathContext {
|
|
35
|
+
db: Db;
|
|
36
|
+
workspaceId: number;
|
|
37
|
+
call: Record<string, unknown>;
|
|
38
|
+
variables: Record<string, string>;
|
|
39
|
+
generation: number;
|
|
40
|
+
callType: string;
|
|
41
|
+
servicePath?: string;
|
|
42
|
+
destination?: string;
|
|
43
|
+
bindingDynamic: boolean;
|
|
44
|
+
remoteEntity: boolean;
|
|
45
|
+
indexedOperationCandidateCount: number;
|
|
46
|
+
resolution: OperationResolution;
|
|
47
|
+
evidence: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function callPathContext(
|
|
51
|
+
call: Record<string, unknown>,
|
|
52
|
+
variables: Record<string, string>,
|
|
53
|
+
): CallPathContext {
|
|
54
|
+
const raw = applyVariables(String(call.operation_path_expr ?? ''), variables);
|
|
55
|
+
const intent = classifyODataPathIntent(
|
|
56
|
+
raw, call.method as string | undefined,
|
|
57
|
+
);
|
|
58
|
+
const entityQueryIntent = [
|
|
59
|
+
'entity_query', 'entity_key_read', 'entity_navigation_query',
|
|
60
|
+
].includes(intent.kind);
|
|
61
|
+
const resolutionPath = call.call_type === 'remote_query'
|
|
62
|
+
&& entityQueryIntent ? intent.pathWithoutQuery : raw;
|
|
63
|
+
const normalized = normalizeODataOperationInvocationPath(resolutionPath);
|
|
64
|
+
return {
|
|
65
|
+
intent,
|
|
66
|
+
entityQueryIntent,
|
|
67
|
+
normalized,
|
|
68
|
+
operation: normalized?.normalizedOperationPath ?? resolutionPath,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolvedServicePath(
|
|
73
|
+
call: Record<string, unknown>,
|
|
74
|
+
variables: Record<string, string>,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
const expression = (call.servicePathExpr as string | undefined)
|
|
77
|
+
?? (call.requireServicePath as string | undefined);
|
|
78
|
+
return applyVariables(expression, variables);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolvedDestination(
|
|
82
|
+
call: Record<string, unknown>,
|
|
83
|
+
variables: Record<string, string>,
|
|
84
|
+
): string | undefined {
|
|
85
|
+
const expression = (call.destinationExpr as string | undefined)
|
|
86
|
+
?? (call.requireDestination as string | undefined);
|
|
87
|
+
return expression ? applyVariables(expression, variables) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function credibleOperationSignal(
|
|
91
|
+
path: CallPathContext,
|
|
92
|
+
indexedCandidateCount: number,
|
|
93
|
+
): boolean {
|
|
94
|
+
if (path.normalized?.wasInvocation) return true;
|
|
95
|
+
return Boolean(path.intent.topLevelOperationNameCandidate)
|
|
96
|
+
&& indexedCandidateCount > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function strongEntitySignal(intent: ODataPathIntent): boolean {
|
|
100
|
+
if (['entity_media', 'entity_delete', 'entity_key_read',
|
|
101
|
+
'entity_navigation_query'].includes(intent.kind)) return true;
|
|
102
|
+
return intent.kind === 'entity_mutation'
|
|
103
|
+
&& (intent.hasEntityKeyPredicate || intent.hasNavigationSuffix);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function operationCallEligible(
|
|
107
|
+
callType: string,
|
|
108
|
+
remoteEntity: boolean,
|
|
109
|
+
path: CallPathContext,
|
|
110
|
+
indexedCandidateCount: number,
|
|
111
|
+
): boolean {
|
|
112
|
+
if (remoteEntity)
|
|
113
|
+
return Boolean(path.operation)
|
|
114
|
+
&& credibleOperationSignal(path, indexedCandidateCount)
|
|
115
|
+
&& (!strongEntitySignal(path.intent) || indexedCandidateCount > 0);
|
|
116
|
+
if (callType === 'remote_action' || callType === 'local_service_call')
|
|
117
|
+
return true;
|
|
118
|
+
return callType === 'remote_query' && Boolean(path.operation);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function callResolution(
|
|
122
|
+
db: Db,
|
|
123
|
+
workspaceId: number,
|
|
124
|
+
call: Record<string, unknown>,
|
|
125
|
+
variables: Record<string, string>,
|
|
126
|
+
path: CallPathContext,
|
|
127
|
+
servicePath: string | undefined,
|
|
128
|
+
destination: string | undefined,
|
|
129
|
+
bindingDynamic: boolean,
|
|
130
|
+
remoteEntity: boolean,
|
|
131
|
+
): OperationResolution {
|
|
132
|
+
const callType = String(call.call_type);
|
|
133
|
+
const count = operationCandidateCount(
|
|
134
|
+
db, workspaceId, path.operation, path.intent.topLevelOperationName,
|
|
135
|
+
);
|
|
136
|
+
if (!operationCallEligible(callType, remoteEntity, path, count))
|
|
137
|
+
return { status: 'unresolved', candidates: [], reasons: [] };
|
|
138
|
+
const aliasExpression = (call.aliasExpr as string | undefined)
|
|
139
|
+
?? (call.alias as string | undefined);
|
|
140
|
+
return resolveOperation(db, {
|
|
141
|
+
servicePath,
|
|
142
|
+
operationPath: path.operation,
|
|
143
|
+
serviceName: call.local_service_name as string | undefined,
|
|
144
|
+
repoId: callType === 'local_service_call'
|
|
145
|
+
? Number(call.repo_id) : undefined,
|
|
146
|
+
alias: applyVariables(aliasExpression, variables),
|
|
147
|
+
destination,
|
|
148
|
+
isDynamic: bindingDynamic,
|
|
149
|
+
hasExplicitOverride: Object.keys(variables).length > 0
|
|
150
|
+
|| callType === 'local_service_call',
|
|
151
|
+
}, workspaceId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function callEvidence(
|
|
155
|
+
call: Record<string, unknown>,
|
|
156
|
+
path: CallPathContext,
|
|
157
|
+
resolution: OperationResolution,
|
|
158
|
+
servicePath: string | undefined,
|
|
159
|
+
destination: string | undefined,
|
|
160
|
+
candidateCount: number,
|
|
161
|
+
): Record<string, unknown> {
|
|
162
|
+
const callType = String(call.call_type);
|
|
163
|
+
return {
|
|
164
|
+
...linkedCallEvidence(
|
|
165
|
+
call, resolution, servicePath, path.operation, destination,
|
|
166
|
+
path.normalized, path.intent,
|
|
167
|
+
),
|
|
168
|
+
indexedOperationCandidateCount: candidateCount,
|
|
169
|
+
parserCallType: callType,
|
|
170
|
+
entityOperationPrecedence: operationPrecedence(
|
|
171
|
+
callType, path.intent, candidateCount, Boolean(resolution.target),
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createCallEdgeContext(
|
|
177
|
+
db: Db,
|
|
178
|
+
workspaceId: number,
|
|
179
|
+
call: Record<string, unknown>,
|
|
180
|
+
variables: Record<string, string>,
|
|
181
|
+
generation: number,
|
|
182
|
+
): CallEdgeContext {
|
|
183
|
+
const callType = String(call.call_type);
|
|
184
|
+
const path = callPathContext(call, variables);
|
|
185
|
+
const servicePath = resolvedServicePath(call, variables);
|
|
186
|
+
const destination = resolvedDestination(call, variables);
|
|
187
|
+
const bindingDynamic = Boolean(Number(call.isDynamic ?? 0));
|
|
188
|
+
const remoteEntity = callType.startsWith('remote_entity_');
|
|
189
|
+
const indexedOperationCandidateCount = operationCandidateCount(
|
|
190
|
+
db, workspaceId, path.operation, path.intent.topLevelOperationName,
|
|
191
|
+
);
|
|
192
|
+
const resolution = callResolution(
|
|
193
|
+
db, workspaceId, call, variables, path, servicePath, destination,
|
|
194
|
+
bindingDynamic, remoteEntity,
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
db, workspaceId, call, variables, generation, callType, ...path,
|
|
198
|
+
servicePath, destination, bindingDynamic, remoteEntity,
|
|
199
|
+
indexedOperationCandidateCount, resolution,
|
|
200
|
+
evidence: callEvidence(
|
|
201
|
+
call, path, resolution, servicePath, destination,
|
|
202
|
+
indexedOperationCandidateCount,
|
|
203
|
+
),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function insertAmbiguousPathEdge(
|
|
208
|
+
context: CallEdgeContext,
|
|
209
|
+
): CallEdgeInsertionResult | undefined {
|
|
210
|
+
const analysis = objectValue(
|
|
211
|
+
objectJson(context.call.evidence_json)?.pathAnalysis,
|
|
212
|
+
);
|
|
213
|
+
if (context.callType !== 'remote_action'
|
|
214
|
+
|| analysis?.status !== 'ambiguous') return undefined;
|
|
215
|
+
const paths = ambiguousPathCandidates(analysis);
|
|
216
|
+
const evidence = {
|
|
217
|
+
...context.evidence,
|
|
218
|
+
ambiguousOperationPathCandidateCount: paths.totalCount,
|
|
219
|
+
shownAmbiguousOperationPathCandidateCount: paths.shownCount,
|
|
220
|
+
omittedAmbiguousOperationPathCandidateCount: paths.omittedCount,
|
|
221
|
+
};
|
|
222
|
+
context.db.prepare(`INSERT INTO graph_edges(
|
|
223
|
+
workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
|
|
224
|
+
confidence,evidence_json,is_dynamic,unresolved_reason,generation
|
|
225
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(
|
|
226
|
+
context.workspaceId, 'UNRESOLVED_EDGE', 'ambiguous', 'call',
|
|
227
|
+
String(context.call.id), 'operation_candidates', paths.items.join(','),
|
|
228
|
+
Number(context.call.confidence ?? 0.5), JSON.stringify(evidence), 0,
|
|
229
|
+
'Ambiguous operation path candidates require explicit disambiguation',
|
|
230
|
+
context.generation,
|
|
231
|
+
);
|
|
232
|
+
return { status: 'ambiguous', callType: context.callType };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolvedOperationEdge(
|
|
236
|
+
context: CallEdgeContext,
|
|
237
|
+
evidence = context.evidence,
|
|
238
|
+
): CallEdgeInsertionResult {
|
|
239
|
+
const target = context.resolution.target;
|
|
240
|
+
if (!target) throw new Error('resolved_operation_target_missing');
|
|
241
|
+
const edgeType = context.callType === 'local_service_call'
|
|
242
|
+
? 'LOCAL_CALL_RESOLVES_TO_OPERATION'
|
|
243
|
+
: 'REMOTE_CALL_RESOLVES_TO_OPERATION';
|
|
244
|
+
context.db.prepare(`INSERT INTO graph_edges(
|
|
245
|
+
workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
|
|
246
|
+
confidence,evidence_json,is_dynamic,generation
|
|
247
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(
|
|
248
|
+
context.workspaceId, edgeType, 'resolved', 'call',
|
|
249
|
+
String(context.call.id), 'operation', String(target.operationId),
|
|
250
|
+
target.score, JSON.stringify(evidence), 0, context.generation,
|
|
251
|
+
);
|
|
252
|
+
return { status: 'resolved', callType: context.callType };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolutionStatus(resolution: OperationResolution): string {
|
|
256
|
+
if (resolution.status === 'dynamic') return 'dynamic';
|
|
257
|
+
return resolution.status === 'ambiguous' ? 'ambiguous' : 'unresolved';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function remoteEntityOperationEdge(
|
|
261
|
+
context: CallEdgeContext,
|
|
262
|
+
): CallEdgeInsertionResult {
|
|
263
|
+
if (context.resolution.target)
|
|
264
|
+
return resolvedOperationEdge(context, {
|
|
265
|
+
...context.evidence,
|
|
266
|
+
operationEntityPrecedence: 'indexed_operation_over_parser_entity',
|
|
267
|
+
});
|
|
268
|
+
const status = resolutionStatus(context.resolution);
|
|
269
|
+
const precedence = context.resolution.candidates.length > 0
|
|
270
|
+
? 'parser_entity_with_indexed_operation_candidates'
|
|
271
|
+
: 'parser_entity_operation_candidate_without_indexed_match';
|
|
272
|
+
context.db.prepare(`INSERT INTO graph_edges(
|
|
273
|
+
workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
|
|
274
|
+
confidence,evidence_json,is_dynamic,unresolved_reason,generation
|
|
275
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(
|
|
276
|
+
context.workspaceId,
|
|
277
|
+
status === 'dynamic' ? 'DYNAMIC_EDGE_CANDIDATE' : 'UNRESOLVED_EDGE',
|
|
278
|
+
status, 'call', String(context.call.id), 'operation_candidate',
|
|
279
|
+
context.operation
|
|
280
|
+
? `Remote action: ${context.operation}` : 'Remote action: unknown path',
|
|
281
|
+
Number(context.call.confidence ?? 0.2),
|
|
282
|
+
JSON.stringify({
|
|
283
|
+
...context.evidence, operationEntityPrecedence: precedence,
|
|
284
|
+
}),
|
|
285
|
+
status === 'dynamic' ? 1 : 0,
|
|
286
|
+
unresolvedOperationReason(context.resolution), context.generation,
|
|
287
|
+
);
|
|
288
|
+
return { status, callType: context.callType };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function remoteQueryTarget(
|
|
292
|
+
context: CallEdgeContext,
|
|
293
|
+
queryEntity: unknown,
|
|
294
|
+
): ReturnType<typeof buildRemoteQueryTarget> {
|
|
295
|
+
return buildRemoteQueryTarget({
|
|
296
|
+
queryEntity,
|
|
297
|
+
servicePath: context.servicePath,
|
|
298
|
+
serviceAlias: context.call.alias,
|
|
299
|
+
serviceAliasExpr: context.call.aliasExpr,
|
|
300
|
+
destination: context.destination,
|
|
301
|
+
isDynamic: context.bindingDynamic,
|
|
302
|
+
parserWarning: context.evidence.parserWarning,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function insertRemoteEntityEdge(
|
|
307
|
+
context: CallEdgeContext,
|
|
308
|
+
): CallEdgeInsertionResult | undefined {
|
|
309
|
+
if (!context.remoteEntity) return undefined;
|
|
310
|
+
const resolution = context.resolution;
|
|
311
|
+
if (resolution.target || resolution.candidates.length > 0
|
|
312
|
+
|| resolution.status === 'dynamic')
|
|
313
|
+
return remoteEntityOperationEdge(context);
|
|
314
|
+
const target = remoteQueryTarget(
|
|
315
|
+
context, context.intent.entitySegment ?? context.call.query_entity,
|
|
316
|
+
);
|
|
317
|
+
context.db.prepare(`INSERT INTO graph_edges(
|
|
318
|
+
workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
|
|
319
|
+
confidence,evidence_json,is_dynamic,generation
|
|
320
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(
|
|
321
|
+
context.workspaceId, 'HANDLER_ACCESSES_REMOTE_ENTITY', 'terminal',
|
|
322
|
+
'call', String(context.call.id), target.toKind, target.toId,
|
|
323
|
+
Number(context.call.confidence ?? 0.5),
|
|
324
|
+
JSON.stringify({
|
|
325
|
+
...context.evidence, ...target.evidence,
|
|
326
|
+
remoteEntityAccess: context.callType,
|
|
327
|
+
}),
|
|
328
|
+
0, context.generation,
|
|
329
|
+
);
|
|
330
|
+
return { status: 'terminal', callType: context.callType };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function insertRemoteQueryEdge(
|
|
334
|
+
context: CallEdgeContext,
|
|
335
|
+
): CallEdgeInsertionResult | undefined {
|
|
336
|
+
if (context.callType !== 'remote_query'
|
|
337
|
+
|| !context.entityQueryIntent && context.operation
|
|
338
|
+
|| context.resolution.target) return undefined;
|
|
339
|
+
const target = remoteQueryTarget(context, context.call.query_entity);
|
|
340
|
+
context.db.prepare(`INSERT INTO graph_edges(
|
|
341
|
+
workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
|
|
342
|
+
confidence,evidence_json,is_dynamic,generation
|
|
343
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(
|
|
344
|
+
context.workspaceId, 'HANDLER_RUNS_REMOTE_QUERY', 'terminal', 'call',
|
|
345
|
+
String(context.call.id), target.toKind, target.toId,
|
|
346
|
+
Number(context.call.confidence ?? 0.5),
|
|
347
|
+
JSON.stringify({ ...context.evidence, ...target.evidence }),
|
|
348
|
+
0, context.generation,
|
|
349
|
+
);
|
|
350
|
+
return { status: 'terminal', callType: context.callType };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function insertTransportEdge(
|
|
354
|
+
context: CallEdgeContext,
|
|
355
|
+
): CallEdgeInsertionResult | undefined {
|
|
356
|
+
if (context.callType !== 'local_service_call'
|
|
357
|
+
|| context.call.unresolved_reason !== 'transport_client_method'
|
|
358
|
+
|| context.resolution.target
|
|
359
|
+
|| context.resolution.candidates.length > 0) return undefined;
|
|
360
|
+
context.db.prepare(`INSERT INTO graph_edges(
|
|
361
|
+
workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
|
|
362
|
+
confidence,evidence_json,is_dynamic,generation
|
|
363
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(
|
|
364
|
+
context.workspaceId, 'HANDLER_CALLS_TRANSPORT_METHOD', 'terminal',
|
|
365
|
+
'call', String(context.call.id), 'transport_method',
|
|
366
|
+
String(context.operation || 'transport_client_method'),
|
|
367
|
+
Number(context.call.confidence ?? 0.5),
|
|
368
|
+
JSON.stringify({
|
|
369
|
+
...context.evidence, classification: 'transport_client_method',
|
|
370
|
+
}),
|
|
371
|
+
0, context.generation,
|
|
372
|
+
);
|
|
373
|
+
return { status: 'terminal', callType: context.callType };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function insertResolvedEdge(
|
|
377
|
+
context: CallEdgeContext,
|
|
378
|
+
): CallEdgeInsertionResult | undefined {
|
|
379
|
+
return context.resolution.target
|
|
380
|
+
? resolvedOperationEdge(context) : undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function insertEventEdge(
|
|
384
|
+
context: CallEdgeContext,
|
|
385
|
+
): CallEdgeInsertionResult | undefined {
|
|
386
|
+
if (context.callType !== 'async_emit'
|
|
387
|
+
&& context.callType !== 'async_subscribe') return undefined;
|
|
388
|
+
return insertEventCallEdge(
|
|
389
|
+
context.db, context.workspaceId, context.generation, context.call,
|
|
390
|
+
context.variables, context.evidence,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function fallbackEdgeType(context: CallEdgeContext): string {
|
|
395
|
+
const fixed: Readonly<Record<string, string>> = {
|
|
396
|
+
local_db_query: 'HANDLER_RUNS_DB_QUERY',
|
|
397
|
+
external_http: 'HANDLER_CALLS_EXTERNAL_HTTP',
|
|
398
|
+
async_emit: 'HANDLER_EMITS_EVENT',
|
|
399
|
+
async_subscribe: 'EVENT_CONSUMED_BY_HANDLER',
|
|
400
|
+
};
|
|
401
|
+
return fixed[context.callType]
|
|
402
|
+
?? (context.resolution.status === 'dynamic'
|
|
403
|
+
? 'DYNAMIC_EDGE_CANDIDATE' : 'UNRESOLVED_EDGE');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function fallbackStatus(
|
|
407
|
+
edgeType: string,
|
|
408
|
+
resolution: OperationResolution,
|
|
409
|
+
): string {
|
|
410
|
+
if (edgeType === 'DYNAMIC_EDGE_CANDIDATE') return 'dynamic';
|
|
411
|
+
if (resolution.status === 'ambiguous') return 'ambiguous';
|
|
412
|
+
return edgeType === 'UNRESOLVED_EDGE' ? 'unresolved' : 'terminal';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function fallbackTargetKind(
|
|
416
|
+
context: CallEdgeContext,
|
|
417
|
+
externalKind: string | undefined,
|
|
418
|
+
): string {
|
|
419
|
+
if (context.callType === 'local_db_query') return 'db_entity';
|
|
420
|
+
if (context.callType.startsWith('async_')) return 'event';
|
|
421
|
+
if (context.callType === 'external_http')
|
|
422
|
+
return externalKind ?? 'external_endpoint';
|
|
423
|
+
return 'operation_candidate';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function remoteActionTarget(context: CallEdgeContext): string {
|
|
427
|
+
if (context.operation) return `Remote action: ${context.operation}`;
|
|
428
|
+
return context.call.unresolved_reason === 'dynamic_operation_path_identifier'
|
|
429
|
+
? 'Remote action: dynamic path' : 'Remote action: unknown path';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function fallbackTargetId(
|
|
433
|
+
context: CallEdgeContext,
|
|
434
|
+
externalId: string | undefined,
|
|
435
|
+
): string {
|
|
436
|
+
if (context.callType === 'local_db_query')
|
|
437
|
+
return String(context.call.query_entity ?? 'unknown');
|
|
438
|
+
if (context.callType === 'remote_action') return remoteActionTarget(context);
|
|
439
|
+
if (context.callType === 'external_http')
|
|
440
|
+
return String(externalId ?? 'unknown');
|
|
441
|
+
return String(
|
|
442
|
+
context.call.event_name_expr ?? context.operation ?? 'unknown',
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function insertFallbackEdge(context: CallEdgeContext): CallEdgeInsertionResult {
|
|
447
|
+
const edgeType = fallbackEdgeType(context);
|
|
448
|
+
const status = fallbackStatus(edgeType, context.resolution);
|
|
449
|
+
const unresolvedReason = status === 'terminal' ? null : String(
|
|
450
|
+
context.call.unresolved_reason
|
|
451
|
+
?? unresolvedOperationReason(context.resolution),
|
|
452
|
+
);
|
|
453
|
+
const external = context.callType === 'external_http'
|
|
454
|
+
? externalHttpTarget(context.call) : undefined;
|
|
455
|
+
const targetKind = fallbackTargetKind(context, external?.toKind);
|
|
456
|
+
const targetId = fallbackTargetId(context, external?.toId);
|
|
457
|
+
const dynamic = edgeType === 'DYNAMIC_EDGE_CANDIDATE'
|
|
458
|
+
&& context.resolution.status === 'dynamic';
|
|
459
|
+
const evidence = external
|
|
460
|
+
? { ...context.evidence, externalTarget: external } : context.evidence;
|
|
461
|
+
context.db.prepare(`INSERT INTO graph_edges(
|
|
462
|
+
workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,
|
|
463
|
+
confidence,evidence_json,is_dynamic,unresolved_reason,generation
|
|
464
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(
|
|
465
|
+
context.workspaceId, edgeType, status, 'call', String(context.call.id),
|
|
466
|
+
targetKind, targetId, Number(context.call.confidence ?? 0.2),
|
|
467
|
+
JSON.stringify(evidence), dynamic ? 1 : 0, unresolvedReason,
|
|
468
|
+
context.generation,
|
|
469
|
+
);
|
|
470
|
+
return { status, callType: context.callType };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function insertCallEdge(
|
|
474
|
+
db: Db,
|
|
475
|
+
workspaceId: number,
|
|
476
|
+
call: Record<string, unknown>,
|
|
477
|
+
variables: Record<string, string>,
|
|
478
|
+
generation: number,
|
|
479
|
+
): CallEdgeInsertionResult {
|
|
480
|
+
const context = createCallEdgeContext(
|
|
481
|
+
db, workspaceId, call, variables, generation,
|
|
482
|
+
);
|
|
483
|
+
const ambiguous = insertAmbiguousPathEdge(context);
|
|
484
|
+
if (ambiguous) return ambiguous;
|
|
485
|
+
const remoteEntity = insertRemoteEntityEdge(context);
|
|
486
|
+
if (remoteEntity) return remoteEntity;
|
|
487
|
+
const remoteQuery = insertRemoteQueryEdge(context);
|
|
488
|
+
if (remoteQuery) return remoteQuery;
|
|
489
|
+
const transport = insertTransportEdge(context);
|
|
490
|
+
if (transport) return transport;
|
|
491
|
+
const resolved = insertResolvedEdge(context);
|
|
492
|
+
if (resolved) return resolved;
|
|
493
|
+
const event = insertEventEdge(context);
|
|
494
|
+
return event ?? insertFallbackEdge(context);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function operationCandidateCount(
|
|
498
|
+
db: Db,
|
|
499
|
+
workspaceId: number,
|
|
500
|
+
operationPath: string | undefined,
|
|
501
|
+
operationName: string | undefined,
|
|
502
|
+
): number {
|
|
503
|
+
if (!operationPath && !operationName) return 0;
|
|
504
|
+
const normalizedName = operationName
|
|
505
|
+
?? operationPath?.replace(/^\//, '').split('.').at(-1);
|
|
506
|
+
const row = db.prepare(`SELECT COUNT(*) count FROM cds_operations o
|
|
507
|
+
JOIN cds_services s ON s.id=o.service_id
|
|
508
|
+
JOIN repositories r ON r.id=s.repo_id
|
|
509
|
+
WHERE r.workspace_id=? AND (
|
|
510
|
+
o.operation_path=? OR o.operation_path=? OR o.operation_name=?
|
|
511
|
+
)`).get(
|
|
512
|
+
workspaceId, operationPath,
|
|
513
|
+
normalizedName ? `/${normalizedName}` : operationPath,
|
|
514
|
+
normalizedName,
|
|
515
|
+
) as { count?: number } | undefined;
|
|
516
|
+
return Number(row?.count ?? 0);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function operationPrecedence(
|
|
520
|
+
callType: string,
|
|
521
|
+
intent: ODataPathIntent,
|
|
522
|
+
indexedOperationCandidateCount: number,
|
|
523
|
+
resolvedOperation: boolean,
|
|
524
|
+
): Record<string, unknown> {
|
|
525
|
+
if (resolvedOperation) return {
|
|
526
|
+
decision: 'operation',
|
|
527
|
+
reason: 'indexed_operation_with_strong_service_context',
|
|
528
|
+
indexedOperationCandidateCount,
|
|
529
|
+
};
|
|
530
|
+
if (callType === 'remote_action'
|
|
531
|
+
&& intent.kind === 'operation_invocation') return {
|
|
532
|
+
decision: 'operation_candidate',
|
|
533
|
+
rejectionReason: indexedOperationCandidateCount > 0
|
|
534
|
+
? 'indexed_candidates_lack_unique_strong_service_context'
|
|
535
|
+
: 'no_indexed_operation_candidate',
|
|
536
|
+
indexedOperationCandidateCount,
|
|
537
|
+
};
|
|
538
|
+
if (intent.kind.startsWith('entity_')) return {
|
|
539
|
+
decision: 'entity',
|
|
540
|
+
rejectionReason: indexedOperationCandidateCount > 0
|
|
541
|
+
? 'entity_shape_has_precedence_without_resolved_operation_context'
|
|
542
|
+
: 'entity_shape_has_no_indexed_operation_evidence',
|
|
543
|
+
indexedOperationCandidateCount,
|
|
544
|
+
};
|
|
545
|
+
return {
|
|
546
|
+
decision: 'unresolved',
|
|
547
|
+
rejectionReason: 'path_has_no_safe_entity_or_operation_precedence',
|
|
548
|
+
indexedOperationCandidateCount,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function unresolvedOperationReason(resolution: OperationResolution): string {
|
|
553
|
+
if (resolution.status === 'dynamic')
|
|
554
|
+
return `Dynamic target requires runtime variable overrides: ${
|
|
555
|
+
(resolution.reasons.length
|
|
556
|
+
? resolution.reasons : ['missing runtime variables']).join(', ')}`;
|
|
557
|
+
if (resolution.candidates.length === 0)
|
|
558
|
+
return 'No indexed target operation matched';
|
|
559
|
+
if (resolution.reasons.includes(
|
|
560
|
+
'operation_path_only_has_no_strong_target_signal',
|
|
561
|
+
)) return 'Operation candidates found but no strong service signal is available';
|
|
562
|
+
if (resolution.reasons.includes(
|
|
563
|
+
'candidate_score_below_resolution_threshold',
|
|
564
|
+
)) return 'Operation candidates found but resolution score is below threshold';
|
|
565
|
+
if (resolution.status === 'ambiguous')
|
|
566
|
+
return 'Ambiguous operation candidates require a strong service signal';
|
|
567
|
+
return 'Operation candidates found but resolution could not select a target';
|
|
568
|
+
}
|