@saptools/service-flow 0.1.44 → 0.1.45
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 +7 -0
- package/README.md +9 -1
- package/dist/{chunk-UKNPHTUS.js → chunk-BXSCE5CR.js} +1790 -1460
- package/dist/chunk-BXSCE5CR.js.map +1 -0
- package/dist/cli.js +73 -29
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +49 -12
- package/src/cli.ts +10 -2
- package/src/index.ts +2 -0
- package/src/linker/cross-repo-linker.ts +23 -1
- package/src/linker/odata-path-normalizer.ts +4 -1
- package/src/parsers/imported-wrapper-parser.ts +262 -0
- package/src/parsers/outbound-call-parser.ts +5 -1
- package/src/parsers/symbol-parser.ts +25 -10
- package/src/trace/evidence.ts +6 -1
- package/src/trace/implementation-hints.ts +151 -0
- package/src/trace/trace-engine.ts +52 -45
- package/src/types.ts +8 -0
- package/dist/chunk-UKNPHTUS.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -121,6 +121,14 @@ interface TraceStart {
|
|
|
121
121
|
operationPath?: string;
|
|
122
122
|
handler?: string;
|
|
123
123
|
}
|
|
124
|
+
interface ImplementationHint {
|
|
125
|
+
servicePath?: string;
|
|
126
|
+
operationPath?: string;
|
|
127
|
+
packageName?: string;
|
|
128
|
+
repositoryName?: string;
|
|
129
|
+
candidateFamily?: string;
|
|
130
|
+
implementationRepo: string;
|
|
131
|
+
}
|
|
124
132
|
interface TraceEdge {
|
|
125
133
|
step: number;
|
|
126
134
|
type: string;
|
|
@@ -206,9 +214,12 @@ declare function trace(db: Db, start: TraceStart, options: {
|
|
|
206
214
|
includeDb?: boolean;
|
|
207
215
|
includeAsync?: boolean;
|
|
208
216
|
implementationRepo?: string;
|
|
217
|
+
implementationHints?: ImplementationHint[];
|
|
209
218
|
}): TraceResult;
|
|
210
219
|
|
|
220
|
+
declare function parseImplementationHint(value: string): ImplementationHint;
|
|
221
|
+
|
|
211
222
|
declare function redactText(text: string): string;
|
|
212
223
|
declare function redactValue(value: unknown): unknown;
|
|
213
224
|
|
|
214
|
-
export { type RuntimeSubstitution, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
|
|
225
|
+
export { type ImplementationHint, type RuntimeSubstitution, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseImplementationHint, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
parseCdsFile,
|
|
9
9
|
parseDecorators,
|
|
10
10
|
parseHandlerRegistrations,
|
|
11
|
+
parseImplementationHint,
|
|
11
12
|
parseOutboundCalls,
|
|
12
13
|
parsePackageJson,
|
|
13
14
|
parseServiceBindings,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
stripQuotes,
|
|
17
18
|
substituteVariables,
|
|
18
19
|
trace
|
|
19
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-BXSCE5CR.js";
|
|
20
21
|
|
|
21
22
|
// src/parsers/generated-constants-parser.ts
|
|
22
23
|
import fs from "fs/promises";
|
|
@@ -46,6 +47,7 @@ export {
|
|
|
46
47
|
parseDecorators,
|
|
47
48
|
parseGeneratedConstants,
|
|
48
49
|
parseHandlerRegistrations,
|
|
50
|
+
parseImplementationHint,
|
|
49
51
|
parseOutboundCalls,
|
|
50
52
|
parsePackageJson,
|
|
51
53
|
parseServiceBindings,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/parsers/generated-constants-parser.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { GeneratedConstantFact } from '../types.js';\nimport { normalizePath, stripQuotes } from '../utils/path-utils.js';\nfunction lineOf(text: string, idx: number): number {\n return text.slice(0, idx).split('\\n').length;\n}\nexport async function parseGeneratedConstants(\n repoPath: string,\n filePath: string\n): Promise<GeneratedConstantFact[]> {\n const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');\n return [\n ...text.matchAll(\n /(?:export\\s+)?(?:const|static\\s+readonly)\\s+(\\w+)\\s*=\\s*(['\"])([^'\"]+)\\2/g\n )\n ].map((m) => ({\n name: m[1] ?? 'constant',\n value: stripQuotes(m[3] ?? ''),\n sourceFile: normalizePath(filePath),\n sourceLine: lineOf(text, m.index ?? 0)\n }));\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/parsers/generated-constants-parser.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { GeneratedConstantFact } from '../types.js';\nimport { normalizePath, stripQuotes } from '../utils/path-utils.js';\nfunction lineOf(text: string, idx: number): number {\n return text.slice(0, idx).split('\\n').length;\n}\nexport async function parseGeneratedConstants(\n repoPath: string,\n filePath: string\n): Promise<GeneratedConstantFact[]> {\n const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');\n return [\n ...text.matchAll(\n /(?:export\\s+)?(?:const|static\\s+readonly)\\s+(\\w+)\\s*=\\s*(['\"])([^'\"]+)\\2/g\n )\n ].map((m) => ({\n name: m[1] ?? 'constant',\n value: stripQuotes(m[3] ?? ''),\n sourceFile: normalizePath(filePath),\n sourceLine: lineOf(text, m.index ?? 0)\n }));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAGjB,SAAS,OAAO,MAAc,KAAqB;AACjD,SAAO,KAAK,MAAM,GAAG,GAAG,EAAE,MAAM,IAAI,EAAE;AACxC;AACA,eAAsB,wBACpB,UACA,UACkC;AAClC,QAAM,OAAO,MAAM,GAAG,SAAS,KAAK,KAAK,UAAU,QAAQ,GAAG,MAAM;AACpE,SAAO;AAAA,IACL,GAAG,KAAK;AAAA,MACN;AAAA,IACF;AAAA,EACF,EAAE,IAAI,CAAC,OAAO;AAAA,IACZ,MAAM,EAAE,CAAC,KAAK;AAAA,IACd,OAAO,YAAY,EAAE,CAAC,KAAK,EAAE;AAAA,IAC7B,YAAY,cAAc,QAAQ;AAAA,IAClC,YAAY,OAAO,MAAM,EAAE,SAAS,CAAC;AAAA,EACvC,EAAE;AACJ;","names":[]}
|
package/package.json
CHANGED
package/src/cli/doctor.ts
CHANGED
|
@@ -3,13 +3,16 @@ import { classifyODataPathIntent, normalizeODataOperationInvocationPath } from '
|
|
|
3
3
|
import { ANALYZER_VERSION } from '../version.js';
|
|
4
4
|
|
|
5
5
|
type Diagnostic = Record<string, unknown>;
|
|
6
|
+
interface DoctorOptions {
|
|
7
|
+
detail?: boolean;
|
|
8
|
+
}
|
|
6
9
|
|
|
7
10
|
export function linkUpgradeWarnings(db: Db): Diagnostic[] {
|
|
8
11
|
return [...schemaDriftDiagnostics(db, true), ...analyzerVersionDiagnostics(db, true)]
|
|
9
12
|
.filter((item) => ['schema_legacy_columns_present', 'external_target_columns_missing_data', 'reindex_required_after_upgrade', 'reindex_required_after_analyzer_upgrade'].includes(String(item.code)));
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
export function doctorDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
15
|
+
export function doctorDiagnostics(db: Db, strict: boolean, options: DoctorOptions = {}): Diagnostic[] {
|
|
13
16
|
const diagnostics = db.prepare('SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics ORDER BY id').all() as Diagnostic[];
|
|
14
17
|
return [
|
|
15
18
|
...diagnostics,
|
|
@@ -17,7 +20,7 @@ export function doctorDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
|
17
20
|
...localServiceDiagnostics(db, strict),
|
|
18
21
|
...schemaDriftDiagnostics(db, strict),
|
|
19
22
|
...analyzerVersionDiagnostics(db, strict),
|
|
20
|
-
...parserQualityDiagnostics(db, strict),
|
|
23
|
+
...parserQualityDiagnostics(db, strict, options),
|
|
21
24
|
];
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -99,7 +102,7 @@ function localServiceDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
|
99
102
|
return out;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
function parserQualityDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
105
|
+
function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOptions): Diagnostic[] {
|
|
103
106
|
if (!strict) return [];
|
|
104
107
|
const symbol = symbolCallQuality(db);
|
|
105
108
|
const dbq = dbQueryQuality(db);
|
|
@@ -108,7 +111,7 @@ function parserQualityDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
|
108
111
|
identityAliasBindingQuality(db),
|
|
109
112
|
remoteActionNoBindingQuality(db),
|
|
110
113
|
contextualImplementationQuality(db),
|
|
111
|
-
implementationCandidateQuality(db),
|
|
114
|
+
implementationCandidateQuality(db, Boolean(options.detail)),
|
|
112
115
|
classInstanceNoiseQuality(db),
|
|
113
116
|
contextualBindingPropagationQuality(db),
|
|
114
117
|
nestedThisReceiverQuality(db),
|
|
@@ -178,20 +181,26 @@ function graphDynamicFlagQuality(db: Db): Diagnostic {
|
|
|
178
181
|
return { severity: Number(row.count ?? 0) > 0 ? 'warning' : 'info', code: 'strict_graph_dynamic_flag_consistency', message: 'Graph dynamic flag consistency aggregate', dynamicTerminalEdges: Number(row.count ?? 0) };
|
|
179
182
|
}
|
|
180
183
|
|
|
181
|
-
function implementationCandidateQuality(db: Db): Diagnostic {
|
|
182
|
-
const categories = [...implementationEdgeCategories(db), missingParameterMetadataCategory(db)].filter((item) => item.count > 0);
|
|
184
|
+
function implementationCandidateQuality(db: Db, detail: boolean): Diagnostic {
|
|
185
|
+
const categories = [...implementationEdgeCategories(db, detail), missingParameterMetadataCategory(db, detail), dynamicWrapperCategory(db, detail)].filter((item) => item.count > 0);
|
|
183
186
|
const total = categories.reduce((sum, item) => sum + item.count, 0);
|
|
184
|
-
return { severity: total > 0 ? 'warning' : 'info', code: 'strict_implementation_candidate_quality', message: 'Implementation candidate ambiguity and rejection aggregate', total, categories };
|
|
187
|
+
return { severity: total > 0 ? 'warning' : 'info', code: 'strict_implementation_candidate_quality', message: 'Implementation candidate ambiguity and rejection aggregate', total, summary: implementationSummary(categories), categories };
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
function implementationEdgeCategories(db: Db): Array<Diagnostic & { count: number }> {
|
|
190
|
+
function implementationEdgeCategories(db: Db, detail: boolean): Array<Diagnostic & { count: number }> {
|
|
188
191
|
const rows = db.prepare(`SELECT e.status,e.unresolved_reason unresolvedReason,e.evidence_json evidenceJson,o.operation_name operationName,base.operation_name baseOperation,s.service_path servicePath
|
|
189
192
|
FROM graph_edges e JOIN cds_operations o ON o.id=CAST(e.from_id AS INTEGER)
|
|
190
193
|
JOIN cds_services s ON s.id=o.service_id LEFT JOIN cds_operations base ON base.id=o.base_operation_id
|
|
191
194
|
WHERE e.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND e.status IN ('ambiguous','unresolved') ORDER BY s.service_path,o.operation_name,e.id`).all() as Diagnostic[];
|
|
192
195
|
const grouped = new Map<string, Diagnostic & { count: number; servicePaths: string[]; examples: Diagnostic[] }>();
|
|
193
196
|
for (const row of rows) addImplementationCategory(grouped, row);
|
|
194
|
-
return [...grouped.values()].map(({ servicePaths, ...item }) => ({
|
|
197
|
+
return [...grouped.values()].map(({ servicePaths, ...item }) => ({
|
|
198
|
+
...item,
|
|
199
|
+
servicePathPattern: pathPattern(servicePaths),
|
|
200
|
+
suggestedAction: categoryAction(String(item.category)),
|
|
201
|
+
examples: item.examples.slice(0, 3),
|
|
202
|
+
expandedExamples: detail ? item.examples : undefined,
|
|
203
|
+
}));
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
function addImplementationCategory(grouped: Map<string, Diagnostic & { count: number; servicePaths: string[]; examples: Diagnostic[] }>, row: Diagnostic): void {
|
|
@@ -208,16 +217,44 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
|
|
|
208
217
|
grouped.set(key, current);
|
|
209
218
|
}
|
|
210
219
|
|
|
211
|
-
function missingParameterMetadataCategory(db: Db): Diagnostic & { count: number } {
|
|
220
|
+
function missingParameterMetadataCategory(db: Db, detail = false): Diagnostic & { count: number } {
|
|
212
221
|
const examples = db.prepare(`SELECT sc.source_file sourceFile,sc.source_line sourceLine,sc.callee_expression calleeExpression
|
|
213
222
|
FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
|
|
214
223
|
WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
|
|
215
224
|
AND (s.evidence_json IS NULL OR json_extract(s.evidence_json,'$.parameterBindings') IS NULL)
|
|
216
|
-
ORDER BY sc.source_file,sc.source_line
|
|
225
|
+
ORDER BY sc.source_file,sc.source_line`).all() as Diagnostic[];
|
|
217
226
|
const row = db.prepare(`SELECT COUNT(*) count FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
|
|
218
227
|
WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
|
|
219
228
|
AND (s.evidence_json IS NULL OR json_extract(s.evidence_json,'$.parameterBindings') IS NULL)`).get() as { count?: number };
|
|
220
|
-
return { category: 'missing_parameter_metadata', reason: 'callee symbol is missing parameter binding metadata', candidateFamily: 'symbol_parameter_metadata', count: Number(row.count ?? 0), examples };
|
|
229
|
+
return { category: 'missing_parameter_metadata', reason: 'callee symbol is missing parameter binding metadata', candidateFamily: 'symbol_parameter_metadata', count: Number(row.count ?? 0), suggestedAction: categoryAction('missing_parameter_metadata'), examples: examples.slice(0, 3), expandedExamples: detail ? examples : undefined };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function dynamicWrapperCategory(db: Db, detail: boolean): Diagnostic & { count: number } {
|
|
233
|
+
const rows = db.prepare(`SELECT source_file sourceFile,source_line sourceLine,
|
|
234
|
+
json_extract(evidence_json,'$.receiver') receiverName,
|
|
235
|
+
COALESCE(json_extract(evidence_json,'$.missingPathIdentifier'),json_extract(evidence_json,'$.operationPathExpression')) pathIdentifier
|
|
236
|
+
FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier'
|
|
237
|
+
ORDER BY source_file,source_line`).all() as Diagnostic[];
|
|
238
|
+
return { category: 'dynamic_wrapper_paths', reason: 'wrapper path cannot be proven statically', candidateFamily: 'wrapper_path', count: rows.length, suggestedAction: categoryAction('dynamic_wrapper_paths'), examples: rows.slice(0, 3), expandedExamples: detail ? rows : undefined };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function implementationSummary(categories: Array<Diagnostic & { count: number }>): Diagnostic[] {
|
|
242
|
+
const grouped = new Map<string, Diagnostic & { count: number }>();
|
|
243
|
+
for (const category of categories) {
|
|
244
|
+
const key = [category.category, category.candidateFamily, category.reason].join('\0');
|
|
245
|
+
const current = grouped.get(key) ?? { category: category.category, candidateFamily: category.candidateFamily, reason: category.reason, severity: 'warning', suggestedAction: category.suggestedAction, count: 0 };
|
|
246
|
+
current.count += category.count;
|
|
247
|
+
grouped.set(key, current);
|
|
248
|
+
}
|
|
249
|
+
return [...grouped.values()].sort((left, right) => String(left.category).localeCompare(String(right.category)) || String(left.candidateFamily).localeCompare(String(right.candidateFamily)));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function categoryAction(category: string): string {
|
|
253
|
+
if (category === 'duplicate_package_name_candidates') return 'Use scoped --implementation-hint fields to select one repository for each ambiguous hop.';
|
|
254
|
+
if (category === 'missing_strong_ownership_evidence') return 'Add an explicit package dependency, local service-path ownership, or registration ownership evidence.';
|
|
255
|
+
if (category === 'missing_parameter_metadata') return 'Export a statically analyzable helper with named or destructured parameters.';
|
|
256
|
+
if (category === 'dynamic_wrapper_paths') return 'Pass a literal path or provide the reported runtime identifier with --var key=value.';
|
|
257
|
+
return 'Inspect the capped examples and add stronger implementation ownership evidence.';
|
|
221
258
|
}
|
|
222
259
|
|
|
223
260
|
function implementationCategory(row: Diagnostic, evidence: Diagnostic): string {
|
package/src/cli.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { linkWorkspace } from './linker/cross-repo-linker.js';
|
|
|
24
24
|
import { doctorDiagnostics, linkUpgradeWarnings } from './cli/doctor.js';
|
|
25
25
|
import { trace } from './trace/trace-engine.js';
|
|
26
26
|
import { parseVars } from './trace/selectors.js';
|
|
27
|
+
import { parseImplementationHint } from './trace/implementation-hints.js';
|
|
27
28
|
import { renderTraceTable } from './output/table-output.js';
|
|
28
29
|
import { renderTraceJson, renderJson } from './output/json-output.js';
|
|
29
30
|
import { renderMermaid } from './output/mermaid-output.js';
|
|
@@ -152,6 +153,7 @@ export function createProgram(): Command {
|
|
|
152
153
|
.option('--include-db')
|
|
153
154
|
.option('--include-async')
|
|
154
155
|
.option('--implementation-repo <name>')
|
|
156
|
+
.option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
|
|
155
157
|
.option('--var <key=value>', 'dynamic variable', collect, [])
|
|
156
158
|
.action(
|
|
157
159
|
(opts: {
|
|
@@ -167,6 +169,7 @@ export function createProgram(): Command {
|
|
|
167
169
|
includeDb?: boolean;
|
|
168
170
|
includeAsync?: boolean;
|
|
169
171
|
implementationRepo?: string;
|
|
172
|
+
implementationHint: string[];
|
|
170
173
|
var: string[];
|
|
171
174
|
}) =>
|
|
172
175
|
void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
@@ -186,6 +189,7 @@ export function createProgram(): Command {
|
|
|
186
189
|
includeDb: Boolean(opts.includeDb),
|
|
187
190
|
includeAsync: Boolean(opts.includeAsync),
|
|
188
191
|
implementationRepo: opts.implementationRepo,
|
|
192
|
+
implementationHints: opts.implementationHint.map(parseImplementationHint),
|
|
189
193
|
},
|
|
190
194
|
);
|
|
191
195
|
process.stdout.write(
|
|
@@ -293,6 +297,7 @@ export function createProgram(): Command {
|
|
|
293
297
|
.option('--path <operationPath>')
|
|
294
298
|
.option('--format <format>', 'mermaid|json', 'mermaid')
|
|
295
299
|
.option('--implementation-repo <name>')
|
|
300
|
+
.option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
|
|
296
301
|
.option('--var <key=value>', 'dynamic variable', collect, [])
|
|
297
302
|
.action(
|
|
298
303
|
(opts: {
|
|
@@ -303,6 +308,7 @@ export function createProgram(): Command {
|
|
|
303
308
|
path?: string;
|
|
304
309
|
format: string;
|
|
305
310
|
implementationRepo?: string;
|
|
311
|
+
implementationHint: string[];
|
|
306
312
|
var: string[];
|
|
307
313
|
}) =>
|
|
308
314
|
void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
@@ -321,6 +327,7 @@ export function createProgram(): Command {
|
|
|
321
327
|
includeExternal: true,
|
|
322
328
|
vars: parseVars(opts.var),
|
|
323
329
|
implementationRepo: opts.implementationRepo,
|
|
330
|
+
implementationHints: opts.implementationHint.map(parseImplementationHint),
|
|
324
331
|
},
|
|
325
332
|
);
|
|
326
333
|
process.stdout.write(
|
|
@@ -362,10 +369,11 @@ export function createProgram(): Command {
|
|
|
362
369
|
.command('doctor')
|
|
363
370
|
.option('--workspace <path>')
|
|
364
371
|
.option('--strict')
|
|
372
|
+
.option('--detail')
|
|
365
373
|
.action(
|
|
366
|
-
(opts: { workspace?: string; strict?: boolean }) =>
|
|
374
|
+
(opts: { workspace?: string; strict?: boolean; detail?: boolean }) =>
|
|
367
375
|
void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
368
|
-
const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict));
|
|
376
|
+
const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
|
|
369
377
|
process.stdout.write(
|
|
370
378
|
allDiagnostics.length
|
|
371
379
|
? renderJson(allDiagnostics)
|
package/src/index.ts
CHANGED
|
@@ -10,4 +10,6 @@ export { linkWorkspace } from './linker/cross-repo-linker.js';
|
|
|
10
10
|
export { applyVariables, extractPlaceholders, substituteVariables } from './linker/dynamic-edge-resolver.js';
|
|
11
11
|
export type { RuntimeSubstitution } from './linker/dynamic-edge-resolver.js';
|
|
12
12
|
export { trace } from './trace/trace-engine.js';
|
|
13
|
+
export { parseImplementationHint } from './trace/implementation-hints.js';
|
|
14
|
+
export type { ImplementationHint } from './types.js';
|
|
13
15
|
export { redactValue, redactText } from './utils/redaction.js';
|
|
@@ -146,7 +146,29 @@ function objectJson(value: unknown): Record<string, unknown> | undefined {
|
|
|
146
146
|
}
|
|
147
147
|
function callEvidence(call: Record<string, unknown>, resolution: { target?: { repoName?: string; servicePath?: string; operationPath?: string; operationName?: string }; candidates: unknown[]; status: string; reasons: string[] }, servicePath: string | undefined, op: string | undefined, destination: string | undefined, normalized?: NormalizedODataOperationPath, odataPathIntent?: ReturnType<typeof classifyODataPathIntent>): Record<string, unknown> {
|
|
148
148
|
const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
|
|
149
|
-
|
|
149
|
+
const routingPlaceholderKeys = placeholderKeys([servicePath, destination, stringValue(call.aliasExpr), stringValue(call.alias)]);
|
|
150
|
+
return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, rawOperationPath: normalized?.wasInvocation ? normalized.rawOperationPath : odataPathIntent?.rawPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArguments: normalized?.wasInvocation ? normalized.invocationArguments : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, routingPlaceholderKeys: routingPlaceholderKeys.length ? routingPlaceholderKeys : undefined, odataOperationNormalizationReason: normalized?.normalizationReason, odataOperationNormalizationRejectedReason: normalized?.normalizationRejectedReason, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: parseJson(call.alias_chain_json), transport: call.call_type === 'local_service_call' ? 'local' : undefined, targetRepo: resolution.target?.repoName, targetServicePath: resolution.target?.servicePath, targetOperationPath: resolution.target?.operationPath, targetOperation: resolution.target?.operationName, helperChain: parseJson(call.helperChainJson), candidates: resolution.candidates, candidateScores: compactCandidateScores(resolution.candidates), candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, odataPathIntent, queryStringPresent: odataPathIntent?.hasQueryString || undefined, queryPlaceholderKeys: odataPathIntent?.placeholderKeys.length ? odataPathIntent.placeholderKeys : undefined, bindingHasDynamicExpression: bindingHasDynamicExpression || undefined, outboundEvidence: objectJson(call.evidence_json), analysisCompleteness: call.unresolved_reason ? 'partial' : 'complete', parserWarning: call.unresolved_reason ? { code: 'parser_warning', message: call.unresolved_reason } : undefined };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function compactCandidateScores(candidates: unknown[]): Array<Record<string, unknown>> {
|
|
154
|
+
return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
|
|
155
|
+
const row = objectValue(candidate);
|
|
156
|
+
if (!row) return [];
|
|
157
|
+
return [{ repo: row.repoName, servicePath: row.servicePath, operationPath: row.operationPath, score: row.score, reasons: row.reasons }];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function placeholderKeys(values: Array<string | undefined>): string[] {
|
|
162
|
+
const keys = values.flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim()).filter(Boolean));
|
|
163
|
+
return [...new Set(keys)].sort();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
167
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function stringValue(value: unknown): string | undefined {
|
|
171
|
+
return typeof value === 'string' ? value : undefined;
|
|
150
172
|
}
|
|
151
173
|
|
|
152
174
|
function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
|
|
@@ -2,6 +2,7 @@ export interface NormalizedODataOperationPath {
|
|
|
2
2
|
rawOperationPath: string;
|
|
3
3
|
normalizedOperationPath: string;
|
|
4
4
|
wasInvocation: boolean;
|
|
5
|
+
invocationArguments?: string;
|
|
5
6
|
invocationArgumentPlaceholderKeys: string[];
|
|
6
7
|
normalizationReason?: string;
|
|
7
8
|
normalizationRejectedReason?: string;
|
|
@@ -20,6 +21,7 @@ export interface ODataPathIntent {
|
|
|
20
21
|
placeholderKeys: string[];
|
|
21
22
|
keyPredicatePlaceholderKeys: string[];
|
|
22
23
|
invocationArgumentPlaceholderKeys: string[];
|
|
24
|
+
invocationArguments?: string;
|
|
23
25
|
navigationSuffix?: string;
|
|
24
26
|
mediaOrPropertySuffix?: string;
|
|
25
27
|
hasEntityKeyPredicate: boolean;
|
|
@@ -51,6 +53,7 @@ export function normalizeODataOperationInvocationPath(path: string | undefined):
|
|
|
51
53
|
rawOperationPath: raw,
|
|
52
54
|
normalizedOperationPath: operationSegment,
|
|
53
55
|
wasInvocation: true,
|
|
56
|
+
invocationArguments: raw.slice(open + 1, close),
|
|
54
57
|
invocationArgumentPlaceholderKeys: [...new Set(extractTemplatePlaceholders(raw.slice(open + 1, close)))],
|
|
55
58
|
normalizationReason: 'balanced_top_level_operation_invocation',
|
|
56
59
|
};
|
|
@@ -81,7 +84,7 @@ export function classifyODataPathIntent(path: string | undefined, method: string
|
|
|
81
84
|
const topLevelOperationInvocation = Boolean(invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment));
|
|
82
85
|
const keyPredicatePlaceholderKeys = topLevelOperationInvocation ? [] : rawKeyPredicatePlaceholderKeys;
|
|
83
86
|
const topLevelOperationNameCandidate = Boolean(topLevelOperationName && !hasNavigationSegments && !firstSegment.includes('('));
|
|
84
|
-
const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
|
|
87
|
+
const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArguments: invocation?.invocationArguments, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
|
|
85
88
|
if (!rawPath || !rawPath.startsWith('/')) return { ...base, kind: 'unknown', reason: 'path_missing_or_not_absolute' };
|
|
86
89
|
const upperEntityLike = /^[A-Z][A-Za-z0-9_]*$/.test(entitySegment ?? firstSegment);
|
|
87
90
|
const mediaLike = isMediaOrPropertySuffix(segments.at(-1) ?? '');
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
4
|
+
import type { OutboundCallFact } from '../types.js';
|
|
5
|
+
import { normalizePath } from '../utils/path-utils.js';
|
|
6
|
+
import { importsFor, lineOf, readSource, type ImportBinding } from './service-binding-parser-helpers.js';
|
|
7
|
+
|
|
8
|
+
interface WrapperSpec {
|
|
9
|
+
clientIndex: number;
|
|
10
|
+
pathIndex: number;
|
|
11
|
+
methodIndex?: number;
|
|
12
|
+
methodLiteral?: string;
|
|
13
|
+
sourceFile: string;
|
|
14
|
+
sourceLine: number;
|
|
15
|
+
chain: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PathValue {
|
|
19
|
+
value?: string;
|
|
20
|
+
sourceKind: string;
|
|
21
|
+
rawExpression: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function parseImportedWrapperCalls(
|
|
25
|
+
repoPath: string,
|
|
26
|
+
filePath: string,
|
|
27
|
+
source: ts.SourceFile,
|
|
28
|
+
serviceBindings: Set<string>,
|
|
29
|
+
): Promise<OutboundCallFact[]> {
|
|
30
|
+
const imports = await importsFor(repoPath, filePath, source);
|
|
31
|
+
const importedByLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
|
|
32
|
+
const calls = collectImportedCalls(source, importedByLocal);
|
|
33
|
+
const out: OutboundCallFact[] = [];
|
|
34
|
+
const cache = new Map<string, Promise<WrapperSpec | undefined>>();
|
|
35
|
+
for (const call of calls) {
|
|
36
|
+
if (!ts.isIdentifier(call.expression)) continue;
|
|
37
|
+
const imported = importedByLocal.get(call.expression.text);
|
|
38
|
+
if (!imported?.sourceFile) continue;
|
|
39
|
+
const spec = await loadWrapperSpec(repoPath, imported, cache, 0);
|
|
40
|
+
const fact = spec ? wrapperCallFact(source, filePath, call, spec, serviceBindings) : undefined;
|
|
41
|
+
if (fact) out.push(fact);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectImportedCalls(source: ts.SourceFile, imports: Map<string, ImportBinding>): ts.CallExpression[] {
|
|
47
|
+
const calls: ts.CallExpression[] = [];
|
|
48
|
+
const visit = (node: ts.Node): void => {
|
|
49
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && imports.has(node.expression.text)) calls.push(node);
|
|
50
|
+
ts.forEachChild(node, visit);
|
|
51
|
+
};
|
|
52
|
+
visit(source);
|
|
53
|
+
return calls;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function loadWrapperSpec(
|
|
57
|
+
repoPath: string,
|
|
58
|
+
imported: ImportBinding,
|
|
59
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
60
|
+
depth: number,
|
|
61
|
+
): Promise<WrapperSpec | undefined> {
|
|
62
|
+
if (!imported.sourceFile || depth > 5) return undefined;
|
|
63
|
+
const key = `${imported.sourceFile}#${imported.exportedName}`;
|
|
64
|
+
const existing = cache.get(key);
|
|
65
|
+
if (existing) return existing;
|
|
66
|
+
const pending = inspectWrapper(repoPath, imported.sourceFile, imported.exportedName, cache, depth);
|
|
67
|
+
cache.set(key, pending);
|
|
68
|
+
return pending;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function inspectWrapper(
|
|
72
|
+
repoPath: string,
|
|
73
|
+
sourceFile: string,
|
|
74
|
+
exportedName: string,
|
|
75
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
76
|
+
depth: number,
|
|
77
|
+
): Promise<WrapperSpec | undefined> {
|
|
78
|
+
const source = await readSource(path.join(repoPath, sourceFile));
|
|
79
|
+
if (!source) return undefined;
|
|
80
|
+
const named = findFunction(source, exportedName);
|
|
81
|
+
if (!named) return undefined;
|
|
82
|
+
const direct = directSendSpec(source, sourceFile, named.name, named.fn);
|
|
83
|
+
if (direct) return direct;
|
|
84
|
+
return nestedSendSpec(repoPath, sourceFile, source, named.name, named.fn, cache, depth);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function directSendSpec(source: ts.SourceFile, sourceFile: string, name: string, fn: ts.FunctionLikeDeclaration): WrapperSpec | undefined {
|
|
88
|
+
const params = parameterNames(fn);
|
|
89
|
+
const sends: ts.CallExpression[] = [];
|
|
90
|
+
visitFunctionBody(fn, (node) => {
|
|
91
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send') sends.push(node);
|
|
92
|
+
});
|
|
93
|
+
if (sends.length !== 1) return undefined;
|
|
94
|
+
const send = sends[0];
|
|
95
|
+
const receiver = send && ts.isPropertyAccessExpression(send.expression) && ts.isIdentifier(send.expression.expression) ? send.expression.expression.text : undefined;
|
|
96
|
+
const object = send?.arguments[0];
|
|
97
|
+
if (!receiver || !object || !ts.isObjectLiteralExpression(object)) return undefined;
|
|
98
|
+
const pathExpr = propertyExpression(object, 'path');
|
|
99
|
+
const methodExpr = propertyExpression(object, 'method');
|
|
100
|
+
const pathName = pathExpr && ts.isIdentifier(pathExpr) ? pathExpr.text : undefined;
|
|
101
|
+
const clientIndex = params.indexOf(receiver);
|
|
102
|
+
const pathIndex = pathName ? params.indexOf(pathName) : -1;
|
|
103
|
+
if (clientIndex < 0 || pathIndex < 0) return undefined;
|
|
104
|
+
const methodName = methodExpr && ts.isIdentifier(methodExpr) ? methodExpr.text : undefined;
|
|
105
|
+
const methodIndex = methodName ? params.indexOf(methodName) : -1;
|
|
106
|
+
return { clientIndex, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodLiteral: literal(methodExpr), sourceFile, sourceLine: lineOf(source, fn), chain: [name] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function nestedSendSpec(
|
|
110
|
+
repoPath: string,
|
|
111
|
+
sourceFile: string,
|
|
112
|
+
source: ts.SourceFile,
|
|
113
|
+
name: string,
|
|
114
|
+
fn: ts.FunctionLikeDeclaration,
|
|
115
|
+
cache: Map<string, Promise<WrapperSpec | undefined>>,
|
|
116
|
+
depth: number,
|
|
117
|
+
): Promise<WrapperSpec | undefined> {
|
|
118
|
+
const imports = await importsFor(repoPath, sourceFile, source);
|
|
119
|
+
const byLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
|
|
120
|
+
const calls: ts.CallExpression[] = [];
|
|
121
|
+
visitFunctionBody(fn, (node) => {
|
|
122
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && byLocal.has(node.expression.text)) calls.push(node);
|
|
123
|
+
});
|
|
124
|
+
if (calls.length !== 1) return undefined;
|
|
125
|
+
const call = calls[0];
|
|
126
|
+
const imported = call && ts.isIdentifier(call.expression) ? byLocal.get(call.expression.text) : undefined;
|
|
127
|
+
const nested = imported ? await loadWrapperSpec(repoPath, imported, cache, depth + 1) : undefined;
|
|
128
|
+
if (!call || !nested) return undefined;
|
|
129
|
+
const params = parameterNames(fn);
|
|
130
|
+
const clientIndex = mappedParameterIndex(call.arguments[nested.clientIndex], params);
|
|
131
|
+
const pathIndex = mappedParameterIndex(call.arguments[nested.pathIndex], params);
|
|
132
|
+
if (clientIndex < 0 || pathIndex < 0) return undefined;
|
|
133
|
+
const methodIndex = nested.methodIndex === undefined ? undefined : mappedParameterIndex(call.arguments[nested.methodIndex], params);
|
|
134
|
+
return { clientIndex, pathIndex, methodIndex: methodIndex !== undefined && methodIndex >= 0 ? methodIndex : undefined, methodLiteral: nested.methodLiteral, sourceFile, sourceLine: lineOf(source, fn), chain: [name, ...nested.chain] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function wrapperCallFact(
|
|
138
|
+
source: ts.SourceFile,
|
|
139
|
+
filePath: string,
|
|
140
|
+
call: ts.CallExpression,
|
|
141
|
+
spec: WrapperSpec,
|
|
142
|
+
serviceBindings: Set<string>,
|
|
143
|
+
): OutboundCallFact | undefined {
|
|
144
|
+
const client = call.arguments[spec.clientIndex];
|
|
145
|
+
if (!client || !ts.isIdentifier(client) || !serviceBindings.has(client.text)) return undefined;
|
|
146
|
+
const pathValue = resolveCallerPath(call.arguments[spec.pathIndex], call);
|
|
147
|
+
const methodValue = spec.methodIndex === undefined ? spec.methodLiteral : literal(call.arguments[spec.methodIndex]);
|
|
148
|
+
const method = (methodValue ?? 'POST').toUpperCase();
|
|
149
|
+
const rawPath = pathValue.rawExpression;
|
|
150
|
+
const operationPathExpr = pathValue.value ? normalizeOperationPath(pathValue.value) : runtimePathExpression(rawPath);
|
|
151
|
+
return {
|
|
152
|
+
callType: 'remote_action',
|
|
153
|
+
serviceVariableName: client.text,
|
|
154
|
+
method,
|
|
155
|
+
operationPathExpr,
|
|
156
|
+
payloadSummary: call.getText(source),
|
|
157
|
+
sourceFile: normalizePath(filePath),
|
|
158
|
+
sourceLine: lineOf(source, call),
|
|
159
|
+
confidence: operationPathExpr ? 0.85 : 0.5,
|
|
160
|
+
unresolvedReason: pathValue.value ? undefined : 'dynamic_operation_path_identifier',
|
|
161
|
+
evidence: {
|
|
162
|
+
parser: 'typescript_ast',
|
|
163
|
+
classifier: pathValue.value ? 'imported_wrapper_literal_path' : 'imported_wrapper_dynamic_path',
|
|
164
|
+
receiver: client.text,
|
|
165
|
+
wrapperFunction: spec.chain[0],
|
|
166
|
+
wrapperChain: spec.chain,
|
|
167
|
+
callerSite: { sourceFile: normalizePath(filePath), sourceLine: lineOf(source, call) },
|
|
168
|
+
calleeSite: { sourceFile: spec.sourceFile, sourceLine: spec.sourceLine },
|
|
169
|
+
rawPathExpression: rawPath,
|
|
170
|
+
missingPathIdentifier: pathValue.value ? undefined : rawPath,
|
|
171
|
+
odataPathIntent: pathValue.value ? classifyODataPathIntent(operationPathExpr, method) : undefined,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveCallerPath(expr: ts.Expression | undefined, use: ts.Node): PathValue {
|
|
177
|
+
if (!expr) return { sourceKind: 'missing', rawExpression: '' };
|
|
178
|
+
if (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { value: expr.text, sourceKind: 'literal', rawExpression: expr.getText() };
|
|
179
|
+
if (ts.isTemplateExpression(expr)) return { value: expr.getText().slice(1, -1), sourceKind: 'template', rawExpression: expr.getText() };
|
|
180
|
+
if (ts.isIdentifier(expr)) {
|
|
181
|
+
const initializer = constInitializer(expr.text, use);
|
|
182
|
+
if (initializer) {
|
|
183
|
+
const resolved = resolveCallerPath(initializer, initializer);
|
|
184
|
+
return { ...resolved, sourceKind: 'const', rawExpression: expr.text };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { sourceKind: 'dynamic', rawExpression: expr.getText() };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function constInitializer(name: string, use: ts.Node): ts.Expression | undefined {
|
|
191
|
+
let found: ts.Expression | undefined;
|
|
192
|
+
const source = use.getSourceFile();
|
|
193
|
+
const visit = (node: ts.Node): void => {
|
|
194
|
+
if (node.getStart(source) >= use.getStart(source)) return;
|
|
195
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer && (node.parent.flags & ts.NodeFlags.Const) !== 0) found = node.initializer;
|
|
196
|
+
ts.forEachChild(node, visit);
|
|
197
|
+
};
|
|
198
|
+
visit(source);
|
|
199
|
+
return found;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function findFunction(source: ts.SourceFile, exportedName: string): { name: string; fn: ts.FunctionLikeDeclaration } | undefined {
|
|
203
|
+
const localName = exportedLocalName(source, exportedName);
|
|
204
|
+
for (const statement of source.statements) {
|
|
205
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === localName) return { name: exportedName, fn: statement };
|
|
206
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
207
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
208
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === localName && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return { name: exportedName, fn: declaration.initializer };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function exportedLocalName(source: ts.SourceFile, exportedName: string): string {
|
|
215
|
+
for (const statement of source.statements) {
|
|
216
|
+
if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue;
|
|
217
|
+
const match = statement.exportClause.elements.find((item) => item.name.text === exportedName);
|
|
218
|
+
if (match) return match.propertyName?.text ?? match.name.text;
|
|
219
|
+
}
|
|
220
|
+
return exportedName;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function visitFunctionBody(fn: ts.FunctionLikeDeclaration, visitor: (node: ts.Node) => void): void {
|
|
224
|
+
const visit = (node: ts.Node): void => {
|
|
225
|
+
if (node !== fn && ts.isFunctionLike(node)) return;
|
|
226
|
+
visitor(node);
|
|
227
|
+
ts.forEachChild(node, visit);
|
|
228
|
+
};
|
|
229
|
+
if (fn.body) visit(fn.body);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parameterNames(fn: ts.FunctionLikeDeclaration): string[] {
|
|
233
|
+
return fn.parameters.map((parameter) => ts.isIdentifier(parameter.name) ? parameter.name.text : '');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function mappedParameterIndex(expr: ts.Expression | undefined, parameters: string[]): number {
|
|
237
|
+
return expr && ts.isIdentifier(expr) ? parameters.indexOf(expr.text) : -1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function propertyExpression(object: ts.ObjectLiteralExpression, key: string): ts.Expression | undefined {
|
|
241
|
+
for (const property of object.properties) {
|
|
242
|
+
if (ts.isPropertyAssignment(property) && propertyName(property.name) === key) return property.initializer;
|
|
243
|
+
if (ts.isShorthandPropertyAssignment(property) && property.name.text === key) return property.name;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function propertyName(name: ts.PropertyName): string | undefined {
|
|
249
|
+
return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function literal(expr: ts.Expression | undefined): string | undefined {
|
|
253
|
+
return expr && (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) ? expr.text : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizeOperationPath(value: string): string {
|
|
257
|
+
return value.startsWith('/') ? value : `/${value}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function runtimePathExpression(value: string): string | undefined {
|
|
261
|
+
return value ? `\${${value}}` : undefined;
|
|
262
|
+
}
|
|
@@ -6,6 +6,8 @@ import type { OutboundCallFact } from '../types.js';
|
|
|
6
6
|
import { normalizePath, stripQuotes } from '../utils/path-utils.js';
|
|
7
7
|
import { summarizeExpression } from '../utils/redaction.js';
|
|
8
8
|
import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
9
|
+
import { parseServiceBindings } from './service-binding-parser.js';
|
|
10
|
+
import { parseImportedWrapperCalls } from './imported-wrapper-parser.js';
|
|
9
11
|
function lineOf(text: string, idx: number): number {
|
|
10
12
|
return text.slice(0, idx).split('\n').length;
|
|
11
13
|
}
|
|
@@ -501,7 +503,9 @@ export async function parseOutboundCalls(
|
|
|
501
503
|
): Promise<OutboundCallFact[]> {
|
|
502
504
|
const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
503
505
|
const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
504
|
-
|
|
506
|
+
const bindingNames = new Set((await parseServiceBindings(repoPath, filePath)).map((binding) => binding.variableName));
|
|
507
|
+
const importedWrappers = await parseImportedWrapperCalls(repoPath, filePath, source, bindingNames);
|
|
508
|
+
return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...importedWrappers, ...parseLocalServiceCalls(text, filePath)];
|
|
505
509
|
}
|
|
506
510
|
function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFact[] {
|
|
507
511
|
const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|