@saptools/service-flow 0.1.47 → 0.1.49
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 +13 -0
- package/README.md +15 -1
- package/dist/{chunk-EGY2A4AT.js → chunk-XOROZHW4.js} +593 -242
- package/dist/chunk-XOROZHW4.js.map +1 -0
- package/dist/cli.js +387 -33
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +167 -7
- package/src/cli.ts +4 -7
- package/src/db/repositories.ts +186 -10
- package/src/linker/cross-repo-linker.ts +70 -1
- package/src/output/doctor-output.ts +98 -0
- package/src/output/table-output.ts +18 -8
- package/src/parsers/imported-wrapper-parser.ts +20 -46
- package/src/parsers/operation-path-analysis.ts +350 -0
- package/src/parsers/outbound-call-parser.ts +63 -53
- package/src/trace/evidence.ts +18 -3
- package/src/trace/trace-engine.ts +118 -25
- package/dist/chunk-EGY2A4AT.js.map +0 -1
|
@@ -8,6 +8,12 @@ import { summarizeExpression } from '../utils/redaction.js';
|
|
|
8
8
|
import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
9
9
|
import { parseServiceBindings } from './service-binding-parser.js';
|
|
10
10
|
import { parseImportedWrapperCalls } from './imported-wrapper-parser.js';
|
|
11
|
+
import {
|
|
12
|
+
analyzeOperationPath,
|
|
13
|
+
operationPathExpression,
|
|
14
|
+
pathUnresolvedReason,
|
|
15
|
+
type OperationPathAnalysis,
|
|
16
|
+
} from './operation-path-analysis.js';
|
|
11
17
|
function lineOf(text: string, idx: number): number {
|
|
12
18
|
return text.slice(0, idx).split('\n').length;
|
|
13
19
|
}
|
|
@@ -180,44 +186,9 @@ function staticExpressionText(expr: ts.Expression | undefined, initializers: Map
|
|
|
180
186
|
if (ts.isIdentifier(expr) && initializers.has(expr.text)) return staticExpressionText(initializers.get(expr.text), initializers);
|
|
181
187
|
return undefined;
|
|
182
188
|
}
|
|
183
|
-
interface StaticPathResolution { text?: string; sourceKind: 'literal' | 'template' | 'const' | 'dynamic'; rawExpression?: string; constName?: string; resolution: ExpressionResolution }
|
|
184
|
-
function staticPathExpression(expr: ts.Expression | undefined, use: ts.Node): StaticPathResolution {
|
|
185
|
-
const resolution = resolveExpression(expr, use, 'operation_path');
|
|
186
|
-
const sourceKind = resolution.sourceKind === 'const_alias' ? 'const' : resolution.sourceKind === 'template_with_substitutions' || resolution.sourceKind === 'no_substitution_template' ? 'template' : resolution.status === 'static' ? 'literal' : 'dynamic';
|
|
187
|
-
return { text: resolution.value, sourceKind, rawExpression: resolution.rawExpression, constName: resolution.constName, resolution };
|
|
188
|
-
}
|
|
189
189
|
function operationPathFromStatic(text: string | undefined): string | undefined {
|
|
190
190
|
return text ? `/${stripQuotes(text).replace(/^\//, '')}` : undefined;
|
|
191
191
|
}
|
|
192
|
-
interface PathCandidateEvidence { candidatePaths: string[]; normalizedCandidateOperations: string[]; candidateSourceKind: string; candidateIdentifier: string; hasDynamicAssignments: boolean; conservativeReason?: string }
|
|
193
|
-
function staticPathCandidates(identifier: ts.Identifier, call: ts.Node, method: string): PathCandidateEvidence | undefined {
|
|
194
|
-
const binding = resolveBinding(identifier, call);
|
|
195
|
-
const declaration = binding.declaration;
|
|
196
|
-
if (!declaration) return undefined;
|
|
197
|
-
const source = call.getSourceFile();
|
|
198
|
-
const paths: string[] = [];
|
|
199
|
-
let hasDynamicAssignments = false;
|
|
200
|
-
const addExpr = (expr: ts.Expression | undefined, origin: ts.Node): void => {
|
|
201
|
-
const resolved = staticPathExpression(expr, origin);
|
|
202
|
-
if (resolved.text) paths.push(operationPathFromStatic(resolved.text) ?? resolved.text);
|
|
203
|
-
else hasDynamicAssignments = true;
|
|
204
|
-
};
|
|
205
|
-
if (ts.isVariableDeclaration(declaration)) addExpr(declaration.initializer, declaration);
|
|
206
|
-
const visit = (node: ts.Node): void => {
|
|
207
|
-
if (node !== source && isFunctionLikeScope(node) && !nodeContains(node, call)) return;
|
|
208
|
-
if (node.getStart(source) >= call.getStart(source)) return;
|
|
209
|
-
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)) {
|
|
210
|
-
const target = resolveBinding(node.left, node);
|
|
211
|
-
if (target.declaration === declaration) addExpr(node.right, node);
|
|
212
|
-
}
|
|
213
|
-
ts.forEachChild(node, visit);
|
|
214
|
-
};
|
|
215
|
-
visit(source);
|
|
216
|
-
const candidatePaths = [...new Set(paths)];
|
|
217
|
-
if (candidatePaths.length === 0) return undefined;
|
|
218
|
-
const normalizedCandidateOperations = [...new Set(candidatePaths.map((candidate) => classifyODataPathIntent(candidate, method).topLevelOperationName).filter((value): value is string => Boolean(value)))];
|
|
219
|
-
return { candidatePaths, normalizedCandidateOperations, candidateSourceKind: 'static candidates observed before call in lexical scope', candidateIdentifier: identifier.text, hasDynamicAssignments, conservativeReason: hasDynamicAssignments ? 'dynamic_assignment_observed' : normalizedCandidateOperations.length > 1 ? 'candidate_tie' : undefined };
|
|
220
|
-
}
|
|
221
192
|
function destinationExpressionShape(expr: ts.Expression | undefined): string | undefined {
|
|
222
193
|
if (!expr) return undefined;
|
|
223
194
|
if (ts.isIdentifier(expr)) return 'identifier';
|
|
@@ -252,6 +223,33 @@ function safeOperationName(value: string | undefined): string | undefined {
|
|
|
252
223
|
if (!value || !/^[A-Za-z_$][\w$]*(?:[./][A-Za-z_$][\w$]*)*$/.test(value)) return undefined;
|
|
253
224
|
return operationPathFromStatic(value);
|
|
254
225
|
}
|
|
226
|
+
function wrapperSourceKind(sourceKind: string): string {
|
|
227
|
+
if (sourceKind.includes('const_alias')) return 'const';
|
|
228
|
+
if (sourceKind.includes('template')) return 'template';
|
|
229
|
+
if (sourceKind.includes('string_literal')) return 'literal';
|
|
230
|
+
return sourceKind.includes('conditional') ? 'ambiguous' : 'dynamic';
|
|
231
|
+
}
|
|
232
|
+
function literalPathSource(analysis: OperationPathAnalysis): string | undefined {
|
|
233
|
+
if (analysis.status !== 'static') return undefined;
|
|
234
|
+
if (analysis.sourceKind.includes('const_alias')) return 'same_scope_const_initializer';
|
|
235
|
+
if (analysis.sourceKind.includes('no_substitution_template')) return 'template';
|
|
236
|
+
return analysis.sourceKind.includes('string_literal') ? 'literal' : analysis.sourceKind;
|
|
237
|
+
}
|
|
238
|
+
function legacyPathCandidates(analysis: OperationPathAnalysis): Record<string, unknown> | undefined {
|
|
239
|
+
if (analysis.candidateRawPaths.length < 2 && analysis.dynamicReassignments.length === 0)
|
|
240
|
+
return undefined;
|
|
241
|
+
return {
|
|
242
|
+
candidatePaths: analysis.candidateRawPaths,
|
|
243
|
+
normalizedCandidateOperations: analysis.candidateNormalizedOperationPaths
|
|
244
|
+
.map((value) => value.replace(/^\//, '')),
|
|
245
|
+
candidateSourceKind: analysis.sourceKind,
|
|
246
|
+
candidateIdentifier: analysis.candidateIdentifier,
|
|
247
|
+
hasDynamicAssignments: analysis.dynamicReassignments.length > 0,
|
|
248
|
+
conservativeReason: analysis.dynamicReassignments.length > 0
|
|
249
|
+
? 'dynamic_assignment_observed'
|
|
250
|
+
: 'candidate_tie',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
255
253
|
function hasTemplatePlaceholder(value: string): boolean { return /\$\{|%7B|%7D/i.test(value); }
|
|
256
254
|
function urlTargetFromExpression(expr: ts.Expression | undefined, use: ts.Node): Record<string, unknown> {
|
|
257
255
|
const resolved = resolveExpression(expr, use, 'external');
|
|
@@ -346,15 +344,16 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
346
344
|
const serviceVariables = collectServiceVariables(source);
|
|
347
345
|
const calledNames = new Set<string>();
|
|
348
346
|
const collectCalls = (node: ts.Node): void => {
|
|
349
|
-
if (ts.isCallExpression(node))
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
347
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression))
|
|
348
|
+
calledNames.add(node.expression.text);
|
|
349
|
+
if (ts.isCallExpression(node) && ts.isCallExpression(node.expression)
|
|
350
|
+
&& ts.isIdentifier(node.expression.expression))
|
|
351
|
+
calledNames.add(node.expression.expression.text);
|
|
353
352
|
ts.forEachChild(node, collectCalls);
|
|
354
353
|
};
|
|
355
354
|
collectCalls(source);
|
|
356
355
|
const scanFunction = (name: string, fn: ts.FunctionLikeDeclaration): void => {
|
|
357
|
-
if (!calledNames.has(name)) return;
|
|
356
|
+
if (!calledNames.has(name) && !isExportedWrapper(fn)) return;
|
|
358
357
|
const params = fn.parameters.map((param) => ts.isIdentifier(param.name) ? param.name.text : undefined);
|
|
359
358
|
const sends: Array<{ client: string; path: string; method?: string; methodLiteral?: string; nestedWrapperFunction?: string; start: number; end: number }> = [];
|
|
360
359
|
const visit = (node: ts.Node): void => {
|
|
@@ -396,6 +395,16 @@ function collectWrapperSpecs(source: ts.SourceFile): Map<string, WrapperSpec> {
|
|
|
396
395
|
visitTop(source);
|
|
397
396
|
return specs;
|
|
398
397
|
}
|
|
398
|
+
function isExportedWrapper(fn: ts.FunctionLikeDeclaration): boolean {
|
|
399
|
+
const declaration = ts.isFunctionDeclaration(fn)
|
|
400
|
+
? fn
|
|
401
|
+
: ts.isVariableDeclaration(fn.parent)
|
|
402
|
+
? fn.parent.parent.parent
|
|
403
|
+
: undefined;
|
|
404
|
+
if (!declaration || !ts.canHaveModifiers(declaration)) return false;
|
|
405
|
+
return ts.getModifiers(declaration)?.some((modifier) =>
|
|
406
|
+
modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
407
|
+
}
|
|
399
408
|
export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: string): ClassifiedOutboundCall[] {
|
|
400
409
|
const calls: ClassifiedOutboundCall[] = [];
|
|
401
410
|
const sourceFile = normalizePath(filePath);
|
|
@@ -425,17 +434,16 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
425
434
|
const query = objectPropertyText(objectArg, 'query');
|
|
426
435
|
const method = stripQuotes(resolveExpression(propertyInitializer(objectArg, 'method'), node, 'literal').value ?? objectPropertyText(objectArg, 'method') ?? 'POST');
|
|
427
436
|
const pathExpr = propertyInitializer(objectArg, 'path') ?? propertyInitializer(objectArg, 'event');
|
|
428
|
-
const
|
|
429
|
-
const op = pathExpr ?
|
|
437
|
+
const pathAnalysis = analyzeOperationPath(pathExpr, node, method);
|
|
438
|
+
const op = pathExpr ? operationPathExpression(pathAnalysis) ?? pathExpr.getText(source) : undefined;
|
|
430
439
|
const shorthandPath = objectPropertyIsShorthand(objectArg, 'path');
|
|
431
|
-
const
|
|
432
|
-
const candidateOperationPath = candidateEvidence && !candidateEvidence.hasDynamicAssignments && candidateEvidence.normalizedCandidateOperations.length === 1 && candidateEvidence.candidatePaths.length > 0 ? candidateEvidence.candidatePaths[0] : undefined;
|
|
433
|
-
const operationPathExpr = operationPathFromStatic(resolvedPath.text) ?? candidateOperationPath;
|
|
440
|
+
const operationPathExpr = operationPathExpression(pathAnalysis);
|
|
434
441
|
const intent = classifyODataPathIntent(operationPathExpr, method);
|
|
435
442
|
const entityCallTypes: Record<string, OutboundCallFact['callType']> = { entity_mutation: 'remote_entity_mutation', entity_delete: 'remote_entity_delete', entity_media: 'remote_entity_media', entity_candidate: 'remote_entity_candidate' };
|
|
436
443
|
const entityCallType = entityCallTypes[intent.kind];
|
|
437
444
|
const isODataQueryRead = method.toUpperCase() === 'GET' && ['entity_query', 'entity_key_read', 'entity_navigation_query'].includes(intent.kind);
|
|
438
|
-
|
|
445
|
+
const unresolvedReason = !query && pathExpr ? pathUnresolvedReason(pathAnalysis) : undefined;
|
|
446
|
+
add(node, { callType: query ? 'remote_query' : entityCallType ?? (isODataQueryRead ? 'remote_query' : 'remote_action'), serviceVariableName: receiver, method, operationPathExpr, queryEntity: query ? extractQueryEntity(query) : isODataQueryRead ? intent.entitySegment : undefined, payloadSummary: summarizeExpression(objectArg.getText(source)), confidence: op || query ? 0.8 : 0.4, unresolvedReason }, { receiver, classifier: 'service_client_send_object', operationPathExpression: shorthandPath ? op : undefined, rawPathExpression: pathAnalysis.rawExpression, literalPathSource: literalPathSource(pathAnalysis), odataPathIntent: operationPathExpr ? intent : undefined, pathAnalysis, staticPathCandidates: legacyPathCandidates(pathAnalysis), parserWarning: unresolvedReason });
|
|
439
447
|
} else {
|
|
440
448
|
const receiver = receiverName(expr.expression);
|
|
441
449
|
const rootReceiver = rootReceiverName(expr.expression);
|
|
@@ -444,10 +452,11 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
444
452
|
const pathArg = node.arguments[1];
|
|
445
453
|
const supported = method && supportedHttpMethods.has(method);
|
|
446
454
|
if (receiver && supported && serviceVariables.has(rootReceiver ?? receiver)) {
|
|
447
|
-
const
|
|
448
|
-
const operationPathExpr =
|
|
455
|
+
const pathAnalysis = analyzeOperationPath(pathArg, node, method);
|
|
456
|
+
const operationPathExpr = operationPathExpression(pathAnalysis);
|
|
449
457
|
const intent = classifyODataPathIntent(operationPathExpr, method);
|
|
450
|
-
|
|
458
|
+
const unresolvedReason = pathUnresolvedReason(pathAnalysis);
|
|
459
|
+
add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.8 : 0.45, unresolvedReason }, { receiver, rootReceiver, classifier: 'service_client_send_method_path', rawPathExpression: pathAnalysis.rawExpression, literalPathSource: literalPathSource(pathAnalysis), odataPathIntent: operationPathExpr ? intent : undefined, pathAnalysis, staticPathCandidates: legacyPathCandidates(pathAnalysis), parserWarning: unresolvedReason });
|
|
451
460
|
} else if (receiver && serviceVariables.has(rootReceiver ?? receiver)) {
|
|
452
461
|
const operationPathExpr = safeOperationName(firstArg.value);
|
|
453
462
|
add(node, { callType: 'remote_action', serviceVariableName: rootReceiver ?? receiver, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: operationPathExpr ? 0.75 : 0.35, unresolvedReason: operationPathExpr ? undefined : 'unsupported_cap_send_signature' }, { receiver, rootReceiver, classifier: operationPathExpr ? 'service_client_send_operation_event' : 'service_client_send_unsupported_signature', rawOperationExpression: firstArg.rawExpression, literalOperationSource: firstArg.value ? firstArg.sourceKind : undefined, parserWarning: operationPathExpr ? undefined : 'unsupported_cap_send_signature' });
|
|
@@ -462,13 +471,14 @@ export function classifyOutboundCallsInSource(source: ts.SourceFile, filePath: s
|
|
|
462
471
|
const methodArg = spec?.methodIndex === undefined ? undefined : wrapperArgs[spec.methodIndex];
|
|
463
472
|
const receiver = clientArg && ts.isIdentifier(clientArg) ? clientArg.text : spec?.clientName;
|
|
464
473
|
const method = stripQuotes(resolveExpression(methodArg, node, 'literal').value ?? spec?.methodLiteral ?? 'POST');
|
|
465
|
-
const
|
|
466
|
-
const operationPathExpr =
|
|
474
|
+
const pathAnalysis = analyzeOperationPath(pathArg, node, method);
|
|
475
|
+
const operationPathExpr = operationPathExpression(pathAnalysis);
|
|
467
476
|
const normalizedOperationPath = operationPathExpr ? classifyODataPathIntent(operationPathExpr, method).topLevelOperationName : undefined;
|
|
477
|
+
const unresolvedReason = pathUnresolvedReason(pathAnalysis);
|
|
468
478
|
if (spec && receiver && operationPathExpr) {
|
|
469
|
-
add(node, { callType: 'remote_action', serviceVariableName: receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75 }, { receiver, classifier:
|
|
479
|
+
add(node, { callType: 'remote_action', serviceVariableName: receiver, method, operationPathExpr, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.75, unresolvedReason }, { receiver, classifier: pathAnalysis.sourceKind.includes('string_literal') ? 'higher_order_wrapper_literal_path' : 'higher_order_wrapper_static_path', wrapperFunction: wrapperName, nestedWrapperFunction: spec.nestedWrapperFunction, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: wrapperSourceKind(pathAnalysis.sourceKind), rawPathExpression: pathAnalysis.rawExpression, normalizedOperationPath, literalPathSource: pathAnalysis.sourceKind.includes('const_alias') ? 'same_scope_const_initializer' : `wrapper_call_${wrapperSourceKind(pathAnalysis.sourceKind)}`, literalCallerArgumentDetected: true, pathAnalysis });
|
|
470
480
|
} else if (spec && receiver) {
|
|
471
|
-
add(node, { callType: 'remote_action', serviceVariableName: receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason
|
|
481
|
+
add(node, { callType: 'remote_action', serviceVariableName: receiver, method, payloadSummary: summarizeExpression(node.getText(source)), confidence: 0.45, unresolvedReason }, { receiver, classifier: pathAnalysis.status === 'ambiguous' ? 'higher_order_wrapper_ambiguous_path' : 'higher_order_wrapper_dynamic_path', wrapperFunction: wrapperName, wrapperDefinitionLine: spec.definitionLine, callerLine: lineOf(source.text, node.getStart(source)), wrapperPathSourceKind: wrapperSourceKind(pathAnalysis.sourceKind), rawPathExpression: pathAnalysis.rawExpression, pathAnalysis, parserWarning: unresolvedReason });
|
|
472
482
|
}
|
|
473
483
|
} else if (ts.isPropertyAccessExpression(expr) && ['emit', 'publish', 'on'].includes(expr.name.text)) {
|
|
474
484
|
const receiver = receiverName(expr.expression);
|
package/src/trace/evidence.ts
CHANGED
|
@@ -52,11 +52,13 @@ export function runtimeResolution(
|
|
|
52
52
|
evidence: Record<string, unknown>,
|
|
53
53
|
vars: Record<string, string> | undefined,
|
|
54
54
|
workspaceId: number | undefined,
|
|
55
|
+
contextualUnresolvedReason?: string,
|
|
55
56
|
): { row: TraceGraphRow; evidence: Record<string, unknown>; target?: OperationTarget; unresolvedReason?: string } {
|
|
56
57
|
const substituted = evidenceWithRuntimeVariables(evidence, vars);
|
|
57
58
|
if (!isRemoteRuntimeCandidate(row, evidence, vars)) {
|
|
58
|
-
const
|
|
59
|
-
|
|
59
|
+
const unresolvedReason = contextualUnresolvedReason ?? row.unresolved_reason;
|
|
60
|
+
const withSections = withEffectiveResolution(substituted, row, unresolvedReason);
|
|
61
|
+
return { row, evidence: withSections, unresolvedReason };
|
|
60
62
|
}
|
|
61
63
|
const resolution = resolveRuntimeOperation(db, substituted, workspaceId);
|
|
62
64
|
if (resolution.target) {
|
|
@@ -70,6 +72,9 @@ export function runtimeResolution(
|
|
|
70
72
|
export function runtimeVariableDiagnostic(edges: Array<{ evidence: Record<string, unknown> }>): Record<string, unknown> | undefined {
|
|
71
73
|
const missing = new Set<string>();
|
|
72
74
|
for (const edge of edges) {
|
|
75
|
+
const effective = parseObject(edge.evidence.effectiveResolution);
|
|
76
|
+
if (!['dynamic', 'unresolved', 'ambiguous'].includes(String(effective.status ?? '')))
|
|
77
|
+
continue;
|
|
73
78
|
const substitutions = edge.evidence.runtimeSubstitutions;
|
|
74
79
|
if (!substitutions || typeof substitutions !== 'object' || Array.isArray(substitutions)) continue;
|
|
75
80
|
for (const value of Object.values(substitutions as Record<string, RuntimeSubstitution>))
|
|
@@ -178,12 +183,22 @@ function evidenceWithRuntimeVariables(evidence: Record<string, unknown>, vars: R
|
|
|
178
183
|
function runtimeSubstitutions(evidence: Record<string, unknown>, vars: Record<string, string>): Record<string, RuntimeSubstitution> {
|
|
179
184
|
const substitutions: Record<string, RuntimeSubstitution> = {};
|
|
180
185
|
for (const key of ['servicePath', 'operationPath', 'serviceAliasExpr', 'serviceAlias', 'destination']) {
|
|
181
|
-
const substitution = substituteVariables(
|
|
186
|
+
const substitution = substituteVariables(substitutionValue(evidence, key), vars);
|
|
182
187
|
if (substitution.placeholders.length > 0) substitutions[key] = substitution;
|
|
183
188
|
}
|
|
184
189
|
return substitutions;
|
|
185
190
|
}
|
|
186
191
|
|
|
192
|
+
function substitutionValue(
|
|
193
|
+
evidence: Record<string, unknown>,
|
|
194
|
+
key: string,
|
|
195
|
+
): string | undefined {
|
|
196
|
+
const value = stringValue(evidence[key]);
|
|
197
|
+
if (key !== 'operationPath') return value;
|
|
198
|
+
const normalized = normalizeODataOperationInvocationPath(value);
|
|
199
|
+
return normalized?.wasInvocation ? normalized.normalizedOperationPath : value;
|
|
200
|
+
}
|
|
201
|
+
|
|
187
202
|
function isRemoteRuntimeCandidate(row: TraceGraphRow, evidence: Record<string, unknown>, vars: Record<string, string> | undefined): boolean {
|
|
188
203
|
if (!vars || Object.keys(vars).length === 0) return false;
|
|
189
204
|
if (!['dynamic', 'ambiguous', 'unresolved'].includes(String(row.status ?? ''))) return false;
|
|
@@ -5,13 +5,13 @@ import { resolveOperation } from '../linker/service-resolver.js';
|
|
|
5
5
|
import type { ImplementationHint, TraceEdge, TraceResult, TraceStart } from '../types.js';
|
|
6
6
|
import { baseTraceEvidence, edgeTarget, runtimeResolution, runtimeVariableDiagnostic, type TraceGraphRow } from './evidence.js';
|
|
7
7
|
import { implementationHintDiagnostic, selectImplementation, type ImplementationSelection } from './implementation-hints.js';
|
|
8
|
-
|
|
9
8
|
interface RepoRef {
|
|
10
9
|
id: number;
|
|
11
10
|
name: string;
|
|
12
11
|
}
|
|
13
12
|
interface StartScope {
|
|
14
13
|
repo?: RepoRef;
|
|
14
|
+
executionRepoId?: number;
|
|
15
15
|
sourceFiles?: Set<string>;
|
|
16
16
|
symbolIds?: Set<number>;
|
|
17
17
|
selectorMatched: boolean;
|
|
@@ -40,7 +40,7 @@ interface GraphRow extends Record<string, unknown> {
|
|
|
40
40
|
status?: string;
|
|
41
41
|
}
|
|
42
42
|
interface ContextBinding {
|
|
43
|
-
bindingId
|
|
43
|
+
bindingId?: number;
|
|
44
44
|
alias?: string;
|
|
45
45
|
aliasExpr?: string;
|
|
46
46
|
destinationExpr?: string;
|
|
@@ -62,6 +62,8 @@ interface ContextBinding {
|
|
|
62
62
|
calleeReceiver: string;
|
|
63
63
|
callerSite?: { sourceFile?: string; sourceLine?: number };
|
|
64
64
|
calleeSite?: { sourceFile?: string; sourceLine?: number };
|
|
65
|
+
resolutionStatus?: 'selected' | 'ambiguous';
|
|
66
|
+
bindingCandidates?: Array<Record<string, unknown>>;
|
|
65
67
|
}
|
|
66
68
|
interface ImplementationHintOptions {
|
|
67
69
|
implementationRepo?: string;
|
|
@@ -74,42 +76,75 @@ function normalizeOperation(value: string | undefined): string | undefined {
|
|
|
74
76
|
function positiveDepth(value: number): number {
|
|
75
77
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 25;
|
|
76
78
|
}
|
|
77
|
-
|
|
78
|
-
function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart, hintOptions: ImplementationHintOptions): { files?: Set<string>; symbols?: Set<number>; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
|
|
79
|
+
function operationStartScope(db: Db, repoId: number | undefined, start: TraceStart, hintOptions: ImplementationHintOptions): { files?: Set<string>; symbols?: Set<number>; repoId?: number; operationId?: string; diagnostics?: Array<Record<string, unknown>> } | undefined {
|
|
79
80
|
const requested = normalizeOperation(start.operationPath ?? start.operation);
|
|
80
81
|
if (!requested) return undefined;
|
|
81
|
-
const rows = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_path operationPath,s.service_path servicePath,r.id repoId,r.name repoName
|
|
82
|
+
const rows = db.prepare(`SELECT o.id operationId,o.operation_name operationName,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,s.service_path servicePath,r.id repoId,r.name repoName
|
|
82
83
|
FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
|
|
83
84
|
WHERE (? IS NULL OR r.id=?) AND (? IS NULL OR s.service_path=?) AND (o.operation_name=? OR o.operation_path=? OR o.operation_path=?)
|
|
84
85
|
ORDER BY r.name,s.service_path,o.operation_name,o.id`).all(repoId, repoId, start.servicePath, start.servicePath, requested, requested, requested.startsWith('/') ? requested : `/${requested}`) as Array<Record<string, unknown>>;
|
|
85
86
|
if (rows.length === 0) return undefined;
|
|
86
87
|
const repoCount = new Set(rows.map((row) => String(row.repoName))).size;
|
|
87
88
|
const serviceCount = new Set(rows.map((row) => `${String(row.repoName)}:${String(row.servicePath)}`)).size;
|
|
88
|
-
if (!repoId && repoCount > 1)
|
|
89
|
-
|
|
90
|
-
if (
|
|
89
|
+
if (!repoId && repoCount > 1)
|
|
90
|
+
return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple repositories; add --repo to disambiguate')] };
|
|
91
|
+
if (!start.servicePath && serviceCount > 1)
|
|
92
|
+
return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple services; add --service to disambiguate')] };
|
|
93
|
+
if (rows.length !== 1)
|
|
94
|
+
return { diagnostics: [ambiguousStartDiagnostic(requested, rows, 'Operation trace start matched multiple indexed operations')] };
|
|
91
95
|
const operationId = String(rows[0]?.operationId);
|
|
92
96
|
const impl = implementationScope(db, operationId);
|
|
93
|
-
if (impl.edge?.status === 'resolved' && impl.files.size > 0) return { files: impl.files, symbols: impl.symbolId ? new Set([impl.symbolId]) : undefined, operationId, diagnostics: [] };
|
|
97
|
+
if (impl.edge?.status === 'resolved' && impl.files.size > 0) return { files: impl.files, symbols: impl.symbolId ? new Set([impl.symbolId]) : undefined, repoId: impl.repoId, operationId, diagnostics: [] };
|
|
94
98
|
const hinted = implementationMethodIdFromHint(impl.edge, hintOptions);
|
|
95
99
|
if (hinted.methodId) {
|
|
96
100
|
const hintedScope = handlerScope(db, hinted.methodId);
|
|
97
|
-
if (hintedScope?.files.size) return { files: hintedScope.files, symbols: hintedScope.symbolId ? new Set([hintedScope.symbolId]) : undefined, operationId, diagnostics: [] };
|
|
101
|
+
if (hintedScope?.files.size) return { files: hintedScope.files, symbols: hintedScope.symbolId ? new Set([hintedScope.symbolId]) : undefined, repoId: hintedScope.repoId, operationId, diagnostics: [] };
|
|
98
102
|
}
|
|
99
103
|
if (impl.edge) {
|
|
100
104
|
const evidence = parseEvidence(impl.edge.evidence_json);
|
|
101
105
|
const hintDiagnostic = implementationHintDiagnostic(hinted, evidence.implementationHintSuggestions);
|
|
102
|
-
const diagnostics = [{ severity: 'warning', code: impl.edge.status === 'ambiguous' ? 'trace_start_ambiguous' : 'trace_start_implementation_unresolved', message: `Indexed operation matched but implementation edge is ${String(impl.edge.status ?? 'unresolved')}`, resolutionStage: 'implementation', resolutionStatus: impl.edge.status === 'ambiguous' ? 'ambiguous_implementation' : 'rejected_implementation', implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, implementationAmbiguityReasons: evidence.ambiguityReasons, implementationHintSuggestions: evidence.implementationHintSuggestions, candidates: evidence.candidates }];
|
|
106
|
+
const diagnostics = [{ severity: 'warning', code: impl.edge.status === 'ambiguous' ? 'trace_start_ambiguous' : 'trace_start_implementation_unresolved', message: `Indexed operation matched but implementation edge is ${String(impl.edge.status ?? 'unresolved')}`, resolutionStage: 'implementation', resolutionStatus: impl.edge.status === 'ambiguous' ? 'ambiguous_implementation' : 'rejected_implementation', implementationEdgeId: impl.edge.id, implementationStatus: impl.edge.status, implementationAmbiguityReasons: evidence.ambiguityReasons, implementationRejectionReasons: implementationRejectionReasons(evidence), implementationHintSuggestions: evidence.implementationHintSuggestions, candidates: evidence.candidates }];
|
|
103
107
|
return { operationId, diagnostics: hintDiagnostic ? [hintDiagnostic, ...diagnostics] : diagnostics };
|
|
104
108
|
}
|
|
105
109
|
return { operationId, diagnostics: [{ severity: 'warning', code: 'trace_start_implementation_unresolved', message: 'Indexed operation matched but no implementation candidate exists', resolutionStage: 'implementation', resolutionStatus: 'operation_without_implementation' }] };
|
|
106
110
|
}
|
|
107
|
-
|
|
111
|
+
function implementationRejectionReasons(evidence: Record<string, unknown>): string[] {
|
|
112
|
+
const candidates = Array.isArray(evidence.candidates)
|
|
113
|
+
? evidence.candidates.filter((item): item is Record<string, unknown> =>
|
|
114
|
+
Boolean(item && typeof item === 'object' && !Array.isArray(item)))
|
|
115
|
+
: [];
|
|
116
|
+
const reasons = candidates.flatMap((candidate) =>
|
|
117
|
+
Array.isArray(candidate.rejectedReasons)
|
|
118
|
+
? candidate.rejectedReasons.filter((reason): reason is string =>
|
|
119
|
+
typeof reason === 'string')
|
|
120
|
+
: []);
|
|
121
|
+
return [...new Set(reasons)].sort();
|
|
122
|
+
}
|
|
123
|
+
function ambiguousStartDiagnostic(
|
|
124
|
+
requested: string,
|
|
125
|
+
candidates: Array<Record<string, unknown>>,
|
|
126
|
+
message: string,
|
|
127
|
+
): Record<string, unknown> {
|
|
128
|
+
const serviceSuggestions = [...new Set(candidates
|
|
129
|
+
.flatMap((row) => typeof row.servicePath === 'string'
|
|
130
|
+
? [`--service ${row.servicePath}`]
|
|
131
|
+
: []))].sort();
|
|
132
|
+
return {
|
|
133
|
+
severity: 'warning',
|
|
134
|
+
code: 'trace_start_ambiguous',
|
|
135
|
+
message,
|
|
136
|
+
normalizedSelectorValue: requested,
|
|
137
|
+
resolutionStage: 'operation',
|
|
138
|
+
resolutionStatus: 'ambiguous_operation',
|
|
139
|
+
candidates,
|
|
140
|
+
serviceSuggestions,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
108
143
|
function sourceFilesForStart(
|
|
109
144
|
db: Db,
|
|
110
145
|
repoId: number | undefined,
|
|
111
146
|
start: TraceStart,
|
|
112
|
-
): { files?: Set<string>; symbols?: Set<number
|
|
147
|
+
): { files?: Set<string>; symbols?: Set<number>; repoId?: number } | undefined {
|
|
113
148
|
const handler = start.handler;
|
|
114
149
|
const operation = normalizeOperation(start.operation ?? start.operationPath);
|
|
115
150
|
if (!handler && !operation) return undefined;
|
|
@@ -138,7 +173,7 @@ function sourceFilesForStart(
|
|
|
138
173
|
operation,
|
|
139
174
|
operation,
|
|
140
175
|
) as Array<{ sourceFile?: string; symbolId?: number }>;
|
|
141
|
-
if (rows.length > 0) return { files: new Set(rows.map((row) => row.sourceFile).filter(Boolean) as string[]), symbols: new Set(rows.map((row) => Number(row.symbolId)).filter(Boolean)) };
|
|
176
|
+
if (rows.length > 0) return { files: new Set(rows.map((row) => row.sourceFile).filter(Boolean) as string[]), symbols: new Set(rows.map((row) => Number(row.symbolId)).filter(Boolean)), repoId };
|
|
142
177
|
if (start.servicePath && operation) {
|
|
143
178
|
const implRows = db.prepare(`SELECT DISTINCT hc.source_file sourceFile,sym.id symbolId
|
|
144
179
|
FROM cds_services s JOIN cds_operations o ON o.service_id=s.id
|
|
@@ -147,7 +182,7 @@ function sourceFilesForStart(
|
|
|
147
182
|
JOIN handler_classes hc ON hc.id=hm.handler_class_id
|
|
148
183
|
LEFT JOIN symbols sym ON sym.repo_id=hc.repo_id AND sym.source_file=hc.source_file AND sym.name=hm.method_name
|
|
149
184
|
WHERE (? IS NULL OR s.repo_id=?) AND s.service_path=? AND (o.operation_path=? OR o.operation_name=?)`).all(repoId, repoId, start.servicePath, operation, operation) as Array<{ sourceFile?: string; symbolId?: number }>;
|
|
150
|
-
if (implRows.length > 0) return { files: new Set(implRows.map((row) => row.sourceFile).filter(Boolean) as string[]), symbols: new Set(implRows.map((row) => Number(row.symbolId)).filter(Boolean)) };
|
|
185
|
+
if (implRows.length > 0) return { files: new Set(implRows.map((row) => row.sourceFile).filter(Boolean) as string[]), symbols: new Set(implRows.map((row) => Number(row.symbolId)).filter(Boolean)), repoId };
|
|
151
186
|
}
|
|
152
187
|
return undefined;
|
|
153
188
|
}
|
|
@@ -171,6 +206,7 @@ function startScope(db: Db, start: TraceStart, hintOptions: ImplementationHintOp
|
|
|
171
206
|
return { repo, selectorMatched: false };
|
|
172
207
|
return {
|
|
173
208
|
repo,
|
|
209
|
+
executionRepoId: sourceScope?.repoId ?? repo?.id,
|
|
174
210
|
sourceFiles,
|
|
175
211
|
symbolIds: sourceScope?.symbols,
|
|
176
212
|
selectorMatched: !terminalOperationScope && (!hasSelector || sourceFiles !== undefined),
|
|
@@ -201,7 +237,6 @@ function handlerFilesForOperation(db: Db, operationId: string): Set<string> {
|
|
|
201
237
|
}>;
|
|
202
238
|
return new Set(rows.map((row) => row.sourceFile).filter(Boolean) as string[]);
|
|
203
239
|
}
|
|
204
|
-
|
|
205
240
|
function implementationEdge(db: Db, operationId: string): GraphRow | undefined {
|
|
206
241
|
return db.prepare("SELECT * FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND from_kind='operation' AND from_id=? ORDER BY CASE status WHEN 'resolved' THEN 0 WHEN 'ambiguous' THEN 1 ELSE 2 END,id LIMIT 1").get(operationId) as GraphRow | undefined;
|
|
207
242
|
}
|
|
@@ -220,7 +255,6 @@ function implementationMethodIdFromHint(edge: GraphRow | undefined, options: Imp
|
|
|
220
255
|
if (!edge || edge.status !== 'ambiguous') return { blocksAutomatic: false, evidence: { status: 'not_applicable' } };
|
|
221
256
|
return selectImplementation(parseEvidence(edge.evidence_json), options.implementationHints, options.implementationRepo);
|
|
222
257
|
}
|
|
223
|
-
|
|
224
258
|
function contextImplementationMethodId(edge: GraphRow | undefined, callerRepoId: number | undefined, remoteEvidence: Record<string, unknown>, hintOptions: ImplementationHintOptions): ImplementationSelection {
|
|
225
259
|
const hinted = implementationMethodIdFromHint(edge, hintOptions);
|
|
226
260
|
if (hinted.methodId) return hinted;
|
|
@@ -331,21 +365,65 @@ function knownBindingsForCalls(db: Db, calls: CallRow[]): Map<string, ContextBin
|
|
|
331
365
|
function knownBindingsForScope(db: Db, repoId: number | undefined, symbolIds: Set<number> | undefined, files: Set<string> | undefined): Map<string, ContextBinding> {
|
|
332
366
|
const map = new Map<string, ContextBinding>();
|
|
333
367
|
if (repoId === undefined) return map;
|
|
334
|
-
type BindingRow = Omit<ContextBinding, 'bindingId' | 'source' | 'calleeReceiver'> & { id?: number; variableName?: string };
|
|
335
|
-
const rows = db.prepare(`SELECT b.id,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
|
|
368
|
+
type BindingRow = Omit<ContextBinding, 'bindingId' | 'source' | 'calleeReceiver'> & { id?: number; symbolId?: number | null; variableName?: string };
|
|
369
|
+
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
|
|
336
370
|
FROM service_bindings b LEFT JOIN cds_requires req ON req.repo_id=b.repo_id AND req.alias=b.alias
|
|
337
371
|
WHERE b.repo_id=?`).all(repoId) as BindingRow[];
|
|
338
372
|
for (const row of rows) {
|
|
339
373
|
if (!row.variableName) continue;
|
|
340
374
|
if (files && !files.has(String(row.sourceFile))) continue;
|
|
341
|
-
if (symbolIds && symbolIds.size > 0
|
|
342
|
-
|
|
343
|
-
|
|
375
|
+
if (symbolIds && symbolIds.size > 0 && row.symbolId != null
|
|
376
|
+
&& !symbolIds.has(Number(row.symbolId))) continue;
|
|
377
|
+
const candidate = enrichBinding({
|
|
378
|
+
...row,
|
|
379
|
+
bindingId: Number(row.id),
|
|
380
|
+
source: 'local_service_binding',
|
|
381
|
+
calleeReceiver: row.variableName,
|
|
382
|
+
resolutionStatus: 'selected',
|
|
383
|
+
});
|
|
384
|
+
const existing = map.get(row.variableName);
|
|
385
|
+
if (!existing) {
|
|
386
|
+
map.set(row.variableName, candidate);
|
|
387
|
+
continue;
|
|
344
388
|
}
|
|
345
|
-
|
|
389
|
+
const candidates = uniqueBindingCandidates([
|
|
390
|
+
...(existing.bindingCandidates ?? [bindingCandidateEvidence(existing)]),
|
|
391
|
+
bindingCandidateEvidence(candidate),
|
|
392
|
+
]);
|
|
393
|
+
map.set(row.variableName, {
|
|
394
|
+
...candidate,
|
|
395
|
+
bindingId: undefined,
|
|
396
|
+
source: 'ambiguous_local_service_bindings',
|
|
397
|
+
resolutionStatus: 'ambiguous',
|
|
398
|
+
bindingCandidates: candidates,
|
|
399
|
+
});
|
|
346
400
|
}
|
|
347
401
|
return map;
|
|
348
402
|
}
|
|
403
|
+
|
|
404
|
+
function bindingCandidateEvidence(binding: ContextBinding): Record<string, unknown> {
|
|
405
|
+
return {
|
|
406
|
+
bindingId: binding.bindingId,
|
|
407
|
+
sourceFile: binding.sourceFile,
|
|
408
|
+
sourceLine: binding.sourceLine,
|
|
409
|
+
alias: binding.alias,
|
|
410
|
+
aliasExpr: binding.aliasExpr,
|
|
411
|
+
destinationExpr: binding.destinationExpr,
|
|
412
|
+
servicePathExpr: binding.servicePathExpr,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function uniqueBindingCandidates(
|
|
417
|
+
candidates: Array<Record<string, unknown>>,
|
|
418
|
+
): Array<Record<string, unknown>> {
|
|
419
|
+
const seen = new Set<string>();
|
|
420
|
+
return candidates.filter((candidate) => {
|
|
421
|
+
const key = JSON.stringify(candidate);
|
|
422
|
+
if (seen.has(key)) return false;
|
|
423
|
+
seen.add(key);
|
|
424
|
+
return true;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
349
427
|
function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, callerBindings: Map<string, ContextBinding>): Map<string, ContextBinding> {
|
|
350
428
|
const next = new Map<string, ContextBinding>();
|
|
351
429
|
if (callerBindings.size === 0) return next;
|
|
@@ -397,6 +475,21 @@ function contextForSymbolCall(db: Db, symbolCall: Record<string, unknown>, calle
|
|
|
397
475
|
}
|
|
398
476
|
function contextualRuntimeResolution(db: Db, call: CallRow, binding: ContextBinding | undefined, workspaceId: number | undefined, persistedRows: GraphRow[] = []): { row?: GraphRow; evidence?: Record<string, unknown>; unresolvedReason?: string } {
|
|
399
477
|
if (!binding || String(call.call_type) !== 'remote_action' || call.operation_path_expr === undefined || call.operation_path_expr === null) return {};
|
|
478
|
+
if (binding.resolutionStatus === 'ambiguous') {
|
|
479
|
+
return {
|
|
480
|
+
evidence: {
|
|
481
|
+
contextualServiceBindingAttempted: true,
|
|
482
|
+
contextualBinding: {
|
|
483
|
+
source: binding.source,
|
|
484
|
+
status: 'tied',
|
|
485
|
+
candidates: binding.bindingCandidates,
|
|
486
|
+
},
|
|
487
|
+
contextualResolutionStatus: 'ambiguous',
|
|
488
|
+
contextualCandidateCount: binding.bindingCandidates?.length ?? 0,
|
|
489
|
+
},
|
|
490
|
+
unresolvedReason: 'Ambiguous contextual service binding candidates',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
400
493
|
const normalized = normalizeODataOperationInvocationPath(String(call.operation_path_expr));
|
|
401
494
|
const op = normalized?.normalizedOperationPath ?? (String(call.operation_path_expr).startsWith('/') ? String(call.operation_path_expr) : `/${String(call.operation_path_expr)}`);
|
|
402
495
|
const servicePath = binding.effectiveServicePath ?? binding.servicePathExpr ?? binding.requireServicePath;
|
|
@@ -445,7 +538,7 @@ export function trace(
|
|
|
445
538
|
const seenEdges = new Set<number>();
|
|
446
539
|
const queue: Array<{ repoId?: number; files?: Set<string>; symbolIds?: Set<number>; depth: number; context?: Map<string, ContextBinding> }> =
|
|
447
540
|
scope.selectorMatched
|
|
448
|
-
? [{ repoId: scope.
|
|
541
|
+
? [{ repoId: scope.executionRepoId, files: scope.sourceFiles, symbolIds: scope.symbolIds, depth: 1, context: new Map() }]
|
|
449
542
|
: [];
|
|
450
543
|
if (scope.startOperationId && scope.selectorMatched) {
|
|
451
544
|
const op = operationNode(db, scope.startOperationId);
|
|
@@ -521,7 +614,7 @@ export function trace(
|
|
|
521
614
|
String(row.evidence_json || '{}'),
|
|
522
615
|
) as Record<string, unknown>;
|
|
523
616
|
const rawEvidence = baseTraceEvidence(row as TraceGraphRow, call, persistedEvidence, contextual.evidence);
|
|
524
|
-
const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, options.vars, workspaceIdForCall(db, String(call.id)));
|
|
617
|
+
const effective = runtimeResolution(db, row as TraceGraphRow, rawEvidence, options.vars, workspaceIdForCall(db, String(call.id)), contextual.unresolvedReason);
|
|
525
618
|
const evidence = effective.evidence;
|
|
526
619
|
const effectiveRow = effective.row;
|
|
527
620
|
const targetNode = `${effectiveRow.to_kind}:${effectiveRow.to_id}`;
|