@saptools/service-flow 0.1.49 → 0.1.51
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 +14 -0
- package/README.md +10 -2
- package/TECHNICAL-NOTE.md +6 -1
- package/dist/{chunk-XOROZHW4.js → chunk-YZJKE5UX.js} +757 -122
- package/dist/chunk-YZJKE5UX.js.map +1 -0
- package/dist/cli.js +107 -22
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +20 -10
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +59 -1
- package/src/cli.ts +22 -0
- package/src/db/migrations.ts +4 -1
- package/src/db/repositories.ts +2 -1
- package/src/db/schema.ts +1 -1
- package/src/index.ts +1 -1
- package/src/linker/cross-repo-linker.ts +111 -6
- package/src/linker/operation-decorator-normalizer.ts +3 -3
- package/src/linker/service-resolver.ts +15 -4
- package/src/output/table-output.ts +29 -2
- package/src/parsers/decorator-parser.ts +128 -22
- package/src/trace/dynamic-branches.ts +45 -0
- package/src/trace/dynamic-targets.ts +424 -0
- package/src/trace/evidence.ts +117 -7
- package/src/trace/selectors.ts +36 -0
- package/src/trace/trace-engine.ts +12 -31
- package/src/types.ts +24 -0
- package/dist/chunk-XOROZHW4.js.map +0 -1
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import ts from 'typescript';
|
|
4
|
-
import type { HandlerClassFact } from '../types.js';
|
|
4
|
+
import type { HandlerClassFact, HandlerMethodFact } from '../types.js';
|
|
5
|
+
import { generatedOperationNameFromConstant } from '../linker/operation-decorator-normalizer.js';
|
|
5
6
|
import { createSourceFile } from './ts-project.js';
|
|
6
|
-
import { normalizePath
|
|
7
|
+
import { normalizePath } from '../utils/path-utils.js';
|
|
8
|
+
|
|
9
|
+
type DecoratorResolution = HandlerMethodFact['decoratorResolution'];
|
|
10
|
+
interface StringLookups {
|
|
11
|
+
identifiers: Map<string, string>;
|
|
12
|
+
enumMembers: Map<string, string>;
|
|
13
|
+
objectProperties: Map<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
function line(sf: ts.SourceFile, pos: number): number {
|
|
8
17
|
return sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
9
18
|
}
|
|
@@ -14,11 +23,119 @@ function callName(d: ts.Decorator): string {
|
|
|
14
23
|
const e = d.expression;
|
|
15
24
|
return ts.isCallExpression(e) ? e.expression.getText() : e.getText();
|
|
16
25
|
}
|
|
17
|
-
function firstArg(d: ts.Decorator):
|
|
26
|
+
function firstArg(d: ts.Decorator): ts.Expression | undefined {
|
|
18
27
|
const e = d.expression;
|
|
19
|
-
return ts.isCallExpression(e)
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
return ts.isCallExpression(e) ? e.arguments[0] : undefined;
|
|
29
|
+
}
|
|
30
|
+
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
|
31
|
+
let current = expression;
|
|
32
|
+
while (
|
|
33
|
+
ts.isParenthesizedExpression(current)
|
|
34
|
+
|| ts.isAsExpression(current)
|
|
35
|
+
|| ts.isTypeAssertionExpression(current)
|
|
36
|
+
|| ts.isSatisfiesExpression(current)
|
|
37
|
+
) current = current.expression;
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
function stringValue(expression: ts.Expression | undefined): string | undefined {
|
|
41
|
+
if (!expression) return undefined;
|
|
42
|
+
const unwrapped = unwrapExpression(expression);
|
|
43
|
+
return ts.isStringLiteralLike(unwrapped) ? unwrapped.text : undefined;
|
|
44
|
+
}
|
|
45
|
+
function propertyName(node: ts.PropertyName): string | undefined {
|
|
46
|
+
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function collectEnumMembers(
|
|
50
|
+
statement: ts.EnumDeclaration,
|
|
51
|
+
lookups: StringLookups,
|
|
52
|
+
): void {
|
|
53
|
+
for (const member of statement.members) {
|
|
54
|
+
const name = propertyName(member.name);
|
|
55
|
+
const value = stringValue(member.initializer);
|
|
56
|
+
if (name && value !== undefined)
|
|
57
|
+
lookups.enumMembers.set(`${statement.name.text}.${name}`, value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function collectObjectProperties(
|
|
61
|
+
name: string,
|
|
62
|
+
initializer: ts.Expression,
|
|
63
|
+
lookups: StringLookups,
|
|
64
|
+
): void {
|
|
65
|
+
const object = unwrapExpression(initializer);
|
|
66
|
+
if (!ts.isObjectLiteralExpression(object)) return;
|
|
67
|
+
for (const property of object.properties) {
|
|
68
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
69
|
+
const key = propertyName(property.name);
|
|
70
|
+
const value = stringValue(property.initializer);
|
|
71
|
+
if (key && value !== undefined)
|
|
72
|
+
lookups.objectProperties.set(`${name}.${key}`, value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function collectStringLookups(source: ts.SourceFile): StringLookups {
|
|
76
|
+
const lookups: StringLookups = {
|
|
77
|
+
identifiers: new Map(),
|
|
78
|
+
enumMembers: new Map(),
|
|
79
|
+
objectProperties: new Map(),
|
|
80
|
+
};
|
|
81
|
+
for (const statement of source.statements) {
|
|
82
|
+
if (ts.isEnumDeclaration(statement)) collectEnumMembers(statement, lookups);
|
|
83
|
+
if (!ts.isVariableStatement(statement)
|
|
84
|
+
|| !(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
|
|
85
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
86
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
|
|
87
|
+
const value = stringValue(declaration.initializer);
|
|
88
|
+
if (value !== undefined) lookups.identifiers.set(declaration.name.text, value);
|
|
89
|
+
collectObjectProperties(declaration.name.text, declaration.initializer, lookups);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return lookups;
|
|
93
|
+
}
|
|
94
|
+
function unresolved(rawExpression: string, reason: string): DecoratorResolution {
|
|
95
|
+
return { rawExpression, resolutionKind: 'unresolved', unresolvedReason: reason };
|
|
96
|
+
}
|
|
97
|
+
function generatedConstant(rawExpression: string): string | undefined {
|
|
98
|
+
const match = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/
|
|
99
|
+
.exec(rawExpression.trim());
|
|
100
|
+
return match?.[1]
|
|
101
|
+
? generatedOperationNameFromConstant(match[1])
|
|
102
|
+
: undefined;
|
|
103
|
+
}
|
|
104
|
+
function resolveDecoratorArgument(
|
|
105
|
+
argument: ts.Expression | undefined,
|
|
106
|
+
lookups: StringLookups,
|
|
107
|
+
): DecoratorResolution {
|
|
108
|
+
if (!argument) return unresolved('', 'decorator_argument_missing');
|
|
109
|
+
const rawExpression = argument.getText();
|
|
110
|
+
const expression = unwrapExpression(argument);
|
|
111
|
+
if (ts.isStringLiteralLike(expression))
|
|
112
|
+
return { rawExpression, resolvedValue: expression.text, resolutionKind: 'literal' };
|
|
113
|
+
if (ts.isIdentifier(expression)) {
|
|
114
|
+
const value = lookups.identifiers.get(expression.text);
|
|
115
|
+
return value === undefined
|
|
116
|
+
? unresolved(rawExpression, 'identifier_not_resolved_to_local_const_string')
|
|
117
|
+
: { rawExpression, resolvedValue: value, resolutionKind: 'const_identifier' };
|
|
118
|
+
}
|
|
119
|
+
if (ts.isPropertyAccessExpression(expression)
|
|
120
|
+
&& ts.isIdentifier(expression.expression)) {
|
|
121
|
+
const key = `${expression.expression.text}.${expression.name.text}`;
|
|
122
|
+
const enumValue = lookups.enumMembers.get(key);
|
|
123
|
+
if (enumValue !== undefined)
|
|
124
|
+
return { rawExpression, resolvedValue: enumValue, resolutionKind: 'enum_member' };
|
|
125
|
+
const objectValue = lookups.objectProperties.get(key);
|
|
126
|
+
if (objectValue !== undefined)
|
|
127
|
+
return { rawExpression, resolvedValue: objectValue, resolutionKind: 'const_object_property' };
|
|
128
|
+
}
|
|
129
|
+
const generatedValue = generatedConstant(rawExpression);
|
|
130
|
+
if (generatedValue !== undefined)
|
|
131
|
+
return {
|
|
132
|
+
rawExpression,
|
|
133
|
+
resolvedValue: generatedValue,
|
|
134
|
+
resolutionKind: 'generated_constant_name',
|
|
135
|
+
};
|
|
136
|
+
if (ts.isPropertyAccessExpression(expression))
|
|
137
|
+
return unresolved(rawExpression, 'property_access_not_resolved_to_local_string');
|
|
138
|
+
return unresolved(rawExpression, 'unsupported_decorator_expression');
|
|
22
139
|
}
|
|
23
140
|
export async function parseDecorators(
|
|
24
141
|
repoPath: string,
|
|
@@ -26,16 +143,9 @@ export async function parseDecorators(
|
|
|
26
143
|
): Promise<HandlerClassFact[]> {
|
|
27
144
|
const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
28
145
|
const sf = createSourceFile(filePath, text);
|
|
29
|
-
const
|
|
146
|
+
const lookups = collectStringLookups(sf);
|
|
30
147
|
const handlers: HandlerClassFact[] = [];
|
|
31
148
|
function visit(node: ts.Node): void {
|
|
32
|
-
if (
|
|
33
|
-
ts.isVariableDeclaration(node) &&
|
|
34
|
-
ts.isIdentifier(node.name) &&
|
|
35
|
-
node.initializer &&
|
|
36
|
-
ts.isStringLiteralLike(node.initializer)
|
|
37
|
-
)
|
|
38
|
-
constants.set(node.name.text, node.initializer.text);
|
|
39
149
|
if (ts.isClassDeclaration(node)) {
|
|
40
150
|
const className = node.name?.text ?? 'AnonymousHandler';
|
|
41
151
|
const hasHandler = decs(node).some((d) => callName(d) === 'Handler');
|
|
@@ -45,17 +155,13 @@ export async function parseDecorators(
|
|
|
45
155
|
['Func', 'Action', 'On', 'Event'].includes(callName(d))
|
|
46
156
|
)
|
|
47
157
|
.map((d) => {
|
|
48
|
-
const
|
|
49
|
-
const value =
|
|
50
|
-
raw.startsWith('"') || raw.startsWith("'") || raw.startsWith('`')
|
|
51
|
-
? stripQuotes(raw)
|
|
52
|
-
: (constants.get(raw) ??
|
|
53
|
-
(raw.endsWith('.name') ? raw.split('.').at(-2) : undefined));
|
|
158
|
+
const decoratorResolution = resolveDecoratorArgument(firstArg(d), lookups);
|
|
54
159
|
return {
|
|
55
160
|
methodName: m.name.getText(),
|
|
56
161
|
decoratorKind: callName(d),
|
|
57
|
-
decoratorValue:
|
|
58
|
-
decoratorRawExpression:
|
|
162
|
+
decoratorValue: decoratorResolution.resolvedValue,
|
|
163
|
+
decoratorRawExpression: decoratorResolution.rawExpression,
|
|
164
|
+
decoratorResolution,
|
|
59
165
|
sourceFile: normalizePath(filePath),
|
|
60
166
|
sourceLine: line(sf, m.getStart())
|
|
61
167
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { TraceEdge } from '../types.js';
|
|
2
|
+
|
|
3
|
+
interface DynamicBranchCall {
|
|
4
|
+
repoName: string;
|
|
5
|
+
source_file: string;
|
|
6
|
+
source_line: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function dynamicCandidateBranches(
|
|
10
|
+
depth: number,
|
|
11
|
+
call: DynamicBranchCall,
|
|
12
|
+
evidence: Record<string, unknown>,
|
|
13
|
+
): TraceEdge[] {
|
|
14
|
+
const exploration = objectRecord(evidence.dynamicTargetExploration);
|
|
15
|
+
return recordArray(evidence.dynamicTargetCandidateSuggestions).map((candidate) => ({
|
|
16
|
+
step: depth,
|
|
17
|
+
type: 'dynamic_candidate_branch',
|
|
18
|
+
from: `${call.repoName}:${call.source_file}:${call.source_line}`,
|
|
19
|
+
to: `${String(candidate.servicePath ?? '')}${String(candidate.operationPath ?? '')}`,
|
|
20
|
+
evidence: {
|
|
21
|
+
...candidate,
|
|
22
|
+
exploratory: true,
|
|
23
|
+
dynamicMode: String(exploration.mode ?? 'candidates'),
|
|
24
|
+
selected: false,
|
|
25
|
+
omittedCandidateCount: numericValue(exploration.omittedCandidateCount),
|
|
26
|
+
},
|
|
27
|
+
confidence: numericValue(candidate.score),
|
|
28
|
+
unresolvedReason: 'Exploratory dynamic target candidate; provide runtime variables to select it',
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function objectRecord(value: unknown): Record<string, unknown> {
|
|
33
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function recordArray(value: unknown): Record<string, unknown>[] {
|
|
37
|
+
return Array.isArray(value)
|
|
38
|
+
? value.filter((item): item is Record<string, unknown> =>
|
|
39
|
+
Boolean(item && typeof item === 'object' && !Array.isArray(item)))
|
|
40
|
+
: [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function numericValue(value: unknown): number {
|
|
44
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
45
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import type { Db } from '../db/connection.js';
|
|
2
|
+
import { extractPlaceholders } from '../linker/dynamic-edge-resolver.js';
|
|
3
|
+
import type { OperationTarget } from '../linker/service-resolver.js';
|
|
4
|
+
import type { DynamicMode } from '../types.js';
|
|
5
|
+
|
|
6
|
+
export interface DynamicTargetCandidate {
|
|
7
|
+
candidateOperationId: number;
|
|
8
|
+
repoName: string;
|
|
9
|
+
packageName?: string;
|
|
10
|
+
servicePath: string;
|
|
11
|
+
operationPath: string;
|
|
12
|
+
operationName: string;
|
|
13
|
+
derivedVariables: Record<string, string>;
|
|
14
|
+
derivedVariableSources: Record<string, Record<string, unknown>>;
|
|
15
|
+
missingVariables: string[];
|
|
16
|
+
score: number;
|
|
17
|
+
reasons: string[];
|
|
18
|
+
rejectedReasons: string[];
|
|
19
|
+
cli?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DynamicTargetAnalysis {
|
|
23
|
+
mode: DynamicMode;
|
|
24
|
+
candidateCount: number;
|
|
25
|
+
shownCandidateCount: number;
|
|
26
|
+
omittedCandidateCount: number;
|
|
27
|
+
missingVariables: string[];
|
|
28
|
+
candidates: DynamicTargetCandidate[];
|
|
29
|
+
shownCandidates: DynamicTargetCandidate[];
|
|
30
|
+
suggestedVarSets: Array<{ variables: Record<string, string>; cli: string }>;
|
|
31
|
+
inference: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ReferenceRow {
|
|
35
|
+
alias?: string;
|
|
36
|
+
destination?: string;
|
|
37
|
+
servicePath?: string;
|
|
38
|
+
sourceKind: string;
|
|
39
|
+
repoName: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Templates {
|
|
43
|
+
servicePath?: string;
|
|
44
|
+
operationPath?: string;
|
|
45
|
+
alias?: string;
|
|
46
|
+
destination?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function analyzeDynamicTargetCandidates(
|
|
50
|
+
db: Db,
|
|
51
|
+
evidence: Record<string, unknown>,
|
|
52
|
+
workspaceId: number | undefined,
|
|
53
|
+
mode: DynamicMode,
|
|
54
|
+
maxCandidates: number,
|
|
55
|
+
): DynamicTargetAnalysis | undefined {
|
|
56
|
+
const templates = templatesFromEvidence(evidence);
|
|
57
|
+
const missingVariables = allMissingVariables(evidence, templates);
|
|
58
|
+
if (missingVariables.length === 0) return undefined;
|
|
59
|
+
const order = variableOrder(templates, missingVariables);
|
|
60
|
+
const candidates = rankedCandidates(
|
|
61
|
+
db,
|
|
62
|
+
candidateTargets(db, evidence, workspaceId),
|
|
63
|
+
referenceRows(db, workspaceId),
|
|
64
|
+
templates,
|
|
65
|
+
order,
|
|
66
|
+
);
|
|
67
|
+
const inference = inferenceDecision(candidates);
|
|
68
|
+
const shownCandidates = candidates.slice(0, maxCandidates);
|
|
69
|
+
return {
|
|
70
|
+
mode,
|
|
71
|
+
candidateCount: candidates.length,
|
|
72
|
+
shownCandidateCount: shownCandidates.length,
|
|
73
|
+
omittedCandidateCount: Math.max(0, candidates.length - shownCandidates.length),
|
|
74
|
+
missingVariables,
|
|
75
|
+
candidates,
|
|
76
|
+
shownCandidates,
|
|
77
|
+
suggestedVarSets: suggestedVarSets(candidates, missingVariables, order),
|
|
78
|
+
inference,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function candidateTargets(
|
|
83
|
+
db: Db,
|
|
84
|
+
evidence: Record<string, unknown>,
|
|
85
|
+
workspaceId: number | undefined,
|
|
86
|
+
): OperationTarget[] {
|
|
87
|
+
const embedded = rowsFromEvidence(evidence.candidates);
|
|
88
|
+
if (embedded.length > 0) return embedded;
|
|
89
|
+
const operationPath = stringValue(evidence.operationPath);
|
|
90
|
+
if (!operationPath || extractPlaceholders(operationPath).length > 0) return [];
|
|
91
|
+
return queryOperationTargets(db, operationPath, workspaceId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function rowsFromEvidence(value: unknown): OperationTarget[] {
|
|
95
|
+
if (!Array.isArray(value)) return [];
|
|
96
|
+
return value.flatMap((item): OperationTarget[] => {
|
|
97
|
+
const row = record(item);
|
|
98
|
+
const operationId = numberValue(row.operationId);
|
|
99
|
+
const repoName = stringValue(row.repoName);
|
|
100
|
+
const servicePath = stringValue(row.servicePath);
|
|
101
|
+
const operationPath = stringValue(row.operationPath);
|
|
102
|
+
const operationName = stringValue(row.operationName) ?? operationPath?.replace(/^\//, '');
|
|
103
|
+
if (operationId === undefined || !repoName || !servicePath || !operationPath || !operationName) return [];
|
|
104
|
+
return [{
|
|
105
|
+
operationId,
|
|
106
|
+
repoName,
|
|
107
|
+
serviceName: stringValue(row.serviceName) ?? '',
|
|
108
|
+
qualifiedName: stringValue(row.qualifiedName) ?? '',
|
|
109
|
+
servicePath,
|
|
110
|
+
operationPath,
|
|
111
|
+
operationName,
|
|
112
|
+
sourceFile: stringValue(row.sourceFile) ?? '',
|
|
113
|
+
sourceLine: numberValue(row.sourceLine) ?? 0,
|
|
114
|
+
repoId: numberValue(row.repoId),
|
|
115
|
+
packageName: stringValue(row.packageName),
|
|
116
|
+
score: numberValue(row.score) ?? 0,
|
|
117
|
+
reasons: stringArray(row.reasons),
|
|
118
|
+
}];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function queryOperationTargets(
|
|
123
|
+
db: Db,
|
|
124
|
+
operationPath: string,
|
|
125
|
+
workspaceId: number | undefined,
|
|
126
|
+
): OperationTarget[] {
|
|
127
|
+
const simple = operationPath.replace(/^\//, '').split('.').at(-1) ?? operationPath;
|
|
128
|
+
const rows = db.prepare(
|
|
129
|
+
`SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,
|
|
130
|
+
s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,
|
|
131
|
+
o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,
|
|
132
|
+
o.source_line sourceLine
|
|
133
|
+
FROM cds_operations o JOIN cds_services s ON s.id=o.service_id
|
|
134
|
+
JOIN repositories r ON r.id=s.repo_id
|
|
135
|
+
WHERE (? IS NULL OR r.workspace_id=?)
|
|
136
|
+
AND (o.operation_path IN (?,?) OR o.operation_name=?)
|
|
137
|
+
ORDER BY r.name,s.service_path,o.operation_name`,
|
|
138
|
+
).all(workspaceId, workspaceId, operationPath, `/${simple}`, simple) as Array<Record<string, unknown>>;
|
|
139
|
+
return rows.map((row) => ({
|
|
140
|
+
operationId: Number(row.operationId),
|
|
141
|
+
repoId: numberValue(row.repoId),
|
|
142
|
+
repoName: String(row.repoName),
|
|
143
|
+
packageName: stringValue(row.packageName),
|
|
144
|
+
serviceName: String(row.serviceName),
|
|
145
|
+
qualifiedName: String(row.qualifiedName),
|
|
146
|
+
servicePath: String(row.servicePath),
|
|
147
|
+
operationPath: String(row.operationPath),
|
|
148
|
+
operationName: String(row.operationName),
|
|
149
|
+
sourceFile: String(row.sourceFile),
|
|
150
|
+
sourceLine: Number(row.sourceLine),
|
|
151
|
+
score: 0.2,
|
|
152
|
+
reasons: ['operation_path_match'],
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function rankedCandidates(
|
|
157
|
+
db: Db,
|
|
158
|
+
candidates: OperationTarget[],
|
|
159
|
+
references: ReferenceRow[],
|
|
160
|
+
templates: Templates,
|
|
161
|
+
order: string[],
|
|
162
|
+
): DynamicTargetCandidate[] {
|
|
163
|
+
const ranked = candidates.map((candidate) =>
|
|
164
|
+
candidateEvidence(db, candidate, references, templates, order));
|
|
165
|
+
return ranked.sort((a, b) =>
|
|
166
|
+
b.score - a.score
|
|
167
|
+
|| a.repoName.localeCompare(b.repoName)
|
|
168
|
+
|| a.servicePath.localeCompare(b.servicePath));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function candidateEvidence(
|
|
172
|
+
db: Db,
|
|
173
|
+
candidate: OperationTarget,
|
|
174
|
+
references: ReferenceRow[],
|
|
175
|
+
templates: Templates,
|
|
176
|
+
order: string[],
|
|
177
|
+
): DynamicTargetCandidate {
|
|
178
|
+
const state = emptyCandidate(candidate);
|
|
179
|
+
applyDirectTemplate(state, templates.operationPath, candidate.operationPath, 'operation_path');
|
|
180
|
+
applyDirectTemplate(state, templates.servicePath, candidate.servicePath, 'service_path');
|
|
181
|
+
const refs = references.filter((item) => item.servicePath === candidate.servicePath);
|
|
182
|
+
applyReferenceTemplate(state, templates.alias, refs, 'alias');
|
|
183
|
+
applyReferenceTemplate(state, templates.destination, refs, 'destination');
|
|
184
|
+
if (hasResolvedImplementation(db, candidate.operationId)) addScore(state, 0.1, 'implementation_edge_resolved');
|
|
185
|
+
state.missingVariables = order.filter((key) => state.derivedVariables[key] === undefined);
|
|
186
|
+
if (state.missingVariables.length === 0) addScore(state, 0.15, 'all_runtime_variables_derived');
|
|
187
|
+
else state.rejectedReasons.push('missing_required_runtime_variable');
|
|
188
|
+
state.score = Math.max(0, Math.min(1, state.score));
|
|
189
|
+
state.cli = state.missingVariables.length === 0 ? cliFor(state.derivedVariables, order) : undefined;
|
|
190
|
+
return state;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function emptyCandidate(candidate: OperationTarget): DynamicTargetCandidate {
|
|
194
|
+
return {
|
|
195
|
+
candidateOperationId: candidate.operationId,
|
|
196
|
+
repoName: candidate.repoName,
|
|
197
|
+
packageName: candidate.packageName ?? undefined,
|
|
198
|
+
servicePath: candidate.servicePath,
|
|
199
|
+
operationPath: candidate.operationPath,
|
|
200
|
+
operationName: candidate.operationName,
|
|
201
|
+
derivedVariables: {},
|
|
202
|
+
derivedVariableSources: {},
|
|
203
|
+
missingVariables: [],
|
|
204
|
+
score: Math.max(0.2, Number(candidate.score ?? 0)),
|
|
205
|
+
reasons: nonEmptyStrings(candidate.reasons, ['operation_path_match']),
|
|
206
|
+
rejectedReasons: [],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function applyDirectTemplate(
|
|
211
|
+
state: DynamicTargetCandidate,
|
|
212
|
+
template: string | undefined,
|
|
213
|
+
concrete: string,
|
|
214
|
+
kind: 'operation_path' | 'service_path',
|
|
215
|
+
): void {
|
|
216
|
+
const matched = matchTemplate(template, concrete);
|
|
217
|
+
if (!template) return;
|
|
218
|
+
if (!matched) {
|
|
219
|
+
state.rejectedReasons.push(`${kind}_template_mismatch`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
mergeDerived(state, matched, `${kind}_template`);
|
|
223
|
+
addScore(state, kind === 'service_path' ? 0.35 : 0.25, `${kind}_template_match`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function applyReferenceTemplate(
|
|
227
|
+
state: DynamicTargetCandidate,
|
|
228
|
+
template: string | undefined,
|
|
229
|
+
refs: ReferenceRow[],
|
|
230
|
+
kind: 'alias' | 'destination',
|
|
231
|
+
): void {
|
|
232
|
+
if (!template || extractPlaceholders(template).length === 0) return;
|
|
233
|
+
for (const ref of refs) {
|
|
234
|
+
const concrete = kind === 'alias' ? ref.alias : ref.destination;
|
|
235
|
+
const matched = isConcrete(concrete) ? matchTemplate(template, concrete) : undefined;
|
|
236
|
+
if (!matched) continue;
|
|
237
|
+
mergeDerived(state, matched, `${ref.sourceKind}.${kind}`);
|
|
238
|
+
addScore(state, 0.2, `${kind}_template_match`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (refs.some((ref) => isConcrete(kind === 'alias' ? ref.alias : ref.destination)))
|
|
242
|
+
state.rejectedReasons.push(`${kind}_template_mismatch`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function inferenceDecision(candidates: DynamicTargetCandidate[]): Record<string, unknown> {
|
|
246
|
+
const complete = candidates.filter((candidate) =>
|
|
247
|
+
candidate.missingVariables.length === 0
|
|
248
|
+
&& candidate.rejectedReasons.length === 0);
|
|
249
|
+
const first = complete[0];
|
|
250
|
+
const second = complete[1];
|
|
251
|
+
if (!first) return { status: 'unresolved', reason: 'missing_required_runtime_variable' };
|
|
252
|
+
if (first.score < 0.85) return { status: 'unresolved', reason: 'candidate_score_below_inference_threshold' };
|
|
253
|
+
if (second && first.score - second.score <= 0.05) {
|
|
254
|
+
for (const candidate of complete.filter((item) => first.score - item.score <= 0.05))
|
|
255
|
+
candidate.rejectedReasons.push('candidate_tied_with_equal_score');
|
|
256
|
+
return { status: 'ambiguous', reason: 'candidate_tied_with_equal_score' };
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
status: 'resolved',
|
|
260
|
+
candidateOperationId: first.candidateOperationId,
|
|
261
|
+
inferredVariables: first.derivedVariables,
|
|
262
|
+
score: first.score,
|
|
263
|
+
reasons: first.reasons,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function suggestedVarSets(
|
|
268
|
+
candidates: DynamicTargetCandidate[],
|
|
269
|
+
required: string[],
|
|
270
|
+
order: string[],
|
|
271
|
+
): Array<{ variables: Record<string, string>; cli: string }> {
|
|
272
|
+
const seen = new Set<string>();
|
|
273
|
+
return candidates.flatMap((candidate) => {
|
|
274
|
+
if (required.some((key) => candidate.derivedVariables[key] === undefined)) return [];
|
|
275
|
+
const cli = cliFor(candidate.derivedVariables, order);
|
|
276
|
+
if (seen.has(cli)) return [];
|
|
277
|
+
seen.add(cli);
|
|
278
|
+
return [{ variables: candidate.derivedVariables, cli }];
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function referenceRows(db: Db, workspaceId: number | undefined): ReferenceRow[] {
|
|
283
|
+
const rows = db.prepare(
|
|
284
|
+
`SELECT req.alias alias,req.destination destination,req.service_path servicePath,
|
|
285
|
+
'cds_require' sourceKind,r.name repoName
|
|
286
|
+
FROM cds_requires req JOIN repositories r ON r.id=req.repo_id
|
|
287
|
+
WHERE (? IS NULL OR r.workspace_id=?)
|
|
288
|
+
UNION ALL
|
|
289
|
+
SELECT COALESCE(b.alias,b.alias_expr) alias,b.destination_expr destination,
|
|
290
|
+
b.service_path_expr servicePath,'service_binding' sourceKind,r.name repoName
|
|
291
|
+
FROM service_bindings b JOIN repositories r ON r.id=b.repo_id
|
|
292
|
+
WHERE (? IS NULL OR r.workspace_id=?)`,
|
|
293
|
+
).all(workspaceId, workspaceId, workspaceId, workspaceId) as Array<Record<string, unknown>>;
|
|
294
|
+
return rows.map((row) => ({
|
|
295
|
+
alias: stringValue(row.alias),
|
|
296
|
+
destination: stringValue(row.destination),
|
|
297
|
+
servicePath: stringValue(row.servicePath),
|
|
298
|
+
sourceKind: String(row.sourceKind),
|
|
299
|
+
repoName: String(row.repoName),
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function allMissingVariables(evidence: Record<string, unknown>, templates: Templates): string[] {
|
|
304
|
+
const fromSubstitutions = Object.values(record(evidence.runtimeSubstitutions))
|
|
305
|
+
.flatMap((value) => stringArray(record(value).missing));
|
|
306
|
+
const fromTemplates = [
|
|
307
|
+
templates.servicePath,
|
|
308
|
+
templates.operationPath,
|
|
309
|
+
templates.alias,
|
|
310
|
+
templates.destination,
|
|
311
|
+
].flatMap((value) => extractPlaceholders(value));
|
|
312
|
+
return [...new Set([...fromSubstitutions, ...fromTemplates])].sort();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function variableOrder(templates: Templates, missingVariables: string[]): string[] {
|
|
316
|
+
const ordered = [
|
|
317
|
+
templates.servicePath,
|
|
318
|
+
templates.operationPath,
|
|
319
|
+
templates.alias,
|
|
320
|
+
templates.destination,
|
|
321
|
+
].flatMap((value) => extractPlaceholders(value));
|
|
322
|
+
return [...new Set([...ordered, ...missingVariables])];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function templatesFromEvidence(evidence: Record<string, unknown>): Templates {
|
|
326
|
+
return {
|
|
327
|
+
servicePath: stringValue(evidence.servicePath),
|
|
328
|
+
operationPath: stringValue(evidence.operationPath),
|
|
329
|
+
alias: stringValue(evidence.serviceAliasExpr ?? evidence.serviceAlias),
|
|
330
|
+
destination: stringValue(evidence.destination),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function matchTemplate(template: string | undefined, concrete: string | undefined): Record<string, string> | undefined {
|
|
335
|
+
if (!template || !concrete) return undefined;
|
|
336
|
+
const keys = extractPlaceholders(template);
|
|
337
|
+
if (keys.length === 0) return template === concrete ? {} : undefined;
|
|
338
|
+
const regex = new RegExp(`^${templateToPattern(template)}$`);
|
|
339
|
+
const match = regex.exec(concrete);
|
|
340
|
+
if (!match) return undefined;
|
|
341
|
+
const values: Record<string, string> = {};
|
|
342
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
343
|
+
const key = keys[index];
|
|
344
|
+
const value = match[index + 1];
|
|
345
|
+
if (!key || value === undefined) return undefined;
|
|
346
|
+
if (values[key] !== undefined && values[key] !== value) return undefined;
|
|
347
|
+
values[key] = value;
|
|
348
|
+
}
|
|
349
|
+
return values;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function templateToPattern(template: string): string {
|
|
353
|
+
let pattern = '';
|
|
354
|
+
let lastIndex = 0;
|
|
355
|
+
for (const match of template.matchAll(/\$\{([^}]*)\}/g)) {
|
|
356
|
+
pattern += escapeRegex(template.slice(lastIndex, match.index));
|
|
357
|
+
pattern += '([^/]+?)';
|
|
358
|
+
lastIndex = (match.index ?? 0) + match[0].length;
|
|
359
|
+
}
|
|
360
|
+
return `${pattern}${escapeRegex(template.slice(lastIndex))}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function mergeDerived(
|
|
364
|
+
state: DynamicTargetCandidate,
|
|
365
|
+
values: Record<string, string>,
|
|
366
|
+
sourceKind: string,
|
|
367
|
+
): void {
|
|
368
|
+
for (const [key, value] of Object.entries(values)) {
|
|
369
|
+
if (state.derivedVariables[key] !== undefined && state.derivedVariables[key] !== value) {
|
|
370
|
+
state.rejectedReasons.push('template_variable_conflict');
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
state.derivedVariables[key] = value;
|
|
374
|
+
state.derivedVariableSources[key] = { sourceKind, value };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function addScore(state: DynamicTargetCandidate, amount: number, reason: string): void {
|
|
379
|
+
state.score += amount;
|
|
380
|
+
if (!state.reasons.includes(reason)) state.reasons.push(reason);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function hasResolvedImplementation(db: Db, operationId: number): boolean {
|
|
384
|
+
const row = db.prepare(
|
|
385
|
+
"SELECT 1 FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND status='resolved' AND from_kind='operation' AND from_id=? LIMIT 1",
|
|
386
|
+
).get(String(operationId));
|
|
387
|
+
return Boolean(row);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function cliFor(variables: Record<string, string>, order: string[]): string {
|
|
391
|
+
return order
|
|
392
|
+
.filter((key) => variables[key] !== undefined)
|
|
393
|
+
.map((key) => `--var ${key}=${variables[key]}`)
|
|
394
|
+
.join(' ');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function record(value: unknown): Record<string, unknown> {
|
|
398
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function stringValue(value: unknown): string | undefined {
|
|
402
|
+
return typeof value === 'string' ? value : undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function numberValue(value: unknown): number | undefined {
|
|
406
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function stringArray(value: unknown): string[] {
|
|
410
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function nonEmptyStrings(value: unknown, fallback: string[]): string[] {
|
|
414
|
+
const values = stringArray(value).filter((item) => item.length > 0);
|
|
415
|
+
return values.length > 0 ? values : fallback;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isConcrete(value: unknown): value is string {
|
|
419
|
+
return typeof value === 'string' && value.length > 0 && extractPlaceholders(value).length === 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function escapeRegex(value: string): string {
|
|
423
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
424
|
+
}
|