@saptools/service-flow 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -61,6 +61,12 @@ interface HandlerMethodFact {
61
61
  decoratorKind: string;
62
62
  decoratorValue?: string;
63
63
  decoratorRawExpression: string;
64
+ decoratorResolution: {
65
+ rawExpression: string;
66
+ resolvedValue?: string;
67
+ resolutionKind: 'literal' | 'const_identifier' | 'enum_member' | 'const_object_property' | 'generated_constant_name' | 'unresolved';
68
+ unresolvedReason?: string;
69
+ };
64
70
  sourceFile: string;
65
71
  sourceLine: number;
66
72
  }
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-52OUS3MO.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.50",
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,9 @@ 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)),
153
+ decoratorResolutionQuality(db),
154
+ handlerRegistrationPairingQuality(db),
152
155
  nestedThisReceiverQuality(db),
153
156
  wrapperPathPropagationQuality(db),
154
157
  remoteQueryTargetQuality(db),
@@ -248,9 +251,18 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
248
251
  const key = [category, baseOperation, reason, family].join('\0');
249
252
  const current = grouped.get(key) ?? { category, baseOperation, reason, candidateFamily: family, count: 0, servicePaths: [], examples: [] };
250
253
  const hintSuggestions = implementationSuggestions(evidence);
254
+ const candidates = asRecords(evidence.candidates);
251
255
  current.count += 1;
252
256
  current.servicePaths.push(String(row.servicePath ?? evidence.servicePath ?? ''));
253
- current.examples.push({ servicePath: row.servicePath, operation: row.operationName, status: row.status, reason: row.unresolvedReason, implementationHintSuggestions: hintSuggestions });
257
+ current.examples.push({
258
+ servicePath: row.servicePath,
259
+ operation: row.operationName,
260
+ status: row.status,
261
+ reason: row.unresolvedReason,
262
+ candidateCount: candidates.length,
263
+ candidateEvidence: candidates.slice(0, 3),
264
+ implementationHintSuggestions: hintSuggestions,
265
+ });
254
266
  grouped.set(key, current);
255
267
  }
256
268
 
@@ -369,6 +381,135 @@ function contextualBindingPropagationQuality(db: Db): Diagnostic {
369
381
  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
382
  }
371
383
 
384
+ function serviceBindingQuality(db: Db, detail: boolean): Diagnostic {
385
+ const rows = db.prepare(`
386
+ SELECT c.source_file sourceFile,c.source_line sourceLine,
387
+ c.unresolved_reason unresolvedReason,c.evidence_json evidenceJson,
388
+ s.evidence_json symbolEvidenceJson
389
+ FROM outbound_calls c
390
+ LEFT JOIN symbols s ON s.id=c.source_symbol_id
391
+ WHERE c.call_type='remote_action'
392
+ AND c.operation_path_expr IS NOT NULL
393
+ AND c.service_binding_id IS NULL
394
+ ORDER BY c.source_file,c.source_line
395
+ `).all() as Diagnostic[];
396
+ const groups = new Map<string, Diagnostic[]>();
397
+ for (const row of rows) {
398
+ const category = bindingCategory(row);
399
+ groups.set(category, [...(groups.get(category) ?? []), bindingExample(row)]);
400
+ }
401
+ const categories = [...groups.entries()].map(([category, examples]) => ({
402
+ category,
403
+ count: examples.length,
404
+ severity: 'warning',
405
+ suggestedAction: bindingCategoryAction(category),
406
+ examples: examples.slice(0, 3),
407
+ expandedExamples: detail ? examples : undefined,
408
+ }));
409
+ return {
410
+ severity: rows.length > 0 ? 'warning' : 'info',
411
+ code: 'strict_service_binding_quality',
412
+ message: 'Remote service-client binding quality aggregate',
413
+ total: rows.length,
414
+ categories,
415
+ };
416
+ }
417
+
418
+ function bindingCategory(row: Diagnostic): string {
419
+ const evidence = parseObject(row.evidenceJson);
420
+ const resolution = parseObject(evidence.serviceBindingResolution);
421
+ if (resolution.status === 'rejected_future_binding') return 'direct_binding_missing';
422
+ if (resolution.status === 'ambiguous') return 'ambiguous_binding_candidates';
423
+ const receiver = evidence.receiver;
424
+ const symbolEvidence = parseObject(row.symbolEvidenceJson);
425
+ if (symbolHasReceiverParameter(symbolEvidence, receiver))
426
+ return 'contextual_binding_recoverable';
427
+ if (!Array.isArray(symbolEvidence.parameterBindings))
428
+ return 'missing_symbol_parameter_metadata';
429
+ return 'unrecoverable_binding';
430
+ }
431
+
432
+ function symbolHasReceiverParameter(evidence: Diagnostic, receiver: unknown): boolean {
433
+ if (typeof receiver !== 'string' || !Array.isArray(evidence.parameterBindings))
434
+ return false;
435
+ return asRecords(evidence.parameterBindings).some((binding) => {
436
+ if (binding.kind === 'identifier') return binding.name === receiver;
437
+ if (binding.kind === 'object_pattern')
438
+ return asRecords(binding.properties).some((property) => property.local === receiver);
439
+ return asRecords(binding.elements).some((element) => element.local === receiver);
440
+ });
441
+ }
442
+
443
+ function bindingExample(row: Diagnostic): Diagnostic {
444
+ const evidence = parseObject(row.evidenceJson);
445
+ return {
446
+ sourceFile: row.sourceFile,
447
+ sourceLine: row.sourceLine,
448
+ receiver: evidence.receiver,
449
+ unresolvedReason: row.unresolvedReason,
450
+ bindingResolution: evidence.serviceBindingResolution,
451
+ };
452
+ }
453
+
454
+ function bindingCategoryAction(category: string): string {
455
+ if (category === 'direct_binding_missing')
456
+ return 'Move the binding before the call or bind the call to an earlier immutable client.';
457
+ if (category === 'contextual_binding_recoverable')
458
+ return 'Trace from the caller so parameter binding evidence can be applied.';
459
+ if (category === 'ambiguous_binding_candidates')
460
+ return 'Split mutable client alternatives or add a statically unique client assignment.';
461
+ if (category === 'missing_symbol_parameter_metadata')
462
+ return 'Use named or destructured parameters on an indexed helper symbol.';
463
+ return 'Add a direct CAP client binding or statically provable helper-return binding.';
464
+ }
465
+
466
+ function decoratorResolutionQuality(db: Db): Diagnostic {
467
+ const aggregate = db.prepare(`SELECT
468
+ SUM(CASE WHEN json_extract(decorator_resolution_json,'$.resolutionKind')
469
+ IN ('const_identifier','enum_member','const_object_property','generated_constant_name') THEN 1 ELSE 0 END) resolvedFromConstants,
470
+ SUM(CASE WHEN json_extract(decorator_resolution_json,'$.resolutionKind')
471
+ ='unresolved' THEN 1 ELSE 0 END) unresolvedExpressions
472
+ FROM handler_methods`).get() as Diagnostic;
473
+ const unresolved = Number(aggregate.unresolvedExpressions ?? 0);
474
+ const examples = db.prepare(`SELECT hm.method_name methodName,
475
+ hm.decorator_raw_expression rawExpression,
476
+ json_extract(hm.decorator_resolution_json,'$.unresolvedReason') unresolvedReason,
477
+ hm.source_file sourceFile,hm.source_line sourceLine
478
+ FROM handler_methods hm
479
+ WHERE json_extract(hm.decorator_resolution_json,'$.resolutionKind')='unresolved'
480
+ ORDER BY hm.source_file,hm.source_line LIMIT 5`).all() as Diagnostic[];
481
+ return {
482
+ severity: unresolved > 0 ? 'warning' : 'info',
483
+ code: 'strict_decorator_resolution_quality',
484
+ message: 'Handler decorator string-resolution aggregate',
485
+ resolvedFromConstants: Number(aggregate.resolvedFromConstants ?? 0),
486
+ unresolvedExpressions: unresolved,
487
+ unresolvedExamples: examples,
488
+ };
489
+ }
490
+
491
+ function handlerRegistrationPairingQuality(db: Db): Diagnostic {
492
+ const mismatch = db.prepare(`SELECT COUNT(*) count
493
+ FROM handler_registrations hr
494
+ JOIN handler_classes hc ON hc.id=hr.handler_class_id
495
+ WHERE hr.handler_class_id IS NOT NULL
496
+ AND (hc.repo_id<>hr.repo_id OR hc.class_name<>hr.class_name)`).get() as Diagnostic;
497
+ const prevented = db.prepare(`SELECT COUNT(*) count
498
+ FROM handler_registrations hr
499
+ JOIN handler_classes exactClass ON exactClass.id=hr.handler_class_id
500
+ JOIN handler_classes otherClass ON otherClass.class_name=hr.class_name
501
+ AND otherClass.repo_id<>hr.repo_id
502
+ WHERE hr.handler_class_id IS NOT NULL AND hr.import_source IS NOT NULL`).get() as Diagnostic;
503
+ const mismatched = Number(mismatch.count ?? 0);
504
+ return {
505
+ severity: mismatched > 0 ? 'error' : 'info',
506
+ code: 'strict_handler_registration_pairing_quality',
507
+ message: 'Handler registration and class ownership aggregate',
508
+ mismatchedExactRegistrations: mismatched,
509
+ preventedSyntheticCrossRepositoryPairs: Number(prevented.count ?? 0),
510
+ };
511
+ }
512
+
372
513
  function wrapperPathPropagationQuality(db: Db): Diagnostic {
373
514
  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
515
  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 +591,53 @@ function externalHttpTargetQuality(db: Db): Diagnostic {
450
591
  }
451
592
 
452
593
  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 }>;
594
+ const rows = db.prepare(`SELECT c.operation_path_expr operationPathExpr,
595
+ c.source_file sourceFile,c.source_line sourceLine,e.id graphEdgeId,
596
+ e.status status,e.evidence_json evidenceJson
597
+ FROM outbound_calls c JOIN graph_edges e
598
+ ON e.from_kind='call' AND e.from_id=CAST(c.id AS TEXT)
599
+ WHERE c.call_type='remote_action' AND c.operation_path_expr LIKE '%(%'
600
+ ORDER BY c.source_file,c.source_line`).all() as Array<{
601
+ operationPathExpr?: string;
602
+ sourceFile?: string;
603
+ sourceLine?: number;
604
+ graphEdgeId?: number;
605
+ status?: string;
606
+ evidenceJson?: string;
607
+ }>;
454
608
  const unresolved = rows.filter((row) => row.status === 'unresolved' && normalizeODataOperationInvocationPath(row.operationPathExpr)?.wasInvocation).length;
455
609
  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 };
610
+ const examples = rows
611
+ .filter((row) => row.status === 'ambiguous' || row.status === 'unresolved')
612
+ .map(odataInvocationExample)
613
+ .slice(0, 5);
614
+ 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 };
615
+ }
616
+
617
+ function odataInvocationExample(row: {
618
+ operationPathExpr?: string;
619
+ sourceFile?: string;
620
+ sourceLine?: number;
621
+ graphEdgeId?: number;
622
+ status?: string;
623
+ evidenceJson?: string;
624
+ }): Diagnostic {
625
+ const evidence = parseObject(row.evidenceJson);
626
+ const normalized = normalizeODataOperationInvocationPath(row.operationPathExpr);
627
+ return {
628
+ sourceFile: row.sourceFile,
629
+ sourceLine: row.sourceLine,
630
+ graphEdgeId: row.graphEdgeId,
631
+ status: row.status,
632
+ rawPath: row.operationPathExpr,
633
+ normalizedOperationPath: normalized?.wasInvocation
634
+ ? normalized.normalizedOperationPath
635
+ : undefined,
636
+ indexedOperationCandidateCount: evidence.indexedOperationCandidateCount,
637
+ candidateScores: evidence.candidateScores,
638
+ entityOperationPrecedence: evidence.entityOperationPrecedence,
639
+ resolutionReasons: evidence.resolutionReasons,
640
+ };
457
641
  }
458
642
 
459
643
  function identityAliasBindingQuality(db: Db): Diagnostic {
@@ -1,7 +1,10 @@
1
1
  import type { Db } from './connection.js';
2
2
  import { schemaSql } from './schema.js';
3
- const CURRENT_SCHEMA_VERSION = 9;
3
+ const CURRENT_SCHEMA_VERSION = 10;
4
4
  const columns: Record<string, Array<{ name: string; ddl: string }>> = {
5
+ handler_methods: [
6
+ { name: 'decorator_resolution_json', ddl: "ALTER TABLE handler_methods ADD COLUMN decorator_resolution_json TEXT NOT NULL DEFAULT '{}'" },
7
+ ],
5
8
  service_bindings: [
6
9
  { name: 'helper_chain_json', ddl: 'ALTER TABLE service_bindings ADD COLUMN helper_chain_json TEXT' },
7
10
  { name: 'alias_expr', ddl: 'ALTER TABLE service_bindings ADD COLUMN alias_expr TEXT' },
@@ -211,7 +211,7 @@ export function insertHandler(
211
211
  .get(repoId, sid, h.className, h.sourceFile, h.sourceLine)?.id,
212
212
  );
213
213
  const stmt = db.prepare(
214
- 'INSERT INTO handler_methods(handler_class_id,method_name,decorator_kind,decorator_value,decorator_raw_expression,source_file,source_line) VALUES(?,?,?,?,?,?,?)',
214
+ 'INSERT INTO handler_methods(handler_class_id,method_name,decorator_kind,decorator_value,decorator_raw_expression,decorator_resolution_json,source_file,source_line) VALUES(?,?,?,?,?,?,?,?)',
215
215
  );
216
216
  for (const m of h.methods)
217
217
  stmt.run(
@@ -220,6 +220,7 @@ export function insertHandler(
220
220
  m.decoratorKind,
221
221
  m.decoratorValue,
222
222
  m.decoratorRawExpression,
223
+ JSON.stringify(m.decoratorResolution),
223
224
  m.sourceFile,
224
225
  m.sourceLine,
225
226
  );
@@ -259,11 +260,15 @@ export function insertBindings(
259
260
  rows: ServiceBindingFact[],
260
261
  ): void {
261
262
  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(?,?,?,?,?,?,?,?,?,?,?)',
263
+ '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
264
  );
264
265
  for (const r of rows)
265
266
  stmt.run(
266
267
  repoId,
268
+ repoId,
269
+ r.sourceFile,
270
+ r.sourceLine,
271
+ r.sourceLine,
267
272
  r.variableName,
268
273
  r.alias,
269
274
  r.aliasExpr,
@@ -321,9 +326,14 @@ export function insertCalls(
321
326
  rows: OutboundCallFact[],
322
327
  ): void {
323
328
  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))',
329
+ '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
330
  );
326
- for (const r of rows)
331
+ for (const r of rows) {
332
+ const binding = resolvePersistedBinding(db, repoId, r);
333
+ const evidence = {
334
+ ...(r.evidence ?? {}),
335
+ serviceBindingResolution: binding.evidence,
336
+ };
327
337
  stmt.run(
328
338
  repoId,
329
339
  repoId,
@@ -342,19 +352,186 @@ export function insertCalls(
342
352
  r.sourceFile,
343
353
  r.sourceLine,
344
354
  r.confidence,
345
- r.unresolvedReason,
355
+ r.unresolvedReason ?? binding.unresolvedReason,
346
356
  r.localServiceName,
347
357
  r.localServiceLookup,
348
358
  r.aliasChain ? JSON.stringify(r.aliasChain) : null,
349
- r.evidence ? JSON.stringify(r.evidence) : null,
359
+ JSON.stringify(evidence),
350
360
  r.externalTarget?.kind ?? null,
351
361
  r.externalTarget?.stableId ?? null,
352
362
  r.externalTarget?.label ?? null,
353
363
  r.externalTarget?.dynamic ? 1 : 0,
354
- repoId,
355
- r.serviceVariableName,
356
- r.sourceFile,
357
- r.sourceLine,
358
- r.sourceLine,
364
+ binding.bindingId,
359
365
  );
366
+ }
367
+ }
368
+
369
+ interface BindingCandidate {
370
+ id: number;
371
+ symbolId?: number | null;
372
+ variableName: string;
373
+ alias?: string | null;
374
+ aliasExpr?: string | null;
375
+ destinationExpr?: string | null;
376
+ servicePathExpr?: string | null;
377
+ sourceFile: string;
378
+ sourceLine: number;
379
+ helperChainJson?: string | null;
380
+ }
381
+
382
+ function resolvePersistedBinding(
383
+ db: Db,
384
+ repoId: number,
385
+ call: OutboundCallFact,
386
+ ): {
387
+ bindingId: number | null;
388
+ unresolvedReason?: string;
389
+ evidence: Record<string, unknown>;
390
+ } {
391
+ if (!call.serviceVariableName)
392
+ return { bindingId: null, evidence: { status: 'not_applicable', candidateCount: 0 } };
393
+ const candidates = bindingCandidates(db, repoId, call);
394
+ const prior = candidates.filter((candidate) => candidate.sourceLine <= call.sourceLine);
395
+ const families = new Set(prior.map(bindingSignature));
396
+ if (prior.length > 0 && families.size === 1) {
397
+ const selected = prior.at(-1);
398
+ return {
399
+ bindingId: selected?.id ?? null,
400
+ evidence: bindingEvidence('selected', prior, selected),
401
+ };
402
+ }
403
+ if (prior.length > 1) {
404
+ return {
405
+ bindingId: null,
406
+ unresolvedReason: 'ambiguous_service_binding_candidates',
407
+ evidence: bindingEvidence('ambiguous', prior),
408
+ };
409
+ }
410
+ if (candidates.length > 0) {
411
+ return {
412
+ bindingId: null,
413
+ unresolvedReason: 'service_binding_declared_after_call',
414
+ evidence: bindingEvidence('rejected_future_binding', candidates),
415
+ };
416
+ }
417
+ return {
418
+ bindingId: null,
419
+ evidence: bindingEvidence('unrecoverable', []),
420
+ };
421
+ }
422
+
423
+ function bindingCandidates(
424
+ db: Db,
425
+ repoId: number,
426
+ call: OutboundCallFact,
427
+ ): BindingCandidate[] {
428
+ const ownerId = callSymbolId(db, repoId, call);
429
+ const rows = db.prepare(`
430
+ SELECT id,symbol_id symbolId,variable_name variableName,alias,alias_expr aliasExpr,
431
+ destination_expr destinationExpr,service_path_expr servicePathExpr,
432
+ source_file sourceFile,source_line sourceLine,helper_chain_json helperChainJson
433
+ FROM service_bindings
434
+ WHERE repo_id=? AND variable_name=? AND source_file=?
435
+ AND (? IS NULL OR symbol_id IS NULL OR symbol_id=?)
436
+ ORDER BY source_line,id
437
+ `).all(
438
+ repoId,
439
+ call.serviceVariableName,
440
+ call.sourceFile,
441
+ ownerId,
442
+ ownerId,
443
+ ) as Array<Record<string, unknown>>;
444
+ return rows.flatMap((row) => {
445
+ if (typeof row.id !== 'number' || typeof row.variableName !== 'string'
446
+ || typeof row.sourceFile !== 'string' || typeof row.sourceLine !== 'number')
447
+ return [];
448
+ return [{
449
+ id: row.id,
450
+ symbolId: nullableNumber(row.symbolId),
451
+ variableName: row.variableName,
452
+ alias: nullableString(row.alias),
453
+ aliasExpr: nullableString(row.aliasExpr),
454
+ destinationExpr: nullableString(row.destinationExpr),
455
+ servicePathExpr: nullableString(row.servicePathExpr),
456
+ sourceFile: row.sourceFile,
457
+ sourceLine: row.sourceLine,
458
+ helperChainJson: nullableString(row.helperChainJson),
459
+ }];
460
+ });
461
+ }
462
+
463
+ function nullableString(value: unknown): string | null | undefined {
464
+ return value === null || typeof value === 'string' ? value : undefined;
465
+ }
466
+
467
+ function nullableNumber(value: unknown): number | null | undefined {
468
+ return value === null || typeof value === 'number' ? value : undefined;
469
+ }
470
+
471
+ function callSymbolId(
472
+ db: Db,
473
+ repoId: number,
474
+ call: OutboundCallFact,
475
+ ): number | undefined {
476
+ const row = db.prepare(`
477
+ SELECT id FROM symbols
478
+ WHERE repo_id=? AND source_file=?
479
+ AND ((? IS NOT NULL AND qualified_name=?)
480
+ OR (start_line<=? AND end_line>=?))
481
+ ORDER BY CASE WHEN qualified_name=? THEN 0 ELSE 1 END,
482
+ (end_line-start_line),id
483
+ LIMIT 1
484
+ `).get(
485
+ repoId,
486
+ call.sourceFile,
487
+ call.sourceSymbolQualifiedName,
488
+ call.sourceSymbolQualifiedName,
489
+ call.sourceLine,
490
+ call.sourceLine,
491
+ call.sourceSymbolQualifiedName,
492
+ );
493
+ return typeof row?.id === 'number' ? row.id : undefined;
494
+ }
495
+
496
+ function bindingEvidence(
497
+ status: string,
498
+ candidates: BindingCandidate[],
499
+ selected?: BindingCandidate,
500
+ ): Record<string, unknown> {
501
+ return {
502
+ status,
503
+ candidateCount: candidates.length,
504
+ selectedBindingId: selected?.id,
505
+ sourceOrderRule: 'binding_source_line_must_not_follow_call',
506
+ candidates: candidates.map((candidate) => ({
507
+ bindingId: candidate.id,
508
+ symbolId: candidate.symbolId,
509
+ variableName: candidate.variableName,
510
+ alias: candidate.alias,
511
+ aliasExpr: candidate.aliasExpr,
512
+ destinationExpr: candidate.destinationExpr,
513
+ servicePathExpr: candidate.servicePathExpr,
514
+ sourceFile: candidate.sourceFile,
515
+ sourceLine: candidate.sourceLine,
516
+ helperChain: parseBindingChain(candidate.helperChainJson),
517
+ })),
518
+ };
519
+ }
520
+
521
+ function bindingSignature(candidate: BindingCandidate): string {
522
+ return JSON.stringify([
523
+ candidate.alias,
524
+ candidate.aliasExpr,
525
+ candidate.destinationExpr,
526
+ candidate.servicePathExpr,
527
+ ]);
528
+ }
529
+
530
+ function parseBindingChain(value: string | null | undefined): unknown {
531
+ if (!value) return undefined;
532
+ try {
533
+ return JSON.parse(value) as unknown;
534
+ } catch {
535
+ return undefined;
536
+ }
360
537
  }
package/src/db/schema.ts CHANGED
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS cds_services (id INTEGER PRIMARY KEY, repo_id INTEGER
7
7
  CREATE TABLE IF NOT EXISTS cds_operations (id INTEGER PRIMARY KEY, service_id INTEGER NOT NULL, operation_type TEXT NOT NULL, operation_name TEXT NOT NULL, operation_path TEXT NOT NULL, params_json TEXT NOT NULL, return_type TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, provenance TEXT NOT NULL DEFAULT 'direct', base_operation_id INTEGER, FOREIGN KEY(service_id) REFERENCES cds_services(id) ON DELETE CASCADE, FOREIGN KEY(base_operation_id) REFERENCES cds_operations(id) ON DELETE SET NULL);
8
8
  CREATE TABLE IF NOT EXISTS symbols (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, file_id INTEGER, kind TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL, exported INTEGER NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, start_offset INTEGER, end_offset INTEGER, source_file TEXT, exported_name TEXT, evidence_json TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE);
9
9
  CREATE TABLE IF NOT EXISTS handler_classes (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, symbol_id INTEGER, class_name TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(symbol_id) REFERENCES symbols(id) ON DELETE SET NULL);
10
- CREATE TABLE IF NOT EXISTS handler_methods (id INTEGER PRIMARY KEY, handler_class_id INTEGER NOT NULL, method_name TEXT NOT NULL, decorator_kind TEXT NOT NULL, decorator_value TEXT, decorator_raw_expression TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE CASCADE);
10
+ CREATE TABLE IF NOT EXISTS handler_methods (id INTEGER PRIMARY KEY, handler_class_id INTEGER NOT NULL, method_name TEXT NOT NULL, decorator_kind TEXT NOT NULL, decorator_value TEXT, decorator_raw_expression TEXT NOT NULL, decorator_resolution_json TEXT NOT NULL DEFAULT '{}', source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE CASCADE);
11
11
  CREATE TABLE IF NOT EXISTS handler_registrations (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, handler_class_id INTEGER, class_name TEXT, import_source TEXT, registration_file TEXT NOT NULL, registration_line INTEGER NOT NULL, registration_kind TEXT NOT NULL, confidence REAL NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE SET NULL);
12
12
  CREATE TABLE IF NOT EXISTS service_bindings (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, symbol_id INTEGER, variable_name TEXT NOT NULL, alias TEXT, alias_expr TEXT, destination_expr TEXT, service_path_expr TEXT, is_dynamic INTEGER NOT NULL, placeholders_json TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, helper_chain_json TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(symbol_id) REFERENCES symbols(id) ON DELETE SET NULL);
13
13
  CREATE TABLE IF NOT EXISTS outbound_calls (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, source_symbol_id INTEGER, call_type TEXT NOT NULL, service_binding_id INTEGER, method TEXT, operation_path_expr TEXT, query_entity TEXT, event_name_expr TEXT, payload_summary TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, confidence REAL NOT NULL, unresolved_reason TEXT, local_service_name TEXT, local_service_lookup TEXT, alias_chain_json TEXT, evidence_json TEXT, external_target_kind TEXT, external_target_id TEXT, external_target_label TEXT, external_target_dynamic INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(source_symbol_id) REFERENCES symbols(id) ON DELETE SET NULL, FOREIGN KEY(service_binding_id) REFERENCES service_bindings(id) ON DELETE SET NULL);