@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/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  stripQuotes,
18
18
  substituteVariables,
19
19
  trace
20
- } from "./chunk-EGY2A4AT.js";
20
+ } from "./chunk-XOROZHW4.js";
21
21
 
22
22
  // src/parsers/generated-constants-parser.ts
23
23
  import fs from "fs/promises";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
4
4
  "description": "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
5
5
  "type": "module",
6
6
  "publishConfig": {
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, strict) as Diagnostic[];
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("SELECT c.operation_path_expr operationPathExpr,e.status status FROM outbound_calls c JOIN graph_edges e ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT) WHERE c.call_type='remote_action' AND c.operation_path_expr LIKE '%(%'").all() as Array<{ operationPathExpr?: string; status?: string }>;
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
- 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 };
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
@@ -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)),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,(SELECT id FROM service_bindings WHERE repo_id=? AND variable_name=? AND source_file=? ORDER BY CASE WHEN source_line<=? THEN 0 ELSE 1 END, ABS(source_line-?) ASC, id DESC 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
- r.evidence ? JSON.stringify(r.evidence) : null,
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
- repoId,
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> = { ...callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : undefined, normalized, intent), indexedOperationCandidateCount, parserCallType: callType };
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';