@saptools/service-flow 0.1.52 → 0.1.53

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +9 -12
  3. package/TECHNICAL-NOTE.md +11 -3
  4. package/dist/{chunk-PTLDSHRC.js → chunk-LFH7C46B.js} +2214 -1044
  5. package/dist/chunk-LFH7C46B.js.map +1 -0
  6. package/dist/cli.js +157 -11
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/package.json +1 -1
  10. package/src/cli/001-doctor-projection.ts +140 -0
  11. package/src/cli/doctor.ts +20 -3
  12. package/src/db/repositories.ts +10 -2
  13. package/src/linker/000-implementation-candidates.ts +641 -0
  14. package/src/linker/001-implementation-evidence-projection.ts +119 -0
  15. package/src/linker/002-call-evidence.ts +226 -0
  16. package/src/linker/cross-repo-linker.ts +27 -453
  17. package/src/linker/dynamic-edge-resolver.ts +35 -0
  18. package/src/linker/external-http-target.ts +24 -3
  19. package/src/linker/helper-package-linker.ts +18 -2
  20. package/src/linker/service-resolver.ts +45 -2
  21. package/src/output/doctor-output.ts +13 -4
  22. package/src/output/table-output.ts +4 -2
  23. package/src/trace/000-dynamic-target-types.ts +12 -0
  24. package/src/trace/001-dynamic-identity.ts +45 -17
  25. package/src/trace/003-dynamic-references.ts +236 -35
  26. package/src/trace/004-dynamic-candidate-sources.ts +155 -0
  27. package/src/trace/005-implementation-selection.ts +187 -0
  28. package/src/trace/006-contextual-projection.ts +30 -0
  29. package/src/trace/007-implementation-start-diagnostic.ts +61 -0
  30. package/src/trace/dynamic-targets.ts +205 -159
  31. package/src/trace/evidence.ts +35 -9
  32. package/src/trace/implementation-hints.ts +148 -8
  33. package/src/trace/selectors.ts +74 -9
  34. package/src/trace/trace-engine.ts +39 -52
  35. package/src/utils/000-bounded-projection.ts +161 -0
  36. package/dist/chunk-PTLDSHRC.js.map +0 -1
@@ -1,12 +1,17 @@
1
1
  import type { Db } from '../db/connection.js';
2
2
  import { applyVariables } from './dynamic-edge-resolver.js';
3
- import { classifyODataPathIntent, normalizeODataOperationInvocationPath, type NormalizedODataOperationPath } from './odata-path-normalizer.js';
3
+ import { classifyODataPathIntent, normalizeODataOperationInvocationPath } from './odata-path-normalizer.js';
4
4
  import { buildRemoteQueryTarget } from './remote-query-target.js';
5
5
  import { resolveOperation } from './service-resolver.js';
6
6
  import { linkHelperPackages } from './helper-package-linker.js';
7
- import { normalizeDecoratorOperationSignal, normalizedOperationName } from './operation-decorator-normalizer.js';
8
7
  import { externalHttpTarget } from './external-http-target.js';
9
- import { implementationHintSuggestions } from '../trace/implementation-hints.js';
8
+ import { linkImplementations as linkCanonicalImplementations } from './000-implementation-candidates.js';
9
+ import {
10
+ ambiguousPathCandidates,
11
+ linkedCallEvidence,
12
+ objectJson,
13
+ objectValue,
14
+ } from './002-call-evidence.js';
10
15
  export interface LinkWorkspaceResult {
11
16
  edgeCount: number;
12
17
  unresolvedCount: number;
@@ -27,7 +32,7 @@ export function linkWorkspace(db: Db, workspaceId: number, vars: Record<string,
27
32
  const generation = nextGraphGeneration(db, workspaceId);
28
33
  db.prepare('DELETE FROM graph_edges WHERE workspace_id=?').run(workspaceId);
29
34
  const deps = linkHelperPackages(db, workspaceId, generation);
30
- const impl = linkImplementations(db, workspaceId, generation);
35
+ const impl = linkCanonicalImplementations(db, workspaceId, generation);
31
36
  const callSummary = linkCalls(db, workspaceId, vars, generation);
32
37
  db.prepare("UPDATE repositories SET graph_generation=?, graph_stale_reason=NULL, graph_stale_at=NULL WHERE workspace_id=?").run(generation, workspaceId);
33
38
  return { ...callSummary, edgeCount: deps.edgeCount + callSummary.edgeCount + impl.edgeCount, dependencyResolvedCount: deps.resolvedCount, dependencyAmbiguousCount: deps.ambiguousCount, implementationResolvedCount: impl.resolvedCount, implementationAmbiguousCount: impl.ambiguousCount, implementationUnresolvedCount: impl.unresolvedCount };
@@ -46,7 +51,7 @@ function linkCalls(db: Db, workspaceId: number, vars: Record<string, string>, ge
46
51
  let ambiguousCount = 0;
47
52
  let dynamicCount = 0;
48
53
  let terminalCount = 0;
49
- const calls = db.prepare(`SELECT c.*,r.name repoName,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`).all(workspaceId) as Array<Record<string, unknown>>;
54
+ const calls = db.prepare(`SELECT c.*,r.name repoName,b.id selectedBindingId,b.alias,b.alias_expr aliasExpr,b.destination_expr destinationExpr,b.service_path_expr servicePathExpr,b.is_dynamic isDynamic,b.placeholders_json placeholdersJson,b.source_file bindingSourceFile,b.source_line bindingSourceLine,b.helper_chain_json helperChainJson,req.service_path requireServicePath,req.destination requireDestination FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id LEFT JOIN service_bindings b ON b.id=c.service_binding_id LEFT JOIN cds_requires req ON req.repo_id=c.repo_id AND req.alias=b.alias WHERE r.workspace_id=?`).all(workspaceId) as Array<Record<string, unknown>>;
50
55
  for (const call of calls) {
51
56
  const result = insertCallEdge(db, workspaceId, call, vars, generation);
52
57
  edgeCount += 1;
@@ -79,7 +84,15 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
79
84
  const isOperationCall = operationLikeRemoteEntity || ((callType === 'remote_action' || callType === 'local_service_call') || (callType === 'remote_query' && Boolean(op)));
80
85
  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
86
  const evidence: Record<string, unknown> = {
82
- ...callEvidence(call, resolution, servicePath, op, destination ? applyVariables(destination, vars) : undefined, normalized, intent),
87
+ ...linkedCallEvidence(
88
+ call,
89
+ resolution,
90
+ servicePath,
91
+ op,
92
+ destination ? applyVariables(destination, vars) : undefined,
93
+ normalized,
94
+ intent,
95
+ ),
83
96
  indexedOperationCandidateCount,
84
97
  parserCallType: callType,
85
98
  entityOperationPrecedence: operationPrecedence(
@@ -91,9 +104,7 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
91
104
  };
92
105
  const pathAnalysis = objectValue(objectJson(call.evidence_json)?.pathAnalysis);
93
106
  if (callType === 'remote_action' && pathAnalysis?.status === 'ambiguous') {
94
- const candidatePaths = Array.isArray(pathAnalysis.candidateRawPaths)
95
- ? pathAnalysis.candidateRawPaths
96
- : [];
107
+ const candidatePaths = ambiguousPathCandidates(pathAnalysis);
97
108
  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
109
  workspaceId,
99
110
  'UNRESOLVED_EDGE',
@@ -101,9 +112,14 @@ function insertCallEdge(db: Db, workspaceId: number, call: Record<string, unknow
101
112
  'call',
102
113
  String(call.id),
103
114
  'operation_candidates',
104
- candidatePaths.join(','),
115
+ candidatePaths.items.join(','),
105
116
  Number(call.confidence ?? 0.5),
106
- JSON.stringify(evidence),
117
+ JSON.stringify({
118
+ ...evidence,
119
+ ambiguousOperationPathCandidateCount: candidatePaths.totalCount,
120
+ shownAmbiguousOperationPathCandidateCount: candidatePaths.shownCount,
121
+ omittedAmbiguousOperationPathCandidateCount: candidatePaths.omittedCount,
122
+ }),
107
123
  0,
108
124
  'Ambiguous operation path candidates require explicit disambiguation',
109
125
  generation,
@@ -202,445 +218,3 @@ function unresolvedOperationReason(resolution: { candidates: unknown[]; status:
202
218
  if (resolution.status === 'ambiguous') return 'Ambiguous operation candidates require a strong service signal';
203
219
  return 'Operation candidates found but resolution could not select a target';
204
220
  }
205
- function parseJson(value: unknown): unknown {
206
- if (!value) return undefined;
207
- try {
208
- return JSON.parse(String(value)) as unknown;
209
- } catch {
210
- return undefined;
211
- }
212
- }
213
- function objectJson(value: unknown): Record<string, unknown> | undefined {
214
- const parsed = parseJson(value);
215
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
216
- }
217
- function callEvidence(call: Record<string, unknown>, resolution: { target?: { repoName?: string; servicePath?: string; operationPath?: string; operationName?: string }; candidates: unknown[]; status: string; reasons: string[] }, servicePath: string | undefined, op: string | undefined, destination: string | undefined, normalized?: NormalizedODataOperationPath, odataPathIntent?: ReturnType<typeof classifyODataPathIntent>): Record<string, unknown> {
218
- const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
219
- const routingPlaceholderKeys = placeholderKeys([servicePath, destination, stringValue(call.aliasExpr), stringValue(call.alias)]);
220
- return { sourceFile: call.source_file, sourceLine: call.source_line, file: call.source_file, line: call.source_line, callId: call.id, repo: call.repoName, serviceAlias: call.alias, serviceAliasExpr: call.aliasExpr, destination, servicePath, operationPath: op, rawOperationPath: normalized?.wasInvocation ? normalized.rawOperationPath : odataPathIntent?.rawPath, normalizedOperationPath: normalized?.wasInvocation ? normalized.normalizedOperationPath : undefined, invocationArguments: normalized?.wasInvocation ? normalized.invocationArguments : undefined, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : undefined, routingPlaceholderKeys: routingPlaceholderKeys.length ? routingPlaceholderKeys : undefined, odataOperationNormalizationReason: normalized?.normalizationReason, odataOperationNormalizationRejectedReason: normalized?.normalizationRejectedReason, localServiceName: call.local_service_name, localServiceLookup: call.local_service_lookup, aliasChain: parseJson(call.alias_chain_json), transport: call.call_type === 'local_service_call' ? 'local' : undefined, targetRepo: resolution.target?.repoName, targetServicePath: resolution.target?.servicePath, targetOperationPath: resolution.target?.operationPath, targetOperation: resolution.target?.operationName, helperChain: parseJson(call.helperChainJson), candidates: resolution.candidates, candidateScores: compactCandidateScores(resolution.candidates), candidateCount: resolution.candidates.length, resolutionStatus: resolution.status, resolutionReasons: resolution.reasons, odataPathIntent, queryStringPresent: odataPathIntent?.hasQueryString || undefined, queryPlaceholderKeys: odataPathIntent?.placeholderKeys.length ? odataPathIntent.placeholderKeys : undefined, bindingHasDynamicExpression: bindingHasDynamicExpression || undefined, outboundEvidence: objectJson(call.evidence_json), analysisCompleteness: call.unresolved_reason ? 'partial' : 'complete', parserWarning: call.unresolved_reason ? { code: 'parser_warning', message: call.unresolved_reason } : undefined };
221
- }
222
-
223
- function compactCandidateScores(candidates: unknown[]): Array<Record<string, unknown>> {
224
- return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
225
- const row = objectValue(candidate);
226
- if (!row) return [];
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
- }];
236
- });
237
- }
238
-
239
- function placeholderKeys(values: Array<string | undefined>): string[] {
240
- const keys = values.flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim()).filter(Boolean));
241
- return [...new Set(keys)].sort();
242
- }
243
-
244
- function objectValue(value: unknown): Record<string, unknown> | undefined {
245
- return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
246
- }
247
-
248
- function stringValue(value: unknown): string | undefined {
249
- return typeof value === 'string' ? value : undefined;
250
- }
251
-
252
- function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
253
- const operations = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,o.provenance provenance,o.base_operation_id baseOperationId,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=?`).all(workspaceId) as Array<Record<string, unknown>>;
254
- let edgeCount = 0;
255
- let resolvedCount = 0;
256
- let ambiguousCount = 0;
257
- let unresolvedCount = 0;
258
- for (const operation of operations) {
259
- const implementationContext = implementationContextForOperation(db, operation);
260
- const candidates = rankedImplementationCandidates(db, workspaceId, implementationContext);
261
- if (candidates.length === 0) continue;
262
- const accepted = candidates.filter((candidate) => candidate.accepted);
263
- const topScore = accepted[0]?.score ?? 0;
264
- const winners = accepted.filter((candidate) => candidate.score === topScore);
265
- const duplicateFamilies = duplicatePackageFamilies(accepted);
266
- const duplicatePackageAmbiguous = duplicateFamilies.length > 0 && !accepted.some(hasDirectOwnershipEvidence);
267
- const selected = duplicatePackageAmbiguous ? accepted : winners;
268
- const unique = !duplicatePackageAmbiguous && winners.length === 1 ? winners[0] : undefined;
269
- const ambiguityReasons = duplicatePackageAmbiguous ? ['duplicate_package_name_candidates'] : winners.length > 1 ? ['multiple_equal_score_implementation_candidates'] : [];
270
- const evidence = {
271
- servicePath: operation.servicePath,
272
- operationPath: operation.operationPath,
273
- operationName: operation.operationName,
274
- modelPackage: { id: operation.modelRepoId, name: operation.modelRepo, packageName: operation.modelPackage },
275
- implementationSource: implementationContext.operationId === operation.operationId ? 'direct_or_concrete_override' : 'inherited_from_base_operation',
276
- baseOperationId: operation.baseOperationId,
277
- implementationOperationId: implementationContext.operationId,
278
- ambiguityReasons,
279
- candidateFamilies: duplicateFamilies,
280
- candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
281
- };
282
- const evidenceWithHints = unique ? evidence : { ...evidence, implementationHintSuggestions: implementationHintSuggestions(evidence) };
283
- if (accepted.length === 0) {
284
- 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(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', 'unresolved', 'operation', graphId(operation.operationId), 'handler_method_candidates', candidates.map((row) => graphId(row.methodId)).join(','), 0, JSON.stringify(evidenceWithHints), 0, 'No implementation candidate passed policy', generation);
285
- edgeCount += 1;
286
- unresolvedCount += 1;
287
- continue;
288
- }
289
- 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(workspaceId, 'OPERATION_IMPLEMENTED_BY_HANDLER', unique ? 'resolved' : 'ambiguous', 'operation', graphId(operation.operationId), unique ? 'handler_method' : 'handler_method_candidates', unique ? graphId(unique.methodId) : selected.map((row) => graphId(row.methodId)).join(','), unique ? 0.95 : 0.5, JSON.stringify(evidenceWithHints), 0, unique ? null : 'Ambiguous registered handler implementation candidates', generation);
290
- edgeCount += 1;
291
- if (unique) resolvedCount += 1;
292
- else ambiguousCount += 1;
293
- }
294
- return { edgeCount, resolvedCount, ambiguousCount, unresolvedCount };
295
- }
296
- interface ImplementationCandidate extends Record<string, unknown> {
297
- methodId: number;
298
- registrations?: Array<Record<string, unknown>>;
299
- score: number;
300
- accepted: boolean;
301
- acceptedReasons: string[];
302
- rejectedReasons: string[];
303
- }
304
- function implementationContextForOperation(db: Db, operation: Record<string, unknown>): Record<string, unknown> {
305
- if (operation.provenance !== 'inherited' || !operation.baseOperationId) return operation;
306
- const base = db.prepare(`SELECT o.id operationId,o.operation_path operationPath,o.operation_name operationName,o.provenance provenance,o.base_operation_id baseOperationId,s.service_path servicePath,s.repo_id modelRepoId,r.name modelRepo,r.package_name modelPackage,r.kind modelKind FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE o.id=?`).get(operation.baseOperationId) as Record<string, unknown> | undefined;
307
- if (!base) return operation;
308
- return { ...base, effectiveOperationId: operation.operationId, effectiveServicePath: operation.servicePath, effectiveOperationPath: operation.operationPath };
309
- }
310
- function rankedImplementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): ImplementationCandidate[] {
311
- const rows = implementationCandidates(db, workspaceId, operation);
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
- );
363
- }
364
-
365
- function deduplicateCandidates(rows: ImplementationCandidate[]): ImplementationCandidate[] {
366
- const merged = new Map<string, ImplementationCandidate>();
367
- for (const row of rows) {
368
- const key = [row.methodId, row.classId, row.handlerRepoId].join(':');
369
- const registration = registrationEvidence(row);
370
- const existing = merged.get(key);
371
- if (!existing) {
372
- merged.set(key, { ...row, registrations: [registration] });
373
- continue;
374
- }
375
- existing.registrations = uniqueRegistrations([...(existing.registrations ?? []), registration]);
376
- existing.score = Math.max(existing.score, row.score);
377
- existing.accepted = existing.accepted || row.accepted;
378
- existing.acceptedReasons = [...new Set([...existing.acceptedReasons, ...row.acceptedReasons])];
379
- existing.rejectedReasons = [...new Set([...existing.rejectedReasons, ...row.rejectedReasons])];
380
- }
381
- return [...merged.values()];
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
- }
396
- function uniqueRegistrations(rows: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
397
- const seen = new Set<string>();
398
- return rows.filter((row) => {
399
- const key = JSON.stringify(row);
400
- if (seen.has(key)) return false;
401
- seen.add(key);
402
- return true;
403
- });
404
- }
405
- function duplicatePackageFamilies(candidates: ImplementationCandidate[]): Array<Record<string, unknown>> {
406
- const byPackage = new Map<string, ImplementationCandidate[]>();
407
- for (const candidate of candidates) {
408
- const packageName = typeof candidate.handlerPackage === 'string' ? candidate.handlerPackage : undefined;
409
- if (!packageName) continue;
410
- byPackage.set(packageName, [...(byPackage.get(packageName) ?? []), candidate]);
411
- }
412
- return [...byPackage.entries()]
413
- .filter(([, rows]) => new Set(rows.map((row) => Number(row.handlerRepoId))).size > 1)
414
- .map(([packageName, rows]) => ({ reason: 'duplicate_package_name_candidates', packageName, count: rows.length, repositories: rows.map((row) => row.handlerRepo).sort() }));
415
- }
416
- function hasDirectOwnershipEvidence(candidate: ImplementationCandidate): boolean {
417
- return candidate.acceptedReasons.some((reason) => reason === 'model package equals registration package' || reason === 'model package equals handler package' || reason === 'registration package contains exact local service path');
418
- }
419
- function implementationCandidates(db: Db, workspaceId: number, operation: Record<string, unknown>): Array<Record<string, unknown>> {
420
- const modelRepoGraphId = graphId(operation.modelRepoId);
421
- return db.prepare(`SELECT DISTINCT
422
- hm.id methodId,
423
- hm.method_name methodName,
424
- hm.decorator_value decoratorValue,
425
- hm.decorator_raw_expression decoratorRawExpression,
426
- hm.decorator_resolution_json decoratorResolutionJson,
427
- hc.id classId,
428
- hc.class_name className,
429
- hc.source_file sourceFile,
430
- hc.source_line sourceLine,
431
- hr.id registrationId,
432
- hr.handler_class_id registrationHandlerClassId,
433
- hr.class_name registrationClassName,
434
- hr.repo_id applicationRepoId,
435
- hr.registration_file registrationFile,
436
- hr.registration_line registrationLine,
437
- hr.registration_kind registrationKind,
438
- hr.import_source importSource,
439
- handlerRepo.id handlerRepoId,
440
- handlerRepo.name handlerRepo,
441
- handlerRepo.package_name handlerPackage,
442
- appRepo.name applicationRepo,
443
- appRepo.package_name applicationPackage,
444
- ? modelRepoId,
445
- ? modelRepo,
446
- ? modelPackage,
447
- ? modelKind,
448
- ? servicePath,
449
- ? operationPath,
450
- ? operationName,
451
- CASE WHEN appRepo.id=? THEN 1 ELSE 0 END modelIsApplicationRepo,
452
- CASE WHEN handlerRepo.id=? THEN 1 ELSE 0 END modelIsHandlerRepo,
453
- CASE WHEN appRepo.id=handlerRepo.id THEN 1 ELSE 0 END sameRepoRegistration,
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,
455
- CASE WHEN EXISTS (SELECT 1 FROM cds_services localService WHERE localService.repo_id=appRepo.id) THEN 1 ELSE 0 END applicationHasLocalServices,
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 COALESCE(json_extract(localMethod.decorator_resolution_json,'$.handlerKind'),CASE WHEN localMethod.decorator_kind='Event' THEN 'event' WHEN localMethod.decorator_kind IN ('Action','Func','On') THEN 'operation' ELSE 'unsupported' END)='operation' AND COALESCE(json_extract(localMethod.decorator_resolution_json,'$.executable'),CASE WHEN localMethod.decorator_kind IN ('Action','Func','On') THEN 1 ELSE 0 END)=1 AND (localMethod.decorator_value=? OR localMethod.decorator_value=? OR localMethod.method_name=? OR localMethod.decorator_raw_expression LIKE ?)) THEN 1 ELSE 0 END applicationHasLocalRegistrationForOperation,
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,
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,
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
460
- FROM handler_methods hm
461
- JOIN handler_classes hc ON hc.id=hm.handler_class_id
462
- JOIN repositories handlerRepo ON handlerRepo.id=hc.repo_id
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
- )
486
- JOIN repositories appRepo ON appRepo.id=hr.repo_id
487
- WHERE appRepo.workspace_id=?
488
- AND COALESCE(json_extract(hm.decorator_resolution_json,'$.handlerKind'),
489
- CASE WHEN hm.decorator_kind='Event' THEN 'event'
490
- WHEN hm.decorator_kind IN ('Action','Func','On') THEN 'operation'
491
- ELSE 'unsupported' END)='operation'
492
- AND COALESCE(json_extract(hm.decorator_resolution_json,'$.executable'),
493
- CASE WHEN hm.decorator_kind IN ('Action','Func','On')
494
- THEN 1 ELSE 0 END)=1
495
- AND (hm.decorator_value=? OR hm.decorator_value=? OR hm.method_name=? OR hm.decorator_raw_expression LIKE ?)`).all(
496
- operation.modelRepoId,
497
- operation.modelRepo,
498
- operation.modelPackage,
499
- operation.modelKind,
500
- operation.servicePath,
501
- operation.operationPath,
502
- operation.operationName,
503
- operation.modelRepoId,
504
- operation.modelRepoId,
505
- operation.servicePath,
506
- normalizedOperation(String(operation.operationPath ?? '')),
507
- operation.operationName,
508
- operation.operationName,
509
- `%${upperFirst(normalizedOperation(String(operation.operationPath ?? operation.operationName ?? '')))}%`,
510
- modelRepoGraphId,
511
- modelRepoGraphId,
512
- workspaceId,
513
- normalizedOperation(String(operation.operationPath ?? '')),
514
- operation.operationName,
515
- operation.operationName,
516
- `%${upperFirst(normalizedOperation(String(operation.operationPath ?? operation.operationName ?? '')))}%`,
517
- ) as Array<Record<string, unknown>>;
518
- }
519
- function scoreImplementationCandidate(row: Record<string, unknown>, operation: Record<string, unknown>): ImplementationCandidate {
520
- const acceptedReasons: string[] = [];
521
- const rejectedReasons: string[] = [];
522
- let score = 0;
523
- const modelIsApplicationRepo = flag(row.modelIsApplicationRepo);
524
- const modelIsHandlerRepo = flag(row.modelIsHandlerRepo);
525
- const localServicePathMatch = flag(row.localServicePathMatch);
526
- const applicationHasLocalServices = flag(row.applicationHasLocalServices);
527
- const appDependsOnModel = flag(row.appDependsOnModel);
528
- const applicationHasLocalRegistrationForOperation = flag(row.applicationHasLocalRegistrationForOperation);
529
- const appDependsOnHandler = flag(row.appDependsOnHandler);
530
- const handlerDependsOnModel = flag(row.handlerDependsOnModel);
531
- const importSource = typeof row.importSource === 'string' && row.importSource.length > 0;
532
- const sameRepoRegistration = flag(row.sameRepoRegistration);
533
- const modelOriented = row.modelKind === 'cap-db-model' || !applicationHasLocalRegistrationForOperation;
534
- const methodSignal = implementationMethodSignal(row, operation);
535
- const methodMatches = methodSignal.matches;
536
- acceptedReasons.push(...methodSignal.acceptedReasons);
537
- rejectedReasons.push(...methodSignal.rejectedReasons);
538
- const registeredAndLinked = sameRepoRegistration && importSource;
539
- const helperOwned = modelOriented && methodMatches && registeredAndLinked && sameRepoRegistration && !applicationHasLocalServices && !modelIsApplicationRepo && !modelIsHandlerRepo && !localServicePathMatch && !appDependsOnModel && !appDependsOnHandler && !handlerDependsOnModel;
540
- if (modelIsApplicationRepo) {
541
- score += 100;
542
- acceptedReasons.push('model package equals registration package');
543
- }
544
- if (modelIsHandlerRepo) {
545
- score += 100;
546
- acceptedReasons.push('model package equals handler package');
547
- }
548
- if (localServicePathMatch) {
549
- score += 80;
550
- acceptedReasons.push('registration package contains exact local service path');
551
- } else if (applicationHasLocalServices && !appDependsOnModel && !modelIsApplicationRepo) {
552
- rejectedReasons.push(`registration package has local services but none match ${String(operation.servicePath ?? '')}`);
553
- }
554
- if (appDependsOnModel) {
555
- score += 70;
556
- acceptedReasons.push('registration package depends on model package');
557
- }
558
- if (appDependsOnHandler) {
559
- score += 30;
560
- acceptedReasons.push('registration package depends on handler package');
561
- }
562
- if (handlerDependsOnModel) {
563
- score += 20;
564
- acceptedReasons.push('handler package depends on model package');
565
- }
566
- if (helperOwned) {
567
- score += 60;
568
- acceptedReasons.push('unique registered helper implementation for model-only operation');
569
- }
570
- if (importSource) {
571
- score += 10;
572
- acceptedReasons.push('registration imports handler class');
573
- }
574
- const hasOwnership = modelIsApplicationRepo || modelIsHandlerRepo;
575
- const hasCrossPackage = appDependsOnModel && (modelIsHandlerRepo || appDependsOnHandler || !importSource);
576
- const contradicted = applicationHasLocalServices && !localServicePathMatch && !appDependsOnModel && !hasOwnership;
577
- if (!hasOwnership && !localServicePathMatch && !hasCrossPackage && !helperOwned) rejectedReasons.push('missing direct ownership, exact local service path, or validated cross-package dependency evidence');
578
- const accepted = methodMatches && !methodSignal.contradicted && !contradicted && (hasOwnership || localServicePathMatch || hasCrossPackage || handlerDependsOnModel || helperOwned);
579
- if (!accepted && rejectedReasons.length === 0) rejectedReasons.push('candidate did not meet implementation ownership policy');
580
- return { ...row, methodId: Number(row.methodId), score, accepted, acceptedReasons, rejectedReasons };
581
- }
582
- function candidateEvidence(candidate: ImplementationCandidate, rank: number): Record<string, unknown> {
583
- return {
584
- rank,
585
- score: candidate.score,
586
- accepted: candidate.accepted,
587
- acceptedReasons: candidate.acceptedReasons,
588
- rejectedReasons: candidate.rejectedReasons,
589
- methodId: candidate.methodId,
590
- classId: candidate.classId,
591
- className: candidate.className,
592
- sourceFile: candidate.sourceFile,
593
- sourceLine: candidate.sourceLine,
594
- decoratorResolution: objectJson(candidate.decoratorResolutionJson),
595
- registration: registrationEvidence(candidate),
596
- registrations: candidate.registrations ?? [],
597
- registrationPairing: {
598
- strategy: registrationPairingStrategy(candidate),
599
- registrationId: candidate.registrationId,
600
- registrationHandlerClassId: candidate.registrationHandlerClassId,
601
- candidateHandlerClassId: candidate.classId,
602
- invariantStatus: validRegistrationPair(candidate) ? 'valid' : 'invalid',
603
- },
604
- applicationPackage: { id: candidate.applicationRepoId, name: candidate.applicationRepo, packageName: candidate.applicationPackage },
605
- handlerPackage: { id: candidate.handlerRepoId, name: candidate.handlerRepo, packageName: candidate.handlerPackage },
606
- modelPackage: { id: candidate.modelRepoId, name: candidate.modelRepo, packageName: candidate.modelPackage },
607
- servicePath: candidate.servicePath,
608
- operationPath: candidate.operationPath,
609
- operationName: candidate.operationName,
610
- signals: {
611
- directOwnership: { modelIsApplicationRepo: flag(candidate.modelIsApplicationRepo), modelIsHandlerRepo: flag(candidate.modelIsHandlerRepo) },
612
- localServicePathMatch: flag(candidate.localServicePathMatch),
613
- applicationHasLocalServices: flag(candidate.applicationHasLocalServices),
614
- applicationHasLocalRegistrationForOperation: flag(candidate.applicationHasLocalRegistrationForOperation),
615
- appDependsOnModel: flag(candidate.appDependsOnModel),
616
- appDependsOnHandler: flag(candidate.appDependsOnHandler),
617
- handlerDependsOnModel: flag(candidate.handlerDependsOnModel),
618
- sameRepoRegistration: flag(candidate.sameRepoRegistration),
619
- },
620
- };
621
- }
622
- function implementationMethodSignal(row: Record<string, unknown>, operation: Record<string, unknown>): { matches: boolean; contradicted: boolean; acceptedReasons: string[]; rejectedReasons: string[] } {
623
- const resolution = objectJson(row.decoratorResolutionJson) ?? {};
624
- if (resolution.handlerKind && resolution.handlerKind !== 'operation')
625
- return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['non_operation_handler_kind'] };
626
- if (resolution.executable === false)
627
- return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['handler_method_not_executable'] };
628
- const op = normalizedOperationName(String(operation.operationPath ?? operation.operationName ?? ''));
629
- const decorator = normalizeDecoratorOperationSignal(typeof row.decoratorValue === 'string' ? row.decoratorValue : undefined, typeof row.decoratorRawExpression === 'string' ? row.decoratorRawExpression : undefined, op);
630
- if (decorator.status === 'resolved' && decorator.operationName === op) return { matches: true, contradicted: false, acceptedReasons: ['decorator targets operation'], rejectedReasons: [] };
631
- if (decorator.status === 'resolved' && decorator.operationName !== op) return { matches: false, contradicted: true, acceptedReasons: [], rejectedReasons: ['method_name_matches_but_decorator_targets_different_operation'] };
632
- if (String(row.methodName ?? '') === op) return { matches: true, contradicted: false, acceptedReasons: ['method name fallback matched operation'], rejectedReasons: [] };
633
- return { matches: false, contradicted: false, acceptedReasons: [], rejectedReasons: ['method name does not match operation'] };
634
- }
635
- function upperFirst(value: string): string {
636
- return value ? `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}` : value;
637
- }
638
- function flag(value: unknown): boolean {
639
- return Boolean(Number(value ?? 0));
640
- }
641
- function graphId(value: unknown): string {
642
- return String(value);
643
- }
644
- function normalizedOperation(value: string): string {
645
- return value.startsWith('/') ? value.slice(1) : value;
646
- }
@@ -22,6 +22,26 @@ export function extractPlaceholders(template: string | undefined): string[] {
22
22
  .filter(Boolean);
23
23
  }
24
24
 
25
+ export function matchRuntimeTemplate(
26
+ template: string | undefined,
27
+ concrete: string | undefined,
28
+ ): Record<string, string> | undefined {
29
+ if (!template || !concrete) return undefined;
30
+ const keys = extractPlaceholders(template);
31
+ if (keys.length === 0) return template === concrete ? {} : undefined;
32
+ const match = new RegExp(`^${runtimeTemplatePattern(template)}$`).exec(concrete);
33
+ if (!match) return undefined;
34
+ const values: Record<string, string> = {};
35
+ for (let index = 0; index < keys.length; index += 1) {
36
+ const key = keys[index];
37
+ const value = match[index + 1];
38
+ if (!key || value === undefined) return undefined;
39
+ if (values[key] !== undefined && values[key] !== value) return undefined;
40
+ values[key] = value;
41
+ }
42
+ return values;
43
+ }
44
+
25
45
  export function substituteVariables(
26
46
  template: string | undefined,
27
47
  vars: Record<string, string>,
@@ -43,3 +63,18 @@ export function substituteVariables(
43
63
  changed: effective !== template,
44
64
  };
45
65
  }
66
+
67
+ function runtimeTemplatePattern(template: string): string {
68
+ let pattern = '';
69
+ let lastIndex = 0;
70
+ for (const match of template.matchAll(PLACEHOLDER)) {
71
+ pattern += escapeRegex(template.slice(lastIndex, match.index));
72
+ pattern += '([^/]+?)';
73
+ lastIndex = (match.index ?? 0) + match[0].length;
74
+ }
75
+ return `${pattern}${escapeRegex(template.slice(lastIndex))}`;
76
+ }
77
+
78
+ function escapeRegex(value: string): string {
79
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
80
+ }
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { projectBounded } from '../utils/000-bounded-projection.js';
2
3
 
3
4
  export type ExternalTargetKind = 'destination' | 'static_url' | 'url_expression' | 'unknown';
4
- export interface ExternalHttpTarget { kind: ExternalTargetKind; toKind: 'external_destination' | 'external_endpoint'; toId: string; label: string; method?: string; dynamic: boolean; expression?: string; }
5
+ export interface ExternalHttpTarget { kind: ExternalTargetKind; toKind: 'external_destination' | 'external_endpoint'; toId: string; label: string; method?: string; dynamic: boolean; expression?: string; candidateLiteralCount?: number; shownCandidateLiteralCount?: number; omittedCandidateLiteralCount?: number; }
5
6
  const sensitiveKeys = new Set(['token','access_token','id_token','api_key','apikey','key','password','passwd','pwd','secret','client_secret','authorization','cookie','signature']);
6
7
  function hash(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 12); }
7
8
  function methodPrefix(method: unknown): string { return typeof method === 'string' && method.length > 0 ? `${method.toUpperCase()} ` : ''; }
@@ -24,8 +25,22 @@ export function externalHttpTarget(call: Record<string, unknown>): ExternalHttpT
24
25
  const expression = typeof target.expression === 'string' ? target.expression : undefined;
25
26
  if (kind === 'destination' && target.dynamic === true) {
26
27
  const shape = typeof target.expressionShape === 'string' ? target.expressionShape : 'expression';
27
- const candidates = Array.isArray(target.candidateLiterals) ? target.candidateLiterals.filter((item): item is string => typeof item === 'string') : [];
28
- return { kind, toKind: 'external_destination', toId: `destination:dynamic:${hash(`${shape}:${candidates.join('|')}`)}`, label: 'External destination: dynamic destination', method, dynamic: true, expression: candidates.length ? `candidates:${candidates.join('|')}` : `shape:${shape}` };
28
+ const candidates = staticDestinationCandidates(target.candidateLiterals);
29
+ const projection = projectBounded(candidates, (left, right) => left.localeCompare(right));
30
+ return {
31
+ kind,
32
+ toKind: 'external_destination',
33
+ toId: `destination:dynamic:${hash(`${shape}:${candidates.join('|')}`)}`,
34
+ label: 'External destination: dynamic destination',
35
+ method,
36
+ dynamic: true,
37
+ expression: projection.items.length
38
+ ? `candidates:${projection.items.join('|')}`
39
+ : `shape:${shape}`,
40
+ candidateLiteralCount: projection.totalCount,
41
+ shownCandidateLiteralCount: projection.shownCount,
42
+ omittedCandidateLiteralCount: projection.omittedCount,
43
+ };
29
44
  }
30
45
  if (kind === 'destination' && expression) return { kind, toKind: 'external_destination', toId: `destination:${expression}`, label: `External destination: ${expression}`, method, dynamic: false, expression };
31
46
  if (kind === 'static_url' && expression) {
@@ -35,4 +50,10 @@ export function externalHttpTarget(call: Record<string, unknown>): ExternalHttpT
35
50
  if (kind === 'url_expression' && expression) return { kind, toKind: 'external_endpoint', toId: `dynamic:${hash(expression)}`, label: `External endpoint: ${methodPrefix(method)}dynamic URL`, method, dynamic: true, expression: `expr:${hash(expression)}` };
36
51
  return { kind: 'unknown', toKind: 'external_endpoint', toId: 'unknown', label: 'External endpoint: unknown', method, dynamic: false };
37
52
  }
53
+ function staticDestinationCandidates(value: unknown): string[] {
54
+ const candidates = Array.isArray(value)
55
+ ? value.filter((item): item is string => typeof item === 'string')
56
+ : [];
57
+ return [...new Set(candidates)].sort();
58
+ }
38
59
  function safeParse(value: string): Record<string, unknown> { try { const parsed = JSON.parse(value) as unknown; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}; } catch { return {}; } }