@saptools/service-flow 0.1.47 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +15 -1
- package/dist/{chunk-EGY2A4AT.js → chunk-XOROZHW4.js} +593 -242
- package/dist/chunk-XOROZHW4.js.map +1 -0
- package/dist/cli.js +387 -33
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +167 -7
- package/src/cli.ts +4 -7
- package/src/db/repositories.ts +186 -10
- package/src/linker/cross-repo-linker.ts +70 -1
- package/src/output/doctor-output.ts +98 -0
- package/src/output/table-output.ts +18 -8
- package/src/parsers/imported-wrapper-parser.ts +20 -46
- package/src/parsers/operation-path-analysis.ts +350 -0
- package/src/parsers/outbound-call-parser.ts +63 -53
- package/src/trace/evidence.ts +18 -3
- package/src/trace/trace-engine.ts +118 -25
- package/dist/chunk-EGY2A4AT.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
package/src/cli/doctor.ts
CHANGED
|
@@ -18,6 +18,7 @@ export function doctorDiagnostics(db: Db, strict: boolean, options: DoctorOption
|
|
|
18
18
|
return [
|
|
19
19
|
...diagnostics,
|
|
20
20
|
...healthDiagnostics(db, strict),
|
|
21
|
+
...remoteTargetWithoutImplementationDiagnostics(db, strict, Boolean(options.detail)),
|
|
21
22
|
...localServiceDiagnostics(db, strict),
|
|
22
23
|
...schemaDriftDiagnostics(db, strict),
|
|
23
24
|
...analyzerVersionDiagnostics(db, strict),
|
|
@@ -46,10 +47,6 @@ function healthDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
|
46
47
|
SELECT 'warning','legacy_schema_weaker_foreign_keys','Legacy table lacks fresh-schema foreign-key metadata; rebuild the database or re-run init/index in a new database',NULL,NULL
|
|
47
48
|
WHERE (SELECT COUNT(*) FROM pragma_foreign_key_list('graph_edges'))=0 OR (SELECT COUNT(*) FROM pragma_foreign_key_list('index_runs'))=0 OR (SELECT COUNT(*) FROM pragma_foreign_key_list('diagnostics'))=0
|
|
48
49
|
UNION ALL
|
|
49
|
-
SELECT 'warning','remote_target_without_implementation','Remote target operation has no implementation edge: ' || s.service_path || o.operation_path,o.source_file,o.source_line
|
|
50
|
-
FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
|
|
51
|
-
WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation' AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id) AND ?
|
|
52
|
-
UNION ALL
|
|
53
50
|
SELECT CASE WHEN ? THEN 'warning' ELSE 'error' END,'local_service_calls_all_unresolved','All local service calls are unresolved; verify local service alias parsing and linking',NULL,NULL
|
|
54
51
|
WHERE EXISTS (SELECT 1 FROM outbound_calls WHERE call_type='local_service_call') AND NOT EXISTS (SELECT 1 FROM graph_edges e JOIN outbound_calls c ON e.from_kind='call' AND c.id=CAST(e.from_id AS INTEGER) WHERE c.call_type='local_service_call' AND e.status='resolved')
|
|
55
52
|
UNION ALL
|
|
@@ -67,7 +64,44 @@ function healthDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
|
67
64
|
UNION ALL
|
|
68
65
|
SELECT 'warning','index_run_abandoned','Index run ' || id || ' started at ' || started_at || ' is still running after the 60 minute abandonment threshold',NULL,NULL
|
|
69
66
|
FROM index_runs WHERE status='running' AND datetime(started_at) < datetime('now','-60 minutes')`,
|
|
70
|
-
).all(strict, strict, strict, strict, strict, strict
|
|
67
|
+
).all(strict, strict, strict, strict, strict, strict) as Diagnostic[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
function remoteTargetWithoutImplementationDiagnostics(db: Db, strict: boolean, detail: boolean): Diagnostic[] {
|
|
72
|
+
if (!strict) return [];
|
|
73
|
+
const groups = db.prepare(`SELECT s.service_path servicePath,o.operation_path operationPath,o.source_file sourceFile,o.source_line sourceLine,COUNT(*) callSiteCount
|
|
74
|
+
FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
|
|
75
|
+
WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
|
|
76
|
+
AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id)
|
|
77
|
+
GROUP BY s.service_path,o.operation_path,o.source_file,o.source_line
|
|
78
|
+
ORDER BY s.service_path,o.operation_path`).all() as Array<{ servicePath?: string; operationPath?: string; sourceFile?: string | null; sourceLine?: number | null; callSiteCount?: number }>;
|
|
79
|
+
return groups.map((group) => {
|
|
80
|
+
const examples = remoteTargetWithoutImplementationExamples(db, String(group.servicePath ?? ''), String(group.operationPath ?? ''));
|
|
81
|
+
return {
|
|
82
|
+
severity: 'warning',
|
|
83
|
+
code: 'remote_target_without_implementation',
|
|
84
|
+
message: `Remote target operation has no implementation edge: ${String(group.servicePath ?? '')}${String(group.operationPath ?? '')}`,
|
|
85
|
+
sourceFile: group.sourceFile,
|
|
86
|
+
sourceLine: group.sourceLine,
|
|
87
|
+
servicePath: group.servicePath,
|
|
88
|
+
operationPath: group.operationPath,
|
|
89
|
+
callSiteCount: Number(group.callSiteCount ?? 0),
|
|
90
|
+
examples: examples.slice(0, 3),
|
|
91
|
+
expandedExamples: detail ? examples : undefined,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function remoteTargetWithoutImplementationExamples(db: Db, servicePath: string, operationPath: string): Diagnostic[] {
|
|
97
|
+
return db.prepare(`SELECT r.name repo,c.source_file sourceFile,c.source_line sourceLine,c.operation_path_expr operationPathExpr
|
|
98
|
+
FROM graph_edges remote JOIN cds_operations o ON o.id=CAST(remote.to_id AS INTEGER) JOIN cds_services s ON s.id=o.service_id
|
|
99
|
+
LEFT JOIN outbound_calls c ON remote.from_kind='call' AND c.id=CAST(remote.from_id AS INTEGER)
|
|
100
|
+
LEFT JOIN repositories r ON r.id=c.repo_id
|
|
101
|
+
WHERE remote.edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' AND remote.to_kind='operation'
|
|
102
|
+
AND s.service_path=? AND o.operation_path=?
|
|
103
|
+
AND NOT EXISTS (SELECT 1 FROM graph_edges impl WHERE impl.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND impl.from_kind='operation' AND impl.from_id=remote.to_id)
|
|
104
|
+
ORDER BY r.name,c.source_file,c.source_line`).all(servicePath, operationPath) as Diagnostic[];
|
|
71
105
|
}
|
|
72
106
|
|
|
73
107
|
function schemaDriftDiagnostics(db: Db, strict: boolean): Diagnostic[] {
|
|
@@ -115,6 +149,7 @@ function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOption
|
|
|
115
149
|
implementationCandidateQuality(db, Boolean(options.detail)),
|
|
116
150
|
classInstanceNoiseQuality(db),
|
|
117
151
|
contextualBindingPropagationQuality(db),
|
|
152
|
+
serviceBindingQuality(db, Boolean(options.detail)),
|
|
118
153
|
nestedThisReceiverQuality(db),
|
|
119
154
|
wrapperPathPropagationQuality(db),
|
|
120
155
|
remoteQueryTargetQuality(db),
|
|
@@ -335,6 +370,88 @@ function contextualBindingPropagationQuality(db: Db): Diagnostic {
|
|
|
335
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 };
|
|
336
371
|
}
|
|
337
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
|
+
|
|
338
455
|
function wrapperPathPropagationQuality(db: Db): Diagnostic {
|
|
339
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[];
|
|
340
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 };
|
|
@@ -416,10 +533,53 @@ function externalHttpTargetQuality(db: Db): Diagnostic {
|
|
|
416
533
|
}
|
|
417
534
|
|
|
418
535
|
function odataInvocationResolutionQuality(db: Db): Diagnostic {
|
|
419
|
-
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
|
+
}>;
|
|
420
550
|
const unresolved = rows.filter((row) => row.status === 'unresolved' && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
|
|
421
551
|
const ambiguous = rows.filter((row) => row.status === 'ambiguous' && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
|
|
422
|
-
|
|
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
|
+
};
|
|
423
583
|
}
|
|
424
584
|
|
|
425
585
|
function identityAliasBindingQuality(db: Db): Diagnostic {
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
|
-
import pc from 'picocolors';
|
|
5
4
|
import { DEFAULT_IGNORES } from './config/defaults.js';
|
|
6
5
|
import {
|
|
7
6
|
createWorkspaceConfig,
|
|
@@ -27,6 +26,7 @@ import { parseVars } from './trace/selectors.js';
|
|
|
27
26
|
import { parseImplementationHint } from './trace/implementation-hints.js';
|
|
28
27
|
import { renderTraceTable } from './output/table-output.js';
|
|
29
28
|
import { renderTraceJson, renderJson } from './output/json-output.js';
|
|
29
|
+
import { renderDoctorDiagnostics } from './output/doctor-output.js';
|
|
30
30
|
import { renderMermaid } from './output/mermaid-output.js';
|
|
31
31
|
import { VERSION } from './version.js';
|
|
32
32
|
async function init(
|
|
@@ -370,15 +370,12 @@ export function createProgram(): Command {
|
|
|
370
370
|
.option('--workspace <path>')
|
|
371
371
|
.option('--strict')
|
|
372
372
|
.option('--detail')
|
|
373
|
+
.option('--format <format>', 'json|table')
|
|
373
374
|
.action(
|
|
374
|
-
(opts: { workspace?: string; strict?: boolean; detail?: boolean }) =>
|
|
375
|
+
(opts: { workspace?: string; strict?: boolean; detail?: boolean; format?: string }) =>
|
|
375
376
|
void withReadOnlyWorkspace(opts.workspace, (db) => {
|
|
376
377
|
const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
|
|
377
|
-
process.stdout.write(
|
|
378
|
-
allDiagnostics.length
|
|
379
|
-
? renderJson(allDiagnostics)
|
|
380
|
-
: `${pc.green('No diagnostics recorded')}\n`,
|
|
381
|
-
);
|
|
378
|
+
process.stdout.write(renderDoctorDiagnostics(allDiagnostics, opts.format));
|
|
382
379
|
}).catch(fail),
|
|
383
380
|
);
|
|
384
381
|
program
|
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';
|