@saptools/service-flow 0.1.44 → 0.1.46
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 +9 -1
- package/dist/{chunk-UKNPHTUS.js → chunk-EGY2A4AT.js} +1853 -1462
- package/dist/chunk-EGY2A4AT.js.map +1 -0
- package/dist/cli.js +101 -31
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +67 -13
- package/src/cli.ts +10 -2
- package/src/index.ts +2 -0
- package/src/linker/cross-repo-linker.ts +27 -3
- package/src/linker/odata-path-normalizer.ts +4 -1
- package/src/output/table-output.ts +16 -1
- package/src/parsers/imported-wrapper-parser.ts +262 -0
- package/src/parsers/outbound-call-parser.ts +5 -1
- package/src/parsers/symbol-parser.ts +25 -10
- package/src/trace/evidence.ts +6 -1
- package/src/trace/implementation-hints.ts +223 -0
- package/src/trace/trace-engine.ts +52 -45
- package/src/types.ts +8 -0
- package/dist/chunk-UKNPHTUS.js.map +0 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
4
|
+
import type { OutboundCallFact } from '../types.js';
|
|
5
|
+
import { normalizePath } from '../utils/path-utils.js';
|
|
6
|
+
import { importsFor, lineOf, readSource, type ImportBinding } from './service-binding-parser-helpers.js';
|
|
7
|
+
|
|
8
|
+
interface WrapperSpec {
|
|
9
|
+
clientIndex: number;
|
|
10
|
+
pathIndex: number;
|
|
11
|
+
methodIndex?: number;
|
|
12
|
+
methodLiteral?: string;
|
|
13
|
+
sourceFile: string;
|
|
14
|
+
sourceLine: number;
|
|
15
|
+
chain: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PathValue {
|
|
19
|
+
value?: string;
|
|
20
|
+
sourceKind: string;
|
|
21
|
+
rawExpression: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function parseImportedWrapperCalls(
|
|
25
|
+
repoPath: string,
|
|
26
|
+
filePath: string,
|
|
27
|
+
source: ts.SourceFile,
|
|
28
|
+
serviceBindings: Set<string>,
|
|
29
|
+
): Promise<OutboundCallFact[]> {
|
|
30
|
+
const imports = await importsFor(repoPath, filePath, source);
|
|
31
|
+
const importedByLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
|
|
32
|
+
const calls = collectImportedCalls(source, importedByLocal);
|
|
33
|
+
const out: OutboundCallFact[] = [];
|
|
34
|
+
const cache = new Map<string, Promise<WrapperSpec | undefined>>();
|
|
35
|
+
for (const call of calls) {
|
|
36
|
+
if (!ts.isIdentifier(call.expression)) continue;
|
|
37
|
+
const imported = importedByLocal.get(call.expression.text);
|
|
38
|
+
if (!imported?.sourceFile) continue;
|
|
39
|
+
const spec = await loadWrapperSpec(repoPath, imported, cache, 0);
|
|
40
|
+
const fact = spec ? wrapperCallFact(source, filePath, call, spec, serviceBindings) : undefined;
|
|
41
|
+
if (fact) out.push(fact);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectImportedCalls(source: ts.SourceFile, imports: Map<string, ImportBinding>): ts.CallExpression[] {
|
|
47
|
+
const calls: ts.CallExpression[] = [];
|
|
48
|
+
const visit = (node: ts.Node): void => {
|
|
49
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && imports.has(node.expression.text)) calls.push(node);
|
|
50
|
+
ts.forEachChild(node, visit);
|
|
51
|
+
};
|
|
52
|
+
visit(source);
|
|
53
|
+
return calls;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function loadWrapperSpec(
|
|
57
|
+
repoPath: string,
|
|
58
|
+
imported: ImportBinding,
|
|
59
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
60
|
+
depth: number,
|
|
61
|
+
): Promise<WrapperSpec | undefined> {
|
|
62
|
+
if (!imported.sourceFile || depth > 5) return undefined;
|
|
63
|
+
const key = `${imported.sourceFile}#${imported.exportedName}`;
|
|
64
|
+
const existing = cache.get(key);
|
|
65
|
+
if (existing) return existing;
|
|
66
|
+
const pending = inspectWrapper(repoPath, imported.sourceFile, imported.exportedName, cache, depth);
|
|
67
|
+
cache.set(key, pending);
|
|
68
|
+
return pending;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function inspectWrapper(
|
|
72
|
+
repoPath: string,
|
|
73
|
+
sourceFile: string,
|
|
74
|
+
exportedName: string,
|
|
75
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
76
|
+
depth: number,
|
|
77
|
+
): Promise<WrapperSpec | undefined> {
|
|
78
|
+
const source = await readSource(path.join(repoPath, sourceFile));
|
|
79
|
+
if (!source) return undefined;
|
|
80
|
+
const named = findFunction(source, exportedName);
|
|
81
|
+
if (!named) return undefined;
|
|
82
|
+
const direct = directSendSpec(source, sourceFile, named.name, named.fn);
|
|
83
|
+
if (direct) return direct;
|
|
84
|
+
return nestedSendSpec(repoPath, sourceFile, source, named.name, named.fn, cache, depth);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function directSendSpec(source: ts.SourceFile, sourceFile: string, name: string, fn: ts.FunctionLikeDeclaration): WrapperSpec | undefined {
|
|
88
|
+
const params = parameterNames(fn);
|
|
89
|
+
const sends: ts.CallExpression[] = [];
|
|
90
|
+
visitFunctionBody(fn, (node) => {
|
|
91
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send') sends.push(node);
|
|
92
|
+
});
|
|
93
|
+
if (sends.length !== 1) return undefined;
|
|
94
|
+
const send = sends[0];
|
|
95
|
+
const receiver = send && ts.isPropertyAccessExpression(send.expression) && ts.isIdentifier(send.expression.expression) ? send.expression.expression.text : undefined;
|
|
96
|
+
const object = send?.arguments[0];
|
|
97
|
+
if (!receiver || !object || !ts.isObjectLiteralExpression(object)) return undefined;
|
|
98
|
+
const pathExpr = propertyExpression(object, 'path');
|
|
99
|
+
const methodExpr = propertyExpression(object, 'method');
|
|
100
|
+
const pathName = pathExpr && ts.isIdentifier(pathExpr) ? pathExpr.text : undefined;
|
|
101
|
+
const clientIndex = params.indexOf(receiver);
|
|
102
|
+
const pathIndex = pathName ? params.indexOf(pathName) : -1;
|
|
103
|
+
if (clientIndex < 0 || pathIndex < 0) return undefined;
|
|
104
|
+
const methodName = methodExpr && ts.isIdentifier(methodExpr) ? methodExpr.text : undefined;
|
|
105
|
+
const methodIndex = methodName ? params.indexOf(methodName) : -1;
|
|
106
|
+
return { clientIndex, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodLiteral: literal(methodExpr), sourceFile, sourceLine: lineOf(source, fn), chain: [name] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function nestedSendSpec(
|
|
110
|
+
repoPath: string,
|
|
111
|
+
sourceFile: string,
|
|
112
|
+
source: ts.SourceFile,
|
|
113
|
+
name: string,
|
|
114
|
+
fn: ts.FunctionLikeDeclaration,
|
|
115
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
116
|
+
depth: number,
|
|
117
|
+
): Promise<WrapperSpec | undefined> {
|
|
118
|
+
const imports = await importsFor(repoPath, sourceFile, source);
|
|
119
|
+
const byLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
|
|
120
|
+
const calls: ts.CallExpression[] = [];
|
|
121
|
+
visitFunctionBody(fn, (node) => {
|
|
122
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && byLocal.has(node.expression.text)) calls.push(node);
|
|
123
|
+
});
|
|
124
|
+
if (calls.length !== 1) return undefined;
|
|
125
|
+
const call = calls[0];
|
|
126
|
+
const imported = call && ts.isIdentifier(call.expression) ? byLocal.get(call.expression.text) : undefined;
|
|
127
|
+
const nested = imported ? await loadWrapperSpec(repoPath, imported, cache, depth + 1) : undefined;
|
|
128
|
+
if (!call || !nested) return undefined;
|
|
129
|
+
const params = parameterNames(fn);
|
|
130
|
+
const clientIndex = mappedParameterIndex(call.arguments[nested.clientIndex], params);
|
|
131
|
+
const pathIndex = mappedParameterIndex(call.arguments[nested.pathIndex], params);
|
|
132
|
+
if (clientIndex < 0 || pathIndex < 0) return undefined;
|
|
133
|
+
const methodIndex = nested.methodIndex === undefined ? undefined : mappedParameterIndex(call.arguments[nested.methodIndex], params);
|
|
134
|
+
return { clientIndex, pathIndex, methodIndex: methodIndex !== undefined && methodIndex >= 0 ? methodIndex : undefined, methodLiteral: nested.methodLiteral, sourceFile, sourceLine: lineOf(source, fn), chain: [name, ...nested.chain] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function wrapperCallFact(
|
|
138
|
+
source: ts.SourceFile,
|
|
139
|
+
filePath: string,
|
|
140
|
+
call: ts.CallExpression,
|
|
141
|
+
spec: WrapperSpec,
|
|
142
|
+
serviceBindings: Set<string>,
|
|
143
|
+
): OutboundCallFact | undefined {
|
|
144
|
+
const client = call.arguments[spec.clientIndex];
|
|
145
|
+
if (!client || !ts.isIdentifier(client) || !serviceBindings.has(client.text)) return undefined;
|
|
146
|
+
const pathValue = resolveCallerPath(call.arguments[spec.pathIndex], call);
|
|
147
|
+
const methodValue = spec.methodIndex === undefined ? spec.methodLiteral : literal(call.arguments[spec.methodIndex]);
|
|
148
|
+
const method = (methodValue ?? 'POST').toUpperCase();
|
|
149
|
+
const rawPath = pathValue.rawExpression;
|
|
150
|
+
const operationPathExpr = pathValue.value ? normalizeOperationPath(pathValue.value) : runtimePathExpression(rawPath);
|
|
151
|
+
return {
|
|
152
|
+
callType: 'remote_action',
|
|
153
|
+
serviceVariableName: client.text,
|
|
154
|
+
method,
|
|
155
|
+
operationPathExpr,
|
|
156
|
+
payloadSummary: call.getText(source),
|
|
157
|
+
sourceFile: normalizePath(filePath),
|
|
158
|
+
sourceLine: lineOf(source, call),
|
|
159
|
+
confidence: operationPathExpr ? 0.85 : 0.5,
|
|
160
|
+
unresolvedReason: pathValue.value ? undefined : 'dynamic_operation_path_identifier',
|
|
161
|
+
evidence: {
|
|
162
|
+
parser: 'typescript_ast',
|
|
163
|
+
classifier: pathValue.value ? 'imported_wrapper_literal_path' : 'imported_wrapper_dynamic_path',
|
|
164
|
+
receiver: client.text,
|
|
165
|
+
wrapperFunction: spec.chain[0],
|
|
166
|
+
wrapperChain: spec.chain,
|
|
167
|
+
callerSite: { sourceFile: normalizePath(filePath), sourceLine: lineOf(source, call) },
|
|
168
|
+
calleeSite: { sourceFile: spec.sourceFile, sourceLine: spec.sourceLine },
|
|
169
|
+
rawPathExpression: rawPath,
|
|
170
|
+
missingPathIdentifier: pathValue.value ? undefined : rawPath,
|
|
171
|
+
odataPathIntent: pathValue.value ? classifyODataPathIntent(operationPathExpr, method) : undefined,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveCallerPath(expr: ts.Expression | undefined, use: ts.Node): PathValue {
|
|
177
|
+
if (!expr) return { sourceKind: 'missing', rawExpression: '' };
|
|
178
|
+
if (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { value: expr.text, sourceKind: 'literal', rawExpression: expr.getText() };
|
|
179
|
+
if (ts.isTemplateExpression(expr)) return { value: expr.getText().slice(1, -1), sourceKind: 'template', rawExpression: expr.getText() };
|
|
180
|
+
if (ts.isIdentifier(expr)) {
|
|
181
|
+
const initializer = constInitializer(expr.text, use);
|
|
182
|
+
if (initializer) {
|
|
183
|
+
const resolved = resolveCallerPath(initializer, initializer);
|
|
184
|
+
return { ...resolved, sourceKind: 'const', rawExpression: expr.text };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { sourceKind: 'dynamic', rawExpression: expr.getText() };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function constInitializer(name: string, use: ts.Node): ts.Expression | undefined {
|
|
191
|
+
let found: ts.Expression | undefined;
|
|
192
|
+
const source = use.getSourceFile();
|
|
193
|
+
const visit = (node: ts.Node): void => {
|
|
194
|
+
if (node.getStart(source) >= use.getStart(source)) return;
|
|
195
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer && (node.parent.flags & ts.NodeFlags.Const) !== 0) found = node.initializer;
|
|
196
|
+
ts.forEachChild(node, visit);
|
|
197
|
+
};
|
|
198
|
+
visit(source);
|
|
199
|
+
return found;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function findFunction(source: ts.SourceFile, exportedName: string): { name: string; fn: ts.FunctionLikeDeclaration } | undefined {
|
|
203
|
+
const localName = exportedLocalName(source, exportedName);
|
|
204
|
+
for (const statement of source.statements) {
|
|
205
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === localName) return { name: exportedName, fn: statement };
|
|
206
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
207
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
208
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === localName && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return { name: exportedName, fn: declaration.initializer };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function exportedLocalName(source: ts.SourceFile, exportedName: string): string {
|
|
215
|
+
for (const statement of source.statements) {
|
|
216
|
+
if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue;
|
|
217
|
+
const match = statement.exportClause.elements.find((item) => item.name.text === exportedName);
|
|
218
|
+
if (match) return match.propertyName?.text ?? match.name.text;
|
|
219
|
+
}
|
|
220
|
+
return exportedName;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function visitFunctionBody(fn: ts.FunctionLikeDeclaration, visitor: (node: ts.Node) => void): void {
|
|
224
|
+
const visit = (node: ts.Node): void => {
|
|
225
|
+
if (node !== fn && ts.isFunctionLike(node)) return;
|
|
226
|
+
visitor(node);
|
|
227
|
+
ts.forEachChild(node, visit);
|
|
228
|
+
};
|
|
229
|
+
if (fn.body) visit(fn.body);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parameterNames(fn: ts.FunctionLikeDeclaration): string[] {
|
|
233
|
+
return fn.parameters.map((parameter) => ts.isIdentifier(parameter.name) ? parameter.name.text : '');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function mappedParameterIndex(expr: ts.Expression | undefined, parameters: string[]): number {
|
|
237
|
+
return expr && ts.isIdentifier(expr) ? parameters.indexOf(expr.text) : -1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function propertyExpression(object: ts.ObjectLiteralExpression, key: string): ts.Expression | undefined {
|
|
241
|
+
for (const property of object.properties) {
|
|
242
|
+
if (ts.isPropertyAssignment(property) && propertyName(property.name) === key) return property.initializer;
|
|
243
|
+
if (ts.isShorthandPropertyAssignment(property) && property.name.text === key) return property.name;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function propertyName(name: ts.PropertyName): string | undefined {
|
|
249
|
+
return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function literal(expr: ts.Expression | undefined): string | undefined {
|
|
253
|
+
return expr && (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) ? expr.text : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeOperationPath(value: string): string {
|
|
257
|
+
return value.startsWith('/') ? value : `/${value}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function runtimePathExpression(value: string): string | undefined {
|
|
261
|
+
return value ? `\${${value}}` : undefined;
|
|
262
|
+
}
|
|
@@ -6,6 +6,8 @@ import type { OutboundCallFact } from '../types.js';
|
|
|
6
6
|
import { normalizePath, stripQuotes } from '../utils/path-utils.js';
|
|
7
7
|
import { summarizeExpression } from '../utils/redaction.js';
|
|
8
8
|
import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
9
|
+
import { parseServiceBindings } from './service-binding-parser.js';
|
|
10
|
+
import { parseImportedWrapperCalls } from './imported-wrapper-parser.js';
|
|
9
11
|
function lineOf(text: string, idx: number): number {
|
|
10
12
|
return text.slice(0, idx).split('\n').length;
|
|
11
13
|
}
|
|
@@ -501,7 +503,9 @@ export async function parseOutboundCalls(
|
|
|
501
503
|
): Promise<OutboundCallFact[]> {
|
|
502
504
|
const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
503
505
|
const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
504
|
-
|
|
506
|
+
const bindingNames = new Set((await parseServiceBindings(repoPath, filePath)).map((binding) => binding.variableName));
|
|
507
|
+
const importedWrappers = await parseImportedWrapperCalls(repoPath, filePath, source, bindingNames);
|
|
508
|
+
return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...importedWrappers, ...parseLocalServiceCalls(text, filePath)];
|
|
505
509
|
}
|
|
506
510
|
function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFact[] {
|
|
507
511
|
const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
@@ -42,7 +42,8 @@ function isObjectFunction(node: ts.Node): boolean {
|
|
|
42
42
|
}
|
|
43
43
|
type ParameterBinding =
|
|
44
44
|
| { index: number; kind: 'identifier'; name: string }
|
|
45
|
-
| { index: number; kind: 'object_pattern'; properties: Array<{ property: string; local: string }> }
|
|
45
|
+
| { index: number; kind: 'object_pattern'; properties: Array<{ property: string; local: string }> }
|
|
46
|
+
| { index: number; kind: 'array_pattern'; elements: Array<{ index: number; local: string }> };
|
|
46
47
|
type ParameterPropertyAlias = { parameter: string; property: string; local: string; kind: 'object_parameter_destructure'; line: number };
|
|
47
48
|
const commonTerminalMembers = new Set(['push', 'includes', 'find', 'findIndex', 'map', 'filter', 'reduce', 'forEach', 'some', 'every', 'toUpperCase', 'toLowerCase', 'trim', 'split', 'join', 'get', 'set', 'has']);
|
|
48
49
|
const loggerMembers = new Set(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'log']);
|
|
@@ -99,6 +100,11 @@ function argumentEvidence(args: ts.NodeArray<ts.Expression>, source: ts.SourceFi
|
|
|
99
100
|
}
|
|
100
101
|
return { kind: 'object_literal', properties };
|
|
101
102
|
}
|
|
103
|
+
if (ts.isArrayLiteralExpression(arg)) {
|
|
104
|
+
const elements = arg.elements.flatMap((element, index): Array<Record<string, unknown>> =>
|
|
105
|
+
ts.isIdentifier(element) ? [{ index, kind: 'identifier', name: element.text }] : []);
|
|
106
|
+
return { kind: 'array_literal', elements };
|
|
107
|
+
}
|
|
102
108
|
return { kind: 'unsupported', expression: arg.getText(source) };
|
|
103
109
|
});
|
|
104
110
|
}
|
|
@@ -141,15 +147,24 @@ function parameterPropertyAliases(fn: ts.FunctionLikeDeclaration, source: ts.Sou
|
|
|
141
147
|
function parameterBindings(params: ts.NodeArray<ts.ParameterDeclaration>): ParameterBinding[] {
|
|
142
148
|
return params.flatMap((param, index): ParameterBinding[] => {
|
|
143
149
|
if (ts.isIdentifier(param.name)) return [{ index, kind: 'identifier', name: param.name.text }];
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
if (ts.isObjectBindingPattern(param.name)) {
|
|
151
|
+
const properties = param.name.elements.flatMap((element): Array<{ property: string; local: string }> => {
|
|
152
|
+
if (element.dotDotDotToken || ts.isObjectBindingPattern(element.name) || ts.isArrayBindingPattern(element.name)) return [];
|
|
153
|
+
const property = element.propertyName ? nameOf(element.propertyName) : nameOf(element.name);
|
|
154
|
+
if (!property) return [];
|
|
155
|
+
const local = bindingLocalName(element.name, element.initializer);
|
|
156
|
+
return local ? [{ property, local }] : [];
|
|
157
|
+
});
|
|
158
|
+
return properties.length > 0 ? [{ index, kind: 'object_pattern', properties }] : [];
|
|
159
|
+
}
|
|
160
|
+
if (ts.isArrayBindingPattern(param.name)) {
|
|
161
|
+
const elements = param.name.elements.flatMap((element, elementIndex): Array<{ index: number; local: string }> =>
|
|
162
|
+
ts.isBindingElement(element) && !element.dotDotDotToken && ts.isIdentifier(element.name)
|
|
163
|
+
? [{ index: elementIndex, local: element.name.text }]
|
|
164
|
+
: []);
|
|
165
|
+
return elements.length > 0 ? [{ index, kind: 'array_pattern', elements }] : [];
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
153
168
|
});
|
|
154
169
|
}
|
|
155
170
|
export async function parseExecutableSymbols(repoPath: string, filePath: string): Promise<{ symbols: ExecutableSymbolFact[]; calls: SymbolCallFact[] }> {
|
package/src/trace/evidence.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Db } from '../db/connection.js';
|
|
2
2
|
import { extractPlaceholders, substituteVariables, type RuntimeSubstitution } from '../linker/dynamic-edge-resolver.js';
|
|
3
|
+
import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
|
|
3
4
|
import { resolveOperation, type OperationTarget } from '../linker/service-resolver.js';
|
|
4
5
|
|
|
5
6
|
export interface TraceGraphRow extends Record<string, unknown> {
|
|
@@ -154,7 +155,11 @@ function withEffectiveResolution(
|
|
|
154
155
|
|
|
155
156
|
function resolveRuntimeOperation(db: Db, evidence: Record<string, unknown>, workspaceId: number | undefined): ReturnType<typeof resolveOperation> {
|
|
156
157
|
const servicePath = stringValue(evidence.servicePath);
|
|
157
|
-
const
|
|
158
|
+
const rawOperationPath = stringValue(evidence.operationPath);
|
|
159
|
+
const normalized = normalizeODataOperationInvocationPath(rawOperationPath);
|
|
160
|
+
const operationPath = normalized?.wasInvocation
|
|
161
|
+
? normalized.normalizedOperationPath
|
|
162
|
+
: stringValue(evidence.normalizedOperationPath) ?? rawOperationPath;
|
|
158
163
|
const alias = stringValue(evidence.serviceAliasExpr ?? evidence.serviceAlias);
|
|
159
164
|
const destination = stringValue(evidence.destination);
|
|
160
165
|
return resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceId);
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { ImplementationHint } from '../types.js';
|
|
2
|
+
|
|
3
|
+
interface Candidate {
|
|
4
|
+
accepted?: boolean;
|
|
5
|
+
methodId?: number;
|
|
6
|
+
sourceFile?: string;
|
|
7
|
+
handlerPackage?: { name?: string; packageName?: string };
|
|
8
|
+
modelPackage?: { name?: string; packageName?: string };
|
|
9
|
+
servicePath?: string;
|
|
10
|
+
operationPath?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface EdgeEvidence {
|
|
14
|
+
servicePath?: string;
|
|
15
|
+
operationPath?: string;
|
|
16
|
+
ambiguityReasons?: string[];
|
|
17
|
+
candidateFamilies?: Array<{ packageName?: string }>;
|
|
18
|
+
candidates?: Candidate[];
|
|
19
|
+
modelPackage?: { name?: string; packageName?: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ImplementationSelection {
|
|
23
|
+
methodId?: string;
|
|
24
|
+
blocksAutomatic: boolean;
|
|
25
|
+
evidence: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseImplementationHint(value: string): ImplementationHint {
|
|
29
|
+
const hint: Partial<ImplementationHint> = {};
|
|
30
|
+
for (const part of value.split(',')) {
|
|
31
|
+
const separator = part.indexOf('=');
|
|
32
|
+
if (separator <= 0 || separator === part.length - 1) throw new Error(`Invalid implementation hint field: ${part}`);
|
|
33
|
+
assignHintField(hint, part.slice(0, separator).trim(), part.slice(separator + 1).trim());
|
|
34
|
+
}
|
|
35
|
+
if (!hint.implementationRepo) throw new Error('Scoped implementation hint requires an implementation repo selection');
|
|
36
|
+
return { ...hint, implementationRepo: hint.implementationRepo };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function selectImplementation(
|
|
40
|
+
rawEvidence: Record<string, unknown>,
|
|
41
|
+
hints: ImplementationHint[] | undefined,
|
|
42
|
+
legacyRepo: string | undefined,
|
|
43
|
+
): ImplementationSelection {
|
|
44
|
+
const evidence = asEvidence(rawEvidence);
|
|
45
|
+
const scoped = hints ?? [];
|
|
46
|
+
const matchingHints = scoped.filter((hint) => hintMatchesEdge(hint, evidence));
|
|
47
|
+
if (matchingHints.length === 0) {
|
|
48
|
+
if (legacyRepo) return selectCandidate(evidence, legacyHint(legacyRepo), 'implementation_repo_hint');
|
|
49
|
+
const reason = scoped.length > 0 ? 'no_scoped_hint_matched_edge' : 'no_implementation_hint_supplied';
|
|
50
|
+
return { blocksAutomatic: false, evidence: { status: 'not_matched', reason, strategy: 'scoped_implementation_hint' } };
|
|
51
|
+
}
|
|
52
|
+
if (matchingHints.length > 1) {
|
|
53
|
+
return {
|
|
54
|
+
blocksAutomatic: true,
|
|
55
|
+
evidence: {
|
|
56
|
+
status: 'tied',
|
|
57
|
+
reason: 'multiple_scoped_hints_matched_edge',
|
|
58
|
+
strategy: 'scoped_implementation_hint',
|
|
59
|
+
matchedHints: matchingHints,
|
|
60
|
+
candidateCount: matchingHints.length,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const hint = matchingHints[0];
|
|
65
|
+
return hint ? selectCandidate(evidence, hint, 'scoped_implementation_hint') : { blocksAutomatic: false, evidence: { status: 'not_matched' } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function implementationHintDiagnostic(
|
|
69
|
+
selection: ImplementationSelection,
|
|
70
|
+
suggestions?: unknown,
|
|
71
|
+
): Record<string, unknown> | undefined {
|
|
72
|
+
if (!selection.blocksAutomatic || selection.methodId) return undefined;
|
|
73
|
+
return {
|
|
74
|
+
severity: 'warning',
|
|
75
|
+
code: 'implementation_hint_mismatch',
|
|
76
|
+
message: 'Implementation hint did not select exactly one viable candidate',
|
|
77
|
+
hintStatus: selection.evidence.status,
|
|
78
|
+
candidateCount: selection.evidence.candidateCount,
|
|
79
|
+
implementationHintSuggestions: Array.isArray(suggestions) && suggestions.length > 0 ? suggestions : undefined,
|
|
80
|
+
implementationSelection: selection.evidence,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function implementationHintSuggestions(rawEvidence: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
85
|
+
const evidence = asEvidence(rawEvidence);
|
|
86
|
+
const accepted = (evidence.candidates ?? []).filter((candidate) => candidate.accepted);
|
|
87
|
+
if (accepted.length < 2) return [];
|
|
88
|
+
const repos = selectableRepositories(accepted);
|
|
89
|
+
return accepted
|
|
90
|
+
.flatMap((candidate) => {
|
|
91
|
+
const repo = candidate.handlerPackage?.name;
|
|
92
|
+
if (!repo || !repos.includes(repo)) return [];
|
|
93
|
+
const hint = suggestionHint(evidence, candidate, repo);
|
|
94
|
+
return [{
|
|
95
|
+
servicePath: hint.servicePath,
|
|
96
|
+
operationPath: hint.operationPath,
|
|
97
|
+
ambiguityReason: evidence.ambiguityReasons?.[0],
|
|
98
|
+
candidateFamily: hint.candidateFamily,
|
|
99
|
+
selectableImplementationRepositories: repos,
|
|
100
|
+
implementationRepo: repo,
|
|
101
|
+
hint,
|
|
102
|
+
cli: `--implementation-hint ${hintString(hint)}`,
|
|
103
|
+
}];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function selectableRepositories(candidates: Candidate[]): string[] {
|
|
108
|
+
const repos = new Set(candidates.flatMap((candidate) => candidate.handlerPackage?.name ? [candidate.handlerPackage.name] : []));
|
|
109
|
+
return [...repos]
|
|
110
|
+
.filter((repo) => candidates.filter((candidate) => candidateMatchesRepo(candidate, repo)).length === 1)
|
|
111
|
+
.sort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function assignHintField(hint: Partial<ImplementationHint>, key: string, value: string): void {
|
|
115
|
+
if (key === 'service' || key === 'servicePath') hint.servicePath = value;
|
|
116
|
+
else if (key === 'operation' || key === 'operationPath') hint.operationPath = value;
|
|
117
|
+
else if (key === 'package' || key === 'packageName') hint.packageName = value;
|
|
118
|
+
else if (key === 'repository' || key === 'repositoryName') hint.repositoryName = value;
|
|
119
|
+
else if (key === 'family' || key === 'candidateFamily') hint.candidateFamily = value;
|
|
120
|
+
else if (key === 'repo' || key === 'implementationRepo' || key === 'select') hint.implementationRepo = value;
|
|
121
|
+
else throw new Error(`Unknown implementation hint field: ${key}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function selectCandidate(evidence: EdgeEvidence, hint: ImplementationHint, strategy: string): ImplementationSelection {
|
|
125
|
+
const matches = (evidence.candidates ?? []).filter((candidate) =>
|
|
126
|
+
candidate.accepted && candidateMatchesRepo(candidate, hint.implementationRepo));
|
|
127
|
+
const selected = matches.length === 1 ? matches[0] : undefined;
|
|
128
|
+
if (!selected || selected.methodId === undefined) {
|
|
129
|
+
return {
|
|
130
|
+
blocksAutomatic: true,
|
|
131
|
+
evidence: {
|
|
132
|
+
status: matches.length > 1 ? 'tied' : 'not_matched',
|
|
133
|
+
reason: matches.length > 1 ? 'hint_matched_multiple_candidates' : 'hint_matched_zero_candidates',
|
|
134
|
+
strategy,
|
|
135
|
+
matchedHint: hint,
|
|
136
|
+
selectedRepo: hint.implementationRepo,
|
|
137
|
+
candidateCount: matches.length,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
methodId: String(selected.methodId),
|
|
143
|
+
blocksAutomatic: false,
|
|
144
|
+
evidence: {
|
|
145
|
+
status: 'selected',
|
|
146
|
+
guided: true,
|
|
147
|
+
strategy,
|
|
148
|
+
matchedHint: hint,
|
|
149
|
+
selectedRepo: hint.implementationRepo,
|
|
150
|
+
selectedMethodId: selected.methodId,
|
|
151
|
+
ambiguityReason: evidence.ambiguityReasons?.[0],
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function suggestionHint(evidence: EdgeEvidence, candidate: Candidate, repo: string): ImplementationHint {
|
|
157
|
+
const servicePath = evidence.servicePath ?? candidate.servicePath;
|
|
158
|
+
const operationPath = evidence.operationPath ?? candidate.operationPath;
|
|
159
|
+
const family = usefulCandidateFamily(evidence, candidate);
|
|
160
|
+
return {
|
|
161
|
+
...(servicePath ? { servicePath } : {}),
|
|
162
|
+
...(operationPath ? { operationPath } : {}),
|
|
163
|
+
...(evidence.modelPackage?.packageName ? { packageName: evidence.modelPackage.packageName } : {}),
|
|
164
|
+
...(evidence.modelPackage?.name ? { repositoryName: evidence.modelPackage.name } : {}),
|
|
165
|
+
...(family ? { candidateFamily: family } : {}),
|
|
166
|
+
implementationRepo: repo,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function usefulCandidateFamily(evidence: EdgeEvidence, candidate: Candidate): string | undefined {
|
|
171
|
+
const family = candidate.handlerPackage?.packageName;
|
|
172
|
+
if (!family) return undefined;
|
|
173
|
+
if ((evidence.candidateFamilies ?? []).some((item) => item.packageName === family)) return family;
|
|
174
|
+
const acceptedFamilies = new Set(
|
|
175
|
+
(evidence.candidates ?? [])
|
|
176
|
+
.filter((item) => item.accepted)
|
|
177
|
+
.flatMap((item) => item.handlerPackage?.packageName ? [item.handlerPackage.packageName] : []),
|
|
178
|
+
);
|
|
179
|
+
return acceptedFamilies.size > 1 ? family : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function hintString(hint: ImplementationHint): string {
|
|
183
|
+
const fields = [
|
|
184
|
+
['service', hint.servicePath],
|
|
185
|
+
['operation', hint.operationPath],
|
|
186
|
+
['package', hint.packageName],
|
|
187
|
+
['repository', hint.repositoryName],
|
|
188
|
+
['family', hint.candidateFamily],
|
|
189
|
+
['repo', hint.implementationRepo],
|
|
190
|
+
];
|
|
191
|
+
return fields.flatMap(([key, value]) => value ? [`${key}=${value}`] : []).join(',');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function hintMatchesEdge(hint: ImplementationHint, evidence: EdgeEvidence): boolean {
|
|
195
|
+
const model = evidence.modelPackage ?? evidence.candidates?.[0]?.modelPackage;
|
|
196
|
+
const familyNames = new Set([
|
|
197
|
+
...(evidence.candidateFamilies ?? []).flatMap((family) => family.packageName ? [family.packageName] : []),
|
|
198
|
+
...(evidence.candidates ?? []).flatMap((candidate) => candidate.handlerPackage?.packageName ? [candidate.handlerPackage.packageName] : []),
|
|
199
|
+
]);
|
|
200
|
+
return matches(hint.servicePath, evidence.servicePath ?? evidence.candidates?.[0]?.servicePath)
|
|
201
|
+
&& matches(hint.operationPath, evidence.operationPath ?? evidence.candidates?.[0]?.operationPath)
|
|
202
|
+
&& matches(hint.packageName, model?.packageName)
|
|
203
|
+
&& matches(hint.repositoryName, model?.name)
|
|
204
|
+
&& (!hint.candidateFamily || familyNames.has(hint.candidateFamily));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function candidateMatchesRepo(candidate: Candidate, value: string): boolean {
|
|
208
|
+
return candidate.handlerPackage?.name === value
|
|
209
|
+
|| candidate.handlerPackage?.packageName === value
|
|
210
|
+
|| candidate.sourceFile?.startsWith(value) === true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function matches(expected: string | undefined, actual: string | undefined): boolean {
|
|
214
|
+
return expected === undefined || expected === actual;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function legacyHint(implementationRepo: string): ImplementationHint {
|
|
218
|
+
return { implementationRepo };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function asEvidence(value: Record<string, unknown>): EdgeEvidence {
|
|
222
|
+
return value as EdgeEvidence;
|
|
223
|
+
}
|