@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/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.48",
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
@@ -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("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
+ }>;
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
- 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
+ };
457
583
  }
458
584
 
459
585
  function identityAliasBindingQuality(db: Db): Diagnostic {
@@ -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';
@@ -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
- const hint = firstHint(e.evidence);
19
- if (e.unresolvedReason && hint) lines.push(` try ${hint}`);
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
- const hint = firstHint(diagnostic);
28
- return hint ? [first, ` try ${hint}`] : [first];
27
+ return [first, ...hintLines(diagnostic).map((hint) => ` ${hint}`)];
29
28
  }
30
29
 
31
- function firstHint(evidence: Record<string, unknown>): string | undefined {
30
+ function hintLines(evidence: Record<string, unknown>): string[] {
32
31
  const suggestions = evidence.implementationHintSuggestions;
33
- if (!Array.isArray(suggestions)) return undefined;
34
- const first = suggestions.find((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object');
35
- return typeof first?.cli === 'string' ? first.cli : undefined;
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 rawPath = pathValue.rawExpression;
150
- const operationPathExpr = pathValue.value ? normalizeOperationPath(pathValue.value) : runtimePathExpression(rawPath);
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: pathValue.value ? undefined : 'dynamic_operation_path_identifier',
159
+ unresolvedReason,
161
160
  evidence: {
162
161
  parser: 'typescript_ast',
163
- classifier: pathValue.value ? 'imported_wrapper_literal_path' : 'imported_wrapper_dynamic_path',
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: rawPath,
170
- missingPathIdentifier: pathValue.value ? undefined : rawPath,
171
- odataPathIntent: pathValue.value ? classifyODataPathIntent(operationPathExpr, method) : undefined,
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 resolveCallerPath(expr: ts.Expression | undefined, use: ts.Node): PathValue {
177
- if (!expr) return { sourceKind: 'missing', rawExpression: '' };
178
- if (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { value: expr.text, sourceKind: 'literal', rawExpression: expr.getText() };
179
- if (ts.isTemplateExpression(expr)) return { value: expr.getText().slice(1, -1), sourceKind: 'template', rawExpression: expr.getText() };
180
- if (ts.isIdentifier(expr)) {
181
- const initializer = constInitializer(expr.text, use);
182
- if (initializer) {
183
- const resolved = resolveCallerPath(initializer, initializer);
184
- return { ...resolved, sourceKind: 'const', rawExpression: expr.text };
185
- }
186
- }
187
- return { sourceKind: 'dynamic', rawExpression: expr.getText() };
188
- }
189
-
190
- function constInitializer(name: string, use: ts.Node): ts.Expression | undefined {
191
- let found: ts.Expression | undefined;
192
- const source = use.getSourceFile();
193
- const visit = (node: ts.Node): void => {
194
- if (node.getStart(source) >= use.getStart(source)) return;
195
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer && (node.parent.flags & ts.NodeFlags.Const) !== 0) found = node.initializer;
196
- ts.forEachChild(node, visit);
197
- };
198
- visit(source);
199
- return found;
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
- }