@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.
@@ -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
- const hint = firstHint(e.evidence);
19
- if (e.unresolvedReason && hint) lines.push(` try ${hint}`);
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
- const hint = firstHint(diagnostic);
28
- return hint ? [first, ` try ${hint}`] : [first];
27
+ return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
29
28
  }
30
29
 
31
- function firstHint(evidence: Record<string, unknown>): string | undefined {
30
+ function hintLines(evidence: Record<string, unknown>): string[] {
32
31
  const suggestions = evidence.implementationHintSuggestions;
33
- if (!Array.isArray(suggestions)) return undefined;
34
- const first = suggestions.find((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object');
35
- return typeof first?.cli === 'string' ? first.cli : undefined;
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 rawPath = pathValue.rawExpression;
150
- const operationPathExpr = pathValue.value ? normalizeOperationPath(pathValue.value) : runtimePathExpression(rawPath);
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: pathValue.value ? undefined : 'dynamic_operation_path_identifier',
159
+ unresolvedReason,
161
160
  evidence: {
162
161
  parser: 'typescript_ast',
163
- classifier: pathValue.value ? 'imported_wrapper_literal_path' : 'imported_wrapper_dynamic_path',
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: rawPath,
170
- missingPathIdentifier: pathValue.value ? undefined : rawPath,
171
- odataPathIntent: pathValue.value ? classifyODataPathIntent(operationPathExpr, method) : undefined,
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 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;
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
+ }