@saptools/service-flow 0.1.49 → 0.1.51

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
  }
@@ -129,6 +135,18 @@ interface ImplementationHint {
129
135
  candidateFamily?: string;
130
136
  implementationRepo: string;
131
137
  }
138
+ type DynamicMode = 'strict' | 'candidates' | 'infer';
139
+ interface TraceOptions {
140
+ depth: number;
141
+ vars?: Record<string, string>;
142
+ includeExternal?: boolean;
143
+ includeDb?: boolean;
144
+ includeAsync?: boolean;
145
+ implementationRepo?: string;
146
+ implementationHints?: ImplementationHint[];
147
+ dynamicMode?: DynamicMode;
148
+ maxDynamicCandidates?: number;
149
+ }
132
150
  interface TraceEdge {
133
151
  step: number;
134
152
  type: string;
@@ -207,19 +225,11 @@ declare function applyVariables(template: string | undefined, vars: Record<strin
207
225
  declare function extractPlaceholders(template: string | undefined): string[];
208
226
  declare function substituteVariables(template: string | undefined, vars: Record<string, string>): RuntimeSubstitution;
209
227
 
210
- declare function trace(db: Db, start: TraceStart, options: {
211
- depth: number;
212
- vars?: Record<string, string>;
213
- includeExternal?: boolean;
214
- includeDb?: boolean;
215
- includeAsync?: boolean;
216
- implementationRepo?: string;
217
- implementationHints?: ImplementationHint[];
218
- }): TraceResult;
228
+ declare function trace(db: Db, start: TraceStart, options: TraceOptions): TraceResult;
219
229
 
220
230
  declare function parseImplementationHint(value: string): ImplementationHint;
221
231
 
222
232
  declare function redactText(text: string): string;
223
233
  declare function redactValue(value: unknown): unknown;
224
234
 
225
- export { type ImplementationHint, type RuntimeSubstitution, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseImplementationHint, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
235
+ export { type DynamicMode, type ImplementationHint, type RuntimeSubstitution, type TraceOptions, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseImplementationHint, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  stripQuotes,
18
18
  substituteVariables,
19
19
  trace
20
- } from "./chunk-XOROZHW4.js";
20
+ } from "./chunk-YZJKE5UX.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.49",
3
+ "version": "0.1.51",
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
@@ -150,6 +150,8 @@ function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOption
150
150
  classInstanceNoiseQuality(db),
151
151
  contextualBindingPropagationQuality(db),
152
152
  serviceBindingQuality(db, Boolean(options.detail)),
153
+ decoratorResolutionQuality(db),
154
+ handlerRegistrationPairingQuality(db),
153
155
  nestedThisReceiverQuality(db),
154
156
  wrapperPathPropagationQuality(db),
155
157
  remoteQueryTargetQuality(db),
@@ -249,9 +251,18 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
249
251
  const key = [category, baseOperation, reason, family].join('\0');
250
252
  const current = grouped.get(key) ?? { category, baseOperation, reason, candidateFamily: family, count: 0, servicePaths: [], examples: [] };
251
253
  const hintSuggestions = implementationSuggestions(evidence);
254
+ const candidates = asRecords(evidence.candidates);
252
255
  current.count += 1;
253
256
  current.servicePaths.push(String(row.servicePath ?? evidence.servicePath ?? ''));
254
- 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
+ });
255
266
  grouped.set(key, current);
256
267
  }
257
268
 
@@ -452,6 +463,53 @@ function bindingCategoryAction(category: string): string {
452
463
  return 'Add a direct CAP client binding or statically provable helper-return binding.';
453
464
  }
454
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
+
455
513
  function wrapperPathPropagationQuality(db: Db): Diagnostic {
456
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[];
457
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 };
package/src/cli.ts CHANGED
@@ -29,6 +29,7 @@ import { renderTraceJson, renderJson } from './output/json-output.js';
29
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
+ import type { DynamicMode } from './types.js';
32
33
  async function init(
33
34
  workspace: string,
34
35
  options: { db?: string; ignore?: string[] },
@@ -155,6 +156,8 @@ export function createProgram(): Command {
155
156
  .option('--implementation-repo <name>')
156
157
  .option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
157
158
  .option('--var <key=value>', 'dynamic variable', collect, [])
159
+ .option('--dynamic-mode <mode>', 'strict|candidates|infer', 'strict')
160
+ .option('--max-dynamic-candidates <n>', 'maximum dynamic candidates to show', '5')
158
161
  .action(
159
162
  (opts: {
160
163
  workspace?: string;
@@ -171,6 +174,8 @@ export function createProgram(): Command {
171
174
  implementationRepo?: string;
172
175
  implementationHint: string[];
173
176
  var: string[];
177
+ dynamicMode: string;
178
+ maxDynamicCandidates: string;
174
179
  }) =>
175
180
  void withReadOnlyWorkspace(opts.workspace, (db) => {
176
181
  const result = trace(
@@ -190,6 +195,8 @@ export function createProgram(): Command {
190
195
  includeAsync: Boolean(opts.includeAsync),
191
196
  implementationRepo: opts.implementationRepo,
192
197
  implementationHints: opts.implementationHint.map(parseImplementationHint),
198
+ dynamicMode: parseDynamicMode(opts.dynamicMode),
199
+ maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5),
193
200
  },
194
201
  );
195
202
  process.stdout.write(
@@ -299,6 +306,8 @@ export function createProgram(): Command {
299
306
  .option('--implementation-repo <name>')
300
307
  .option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
301
308
  .option('--var <key=value>', 'dynamic variable', collect, [])
309
+ .option('--dynamic-mode <mode>', 'strict|candidates|infer', 'strict')
310
+ .option('--max-dynamic-candidates <n>', 'maximum dynamic candidates to show', '5')
302
311
  .action(
303
312
  (opts: {
304
313
  workspace?: string;
@@ -310,6 +319,8 @@ export function createProgram(): Command {
310
319
  implementationRepo?: string;
311
320
  implementationHint: string[];
312
321
  var: string[];
322
+ dynamicMode: string;
323
+ maxDynamicCandidates: string;
313
324
  }) =>
314
325
  void withReadOnlyWorkspace(opts.workspace, (db) => {
315
326
  const result = trace(
@@ -328,6 +339,8 @@ export function createProgram(): Command {
328
339
  vars: parseVars(opts.var),
329
340
  implementationRepo: opts.implementationRepo,
330
341
  implementationHints: opts.implementationHint.map(parseImplementationHint),
342
+ dynamicMode: parseDynamicMode(opts.dynamicMode),
343
+ maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5),
331
344
  },
332
345
  );
333
346
  process.stdout.write(
@@ -418,6 +431,15 @@ function collect(value: string, previous: string[]): string[] {
418
431
  previous.push(value);
419
432
  return previous;
420
433
  }
434
+ function parseDynamicMode(value: string | undefined): DynamicMode {
435
+ if (value === undefined || value === 'strict') return 'strict';
436
+ if (value === 'candidates' || value === 'infer') return value;
437
+ throw new Error(`Invalid --dynamic-mode ${value}; expected strict, candidates, or infer`);
438
+ }
439
+ function parsePositiveInteger(value: string | undefined, fallback: number): number {
440
+ const parsed = Number(value);
441
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
442
+ }
421
443
  function fail(error: unknown): void {
422
444
  process.stderr.write(
423
445
  `${error instanceof Error ? error.message : String(error)}\n`,
@@ -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
  );
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);
package/src/index.ts CHANGED
@@ -11,5 +11,5 @@ export { applyVariables, extractPlaceholders, substituteVariables } from './link
11
11
  export type { RuntimeSubstitution } from './linker/dynamic-edge-resolver.js';
12
12
  export { trace } from './trace/trace-engine.js';
13
13
  export { parseImplementationHint } from './trace/implementation-hints.js';
14
- export type { ImplementationHint } from './types.js';
14
+ export type { DynamicMode, ImplementationHint, TraceOptions } from './types.js';
15
15
  export { redactValue, redactText } from './utils/redaction.js';
@@ -224,7 +224,15 @@ function compactCandidateScores(candidates: unknown[]): Array<Record<string, unk
224
224
  return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
225
225
  const row = objectValue(candidate);
226
226
  if (!row) return [];
227
- return [{ repo: row.repoName, servicePath: row.servicePath, operationPath: row.operationPath, score: row.score, reasons: row.reasons }];
227
+ return [{
228
+ repo: row.repoName,
229
+ servicePath: row.servicePath,
230
+ operationPath: row.operationPath,
231
+ score: row.score,
232
+ reasons: Array.isArray(row.reasons)
233
+ ? row.reasons.filter((reason): reason is string => typeof reason === 'string')
234
+ : ['operation_path_match'],
235
+ }];
228
236
  });
229
237
  }
230
238
 
@@ -301,14 +309,64 @@ function implementationContextForOperation(db: Db, operation: Record<string, unk
301
309
  }
302
310
  function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
303
311
  const rows = implementationCandidates(db, workspaceId, operation);
304
- return deduplicateCandidates(rows.map((row) => scoreImplementationCandidate(row, operation))).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
312
+ const invalid = rows.filter((row) => !validRegistrationPair(row));
313
+ recordRegistrationInvariantDiagnostics(db, invalid);
314
+ return deduplicateCandidates(
315
+ rows.filter(validRegistrationPair)
316
+ .map((row) => scoreImplementationCandidate(row, operation)),
317
+ ).sort((a, b) => b.score - a.score || String(a.className).localeCompare(String(b.className)) || a.methodId - b.methodId);
318
+ }
319
+ function validRegistrationPair(row: Record<string, unknown>): boolean {
320
+ if (row.registrationHandlerClassId === null
321
+ || row.registrationHandlerClassId === undefined)
322
+ return registrationPairingStrategy(row) !== 'unproven';
323
+ return Number(row.registrationHandlerClassId) === Number(row.classId);
324
+ }
325
+ function registrationPairingStrategy(row: Record<string, unknown>): string {
326
+ if (row.registrationHandlerClassId !== null
327
+ && row.registrationHandlerClassId !== undefined)
328
+ return 'exact_handler_class_id';
329
+ if (Number(row.applicationRepoId) === Number(row.handlerRepoId))
330
+ return 'same_repository_class_name_fallback';
331
+ const source = stringValue(row.importSource);
332
+ const separator = source?.lastIndexOf('#') ?? -1;
333
+ if (!source || separator <= 0) return 'unproven';
334
+ const moduleName = source.slice(0, separator);
335
+ const importedName = source.slice(separator + 1);
336
+ const matchesClass = importedName === row.className
337
+ || (importedName === 'default' && row.registrationClassName === row.className);
338
+ return moduleName === row.handlerPackage && matchesClass
339
+ ? 'explicit_package_import'
340
+ : 'unproven';
341
+ }
342
+ function recordRegistrationInvariantDiagnostics(
343
+ db: Db,
344
+ rows: Array<Record<string, unknown>>,
345
+ ): void {
346
+ const insert = db.prepare(`INSERT INTO diagnostics(repo_id,severity,code,message,source_file,source_line)
347
+ SELECT ?,'error','handler_registration_class_mismatch',
348
+ 'Implementation candidate registration did not match its persisted handler class id',?,?
349
+ WHERE NOT EXISTS (
350
+ SELECT 1 FROM diagnostics
351
+ WHERE repo_id=? AND code='handler_registration_class_mismatch'
352
+ AND source_file=? AND source_line=?
353
+ )`);
354
+ for (const row of rows)
355
+ insert.run(
356
+ row.applicationRepoId,
357
+ row.registrationFile,
358
+ row.registrationLine,
359
+ row.applicationRepoId,
360
+ row.registrationFile,
361
+ row.registrationLine,
362
+ );
305
363
  }
306
364
 
307
365
  function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationCandidate[] {
308
366
  const merged = new Map<string, ImplementationCandidate>();
309
367
  for (const row of rows) {
310
368
  const key = [row.methodId, row.classId, row.handlerRepoId].join(':');
311
- const registration = { file: row.registrationFile, line: row.registrationLine, kind: row.registrationKind, importSource: row.importSource };
369
+ const registration = registrationEvidence(row);
312
370
  const existing = merged.get(key);
313
371
  if (!existing) {
314
372
  merged.set(key, { ...row, registrations: [registration] });
@@ -322,6 +380,19 @@ function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationC
322
380
  }
323
381
  return [...merged.values()];
324
382
  }
383
+ function registrationEvidence(
384
+ row: Record<string, unknown>,
385
+ ): Record<string, unknown> {
386
+ return {
387
+ id: row.registrationId,
388
+ handlerClassId: row.registrationHandlerClassId,
389
+ file: row.registrationFile,
390
+ line: row.registrationLine,
391
+ kind: row.registrationKind,
392
+ importSource: row.importSource,
393
+ pairingStrategy: registrationPairingStrategy(row),
394
+ };
395
+ }
325
396
  function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
326
397
  const seen = new Set<string>();
327
398
  return rows.filter((row) => {
@@ -352,10 +423,14 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
352
423
  hm.method_name methodName,
353
424
  hm.decorator_value decoratorValue,
354
425
  hm.decorator_raw_expression decoratorRawExpression,
426
+ hm.decorator_resolution_json decoratorResolutionJson,
355
427
  hc.id classId,
356
428
  hc.class_name className,
357
429
  hc.source_file sourceFile,
358
430
  hc.source_line sourceLine,
431
+ hr.id registrationId,
432
+ hr.handler_class_id registrationHandlerClassId,
433
+ hr.class_name registrationClassName,
359
434
  hr.repo_id applicationRepoId,
360
435
  hr.registration_file registrationFile,
361
436
  hr.registration_line registrationLine,
@@ -378,14 +453,36 @@ function implementationCandidates(db: Db, workspaceId: number, operation: Record
378
453
  CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
379
454
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id AND localService.service_path=?) THEN 1 ELSE 0 END localServicePathMatch,
380
455
  CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
381
- CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON (localClass.id=localReg.handler_class_id OR localClass.class_name=localReg.class_name) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
456
+ CASE WHEN EXISTS (SELECT 1 FROM handler_registrations localReg JOIN handler_classes localClass ON ((localReg.handler_class_id IS NOT NULL AND localClass.id=localReg.handler_class_id) OR (localReg.handler_class_id IS NULL AND localClass.class_name=localReg.class_name AND localClass.repo_id=localReg.repo_id)) JOIN handler_methods localMethod ON localMethod.handler_class_id=localClass.id WHERE localReg.repo_id=appRepo.id AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
382
457
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END appDependsOnModel,
383
458
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(appRepo.id AS TEXT) AND dep.to_id=CAST(handlerRepo.id AS TEXT)) THEN 1 ELSE 0 END appDependsOnHandler,
384
459
  CASE WHEN EXISTS (SELECT 1 FROM graph_edges dep WHERE dep.edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND dep.status='resolved' AND dep.from_kind='repo' AND dep.from_id=CAST(handlerRepo.id AS TEXT) AND dep.to_id=?) THEN 1 ELSE 0 END handlerDependsOnModel
385
460
  FROM handler_methods hm
386
461
  JOIN handler_classes hc ON hc.id=hm.handler_class_id
387
462
  JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id
388
- JOIN handler_registrations hr ON (hr.handler_class_id=hc.id OR (hr.class_name=hc.class_name AND (hr.repo_id=hc.repo_id OR hr.import_source IS NOT NULL)))
463
+ JOIN handler_registrations hr ON (
464
+ (hr.handler_class_id IS NOT NULL AND hr.handler_class_id=hc.id)
465
+ OR (
466
+ hr.handler_class_id IS NULL
467
+ AND (
468
+ (hr.class_name=hc.class_name AND hr.repo_id=hc.repo_id)
469
+ OR (
470
+ instr(hr.import_source,'#')>1
471
+ AND substr(hr.import_source,1,instr(hr.import_source,'#')-1)
472
+ =handlerRepo.package_name
473
+ AND (
474
+ substr(hr.import_source,instr(hr.import_source,'#')+1)
475
+ =hc.class_name
476
+ OR (
477
+ substr(hr.import_source,instr(hr.import_source,'#')+1)
478
+ ='default'
479
+ AND hr.class_name=hc.class_name
480
+ )
481
+ )
482
+ )
483
+ )
484
+ )
485
+ )
389
486
  JOIN repositories appRepo ON appRepo.id=hr.repo_id
390
487
  WHERE appRepo.workspace_id=?
391
488
  AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
@@ -487,8 +584,16 @@ function candidateEvidence(candidate: ImplementationCandidate, rank: number): Re
487
584
  className: candidate.className,
488
585
  sourceFile: candidate.sourceFile,
489
586
  sourceLine: candidate.sourceLine,
490
- registration: { file: candidate.registrationFile, line: candidate.registrationLine, kind: candidate.registrationKind, importSource: candidate.importSource },
587
+ decoratorResolution: objectJson(candidate.decoratorResolutionJson),
588
+ registration: registrationEvidence(candidate),
491
589
  registrations: candidate.registrations ?? [],
590
+ registrationPairing: {
591
+ strategy: registrationPairingStrategy(candidate),
592
+ registrationId: candidate.registrationId,
593
+ registrationHandlerClassId: candidate.registrationHandlerClassId,
594
+ candidateHandlerClassId: candidate.classId,
595
+ invariantStatus: validRegistrationPair(candidate) ? 'valid' : 'invalid',
596
+ },
492
597
  applicationPackage: { id: candidate.applicationRepoId, name: candidate.applicationRepo, packageName: candidate.applicationPackage },
493
598
  handlerPackage: { id: candidate.handlerRepoId, name: candidate.handlerRepo, packageName: candidate.handlerPackage },
494
599
  modelPackage: { id: candidate.modelRepoId, name: candidate.modelRepo, packageName: candidate.modelPackage },
@@ -12,7 +12,7 @@ export function normalizedOperationName(value: string): string {
12
12
  function clean(value: string): string {
13
13
  return value.replace(/^[`'"]|[`'"]$/g, '');
14
14
  }
15
- function generatedFromConstantName(value: string): string | undefined {
15
+ export function generatedOperationNameFromConstant(value: string): string | undefined {
16
16
  for (const prefix of ['Action', 'Func']) {
17
17
  if (value.startsWith(prefix) && value.length > prefix.length && /^[A-Z]/.test(value.slice(prefix.length))) return lowerFirst(value.slice(prefix.length));
18
18
  }
@@ -20,7 +20,7 @@ function generatedFromConstantName(value: string): string | undefined {
20
20
  }
21
21
  function resolved(value: string, raw?: string): DecoratorOperationSignal {
22
22
  const literal = clean(value);
23
- const generated = generatedFromConstantName(literal);
23
+ const generated = generatedOperationNameFromConstant(literal);
24
24
  return { status: 'resolved', operationName: generated ?? normalizedOperationName(literal), raw };
25
25
  }
26
26
  export function normalizeDecoratorOperationSignal(value: string | undefined, raw: string | undefined, candidateOperation?: string): DecoratorOperationSignal {
@@ -32,7 +32,7 @@ export function normalizeDecoratorOperationSignal(value: string | undefined, raw
32
32
  const stringMatch = /^String\(([A-Za-z_$][\w$]*)\)$/.exec(expression);
33
33
  if (stringMatch?.[1]) {
34
34
  const identifier = stringMatch[1];
35
- const generated = generatedFromConstantName(identifier);
35
+ const generated = generatedOperationNameFromConstant(identifier);
36
36
  const normalizedCandidate = candidateOperation ? normalizedOperationName(candidateOperation) : undefined;
37
37
  if (generated) return { status: 'resolved', operationName: generated, raw: expression };
38
38
  if (normalizedCandidate && identifier === normalizedCandidate) return { status: 'resolved', operationName: identifier, raw: expression };
@@ -26,9 +26,9 @@ function rows(
26
26
  workspaceId?: number,
27
27
  ): OperationTarget[] {
28
28
  const names = operationLookupNames(operationPath);
29
- return db
29
+ const result = db
30
30
  .prepare(
31
- `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine,0 score,'' reasons
31
+ `SELECT o.id operationId,r.id repoId,r.name repoName,r.package_name packageName,s.service_name serviceName,s.qualified_name qualifiedName,s.service_path servicePath,o.operation_path operationPath,o.operation_name operationName,o.source_file sourceFile,o.source_line sourceLine,0 score
32
32
  FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id
33
33
  WHERE (? IS NULL OR r.workspace_id=?) AND (o.operation_path IN (?,?) OR o.operation_name IN (?,?)) ORDER BY r.name,s.service_path,o.operation_name`,
34
34
  )
@@ -39,7 +39,12 @@ function rows(
39
39
  names.simplePath,
40
40
  names.name,
41
41
  names.simpleName,
42
- ) as unknown as OperationTarget[];
42
+ ) as Array<Omit<OperationTarget, 'reasons'>>;
43
+ return result.map((row) => ({
44
+ ...row,
45
+ score: Number(row.score ?? 0),
46
+ reasons: [],
47
+ }));
43
48
  }
44
49
  function operationLookupNames(operationPath: string): { path: string; simplePath: string; name: string; simpleName: string } {
45
50
  const name = operationPath.replace(/^\//, '');
@@ -70,7 +75,13 @@ export function resolveOperation(
70
75
  if (missing.length > 0)
71
76
  return {
72
77
  status: 'dynamic',
73
- candidates: signals.operationPath ? rows(db, signals.operationPath, workspaceId) : [],
78
+ candidates: signals.operationPath
79
+ ? rows(db, signals.operationPath, workspaceId).map((candidate) => ({
80
+ ...candidate,
81
+ score: 0.2,
82
+ reasons: ['operation_path_match'],
83
+ }))
84
+ : [],
74
85
  reasons: [...new Set(missing)].map((name) => `missing_variable:${name}`),
75
86
  };
76
87
  if (!signals.operationPath)
@@ -28,8 +28,9 @@ function diagnosticLines(diagnostic: Record<string, unknown>): string[] {
28
28
  }
29
29
 
30
30
  function hintLines(evidence: Record<string, unknown>): string[] {
31
+ const dynamicLines = dynamicHintLines(evidence);
31
32
  const suggestions = evidence.implementationHintSuggestions;
32
- if (!Array.isArray(suggestions)) return [];
33
+ if (!Array.isArray(suggestions)) return dynamicLines;
33
34
  const hints = suggestions.flatMap((item) =>
34
35
  isRecord(item) && typeof item.cli === 'string'
35
36
  ? [item.cli]
@@ -38,9 +39,35 @@ function hintLines(evidence: Record<string, unknown>): string[] {
38
39
  const shown = unique.slice(0, 3).map((hint) => `try ${hint}`);
39
40
  if (unique.length > shown.length)
40
41
  shown.push(`... ${unique.length - shown.length} more hint(s) available in JSON`);
41
- return shown;
42
+ return [...dynamicLines, ...shown];
43
+ }
44
+
45
+ function dynamicHintLines(evidence: Record<string, unknown>): string[] {
46
+ const exploration = isRecord(evidence.dynamicTargetExploration)
47
+ ? evidence.dynamicTargetExploration
48
+ : evidence;
49
+ const count = numberValue(exploration.candidateCount);
50
+ if (count === 0) return [];
51
+ const shown = numberValue(exploration.shownCandidateCount);
52
+ const omitted = numberValue(exploration.omittedCandidateCount);
53
+ const lines = [`candidates: ${shown} shown, ${omitted} omitted`];
54
+ lines.push(...varSetHints(exploration.suggestedVarSets));
55
+ if (omitted > 0 || shown < count)
56
+ lines.push('use --dynamic-mode candidates --max-dynamic-candidates 20 to explore candidate branches');
57
+ return lines;
58
+ }
59
+
60
+ function varSetHints(value: unknown): string[] {
61
+ if (!Array.isArray(value)) return [];
62
+ const hints = value.flatMap((item) =>
63
+ isRecord(item) && typeof item.cli === 'string' ? [`try ${item.cli}`] : []);
64
+ return [...new Set(hints)].slice(0, 3);
42
65
  }
43
66
 
44
67
  function isRecord(value: unknown): value is Record<string, unknown> {
45
68
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
46
69
  }
70
+
71
+ function numberValue(value: unknown): number {
72
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
73
+ }