@saptools/service-flow 0.1.47 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +15 -1
- package/dist/{chunk-EGY2A4AT.js → chunk-XOROZHW4.js} +593 -242
- package/dist/chunk-XOROZHW4.js.map +1 -0
- package/dist/cli.js +387 -33
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +167 -7
- package/src/cli.ts +4 -7
- package/src/db/repositories.ts +186 -10
- package/src/linker/cross-repo-linker.ts +70 -1
- package/src/output/doctor-output.ts +98 -0
- package/src/output/table-output.ts +18 -8
- package/src/parsers/imported-wrapper-parser.ts +20 -46
- package/src/parsers/operation-path-analysis.ts +350 -0
- package/src/parsers/outbound-call-parser.ts +63 -53
- package/src/trace/evidence.ts +18 -3
- package/src/trace/trace-engine.ts +118 -25
- package/dist/chunk-EGY2A4AT.js.map +0 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { renderJson } from './json-output.js';
|
|
3
|
+
|
|
4
|
+
type Diagnostic = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export function renderDoctorDiagnostics(diagnostics: Diagnostic[], format: string | undefined): string {
|
|
7
|
+
if (format === 'json') return renderJson(diagnostics);
|
|
8
|
+
if (format === 'table') return renderDoctorTable(diagnostics);
|
|
9
|
+
if (format) throw new Error(`Unsupported doctor format: ${format}. Expected json or table.`);
|
|
10
|
+
return renderLegacyDoctorOutput(diagnostics);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function renderLegacyDoctorOutput(diagnostics: Diagnostic[]): string {
|
|
14
|
+
if (diagnostics.length === 0) return cleanDoctorMessage();
|
|
15
|
+
return renderJson(diagnostics);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderDoctorTable(diagnostics: Diagnostic[]): string {
|
|
19
|
+
if (diagnostics.length === 0) return cleanDoctorMessage();
|
|
20
|
+
const rows = diagnostics.map((diagnostic) => ({
|
|
21
|
+
severity: String(diagnostic.severity ?? 'info'),
|
|
22
|
+
code: String(diagnostic.code ?? 'diagnostic'),
|
|
23
|
+
location: diagnosticLocation(diagnostic),
|
|
24
|
+
message: compactMessage(diagnostic),
|
|
25
|
+
hints: suggestedHintLines(diagnostic),
|
|
26
|
+
}));
|
|
27
|
+
const widths = {
|
|
28
|
+
severity: columnWidth('Severity', rows.map((row) => row.severity), 10),
|
|
29
|
+
code: columnWidth('Code', rows.map((row) => row.code), 44),
|
|
30
|
+
location: columnWidth('Location', rows.map((row) => row.location), 28),
|
|
31
|
+
};
|
|
32
|
+
const lines = [
|
|
33
|
+
`${'Severity'.padEnd(widths.severity)} ${'Code'.padEnd(widths.code)} ${'Location'.padEnd(widths.location)} Message`,
|
|
34
|
+
`${'-'.repeat(widths.severity)} ${'-'.repeat(widths.code)} ${'-'.repeat(widths.location)} ${'-'.repeat(7)}`,
|
|
35
|
+
];
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
lines.push(`${truncate(row.severity, widths.severity).padEnd(widths.severity)} ${truncate(row.code, widths.code).padEnd(widths.code)} ${truncate(row.location, widths.location).padEnd(widths.location)} ${row.message}`);
|
|
38
|
+
lines.push(...row.hints.map((hint) => ` try ${hint}`));
|
|
39
|
+
}
|
|
40
|
+
return `${lines.join('\n')}\n`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function diagnosticLocation(diagnostic: Diagnostic): string {
|
|
44
|
+
const file = diagnostic.sourceFile ?? diagnostic.file;
|
|
45
|
+
const line = diagnostic.sourceLine ?? diagnostic.line;
|
|
46
|
+
if (file || line) return `${String(file ?? '')}:${String(line ?? '')}`;
|
|
47
|
+
return '-';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function compactMessage(diagnostic: Diagnostic): string {
|
|
51
|
+
const message = String(diagnostic.message ?? '');
|
|
52
|
+
const count = typeof diagnostic.count === 'number' ? ` count=${diagnostic.count}` : '';
|
|
53
|
+
const total = typeof diagnostic.total === 'number' ? ` total=${diagnostic.total}` : '';
|
|
54
|
+
return `${message}${count}${total}`.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function suggestedHintLines(diagnostic: Diagnostic): string[] {
|
|
58
|
+
const direct = cliHints(diagnostic.suggestedHints);
|
|
59
|
+
if (direct.length > 0) return cappedHints(direct);
|
|
60
|
+
return cappedHints(cliHintsFromSuggestions(diagnostic.implementationHintSuggestions));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cliHints(value: unknown): string[] {
|
|
64
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function cliHintsFromSuggestions(value: unknown): string[] {
|
|
68
|
+
if (!Array.isArray(value)) return [];
|
|
69
|
+
return value.flatMap((item) => {
|
|
70
|
+
if (!isRecord(item)) return [];
|
|
71
|
+
return typeof item.cli === 'string' ? [item.cli] : [];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cappedHints(hints: string[]): string[] {
|
|
80
|
+
const unique = [...new Set(hints)];
|
|
81
|
+
const shown = unique.slice(0, 3);
|
|
82
|
+
if (unique.length > shown.length) shown.push(`... ${unique.length - shown.length} more hint(s) available in --format json`);
|
|
83
|
+
return shown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cleanDoctorMessage(): string {
|
|
87
|
+
return `${pc.green('No diagnostics recorded')}\n`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function columnWidth(header: string, values: string[], max: number): number {
|
|
91
|
+
return Math.min(max, Math.max(header.length, ...values.map((value) => value.length)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function truncate(value: string, width: number): string {
|
|
95
|
+
if (value.length <= width) return value;
|
|
96
|
+
if (width <= 1) return value.slice(0, width);
|
|
97
|
+
return `${value.slice(0, width - 1)}…`;
|
|
98
|
+
}
|
|
@@ -15,8 +15,8 @@ export function renderTraceTable(result: TraceResult): string {
|
|
|
15
15
|
const lines = ['Step Type From To Evidence'];
|
|
16
16
|
for (const e of result.edges) {
|
|
17
17
|
lines.push(`${String(e.step).padEnd(5)} ${e.type.padEnd(20)} ${e.from.slice(0, 34).padEnd(35)} ${e.to.slice(0, 35).padEnd(36)} ${location(e.evidence)}`);
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
if (e.unresolvedReason)
|
|
19
|
+
lines.push(...hintLines(e.evidence).map((hint) => ` ${hint}`));
|
|
20
20
|
}
|
|
21
21
|
if (result.diagnostics.length > 0) lines.push('', 'Diagnostics:', ...result.diagnostics.flatMap(diagnosticLines));
|
|
22
22
|
return `${lines.join('\n')}\n`;
|
|
@@ -24,13 +24,23 @@ export function renderTraceTable(result: TraceResult): string {
|
|
|
24
24
|
|
|
25
25
|
function diagnosticLines(diagnostic: Record<string, unknown>): string[] {
|
|
26
26
|
const first = `${String(diagnostic.severity ?? 'info')} ${String(diagnostic.code ?? 'diagnostic')} ${String(diagnostic.message ?? '')}`;
|
|
27
|
-
|
|
28
|
-
return hint ? [first, ` try ${hint}`] : [first];
|
|
27
|
+
return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
function
|
|
30
|
+
function hintLines(evidence: Record<string, unknown>): string[] {
|
|
32
31
|
const suggestions = evidence.implementationHintSuggestions;
|
|
33
|
-
if (!Array.isArray(suggestions)) return
|
|
34
|
-
const
|
|
35
|
-
|
|
32
|
+
if (!Array.isArray(suggestions)) return [];
|
|
33
|
+
const hints = suggestions.flatMap((item) =>
|
|
34
|
+
isRecord(item) && typeof item.cli === 'string'
|
|
35
|
+
? [item.cli]
|
|
36
|
+
: []);
|
|
37
|
+
const unique = [...new Set(hints)];
|
|
38
|
+
const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
|
|
39
|
+
if (unique.length > shown.length)
|
|
40
|
+
shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
|
|
41
|
+
return shown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
36
46
|
}
|
|
@@ -4,6 +4,11 @@ import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
|
4
4
|
import type { OutboundCallFact } from '../types.js';
|
|
5
5
|
import { normalizePath } from '../utils/path-utils.js';
|
|
6
6
|
import { importsFor, lineOf, readSource, type ImportBinding } from './service-binding-parser-helpers.js';
|
|
7
|
+
import {
|
|
8
|
+
analyzeOperationPath,
|
|
9
|
+
operationPathExpression,
|
|
10
|
+
pathUnresolvedReason,
|
|
11
|
+
} from './operation-path-analysis.js';
|
|
7
12
|
|
|
8
13
|
interface WrapperSpec {
|
|
9
14
|
clientIndex: number;
|
|
@@ -15,12 +20,6 @@ interface WrapperSpec {
|
|
|
15
20
|
chain: string[];
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
interface PathValue {
|
|
19
|
-
value?: string;
|
|
20
|
-
sourceKind: string;
|
|
21
|
-
rawExpression: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
23
|
export async function parseImportedWrapperCalls(
|
|
25
24
|
repoPath: string,
|
|
26
25
|
filePath: string,
|
|
@@ -143,11 +142,11 @@ function wrapperCallFact(
|
|
|
143
142
|
): OutboundCallFact | undefined {
|
|
144
143
|
const client = call.arguments[spec.clientIndex];
|
|
145
144
|
if (!client || !ts.isIdentifier(client) || !serviceBindings.has(client.text)) return undefined;
|
|
146
|
-
const pathValue = resolveCallerPath(call.arguments[spec.pathIndex], call);
|
|
147
145
|
const methodValue = spec.methodIndex === undefined ? spec.methodLiteral : literal(call.arguments[spec.methodIndex]);
|
|
148
146
|
const method = (methodValue ?? 'POST').toUpperCase();
|
|
149
|
-
const
|
|
150
|
-
const operationPathExpr =
|
|
147
|
+
const pathAnalysis = analyzeOperationPath(call.arguments[spec.pathIndex], call, method);
|
|
148
|
+
const operationPathExpr = operationPathExpression(pathAnalysis);
|
|
149
|
+
const unresolvedReason = pathUnresolvedReason(pathAnalysis);
|
|
151
150
|
return {
|
|
152
151
|
callType: 'remote_action',
|
|
153
152
|
serviceVariableName: client.text,
|
|
@@ -157,46 +156,29 @@ function wrapperCallFact(
|
|
|
157
156
|
sourceFile: normalizePath(filePath),
|
|
158
157
|
sourceLine: lineOf(source, call),
|
|
159
158
|
confidence: operationPathExpr ? 0.85 : 0.5,
|
|
160
|
-
unresolvedReason
|
|
159
|
+
unresolvedReason,
|
|
161
160
|
evidence: {
|
|
162
161
|
parser: 'typescript_ast',
|
|
163
|
-
classifier:
|
|
162
|
+
classifier: importedWrapperClassifier(pathAnalysis.status),
|
|
164
163
|
receiver: client.text,
|
|
165
164
|
wrapperFunction: spec.chain[0],
|
|
166
165
|
wrapperChain: spec.chain,
|
|
167
166
|
callerSite: { sourceFile: normalizePath(filePath), sourceLine: lineOf(source, call) },
|
|
168
167
|
calleeSite: { sourceFile: spec.sourceFile, sourceLine: spec.sourceLine },
|
|
169
|
-
rawPathExpression:
|
|
170
|
-
missingPathIdentifier:
|
|
171
|
-
|
|
168
|
+
rawPathExpression: pathAnalysis.rawExpression,
|
|
169
|
+
missingPathIdentifier: pathAnalysis.runtimeIdentifier,
|
|
170
|
+
pathAnalysis,
|
|
171
|
+
odataPathIntent: operationPathExpr
|
|
172
|
+
? classifyODataPathIntent(operationPathExpr, method)
|
|
173
|
+
: undefined,
|
|
172
174
|
},
|
|
173
175
|
};
|
|
174
176
|
}
|
|
175
177
|
|
|
176
|
-
function
|
|
177
|
-
if (
|
|
178
|
-
if (
|
|
179
|
-
|
|
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;
|
|
178
|
+
function importedWrapperClassifier(status: string): string {
|
|
179
|
+
if (status === 'static') return 'imported_wrapper_literal_path';
|
|
180
|
+
if (status === 'ambiguous') return 'imported_wrapper_ambiguous_path';
|
|
181
|
+
return 'imported_wrapper_dynamic_path';
|
|
200
182
|
}
|
|
201
183
|
|
|
202
184
|
function findFunction(source: ts.SourceFile, exportedName: string): { name: string; fn: ts.FunctionLikeDeclaration } | undefined {
|
|
@@ -252,11 +234,3 @@ function propertyName(name: ts.PropertyName): string | undefined {
|
|
|
252
234
|
function literal(expr: ts.Expression | undefined): string | undefined {
|
|
253
235
|
return expr && (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) ? expr.text : undefined;
|
|
254
236
|
}
|
|
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
|
-
}
|
|
@@ -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
|
+
}
|