@saptools/service-flow 0.1.48 → 0.1.50
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 +11 -1
- package/dist/{chunk-EGY2A4AT.js → chunk-52OUS3MO.js} +878 -333
- package/dist/chunk-52OUS3MO.js.map +1 -0
- package/dist/cli.js +330 -38
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +187 -3
- package/src/db/migrations.ts +4 -1
- package/src/db/repositories.ts +188 -11
- package/src/db/schema.ts +1 -1
- package/src/linker/cross-repo-linker.ts +172 -6
- package/src/linker/operation-decorator-normalizer.ts +3 -3
- package/src/output/table-output.ts +18 -8
- package/src/parsers/decorator-parser.ts +128 -22
- 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/selectors.ts +36 -0
- package/src/trace/trace-engine.ts +99 -25
- package/src/types.ts +12 -0
- package/dist/chunk-EGY2A4AT.js.map +0 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import {
|
|
3
|
+
classifyODataPathIntent,
|
|
4
|
+
normalizeODataOperationInvocationPath,
|
|
5
|
+
} from '../linker/odata-path-normalizer.js';
|
|
6
|
+
|
|
7
|
+
export type OperationPathStatus = 'static' | 'ambiguous' | 'dynamic' | 'unknown';
|
|
8
|
+
|
|
9
|
+
export interface OperationPathAnalysis {
|
|
10
|
+
status: OperationPathStatus;
|
|
11
|
+
rawExpression?: string;
|
|
12
|
+
normalizedOperationPath?: string;
|
|
13
|
+
candidateRawPaths: string[];
|
|
14
|
+
candidateNormalizedOperationPaths: string[];
|
|
15
|
+
placeholderKeys: string[];
|
|
16
|
+
sourceKind: string;
|
|
17
|
+
candidateIdentifier?: string;
|
|
18
|
+
runtimeIdentifier?: string;
|
|
19
|
+
dynamicReassignments: Array<{ expression: string; sourceLine: number }>;
|
|
20
|
+
lexicalScope: {
|
|
21
|
+
declarationLine?: number;
|
|
22
|
+
assignmentLines: number[];
|
|
23
|
+
sourceOrderSafe: boolean;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CandidateState {
|
|
28
|
+
paths: string[];
|
|
29
|
+
placeholders: string[];
|
|
30
|
+
dynamic: Array<{ expression: string; sourceLine: number }>;
|
|
31
|
+
sourceKinds: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Binding {
|
|
35
|
+
declaration: ts.VariableDeclaration | ts.ParameterDeclaration;
|
|
36
|
+
immutable: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const maxAliasDepth = 6;
|
|
40
|
+
|
|
41
|
+
export function analyzeOperationPath(
|
|
42
|
+
expression: ts.Expression | undefined,
|
|
43
|
+
use: ts.Node,
|
|
44
|
+
method = 'POST',
|
|
45
|
+
): OperationPathAnalysis {
|
|
46
|
+
if (!expression) return emptyAnalysis();
|
|
47
|
+
const state = collectExpressionState(expression, use, 0, new Set());
|
|
48
|
+
const paths = unique(state.paths.map(normalizeRawPath));
|
|
49
|
+
const normalized = unique(paths.flatMap((value) => normalizedCandidate(value, method)));
|
|
50
|
+
const status = pathStatus(paths, state.placeholders, state.dynamic);
|
|
51
|
+
const runtimeIdentifier = state.dynamic.at(-1)?.expression;
|
|
52
|
+
return {
|
|
53
|
+
status,
|
|
54
|
+
rawExpression: expression.getText(expression.getSourceFile()),
|
|
55
|
+
normalizedOperationPath: status === 'static' && normalized.length === 1 ? normalized[0] : undefined,
|
|
56
|
+
candidateRawPaths: paths,
|
|
57
|
+
candidateNormalizedOperationPaths: normalized,
|
|
58
|
+
placeholderKeys: unique([...state.placeholders, ...(runtimeIdentifier ? [runtimeIdentifier] : [])]),
|
|
59
|
+
sourceKind: unique(state.sourceKinds).join('+') || 'unknown',
|
|
60
|
+
candidateIdentifier: ts.isIdentifier(expression) ? expression.text : undefined,
|
|
61
|
+
runtimeIdentifier,
|
|
62
|
+
dynamicReassignments: state.dynamic,
|
|
63
|
+
lexicalScope: lexicalEvidence(expression, use),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function operationPathExpression(analysis: OperationPathAnalysis): string | undefined {
|
|
68
|
+
if (analysis.status === 'ambiguous' || analysis.status === 'unknown') return undefined;
|
|
69
|
+
if (analysis.sourceKind.includes('parameter_binding')) return undefined;
|
|
70
|
+
if (analysis.candidateRawPaths.length === 1 && analysis.dynamicReassignments.length === 0)
|
|
71
|
+
return analysis.candidateRawPaths[0];
|
|
72
|
+
if (!analysis.runtimeIdentifier || analysis.sourceKind.includes('binding_not_found'))
|
|
73
|
+
return undefined;
|
|
74
|
+
return isRuntimeIdentifier(analysis.runtimeIdentifier)
|
|
75
|
+
? `\${${analysis.runtimeIdentifier}}`
|
|
76
|
+
: undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function pathUnresolvedReason(analysis: OperationPathAnalysis): string | undefined {
|
|
80
|
+
if (analysis.status === 'ambiguous') return 'ambiguous_operation_path_candidates';
|
|
81
|
+
if (analysis.status === 'dynamic' && !operationPathExpression(analysis))
|
|
82
|
+
return 'dynamic_operation_path_identifier';
|
|
83
|
+
if (analysis.dynamicReassignments.length > 0) return 'dynamic_operation_path_identifier';
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectExpressionState(
|
|
88
|
+
expression: ts.Expression,
|
|
89
|
+
use: ts.Node,
|
|
90
|
+
depth: number,
|
|
91
|
+
seen: Set<ts.Node>,
|
|
92
|
+
): CandidateState {
|
|
93
|
+
const unwrapped = unwrap(expression);
|
|
94
|
+
if (ts.isStringLiteral(unwrapped)) return staticState(unwrapped.text, 'string_literal');
|
|
95
|
+
if (ts.isNoSubstitutionTemplateLiteral(unwrapped))
|
|
96
|
+
return staticState(unwrapped.text, 'no_substitution_template');
|
|
97
|
+
if (ts.isTemplateExpression(unwrapped)) return templateState(unwrapped);
|
|
98
|
+
if (ts.isConditionalExpression(unwrapped))
|
|
99
|
+
return mergeStates([
|
|
100
|
+
collectExpressionState(unwrapped.whenTrue, use, depth + 1, seen),
|
|
101
|
+
collectExpressionState(unwrapped.whenFalse, use, depth + 1, seen),
|
|
102
|
+
], 'conditional_candidates');
|
|
103
|
+
if (ts.isIdentifier(unwrapped))
|
|
104
|
+
return collectIdentifierState(unwrapped, use, depth, seen);
|
|
105
|
+
return dynamicState(unwrapped);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function collectIdentifierState(
|
|
109
|
+
identifier: ts.Identifier,
|
|
110
|
+
use: ts.Node,
|
|
111
|
+
depth: number,
|
|
112
|
+
seen: Set<ts.Node>,
|
|
113
|
+
): CandidateState {
|
|
114
|
+
if (depth >= maxAliasDepth)
|
|
115
|
+
return dynamicState(identifier, 'alias_depth_exceeded');
|
|
116
|
+
const binding = resolveBinding(identifier, use);
|
|
117
|
+
if (!binding || seen.has(binding.declaration))
|
|
118
|
+
return dynamicState(identifier, binding ? 'alias_cycle' : 'binding_not_found');
|
|
119
|
+
seen.add(binding.declaration);
|
|
120
|
+
if (ts.isParameter(binding.declaration))
|
|
121
|
+
return dynamicState(identifier, 'parameter_binding');
|
|
122
|
+
const expressions = reachingExpressions(binding.declaration, use);
|
|
123
|
+
if (expressions.length === 0) return dynamicState(identifier, 'initializer_missing');
|
|
124
|
+
const states = expressions.map((item) =>
|
|
125
|
+
collectExpressionState(item.expression, item.node, depth + 1, seen));
|
|
126
|
+
const merged = mergeStates(states, binding.immutable ? 'const_alias' : 'mutable_alias');
|
|
127
|
+
if (merged.dynamic.length === 0) return merged;
|
|
128
|
+
return {
|
|
129
|
+
...merged,
|
|
130
|
+
dynamic: merged.dynamic.map((item) =>
|
|
131
|
+
item.expression === identifier.text ? item : item),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function reachingExpressions(
|
|
136
|
+
declaration: ts.VariableDeclaration,
|
|
137
|
+
use: ts.Node,
|
|
138
|
+
): Array<{ expression: ts.Expression; node: ts.Node }> {
|
|
139
|
+
const rows: Array<{ expression: ts.Expression; node: ts.Node }> = [];
|
|
140
|
+
if (declaration.initializer) rows.push({ expression: declaration.initializer, node: declaration });
|
|
141
|
+
if ((declaration.parent.flags & ts.NodeFlags.Const) !== 0) return rows;
|
|
142
|
+
const source = use.getSourceFile();
|
|
143
|
+
const visit = (node: ts.Node): void => {
|
|
144
|
+
if (node.getStart(source) >= use.getStart(source)) return;
|
|
145
|
+
if (node !== source && ts.isFunctionLike(node) && !contains(node, use)) return;
|
|
146
|
+
if (isAssignmentTo(node, declaration, use))
|
|
147
|
+
rows.push({ expression: node.right, node });
|
|
148
|
+
ts.forEachChild(node, visit);
|
|
149
|
+
};
|
|
150
|
+
visit(source);
|
|
151
|
+
return rows.sort((left, right) =>
|
|
152
|
+
left.node.getStart(source) - right.node.getStart(source));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isAssignmentTo(
|
|
156
|
+
node: ts.Node,
|
|
157
|
+
declaration: ts.VariableDeclaration,
|
|
158
|
+
use: ts.Node,
|
|
159
|
+
): node is ts.BinaryExpression {
|
|
160
|
+
if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
|
|
161
|
+
return false;
|
|
162
|
+
if (!ts.isIdentifier(node.left) || !ts.isIdentifier(declaration.name)) return false;
|
|
163
|
+
return resolveBinding(node.left, node)?.declaration === declaration && contains(declarationScope(declaration), use);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resolveBinding(identifier: ts.Identifier, use: ts.Node): Binding | undefined {
|
|
167
|
+
const source = use.getSourceFile();
|
|
168
|
+
const matches: Array<ts.VariableDeclaration | ts.ParameterDeclaration> = [];
|
|
169
|
+
const visit = (node: ts.Node): void => {
|
|
170
|
+
if (isNamedDeclaration(node, identifier.text) && isAccessible(node, use))
|
|
171
|
+
matches.push(node);
|
|
172
|
+
ts.forEachChild(node, visit);
|
|
173
|
+
};
|
|
174
|
+
visit(source);
|
|
175
|
+
const declaration = matches.sort((left, right) =>
|
|
176
|
+
right.getStart(source) - left.getStart(source))[0];
|
|
177
|
+
if (!declaration) return undefined;
|
|
178
|
+
const immutable = ts.isVariableDeclaration(declaration)
|
|
179
|
+
&& (declaration.parent.flags & ts.NodeFlags.Const) !== 0;
|
|
180
|
+
return { declaration, immutable };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isNamedDeclaration(
|
|
184
|
+
node: ts.Node,
|
|
185
|
+
name: string,
|
|
186
|
+
): node is ts.VariableDeclaration | ts.ParameterDeclaration {
|
|
187
|
+
return (ts.isVariableDeclaration(node) || ts.isParameter(node))
|
|
188
|
+
&& ts.isIdentifier(node.name)
|
|
189
|
+
&& node.name.text === name;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isAccessible(
|
|
193
|
+
declaration: ts.VariableDeclaration | ts.ParameterDeclaration,
|
|
194
|
+
use: ts.Node,
|
|
195
|
+
): boolean {
|
|
196
|
+
const source = use.getSourceFile();
|
|
197
|
+
if (declaration.name.getStart(source) >= use.getStart(source)) return false;
|
|
198
|
+
const scope = declarationScope(declaration);
|
|
199
|
+
return ts.isSourceFile(scope) || contains(scope, use);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function declarationScope(
|
|
203
|
+
declaration: ts.VariableDeclaration | ts.ParameterDeclaration,
|
|
204
|
+
): ts.Node {
|
|
205
|
+
if (ts.isParameter(declaration)) return declaration.parent;
|
|
206
|
+
if (ts.isCatchClause(declaration.parent)) return declaration.parent.block;
|
|
207
|
+
const list = declaration.parent;
|
|
208
|
+
if (isLoopInitializer(list.parent)) return list.parent.statement;
|
|
209
|
+
const blockScoped = (list.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) !== 0;
|
|
210
|
+
let current: ts.Node = list.parent;
|
|
211
|
+
if (!blockScoped) {
|
|
212
|
+
while (current.parent && !ts.isFunctionLike(current) && !ts.isSourceFile(current))
|
|
213
|
+
current = current.parent;
|
|
214
|
+
return current;
|
|
215
|
+
}
|
|
216
|
+
while (current.parent && !isLexicalScope(current)) current = current.parent;
|
|
217
|
+
return current;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isLoopInitializer(
|
|
221
|
+
node: ts.Node,
|
|
222
|
+
): node is ts.ForStatement | ts.ForInStatement | ts.ForOfStatement {
|
|
223
|
+
return ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isLexicalScope(node: ts.Node): boolean {
|
|
227
|
+
return ts.isBlock(node)
|
|
228
|
+
|| ts.isSourceFile(node)
|
|
229
|
+
|| ts.isModuleBlock(node)
|
|
230
|
+
|| ts.isCaseBlock(node)
|
|
231
|
+
|| ts.isFunctionLike(node);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function templateState(expression: ts.TemplateExpression): CandidateState {
|
|
235
|
+
const value = expression.getText(expression.getSourceFile()).slice(1, -1);
|
|
236
|
+
return {
|
|
237
|
+
paths: [value],
|
|
238
|
+
placeholders: expression.templateSpans.map((span) =>
|
|
239
|
+
span.expression.getText(expression.getSourceFile())),
|
|
240
|
+
dynamic: [],
|
|
241
|
+
sourceKinds: ['template_with_placeholders'],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function staticState(path: string, sourceKind: string): CandidateState {
|
|
246
|
+
return { paths: [path], placeholders: [], dynamic: [], sourceKinds: [sourceKind] };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function dynamicState(expression: ts.Expression, sourceKind = 'dynamic_expression'): CandidateState {
|
|
250
|
+
return {
|
|
251
|
+
paths: [],
|
|
252
|
+
placeholders: [],
|
|
253
|
+
dynamic: [{
|
|
254
|
+
expression: expression.getText(expression.getSourceFile()),
|
|
255
|
+
sourceLine: sourceLine(expression),
|
|
256
|
+
}],
|
|
257
|
+
sourceKinds: [sourceKind],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function mergeStates(states: CandidateState[], sourceKind: string): CandidateState {
|
|
262
|
+
return {
|
|
263
|
+
paths: states.flatMap((state) => state.paths),
|
|
264
|
+
placeholders: states.flatMap((state) => state.placeholders),
|
|
265
|
+
dynamic: states.flatMap((state) => state.dynamic),
|
|
266
|
+
sourceKinds: [sourceKind, ...states.flatMap((state) => state.sourceKinds)],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function pathStatus(
|
|
271
|
+
paths: string[],
|
|
272
|
+
placeholders: string[],
|
|
273
|
+
dynamic: CandidateState['dynamic'],
|
|
274
|
+
): OperationPathStatus {
|
|
275
|
+
if (dynamic.length > 0) return 'dynamic';
|
|
276
|
+
if (paths.length > 1) return 'ambiguous';
|
|
277
|
+
if (placeholders.length > 0) return 'dynamic';
|
|
278
|
+
if (paths.length === 1) return 'static';
|
|
279
|
+
return 'unknown';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizedCandidate(value: string, method: string): string[] {
|
|
283
|
+
const invocation = normalizeODataOperationInvocationPath(value);
|
|
284
|
+
if (invocation?.wasInvocation) return [invocation.normalizedOperationPath];
|
|
285
|
+
const intent = classifyODataPathIntent(value, method);
|
|
286
|
+
if (intent.kind.startsWith('entity_')) return [];
|
|
287
|
+
if (!value.startsWith('/') || value.slice(1).includes('/') || value.includes('?')) return [];
|
|
288
|
+
return [value];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function lexicalEvidence(expression: ts.Expression, use: ts.Node): OperationPathAnalysis['lexicalScope'] {
|
|
292
|
+
if (!ts.isIdentifier(expression))
|
|
293
|
+
return { assignmentLines: [], sourceOrderSafe: expression.getStart() < use.getStart() };
|
|
294
|
+
const binding = resolveBinding(expression, use);
|
|
295
|
+
if (!binding) return { assignmentLines: [], sourceOrderSafe: false };
|
|
296
|
+
const assignmentLines = ts.isVariableDeclaration(binding.declaration)
|
|
297
|
+
? reachingExpressions(binding.declaration, use)
|
|
298
|
+
.slice(binding.declaration.initializer ? 1 : 0)
|
|
299
|
+
.map((item) => sourceLine(item.node))
|
|
300
|
+
: [];
|
|
301
|
+
return {
|
|
302
|
+
declarationLine: sourceLine(binding.declaration),
|
|
303
|
+
assignmentLines,
|
|
304
|
+
sourceOrderSafe: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function unwrap(expression: ts.Expression): ts.Expression {
|
|
309
|
+
if (ts.isAwaitExpression(expression) || ts.isParenthesizedExpression(expression))
|
|
310
|
+
return unwrap(expression.expression);
|
|
311
|
+
if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)
|
|
312
|
+
|| ts.isTypeAssertionExpression(expression))
|
|
313
|
+
return unwrap(expression.expression);
|
|
314
|
+
return expression;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function normalizeRawPath(value: string): string {
|
|
318
|
+
return value.startsWith('/') ? value : `/${value}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isRuntimeIdentifier(value: string): boolean {
|
|
322
|
+
return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(value);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function contains(parent: ts.Node, child: ts.Node): boolean {
|
|
326
|
+
const source = child.getSourceFile();
|
|
327
|
+
return child.getStart(source) >= parent.getStart(source)
|
|
328
|
+
&& child.getEnd() <= parent.getEnd();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function sourceLine(node: ts.Node): number {
|
|
332
|
+
const source = node.getSourceFile();
|
|
333
|
+
return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function unique(values: string[]): string[] {
|
|
337
|
+
return [...new Set(values)].sort();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function emptyAnalysis(): OperationPathAnalysis {
|
|
341
|
+
return {
|
|
342
|
+
status: 'unknown',
|
|
343
|
+
candidateRawPaths: [],
|
|
344
|
+
candidateNormalizedOperationPaths: [],
|
|
345
|
+
placeholderKeys: [],
|
|
346
|
+
sourceKind: 'missing',
|
|
347
|
+
dynamicReassignments: [],
|
|
348
|
+
lexicalScope: { assignmentLines: [], sourceOrderSafe: false },
|
|
349
|
+
};
|
|
350
|
+
}
|
|
@@ -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;
|
package/src/trace/selectors.ts
CHANGED
|
@@ -18,3 +18,39 @@ export function startLabel(start: TraceStart): string {
|
|
|
18
18
|
.filter(Boolean)
|
|
19
19
|
.join(' ');
|
|
20
20
|
}
|
|
21
|
+
export function ambiguousStartDiagnostic(
|
|
22
|
+
requested: string,
|
|
23
|
+
candidates: Array<Record<string, unknown>>,
|
|
24
|
+
message: string,
|
|
25
|
+
): Record<string, unknown> {
|
|
26
|
+
const serviceSuggestions = [...new Set(candidates
|
|
27
|
+
.flatMap((row) => typeof row.servicePath === 'string'
|
|
28
|
+
? [`--service ${row.servicePath}`]
|
|
29
|
+
: []))].sort();
|
|
30
|
+
return {
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
code: 'trace_start_ambiguous',
|
|
33
|
+
message,
|
|
34
|
+
normalizedSelectorValue: requested,
|
|
35
|
+
resolutionStage: 'operation',
|
|
36
|
+
resolutionStatus: 'ambiguous_operation',
|
|
37
|
+
candidates,
|
|
38
|
+
serviceSuggestions,
|
|
39
|
+
selectorSuggestions: fullSelectorSuggestions(candidates),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function fullSelectorSuggestions(
|
|
43
|
+
candidates: Array<Record<string, unknown>>,
|
|
44
|
+
): string[] {
|
|
45
|
+
const includeRepo = new Set(candidates.map((row) => row.repoName)).size > 1;
|
|
46
|
+
return [...new Set(candidates.flatMap((row) => {
|
|
47
|
+
if (typeof row.servicePath !== 'string'
|
|
48
|
+
|| typeof row.operationPath !== 'string') return [];
|
|
49
|
+
const repoSelector = includeRepo && typeof row.repoName === 'string'
|
|
50
|
+
? `--repo ${row.repoName} `
|
|
51
|
+
: '';
|
|
52
|
+
return [
|
|
53
|
+
`${repoSelector}--service ${row.servicePath} --path ${row.operationPath}`,
|
|
54
|
+
];
|
|
55
|
+
}))].sort();
|
|
56
|
+
}
|