@saptools/service-flow 0.1.48 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +11 -1
- package/dist/{chunk-EGY2A4AT.js → chunk-52OUS3MO.js} +878 -333
- package/dist/chunk-52OUS3MO.js.map +1 -0
- package/dist/cli.js +330 -38
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +187 -3
- package/src/db/migrations.ts +4 -1
- package/src/db/repositories.ts +188 -11
- package/src/db/schema.ts +1 -1
- package/src/linker/cross-repo-linker.ts +172 -6
- package/src/linker/operation-decorator-normalizer.ts +3 -3
- package/src/output/table-output.ts +18 -8
- package/src/parsers/decorator-parser.ts +128 -22
- package/src/parsers/imported-wrapper-parser.ts +20 -46
- package/src/parsers/operation-path-analysis.ts +350 -0
- package/src/parsers/outbound-call-parser.ts +63 -53
- package/src/trace/evidence.ts +18 -3
- package/src/trace/selectors.ts +36 -0
- package/src/trace/trace-engine.ts +99 -25
- package/src/types.ts +12 -0
- package/dist/chunk-EGY2A4AT.js.map +0 -1
|
@@ -78,7 +78,38 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
|
|
|
78
78
|
const operationLikeRemoteEntity = isRemoteEntityCall && Boolean(op) && credibleOperationSignal && (!strongEntitySignal || indexedOperationCandidateCount > 0);
|
|
79
79
|
const isOperationCall = operationLikeRemoteEntity || ((callType === 'remote_action' || callType === 'local_service_call') || (callType === 'remote_query' && Boolean(op)));
|
|
80
80
|
const resolution = isOperationCall ? resolveOperation(db, { servicePath, operationPath: op, serviceName: call.local_service_name as string | undefined, repoId: callType === 'local_service_call' ? Number(call.repo_id) : undefined, alias: applyVariables((call.aliasExpr as string | undefined) ?? (call.alias as string | undefined), vars), destination: destination ? applyVariables(destination, vars) : undefined, isDynamic, hasExplicitOverride: Object.keys(vars).length > 0 || callType === 'local_service_call' }, workspaceId) : { status: 'unresolved' as const, candidates: [], reasons: [] };
|
|
81
|
-
const evidence: Record<string, unknown> = {
|
|
81
|
+
const evidence: Record<string, unknown> = {
|
|
82
|
+
...callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : undefined, normalized, intent),
|
|
83
|
+
indexedOperationCandidateCount,
|
|
84
|
+
parserCallType: callType,
|
|
85
|
+
entityOperationPrecedence: operationPrecedence(
|
|
86
|
+
callType,
|
|
87
|
+
intent,
|
|
88
|
+
indexedOperationCandidateCount,
|
|
89
|
+
Boolean(resolution.target),
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
const pathAnalysis = objectValue(objectJson(call.evidence_json)?.pathAnalysis);
|
|
93
|
+
if (callType === 'remote_action' && pathAnalysis?.status === 'ambiguous') {
|
|
94
|
+
const candidatePaths = Array.isArray(pathAnalysis.candidateRawPaths)
|
|
95
|
+
? pathAnalysis.candidateRawPaths
|
|
96
|
+
: [];
|
|
97
|
+
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,unresolved_reason,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)').run(
|
|
98
|
+
workspaceId,
|
|
99
|
+
'UNRESOLVED_EDGE',
|
|
100
|
+
'ambiguous',
|
|
101
|
+
'call',
|
|
102
|
+
String(call.id),
|
|
103
|
+
'operation_candidates',
|
|
104
|
+
candidatePaths.join(','),
|
|
105
|
+
Number(call.confidence ?? 0.5),
|
|
106
|
+
JSON.stringify(evidence),
|
|
107
|
+
0,
|
|
108
|
+
'Ambiguous operation path candidates require explicit disambiguation',
|
|
109
|
+
generation,
|
|
110
|
+
);
|
|
111
|
+
return { status: 'ambiguous', callType };
|
|
112
|
+
}
|
|
82
113
|
if (isRemoteEntityCall && (resolution.target || resolution.candidates.length > 0 || resolution.status === 'dynamic')) {
|
|
83
114
|
if (resolution.target) {
|
|
84
115
|
db.prepare('INSERT INTO graph_edges(workspace_id,edge_type,status,from_kind,from_id,to_kind,to_id,confidence,evidence_json,is_dynamic,generation) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run(workspaceId, 'REMOTE_CALL_RESOLVES_TO_OPERATION', 'resolved', 'call', String(call.id), 'operation', String(resolution.target.operationId), resolution.target.score, JSON.stringify({ ...evidence, operationEntityPrecedence: 'indexed_operation_over_parser_entity' }), 0, generation);
|
|
@@ -125,6 +156,44 @@ function operationCandidateCount(db: Db, workspaceId: number, operationPath: str
|
|
|
125
156
|
return Number(row?.count ?? 0);
|
|
126
157
|
}
|
|
127
158
|
|
|
159
|
+
function operationPrecedence(
|
|
160
|
+
callType: string,
|
|
161
|
+
intent: ReturnType<typeof classifyODataPathIntent>,
|
|
162
|
+
indexedOperationCandidateCount: number,
|
|
163
|
+
resolvedOperation: boolean,
|
|
164
|
+
): Record<string, unknown> {
|
|
165
|
+
if (resolvedOperation) {
|
|
166
|
+
return {
|
|
167
|
+
decision: 'operation',
|
|
168
|
+
reason: 'indexed_operation_with_strong_service_context',
|
|
169
|
+
indexedOperationCandidateCount,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (callType === 'remote_action' && intent.kind === 'operation_invocation') {
|
|
173
|
+
return {
|
|
174
|
+
decision: 'operation_candidate',
|
|
175
|
+
rejectionReason: indexedOperationCandidateCount > 0
|
|
176
|
+
? 'indexed_candidates_lack_unique_strong_service_context'
|
|
177
|
+
: 'no_indexed_operation_candidate',
|
|
178
|
+
indexedOperationCandidateCount,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (intent.kind.startsWith('entity_')) {
|
|
182
|
+
return {
|
|
183
|
+
decision: 'entity',
|
|
184
|
+
rejectionReason: indexedOperationCandidateCount > 0
|
|
185
|
+
? 'entity_shape_has_precedence_without_resolved_operation_context'
|
|
186
|
+
: 'entity_shape_has_no_indexed_operation_evidence',
|
|
187
|
+
indexedOperationCandidateCount,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
decision: 'unresolved',
|
|
192
|
+
rejectionReason: 'path_has_no_safe_entity_or_operation_precedence',
|
|
193
|
+
indexedOperationCandidateCount,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
128
197
|
function unresolvedOperationReason(resolution: { candidates: unknown[]; status: string; reasons: string[] }): string {
|
|
129
198
|
if (resolution.status === 'dynamic') return `Dynamic target requires runtime variable overrides: ${(resolution.reasons.length ? resolution.reasons : ['missing runtime variables']).join(', ')}`;
|
|
130
199
|
if (resolution.candidates.length === 0) return 'No indexed target operation matched';
|
|
@@ -232,14 +301,64 @@ function implementationContextForOperation(db: Db, operation: Record<string, unk
|
|
|
232
301
|
}
|
|
233
302
|
function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
|
|
234
303
|
const rows = implementationCandidates(db, workspaceId, operation);
|
|
235
|
-
|
|
304
|
+
const invalid = rows.filter((row) => !validRegistrationPair(row));
|
|
305
|
+
recordRegistrationInvariantDiagnostics(db, invalid);
|
|
306
|
+
return deduplicateCandidates(
|
|
307
|
+
rows.filter(validRegistrationPair)
|
|
308
|
+
.map((row) => scoreImplementationCandidate(row, operation)),
|
|
309
|
+
).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
|
|
310
|
+
}
|
|
311
|
+
function validRegistrationPair(row: Record<string, unknown>): boolean {
|
|
312
|
+
if (row.registrationHandlerClassId === null
|
|
313
|
+
|| row.registrationHandlerClassId === undefined)
|
|
314
|
+
return registrationPairingStrategy(row) !== 'unproven';
|
|
315
|
+
return Number(row.registrationHandlerClassId) === Number(row.classId);
|
|
316
|
+
}
|
|
317
|
+
function registrationPairingStrategy(row: Record<string, unknown>): string {
|
|
318
|
+
if (row.registrationHandlerClassId !== null
|
|
319
|
+
&& row.registrationHandlerClassId !== undefined)
|
|
320
|
+
return 'exact_handler_class_id';
|
|
321
|
+
if (Number(row.applicationRepoId) === Number(row.handlerRepoId))
|
|
322
|
+
return 'same_repository_class_name_fallback';
|
|
323
|
+
const source = stringValue(row.importSource);
|
|
324
|
+
const separator = source?.lastIndexOf('#') ?? -1;
|
|
325
|
+
if (!source || separator <= 0) return 'unproven';
|
|
326
|
+
const moduleName = source.slice(0, separator);
|
|
327
|
+
const importedName = source.slice(separator + 1);
|
|
328
|
+
const matchesClass = importedName === row.className
|
|
329
|
+
|| (importedName === 'default' && row.registrationClassName === row.className);
|
|
330
|
+
return moduleName === row.handlerPackage && matchesClass
|
|
331
|
+
? 'explicit_package_import'
|
|
332
|
+
: 'unproven';
|
|
333
|
+
}
|
|
334
|
+
function recordRegistrationInvariantDiagnostics(
|
|
335
|
+
db: Db,
|
|
336
|
+
rows: Array<Record<string, unknown>>,
|
|
337
|
+
): void {
|
|
338
|
+
const insert = db.prepare(`INSERT INTO diagnostics(repo_id,severity,code,message,source_file,source_line)
|
|
339
|
+
SELECT ?,'error','handler_registration_class_mismatch',
|
|
340
|
+
'Implementation candidate registration did not match its persisted handler class id',?,?
|
|
341
|
+
WHERE NOT EXISTS (
|
|
342
|
+
SELECT 1 FROM diagnostics
|
|
343
|
+
WHERE repo_id=? AND code='handler_registration_class_mismatch'
|
|
344
|
+
AND source_file=? AND source_line=?
|
|
345
|
+
)`);
|
|
346
|
+
for (const row of rows)
|
|
347
|
+
insert.run(
|
|
348
|
+
row.applicationRepoId,
|
|
349
|
+
row.registrationFile,
|
|
350
|
+
row.registrationLine,
|
|
351
|
+
row.applicationRepoId,
|
|
352
|
+
row.registrationFile,
|
|
353
|
+
row.registrationLine,
|
|
354
|
+
);
|
|
236
355
|
}
|
|
237
356
|
|
|
238
357
|
function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationCandidate[] {
|
|
239
358
|
const merged = new Map<string, ImplementationCandidate>();
|
|
240
359
|
for (const row of rows) {
|
|
241
360
|
const key = [row.methodId, row.classId, row.handlerRepoId].join(':');
|
|
242
|
-
const registration =
|
|
361
|
+
const registration = registrationEvidence(row);
|
|
243
362
|
const existing = merged.get(key);
|
|
244
363
|
if (!existing) {
|
|
245
364
|
merged.set(key, { ...row, registrations: [registration] });
|
|
@@ -253,6 +372,19 @@ function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationC
|
|
|
253
372
|
}
|
|
254
373
|
return [...merged.values()];
|
|
255
374
|
}
|
|
375
|
+
function registrationEvidence(
|
|
376
|
+
row: Record<string, unknown>,
|
|
377
|
+
): Record<string, unknown> {
|
|
378
|
+
return {
|
|
379
|
+
id: row.registrationId,
|
|
380
|
+
handlerClassId: row.registrationHandlerClassId,
|
|
381
|
+
file: row.registrationFile,
|
|
382
|
+
line: row.registrationLine,
|
|
383
|
+
kind: row.registrationKind,
|
|
384
|
+
importSource: row.importSource,
|
|
385
|
+
pairingStrategy: registrationPairingStrategy(row),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
256
388
|
function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
|
257
389
|
const seen = new Set<string>();
|
|
258
390
|
return rows.filter((row) => {
|
|
@@ -283,10 +415,14 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
|
|
|
283
415
|
hm.method_name methodName,
|
|
284
416
|
hm.decorator_value decoratorValue,
|
|
285
417
|
hm.decorator_raw_expression decoratorRawExpression,
|
|
418
|
+
hm.decorator_resolution_json decoratorResolutionJson,
|
|
286
419
|
hc.id classId,
|
|
287
420
|
hc.class_name className,
|
|
288
421
|
hc.source_file sourceFile,
|
|
289
422
|
hc.source_line sourceLine,
|
|
423
|
+
hr.id registrationId,
|
|
424
|
+
hr.handler_class_id registrationHandlerClassId,
|
|
425
|
+
hr.class_name registrationClassName,
|
|
290
426
|
hr.repo_id applicationRepoId,
|
|
291
427
|
hr.registration_file registrationFile,
|
|
292
428
|
hr.registration_line registrationLine,
|
|
@@ -309,14 +445,36 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
|
|
|
309
445
|
CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
|
|
310
446
|
CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id AND localService.service_path=?) THEN 1 ELSE 0 END localServicePathMatch,
|
|
311
447
|
CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
|
|
312
|
-
CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON (localClass.id=localReg.handler_class_id OR localClass.class_name=localReg.class_name) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
|
|
448
|
+
CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON ((localReg.handler_class_id IS NOT NULL AND localClass.id=localReg.handler_class_id) OR (localReg.handler_class_id IS NULL AND localClass.class_name=localReg.class_name AND localClass.repo_id=localReg.repo_id)) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
|
|
313
449
|
CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END appDependsOnModel,
|
|
314
450
|
CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(handlerRepo.id AS TEXT)) THEN 1 ELSE 0 END appDependsOnHandler,
|
|
315
451
|
CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END handlerDependsOnModel
|
|
316
452
|
FROM handler_methods hm
|
|
317
453
|
JOIN handler_classes hc ON hc.id=hm.handler_class_id
|
|
318
454
|
JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id
|
|
319
|
-
JOIN handler_registrations hr ON (
|
|
455
|
+
JOIN handler_registrations hr ON (
|
|
456
|
+
(hr.handler_class_id IS NOT NULL AND hr.handler_class_id=hc.id)
|
|
457
|
+
OR (
|
|
458
|
+
hr.handler_class_id IS NULL
|
|
459
|
+
AND (
|
|
460
|
+
(hr.class_name=hc.class_name AND hr.repo_id=hc.repo_id)
|
|
461
|
+
OR (
|
|
462
|
+
instr(hr.import_source,'#')>1
|
|
463
|
+
AND substr(hr.import_source,1,instr(hr.import_source,'#')-1)
|
|
464
|
+
=handlerRepo.package_name
|
|
465
|
+
AND (
|
|
466
|
+
substr(hr.import_source,instr(hr.import_source,'#')+1)
|
|
467
|
+
=hc.class_name
|
|
468
|
+
OR (
|
|
469
|
+
substr(hr.import_source,instr(hr.import_source,'#')+1)
|
|
470
|
+
='default'
|
|
471
|
+
AND hr.class_name=hc.class_name
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
)
|
|
320
478
|
JOIN repositories appRepo ON appRepo.id=hr.repo_id
|
|
321
479
|
WHERE appRepo.workspace_id=?
|
|
322
480
|
AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
|
|
@@ -418,8 +576,16 @@ function candidateEvidence(candidate: ImplementationCandidate, rank: number): Re
|
|
|
418
576
|
className: candidate.className,
|
|
419
577
|
sourceFile: candidate.sourceFile,
|
|
420
578
|
sourceLine: candidate.sourceLine,
|
|
421
|
-
|
|
579
|
+
decoratorResolution: objectJson(candidate.decoratorResolutionJson),
|
|
580
|
+
registration: registrationEvidence(candidate),
|
|
422
581
|
registrations: candidate.registrations ?? [],
|
|
582
|
+
registrationPairing: {
|
|
583
|
+
strategy: registrationPairingStrategy(candidate),
|
|
584
|
+
registrationId: candidate.registrationId,
|
|
585
|
+
registrationHandlerClassId: candidate.registrationHandlerClassId,
|
|
586
|
+
candidateHandlerClassId: candidate.classId,
|
|
587
|
+
invariantStatus: validRegistrationPair(candidate) ? 'valid' : 'invalid',
|
|
588
|
+
},
|
|
423
589
|
applicationPackage: { id: candidate.applicationRepoId, name: candidate.applicationRepo, packageName: candidate.applicationPackage },
|
|
424
590
|
handlerPackage: { id: candidate.handlerRepoId, name: candidate.handlerRepo, packageName: candidate.handlerPackage },
|
|
425
591
|
modelPackage: { id: candidate.modelRepoId, name: candidate.modelRepo, packageName: candidate.modelPackage },
|
|
@@ -12,7 +12,7 @@ export function normalizedOperationName(value: string): string {
|
|
|
12
12
|
function clean(value: string): string {
|
|
13
13
|
return value.replace(/^[`'"]|[`'"]$/g, '');
|
|
14
14
|
}
|
|
15
|
-
function
|
|
15
|
+
export function generatedOperationNameFromConstant(value: string): string | undefined {
|
|
16
16
|
for (const prefix of ['Action', 'Func']) {
|
|
17
17
|
if (value.startsWith(prefix) && value.length > prefix.length && /^[A-Z]/.test(value.slice(prefix.length))) return lowerFirst(value.slice(prefix.length));
|
|
18
18
|
}
|
|
@@ -20,7 +20,7 @@ function generatedFromConstantName(value: string): string | undefined {
|
|
|
20
20
|
}
|
|
21
21
|
function resolved(value: string, raw?: string): DecoratorOperationSignal {
|
|
22
22
|
const literal = clean(value);
|
|
23
|
-
const generated =
|
|
23
|
+
const generated = generatedOperationNameFromConstant(literal);
|
|
24
24
|
return { status: 'resolved', operationName: generated ?? normalizedOperationName(literal), raw };
|
|
25
25
|
}
|
|
26
26
|
export function normalizeDecoratorOperationSignal(value: string | undefined, raw: string | undefined, candidateOperation?: string): DecoratorOperationSignal {
|
|
@@ -32,7 +32,7 @@ export function normalizeDecoratorOperationSignal(value: string | undefined, raw
|
|
|
32
32
|
const stringMatch = /^String\(([A-Za-z_$][\w$]*)\)$/.exec(expression);
|
|
33
33
|
if (stringMatch?.[1]) {
|
|
34
34
|
const identifier = stringMatch[1];
|
|
35
|
-
const generated =
|
|
35
|
+
const generated = generatedOperationNameFromConstant(identifier);
|
|
36
36
|
const normalizedCandidate = candidateOperation ? normalizedOperationName(candidateOperation) : undefined;
|
|
37
37
|
if (generated) return { status: 'resolved', operationName: generated, raw: expression };
|
|
38
38
|
if (normalizedCandidate && identifier === normalizedCandidate) return { status: 'resolved', operationName: identifier, raw: expression };
|
|
@@ -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
|
}
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import ts from 'typescript';
|
|
4
|
-
import type { HandlerClassFact } from '../types.js';
|
|
4
|
+
import type { HandlerClassFact, HandlerMethodFact } from '../types.js';
|
|
5
|
+
import { generatedOperationNameFromConstant } from '../linker/operation-decorator-normalizer.js';
|
|
5
6
|
import { createSourceFile } from './ts-project.js';
|
|
6
|
-
import { normalizePath
|
|
7
|
+
import { normalizePath } from '../utils/path-utils.js';
|
|
8
|
+
|
|
9
|
+
type DecoratorResolution = HandlerMethodFact['decoratorResolution'];
|
|
10
|
+
interface StringLookups {
|
|
11
|
+
identifiers: Map<string, string>;
|
|
12
|
+
enumMembers: Map<string, string>;
|
|
13
|
+
objectProperties: Map<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
function line(sf: ts.SourceFile, pos: number): number {
|
|
8
17
|
return sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
9
18
|
}
|
|
@@ -14,11 +23,119 @@ function callName(d: ts.Decorator): string {
|
|
|
14
23
|
const e = d.expression;
|
|
15
24
|
return ts.isCallExpression(e) ? e.expression.getText() : e.getText();
|
|
16
25
|
}
|
|
17
|
-
function firstArg(d: ts.Decorator):
|
|
26
|
+
function firstArg(d: ts.Decorator): ts.Expression | undefined {
|
|
18
27
|
const e = d.expression;
|
|
19
|
-
return ts.isCallExpression(e)
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
return ts.isCallExpression(e) ? e.arguments[0] : undefined;
|
|
29
|
+
}
|
|
30
|
+
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
|
31
|
+
let current = expression;
|
|
32
|
+
while (
|
|
33
|
+
ts.isParenthesizedExpression(current)
|
|
34
|
+
|| ts.isAsExpression(current)
|
|
35
|
+
|| ts.isTypeAssertionExpression(current)
|
|
36
|
+
|| ts.isSatisfiesExpression(current)
|
|
37
|
+
) current = current.expression;
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
function stringValue(expression: ts.Expression | undefined): string | undefined {
|
|
41
|
+
if (!expression) return undefined;
|
|
42
|
+
const unwrapped = unwrapExpression(expression);
|
|
43
|
+
return ts.isStringLiteralLike(unwrapped) ? unwrapped.text : undefined;
|
|
44
|
+
}
|
|
45
|
+
function propertyName(node: ts.PropertyName): string | undefined {
|
|
46
|
+
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function collectEnumMembers(
|
|
50
|
+
statement: ts.EnumDeclaration,
|
|
51
|
+
lookups: StringLookups,
|
|
52
|
+
): void {
|
|
53
|
+
for (const member of statement.members) {
|
|
54
|
+
const name = propertyName(member.name);
|
|
55
|
+
const value = stringValue(member.initializer);
|
|
56
|
+
if (name && value !== undefined)
|
|
57
|
+
lookups.enumMembers.set(`${statement.name.text}.${name}`, value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function collectObjectProperties(
|
|
61
|
+
name: string,
|
|
62
|
+
initializer: ts.Expression,
|
|
63
|
+
lookups: StringLookups,
|
|
64
|
+
): void {
|
|
65
|
+
const object = unwrapExpression(initializer);
|
|
66
|
+
if (!ts.isObjectLiteralExpression(object)) return;
|
|
67
|
+
for (const property of object.properties) {
|
|
68
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
69
|
+
const key = propertyName(property.name);
|
|
70
|
+
const value = stringValue(property.initializer);
|
|
71
|
+
if (key && value !== undefined)
|
|
72
|
+
lookups.objectProperties.set(`${name}.${key}`, value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function collectStringLookups(source: ts.SourceFile): StringLookups {
|
|
76
|
+
const lookups: StringLookups = {
|
|
77
|
+
identifiers: new Map(),
|
|
78
|
+
enumMembers: new Map(),
|
|
79
|
+
objectProperties: new Map(),
|
|
80
|
+
};
|
|
81
|
+
for (const statement of source.statements) {
|
|
82
|
+
if (ts.isEnumDeclaration(statement)) collectEnumMembers(statement, lookups);
|
|
83
|
+
if (!ts.isVariableStatement(statement)
|
|
84
|
+
|| !(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
|
|
85
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
86
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
|
|
87
|
+
const value = stringValue(declaration.initializer);
|
|
88
|
+
if (value !== undefined) lookups.identifiers.set(declaration.name.text, value);
|
|
89
|
+
collectObjectProperties(declaration.name.text, declaration.initializer, lookups);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return lookups;
|
|
93
|
+
}
|
|
94
|
+
function unresolved(rawExpression: string, reason: string): DecoratorResolution {
|
|
95
|
+
return { rawExpression, resolutionKind: 'unresolved', unresolvedReason: reason };
|
|
96
|
+
}
|
|
97
|
+
function generatedConstant(rawExpression: string): string | undefined {
|
|
98
|
+
const match = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/
|
|
99
|
+
.exec(rawExpression.trim());
|
|
100
|
+
return match?.[1]
|
|
101
|
+
? generatedOperationNameFromConstant(match[1])
|
|
102
|
+
: undefined;
|
|
103
|
+
}
|
|
104
|
+
function resolveDecoratorArgument(
|
|
105
|
+
argument: ts.Expression | undefined,
|
|
106
|
+
lookups: StringLookups,
|
|
107
|
+
): DecoratorResolution {
|
|
108
|
+
if (!argument) return unresolved('', 'decorator_argument_missing');
|
|
109
|
+
const rawExpression = argument.getText();
|
|
110
|
+
const expression = unwrapExpression(argument);
|
|
111
|
+
if (ts.isStringLiteralLike(expression))
|
|
112
|
+
return { rawExpression, resolvedValue: expression.text, resolutionKind: 'literal' };
|
|
113
|
+
if (ts.isIdentifier(expression)) {
|
|
114
|
+
const value = lookups.identifiers.get(expression.text);
|
|
115
|
+
return value === undefined
|
|
116
|
+
? unresolved(rawExpression, 'identifier_not_resolved_to_local_const_string')
|
|
117
|
+
: { rawExpression, resolvedValue: value, resolutionKind: 'const_identifier' };
|
|
118
|
+
}
|
|
119
|
+
if (ts.isPropertyAccessExpression(expression)
|
|
120
|
+
&& ts.isIdentifier(expression.expression)) {
|
|
121
|
+
const key = `${expression.expression.text}.${expression.name.text}`;
|
|
122
|
+
const enumValue = lookups.enumMembers.get(key);
|
|
123
|
+
if (enumValue !== undefined)
|
|
124
|
+
return { rawExpression, resolvedValue: enumValue, resolutionKind: 'enum_member' };
|
|
125
|
+
const objectValue = lookups.objectProperties.get(key);
|
|
126
|
+
if (objectValue !== undefined)
|
|
127
|
+
return { rawExpression, resolvedValue: objectValue, resolutionKind: 'const_object_property' };
|
|
128
|
+
}
|
|
129
|
+
const generatedValue = generatedConstant(rawExpression);
|
|
130
|
+
if (generatedValue !== undefined)
|
|
131
|
+
return {
|
|
132
|
+
rawExpression,
|
|
133
|
+
resolvedValue: generatedValue,
|
|
134
|
+
resolutionKind: 'generated_constant_name',
|
|
135
|
+
};
|
|
136
|
+
if (ts.isPropertyAccessExpression(expression))
|
|
137
|
+
return unresolved(rawExpression, 'property_access_not_resolved_to_local_string');
|
|
138
|
+
return unresolved(rawExpression, 'unsupported_decorator_expression');
|
|
22
139
|
}
|
|
23
140
|
export async function parseDecorators(
|
|
24
141
|
repoPath: string,
|
|
@@ -26,16 +143,9 @@ export async function parseDecorators(
|
|
|
26
143
|
): Promise<HandlerClassFact[]> {
|
|
27
144
|
const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
28
145
|
const sf = createSourceFile(filePath, text);
|
|
29
|
-
const
|
|
146
|
+
const lookups = collectStringLookups(sf);
|
|
30
147
|
const handlers: HandlerClassFact[] = [];
|
|
31
148
|
function visit(node: ts.Node): void {
|
|
32
|
-
if (
|
|
33
|
-
ts.isVariableDeclaration(node) &&
|
|
34
|
-
ts.isIdentifier(node.name) &&
|
|
35
|
-
node.initializer &&
|
|
36
|
-
ts.isStringLiteralLike(node.initializer)
|
|
37
|
-
)
|
|
38
|
-
constants.set(node.name.text, node.initializer.text);
|
|
39
149
|
if (ts.isClassDeclaration(node)) {
|
|
40
150
|
const className = node.name?.text ?? 'AnonymousHandler';
|
|
41
151
|
const hasHandler = decs(node).some((d) => callName(d) === 'Handler');
|
|
@@ -45,17 +155,13 @@ export async function parseDecorators(
|
|
|
45
155
|
['Func', 'Action', 'On', 'Event'].includes(callName(d))
|
|
46
156
|
)
|
|
47
157
|
.map((d) => {
|
|
48
|
-
const
|
|
49
|
-
const value =
|
|
50
|
-
raw.startsWith('"') || raw.startsWith("'") || raw.startsWith('`')
|
|
51
|
-
? stripQuotes(raw)
|
|
52
|
-
: (constants.get(raw) ??
|
|
53
|
-
(raw.endsWith('.name') ? raw.split('.').at(-2) : undefined));
|
|
158
|
+
const decoratorResolution = resolveDecoratorArgument(firstArg(d), lookups);
|
|
54
159
|
return {
|
|
55
160
|
methodName: m.name.getText(),
|
|
56
161
|
decoratorKind: callName(d),
|
|
57
|
-
decoratorValue:
|
|
58
|
-
decoratorRawExpression:
|
|
162
|
+
decoratorValue: decoratorResolution.resolvedValue,
|
|
163
|
+
decoratorRawExpression: decoratorResolution.rawExpression,
|
|
164
|
+
decoratorResolution,
|
|
59
165
|
sourceFile: normalizePath(filePath),
|
|
60
166
|
sourceLine: line(sf, m.getStart())
|
|
61
167
|
};
|
|
@@ -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
|
-
}
|