@saptools/service-flow 0.1.48 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +10 -0
- package/dist/{chunk-EGY2A4AT.js → chunk-XOROZHW4.js} +593 -242
- package/dist/chunk-XOROZHW4.js.map +1 -0
- package/dist/cli.js +265 -24
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +128 -2
- package/src/db/repositories.ts +186 -10
- package/src/linker/cross-repo-linker.ts +70 -1
- package/src/output/table-output.ts +18 -8
- package/src/parsers/imported-wrapper-parser.ts +20 -46
- package/src/parsers/operation-path-analysis.ts +350 -0
- package/src/parsers/outbound-call-parser.ts +63 -53
- package/src/trace/evidence.ts +18 -3
- package/src/trace/trace-engine.ts +118 -25
- package/dist/chunk-EGY2A4AT.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
package/src/cli/doctor.ts
CHANGED
|
@@ -149,6 +149,7 @@ function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOption
|
|
|
149
149
|
implementationCandidateQuality(db, Boolean(options.detail)),
|
|
150
150
|
classInstanceNoiseQuality(db),
|
|
151
151
|
contextualBindingPropagationQuality(db),
|
|
152
|
+
serviceBindingQuality(db, Boolean(options.detail)),
|
|
152
153
|
nestedThisReceiverQuality(db),
|
|
153
154
|
wrapperPathPropagationQuality(db),
|
|
154
155
|
remoteQueryTargetQuality(db),
|
|
@@ -369,6 +370,88 @@ function contextualBindingPropagationQuality(db: Db): Diagnostic {
|
|
|
369
370
|
return { severity: missing.count + opportunities.length > 0 ? 'warning' : 'info', code: 'strict_contextual_binding_propagation_quality', message: 'Contextual service-client propagation opportunities for trace-time helper resolution', calleeSymbolsMissingParameterMetadata: missing.count, traceTimeContextualOpportunities: opportunities.length, examples: opportunities };
|
|
370
371
|
}
|
|
371
372
|
|
|
373
|
+
function serviceBindingQuality(db: Db, detail: boolean): Diagnostic {
|
|
374
|
+
const rows = db.prepare(`
|
|
375
|
+
SELECT c.source_file sourceFile,c.source_line sourceLine,
|
|
376
|
+
c.unresolved_reason unresolvedReason,c.evidence_json evidenceJson,
|
|
377
|
+
s.evidence_json symbolEvidenceJson
|
|
378
|
+
FROM outbound_calls c
|
|
379
|
+
LEFT JOIN symbols s ON s.id=c.source_symbol_id
|
|
380
|
+
WHERE c.call_type='remote_action'
|
|
381
|
+
AND c.operation_path_expr IS NOT NULL
|
|
382
|
+
AND c.service_binding_id IS NULL
|
|
383
|
+
ORDER BY c.source_file,c.source_line
|
|
384
|
+
`).all() as Diagnostic[];
|
|
385
|
+
const groups = new Map<string, Diagnostic[]>();
|
|
386
|
+
for (const row of rows) {
|
|
387
|
+
const category = bindingCategory(row);
|
|
388
|
+
groups.set(category, [...(groups.get(category) ?? []), bindingExample(row)]);
|
|
389
|
+
}
|
|
390
|
+
const categories = [...groups.entries()].map(([category, examples]) => ({
|
|
391
|
+
category,
|
|
392
|
+
count: examples.length,
|
|
393
|
+
severity: 'warning',
|
|
394
|
+
suggestedAction: bindingCategoryAction(category),
|
|
395
|
+
examples: examples.slice(0, 3),
|
|
396
|
+
expandedExamples: detail ? examples : undefined,
|
|
397
|
+
}));
|
|
398
|
+
return {
|
|
399
|
+
severity: rows.length > 0 ? 'warning' : 'info',
|
|
400
|
+
code: 'strict_service_binding_quality',
|
|
401
|
+
message: 'Remote service-client binding quality aggregate',
|
|
402
|
+
total: rows.length,
|
|
403
|
+
categories,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function bindingCategory(row: Diagnostic): string {
|
|
408
|
+
const evidence = parseObject(row.evidenceJson);
|
|
409
|
+
const resolution = parseObject(evidence.serviceBindingResolution);
|
|
410
|
+
if (resolution.status === 'rejected_future_binding') return 'direct_binding_missing';
|
|
411
|
+
if (resolution.status === 'ambiguous') return 'ambiguous_binding_candidates';
|
|
412
|
+
const receiver = evidence.receiver;
|
|
413
|
+
const symbolEvidence = parseObject(row.symbolEvidenceJson);
|
|
414
|
+
if (symbolHasReceiverParameter(symbolEvidence, receiver))
|
|
415
|
+
return 'contextual_binding_recoverable';
|
|
416
|
+
if (!Array.isArray(symbolEvidence.parameterBindings))
|
|
417
|
+
return 'missing_symbol_parameter_metadata';
|
|
418
|
+
return 'unrecoverable_binding';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function symbolHasReceiverParameter(evidence: Diagnostic, receiver: unknown): boolean {
|
|
422
|
+
if (typeof receiver !== 'string' || !Array.isArray(evidence.parameterBindings))
|
|
423
|
+
return false;
|
|
424
|
+
return asRecords(evidence.parameterBindings).some((binding) => {
|
|
425
|
+
if (binding.kind === 'identifier') return binding.name === receiver;
|
|
426
|
+
if (binding.kind === 'object_pattern')
|
|
427
|
+
return asRecords(binding.properties).some((property) => property.local === receiver);
|
|
428
|
+
return asRecords(binding.elements).some((element) => element.local === receiver);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function bindingExample(row: Diagnostic): Diagnostic {
|
|
433
|
+
const evidence = parseObject(row.evidenceJson);
|
|
434
|
+
return {
|
|
435
|
+
sourceFile: row.sourceFile,
|
|
436
|
+
sourceLine: row.sourceLine,
|
|
437
|
+
receiver: evidence.receiver,
|
|
438
|
+
unresolvedReason: row.unresolvedReason,
|
|
439
|
+
bindingResolution: evidence.serviceBindingResolution,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function bindingCategoryAction(category: string): string {
|
|
444
|
+
if (category === 'direct_binding_missing')
|
|
445
|
+
return 'Move the binding before the call or bind the call to an earlier immutable client.';
|
|
446
|
+
if (category === 'contextual_binding_recoverable')
|
|
447
|
+
return 'Trace from the caller so parameter binding evidence can be applied.';
|
|
448
|
+
if (category === 'ambiguous_binding_candidates')
|
|
449
|
+
return 'Split mutable client alternatives or add a statically unique client assignment.';
|
|
450
|
+
if (category === 'missing_symbol_parameter_metadata')
|
|
451
|
+
return 'Use named or destructured parameters on an indexed helper symbol.';
|
|
452
|
+
return 'Add a direct CAP client binding or statically provable helper-return binding.';
|
|
453
|
+
}
|
|
454
|
+
|
|
372
455
|
function wrapperPathPropagationQuality(db: Db): Diagnostic {
|
|
373
456
|
const examples = db.prepare("SELECT source_file sourceFile,source_line sourceLine,json_extract(evidence_json,'$.receiver') receiverName,json_extract(evidence_json,'$.operationPathExpression') pathIdentifier FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier' ORDER BY source_file,source_line LIMIT 5").all() as Diagnostic[];
|
|
374
457
|
return { severity: examples.length > 0 ? 'warning' : 'info', code: 'strict_wrapper_path_propagation_quality', message: 'Dynamic path sends where send({ path }) used a path identifier', dynamicPathIdentifierCalls: examples.length, examples };
|
|
@@ -450,10 +533,53 @@ function externalHttpTargetQuality(db: Db): Diagnostic {
|
|
|
450
533
|
}
|
|
451
534
|
|
|
452
535
|
function odataInvocationResolutionQuality(db: Db): Diagnostic {
|
|
453
|
-
const rows = db.prepare(
|
|
536
|
+
const rows = db.prepare(`SELECT c.operation_path_expr operationPathExpr,
|
|
537
|
+
c.source_file sourceFile,c.source_line sourceLine,e.id graphEdgeId,
|
|
538
|
+
e.status status,e.evidence_json evidenceJson
|
|
539
|
+
FROM outbound_calls c JOIN graph_edges e
|
|
540
|
+
ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
|
|
541
|
+
WHERE c.call_type='remote_action' AND c.operation_path_expr LIKE '%(%'
|
|
542
|
+
ORDER BY c.source_file,c.source_line`).all() as Array<{
|
|
543
|
+
operationPathExpr?: string;
|
|
544
|
+
sourceFile?: string;
|
|
545
|
+
sourceLine?: number;
|
|
546
|
+
graphEdgeId?: number;
|
|
547
|
+
status?: string;
|
|
548
|
+
evidenceJson?: string;
|
|
549
|
+
}>;
|
|
454
550
|
const unresolved = rows.filter((row) => row.status === 'unresolved' && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
|
|
455
551
|
const ambiguous = rows.filter((row) => row.status === 'ambiguous' && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
|
|
456
|
-
|
|
552
|
+
const examples = rows
|
|
553
|
+
.filter((row) => row.status === 'ambiguous' || row.status === 'unresolved')
|
|
554
|
+
.map(odataInvocationExample)
|
|
555
|
+
.slice(0, 5);
|
|
556
|
+
return { severity: unresolved + ambiguous > 0 ? 'warning' : 'info', code: 'strict_odata_invocation_resolution_quality', message: 'OData invocation-path resolution quality aggregate', totalInvocationRemoteActions: rows.length, resolvedInvocationCalls: rows.filter((row) => row.status === 'resolved').length, dynamicInvocationCalls: rows.filter((row) => row.status === 'dynamic').length, ambiguousInvocationCalls: rows.filter((row) => row.status === 'ambiguous').length, unresolvedInvocationCalls: rows.filter((row) => row.status === 'unresolved').length, ambiguousNormalizedCalls: ambiguous, unresolvedNormalizedCallsWithIndexedCandidates: unresolved, examples };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function odataInvocationExample(row: {
|
|
560
|
+
operationPathExpr?: string;
|
|
561
|
+
sourceFile?: string;
|
|
562
|
+
sourceLine?: number;
|
|
563
|
+
graphEdgeId?: number;
|
|
564
|
+
status?: string;
|
|
565
|
+
evidenceJson?: string;
|
|
566
|
+
}): Diagnostic {
|
|
567
|
+
const evidence = parseObject(row.evidenceJson);
|
|
568
|
+
const normalized = normalizeODataOperationInvocationPath(row.operationPathExpr);
|
|
569
|
+
return {
|
|
570
|
+
sourceFile: row.sourceFile,
|
|
571
|
+
sourceLine: row.sourceLine,
|
|
572
|
+
graphEdgeId: row.graphEdgeId,
|
|
573
|
+
status: row.status,
|
|
574
|
+
rawPath: row.operationPathExpr,
|
|
575
|
+
normalizedOperationPath: normalized?.wasInvocation
|
|
576
|
+
? normalized.normalizedOperationPath
|
|
577
|
+
: undefined,
|
|
578
|
+
indexedOperationCandidateCount: evidence.indexedOperationCandidateCount,
|
|
579
|
+
candidateScores: evidence.candidateScores,
|
|
580
|
+
entityOperationPrecedence: evidence.entityOperationPrecedence,
|
|
581
|
+
resolutionReasons: evidence.resolutionReasons,
|
|
582
|
+
};
|
|
457
583
|
}
|
|
458
584
|
|
|
459
585
|
function identityAliasBindingQuality(db: Db): Diagnostic {
|
package/src/db/repositories.ts
CHANGED
|
@@ -259,11 +259,15 @@ export function insertBindings(
|
|
|
259
259
|
rows: ServiceBindingFact[],
|
|
260
260
|
): void {
|
|
261
261
|
const stmt = db.prepare(
|
|
262
|
-
'INSERT INTO service_bindings(repo_id,variable_name,alias,alias_expr,destination_expr,service_path_expr,is_dynamic,placeholders_json,source_file,source_line,helper_chain_json) VALUES(
|
|
262
|
+
'INSERT INTO service_bindings(repo_id,symbol_id,variable_name,alias,alias_expr,destination_expr,service_path_expr,is_dynamic,placeholders_json,source_file,source_line,helper_chain_json) VALUES(?,(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1),?,?,?,?,?,?,?,?,?,?)',
|
|
263
263
|
);
|
|
264
264
|
for (const r of rows)
|
|
265
265
|
stmt.run(
|
|
266
266
|
repoId,
|
|
267
|
+
repoId,
|
|
268
|
+
r.sourceFile,
|
|
269
|
+
r.sourceLine,
|
|
270
|
+
r.sourceLine,
|
|
267
271
|
r.variableName,
|
|
268
272
|
r.alias,
|
|
269
273
|
r.aliasExpr,
|
|
@@ -321,9 +325,14 @@ export function insertCalls(
|
|
|
321
325
|
rows: OutboundCallFact[],
|
|
322
326
|
): void {
|
|
323
327
|
const stmt = db.prepare(
|
|
324
|
-
'INSERT INTO outbound_calls(repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,event_name_expr,payload_summary,source_file,source_line,confidence,unresolved_reason,local_service_name,local_service_lookup,alias_chain_json,evidence_json,external_target_kind,external_target_id,external_target_label,external_target_dynamic,service_binding_id) VALUES(?,COALESCE((SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1))
|
|
328
|
+
'INSERT INTO outbound_calls(repo_id,source_symbol_id,call_type,method,operation_path_expr,query_entity,event_name_expr,payload_summary,source_file,source_line,confidence,unresolved_reason,local_service_name,local_service_lookup,alias_chain_json,evidence_json,external_target_kind,external_target_id,external_target_label,external_target_dynamic,service_binding_id) VALUES(?,COALESCE((SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND qualified_name=? ORDER BY id LIMIT 1),(SELECT id FROM symbols WHERE repo_id=? AND source_file=? AND start_line<=? AND end_line>=? ORDER BY (end_line-start_line),id LIMIT 1)),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
|
325
329
|
);
|
|
326
|
-
for (const r of rows)
|
|
330
|
+
for (const r of rows) {
|
|
331
|
+
const binding = resolvePersistedBinding(db, repoId, r);
|
|
332
|
+
const evidence = {
|
|
333
|
+
...(r.evidence ?? {}),
|
|
334
|
+
serviceBindingResolution: binding.evidence,
|
|
335
|
+
};
|
|
327
336
|
stmt.run(
|
|
328
337
|
repoId,
|
|
329
338
|
repoId,
|
|
@@ -342,19 +351,186 @@ export function insertCalls(
|
|
|
342
351
|
r.sourceFile,
|
|
343
352
|
r.sourceLine,
|
|
344
353
|
r.confidence,
|
|
345
|
-
r.unresolvedReason,
|
|
354
|
+
r.unresolvedReason ?? binding.unresolvedReason,
|
|
346
355
|
r.localServiceName,
|
|
347
356
|
r.localServiceLookup,
|
|
348
357
|
r.aliasChain ? JSON.stringify(r.aliasChain) : null,
|
|
349
|
-
|
|
358
|
+
JSON.stringify(evidence),
|
|
350
359
|
r.externalTarget?.kind ?? null,
|
|
351
360
|
r.externalTarget?.stableId ?? null,
|
|
352
361
|
r.externalTarget?.label ?? null,
|
|
353
362
|
r.externalTarget?.dynamic ? 1 : 0,
|
|
354
|
-
|
|
355
|
-
r.serviceVariableName,
|
|
356
|
-
r.sourceFile,
|
|
357
|
-
r.sourceLine,
|
|
358
|
-
r.sourceLine,
|
|
363
|
+
binding.bindingId,
|
|
359
364
|
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
interface BindingCandidate {
|
|
369
|
+
id: number;
|
|
370
|
+
symbolId?: number | null;
|
|
371
|
+
variableName: string;
|
|
372
|
+
alias?: string | null;
|
|
373
|
+
aliasExpr?: string | null;
|
|
374
|
+
destinationExpr?: string | null;
|
|
375
|
+
servicePathExpr?: string | null;
|
|
376
|
+
sourceFile: string;
|
|
377
|
+
sourceLine: number;
|
|
378
|
+
helperChainJson?: string | null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function resolvePersistedBinding(
|
|
382
|
+
db: Db,
|
|
383
|
+
repoId: number,
|
|
384
|
+
call: OutboundCallFact,
|
|
385
|
+
): {
|
|
386
|
+
bindingId: number | null;
|
|
387
|
+
unresolvedReason?: string;
|
|
388
|
+
evidence: Record<string, unknown>;
|
|
389
|
+
} {
|
|
390
|
+
if (!call.serviceVariableName)
|
|
391
|
+
return { bindingId: null, evidence: { status: 'not_applicable', candidateCount: 0 } };
|
|
392
|
+
const candidates = bindingCandidates(db, repoId, call);
|
|
393
|
+
const prior = candidates.filter((candidate) => candidate.sourceLine <= call.sourceLine);
|
|
394
|
+
const families = new Set(prior.map(bindingSignature));
|
|
395
|
+
if (prior.length > 0 && families.size === 1) {
|
|
396
|
+
const selected = prior.at(-1);
|
|
397
|
+
return {
|
|
398
|
+
bindingId: selected?.id ?? null,
|
|
399
|
+
evidence: bindingEvidence('selected', prior, selected),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (prior.length > 1) {
|
|
403
|
+
return {
|
|
404
|
+
bindingId: null,
|
|
405
|
+
unresolvedReason: 'ambiguous_service_binding_candidates',
|
|
406
|
+
evidence: bindingEvidence('ambiguous', prior),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
if (candidates.length > 0) {
|
|
410
|
+
return {
|
|
411
|
+
bindingId: null,
|
|
412
|
+
unresolvedReason: 'service_binding_declared_after_call',
|
|
413
|
+
evidence: bindingEvidence('rejected_future_binding', candidates),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
bindingId: null,
|
|
418
|
+
evidence: bindingEvidence('unrecoverable', []),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function bindingCandidates(
|
|
423
|
+
db: Db,
|
|
424
|
+
repoId: number,
|
|
425
|
+
call: OutboundCallFact,
|
|
426
|
+
): BindingCandidate[] {
|
|
427
|
+
const ownerId = callSymbolId(db, repoId, call);
|
|
428
|
+
const rows = db.prepare(`
|
|
429
|
+
SELECT id,symbol_id symbolId,variable_name variableName,alias,alias_expr aliasExpr,
|
|
430
|
+
destination_expr destinationExpr,service_path_expr servicePathExpr,
|
|
431
|
+
source_file sourceFile,source_line sourceLine,helper_chain_json helperChainJson
|
|
432
|
+
FROM service_bindings
|
|
433
|
+
WHERE repo_id=? AND variable_name=? AND source_file=?
|
|
434
|
+
AND (? IS NULL OR symbol_id IS NULL OR symbol_id=?)
|
|
435
|
+
ORDER BY source_line,id
|
|
436
|
+
`).all(
|
|
437
|
+
repoId,
|
|
438
|
+
call.serviceVariableName,
|
|
439
|
+
call.sourceFile,
|
|
440
|
+
ownerId,
|
|
441
|
+
ownerId,
|
|
442
|
+
) as Array<Record<string, unknown>>;
|
|
443
|
+
return rows.flatMap((row) => {
|
|
444
|
+
if (typeof row.id !== 'number' || typeof row.variableName !== 'string'
|
|
445
|
+
|| typeof row.sourceFile !== 'string' || typeof row.sourceLine !== 'number')
|
|
446
|
+
return [];
|
|
447
|
+
return [{
|
|
448
|
+
id: row.id,
|
|
449
|
+
symbolId: nullableNumber(row.symbolId),
|
|
450
|
+
variableName: row.variableName,
|
|
451
|
+
alias: nullableString(row.alias),
|
|
452
|
+
aliasExpr: nullableString(row.aliasExpr),
|
|
453
|
+
destinationExpr: nullableString(row.destinationExpr),
|
|
454
|
+
servicePathExpr: nullableString(row.servicePathExpr),
|
|
455
|
+
sourceFile: row.sourceFile,
|
|
456
|
+
sourceLine: row.sourceLine,
|
|
457
|
+
helperChainJson: nullableString(row.helperChainJson),
|
|
458
|
+
}];
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function nullableString(value: unknown): string | null | undefined {
|
|
463
|
+
return value === null || typeof value === 'string' ? value : undefined;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function nullableNumber(value: unknown): number | null | undefined {
|
|
467
|
+
return value === null || typeof value === 'number' ? value : undefined;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function callSymbolId(
|
|
471
|
+
db: Db,
|
|
472
|
+
repoId: number,
|
|
473
|
+
call: OutboundCallFact,
|
|
474
|
+
): number | undefined {
|
|
475
|
+
const row = db.prepare(`
|
|
476
|
+
SELECT id FROM symbols
|
|
477
|
+
WHERE repo_id=? AND source_file=?
|
|
478
|
+
AND ((? IS NOT NULL AND qualified_name=?)
|
|
479
|
+
OR (start_line<=? AND end_line>=?))
|
|
480
|
+
ORDER BY CASE WHEN qualified_name=? THEN 0 ELSE 1 END,
|
|
481
|
+
(end_line-start_line),id
|
|
482
|
+
LIMIT 1
|
|
483
|
+
`).get(
|
|
484
|
+
repoId,
|
|
485
|
+
call.sourceFile,
|
|
486
|
+
call.sourceSymbolQualifiedName,
|
|
487
|
+
call.sourceSymbolQualifiedName,
|
|
488
|
+
call.sourceLine,
|
|
489
|
+
call.sourceLine,
|
|
490
|
+
call.sourceSymbolQualifiedName,
|
|
491
|
+
);
|
|
492
|
+
return typeof row?.id === 'number' ? row.id : undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function bindingEvidence(
|
|
496
|
+
status: string,
|
|
497
|
+
candidates: BindingCandidate[],
|
|
498
|
+
selected?: BindingCandidate,
|
|
499
|
+
): Record<string, unknown> {
|
|
500
|
+
return {
|
|
501
|
+
status,
|
|
502
|
+
candidateCount: candidates.length,
|
|
503
|
+
selectedBindingId: selected?.id,
|
|
504
|
+
sourceOrderRule: 'binding_source_line_must_not_follow_call',
|
|
505
|
+
candidates: candidates.map((candidate) => ({
|
|
506
|
+
bindingId: candidate.id,
|
|
507
|
+
symbolId: candidate.symbolId,
|
|
508
|
+
variableName: candidate.variableName,
|
|
509
|
+
alias: candidate.alias,
|
|
510
|
+
aliasExpr: candidate.aliasExpr,
|
|
511
|
+
destinationExpr: candidate.destinationExpr,
|
|
512
|
+
servicePathExpr: candidate.servicePathExpr,
|
|
513
|
+
sourceFile: candidate.sourceFile,
|
|
514
|
+
sourceLine: candidate.sourceLine,
|
|
515
|
+
helperChain: parseBindingChain(candidate.helperChainJson),
|
|
516
|
+
})),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function bindingSignature(candidate: BindingCandidate): string {
|
|
521
|
+
return JSON.stringify([
|
|
522
|
+
candidate.alias,
|
|
523
|
+
candidate.aliasExpr,
|
|
524
|
+
candidate.destinationExpr,
|
|
525
|
+
candidate.servicePathExpr,
|
|
526
|
+
]);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function parseBindingChain(value: string | null | undefined): unknown {
|
|
530
|
+
if (!value) return undefined;
|
|
531
|
+
try {
|
|
532
|
+
return JSON.parse(value) as unknown;
|
|
533
|
+
} catch {
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
360
536
|
}
|
|
@@ -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';
|
|
@@ -15,8 +15,8 @@ export function renderTraceTable(result: TraceResult): string {
|
|
|
15
15
|
const lines = ['Step Type From To Evidence'];
|
|
16
16
|
for (const e of result.edges) {
|
|
17
17
|
lines.push(`${String(e.step).padEnd(5)} ${e.type.padEnd(20)} ${e.from.slice(0, 34).padEnd(35)} ${e.to.slice(0, 35).padEnd(36)} ${location(e.evidence)}`);
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
if (e.unresolvedReason)
|
|
19
|
+
lines.push(...hintLines(e.evidence).map((hint) => ` ${hint}`));
|
|
20
20
|
}
|
|
21
21
|
if (result.diagnostics.length > 0) lines.push('', 'Diagnostics:', ...result.diagnostics.flatMap(diagnosticLines));
|
|
22
22
|
return `${lines.join('\n')}\n`;
|
|
@@ -24,13 +24,23 @@ export function renderTraceTable(result: TraceResult): string {
|
|
|
24
24
|
|
|
25
25
|
function diagnosticLines(diagnostic: Record<string, unknown>): string[] {
|
|
26
26
|
const first = `${String(diagnostic.severity ?? 'info')} ${String(diagnostic.code ?? 'diagnostic')} ${String(diagnostic.message ?? '')}`;
|
|
27
|
-
|
|
28
|
-
return hint ? [first, ` try ${hint}`] : [first];
|
|
27
|
+
return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
function
|
|
30
|
+
function hintLines(evidence: Record<string, unknown>): string[] {
|
|
32
31
|
const suggestions = evidence.implementationHintSuggestions;
|
|
33
|
-
if (!Array.isArray(suggestions)) return
|
|
34
|
-
const
|
|
35
|
-
|
|
32
|
+
if (!Array.isArray(suggestions)) return [];
|
|
33
|
+
const hints = suggestions.flatMap((item) =>
|
|
34
|
+
isRecord(item) && typeof item.cli === 'string'
|
|
35
|
+
? [item.cli]
|
|
36
|
+
: []);
|
|
37
|
+
const unique = [...new Set(hints)];
|
|
38
|
+
const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
|
|
39
|
+
if (unique.length > shown.length)
|
|
40
|
+
shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
|
|
41
|
+
return shown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
36
46
|
}
|
|
@@ -4,6 +4,11 @@ import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
|
|
|
4
4
|
import type { OutboundCallFact } from '../types.js';
|
|
5
5
|
import { normalizePath } from '../utils/path-utils.js';
|
|
6
6
|
import { importsFor, lineOf, readSource, type ImportBinding } from './service-binding-parser-helpers.js';
|
|
7
|
+
import {
|
|
8
|
+
analyzeOperationPath,
|
|
9
|
+
operationPathExpression,
|
|
10
|
+
pathUnresolvedReason,
|
|
11
|
+
} from './operation-path-analysis.js';
|
|
7
12
|
|
|
8
13
|
interface WrapperSpec {
|
|
9
14
|
clientIndex: number;
|
|
@@ -15,12 +20,6 @@ interface WrapperSpec {
|
|
|
15
20
|
chain: string[];
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
interface PathValue {
|
|
19
|
-
value?: string;
|
|
20
|
-
sourceKind: string;
|
|
21
|
-
rawExpression: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
23
|
export async function parseImportedWrapperCalls(
|
|
25
24
|
repoPath: string,
|
|
26
25
|
filePath: string,
|
|
@@ -143,11 +142,11 @@ function wrapperCallFact(
|
|
|
143
142
|
): OutboundCallFact | undefined {
|
|
144
143
|
const client = call.arguments[spec.clientIndex];
|
|
145
144
|
if (!client || !ts.isIdentifier(client) || !serviceBindings.has(client.text)) return undefined;
|
|
146
|
-
const pathValue = resolveCallerPath(call.arguments[spec.pathIndex], call);
|
|
147
145
|
const methodValue = spec.methodIndex === undefined ? spec.methodLiteral : literal(call.arguments[spec.methodIndex]);
|
|
148
146
|
const method = (methodValue ?? 'POST').toUpperCase();
|
|
149
|
-
const
|
|
150
|
-
const operationPathExpr =
|
|
147
|
+
const pathAnalysis = analyzeOperationPath(call.arguments[spec.pathIndex], call, method);
|
|
148
|
+
const operationPathExpr = operationPathExpression(pathAnalysis);
|
|
149
|
+
const unresolvedReason = pathUnresolvedReason(pathAnalysis);
|
|
151
150
|
return {
|
|
152
151
|
callType: 'remote_action',
|
|
153
152
|
serviceVariableName: client.text,
|
|
@@ -157,46 +156,29 @@ function wrapperCallFact(
|
|
|
157
156
|
sourceFile: normalizePath(filePath),
|
|
158
157
|
sourceLine: lineOf(source, call),
|
|
159
158
|
confidence: operationPathExpr ? 0.85 : 0.5,
|
|
160
|
-
unresolvedReason
|
|
159
|
+
unresolvedReason,
|
|
161
160
|
evidence: {
|
|
162
161
|
parser: 'typescript_ast',
|
|
163
|
-
classifier:
|
|
162
|
+
classifier: importedWrapperClassifier(pathAnalysis.status),
|
|
164
163
|
receiver: client.text,
|
|
165
164
|
wrapperFunction: spec.chain[0],
|
|
166
165
|
wrapperChain: spec.chain,
|
|
167
166
|
callerSite: { sourceFile: normalizePath(filePath), sourceLine: lineOf(source, call) },
|
|
168
167
|
calleeSite: { sourceFile: spec.sourceFile, sourceLine: spec.sourceLine },
|
|
169
|
-
rawPathExpression:
|
|
170
|
-
missingPathIdentifier:
|
|
171
|
-
|
|
168
|
+
rawPathExpression: pathAnalysis.rawExpression,
|
|
169
|
+
missingPathIdentifier: pathAnalysis.runtimeIdentifier,
|
|
170
|
+
pathAnalysis,
|
|
171
|
+
odataPathIntent: operationPathExpr
|
|
172
|
+
? classifyODataPathIntent(operationPathExpr, method)
|
|
173
|
+
: undefined,
|
|
172
174
|
},
|
|
173
175
|
};
|
|
174
176
|
}
|
|
175
177
|
|
|
176
|
-
function
|
|
177
|
-
if (
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
if (ts.isIdentifier(expr)) {
|
|
181
|
-
const initializer = constInitializer(expr.text, use);
|
|
182
|
-
if (initializer) {
|
|
183
|
-
const resolved = resolveCallerPath(initializer, initializer);
|
|
184
|
-
return { ...resolved, sourceKind: 'const', rawExpression: expr.text };
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return { sourceKind: 'dynamic', rawExpression: expr.getText() };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function constInitializer(name: string, use: ts.Node): ts.Expression | undefined {
|
|
191
|
-
let found: ts.Expression | undefined;
|
|
192
|
-
const source = use.getSourceFile();
|
|
193
|
-
const visit = (node: ts.Node): void => {
|
|
194
|
-
if (node.getStart(source) >= use.getStart(source)) return;
|
|
195
|
-
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer && (node.parent.flags & ts.NodeFlags.Const) !== 0) found = node.initializer;
|
|
196
|
-
ts.forEachChild(node, visit);
|
|
197
|
-
};
|
|
198
|
-
visit(source);
|
|
199
|
-
return found;
|
|
178
|
+
function importedWrapperClassifier(status: string): string {
|
|
179
|
+
if (status === 'static') return 'imported_wrapper_literal_path';
|
|
180
|
+
if (status === 'ambiguous') return 'imported_wrapper_ambiguous_path';
|
|
181
|
+
return 'imported_wrapper_dynamic_path';
|
|
200
182
|
}
|
|
201
183
|
|
|
202
184
|
function findFunction(source: ts.SourceFile, exportedName: string): { name: string; fn: ts.FunctionLikeDeclaration } | undefined {
|
|
@@ -252,11 +234,3 @@ function propertyName(name: ts.PropertyName): string | undefined {
|
|
|
252
234
|
function literal(expr: ts.Expression | undefined): string | undefined {
|
|
253
235
|
return expr && (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) ? expr.text : undefined;
|
|
254
236
|
}
|
|
255
|
-
|
|
256
|
-
function normalizeOperationPath(value: string): string {
|
|
257
|
-
return value.startsWith('/') ? value : `/${value}`;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function runtimePathExpression(value: string): string | undefined {
|
|
261
|
-
return value ? `\${${value}}` : undefined;
|
|
262
|
-
}
|