@saptools/service-flow 0.1.37 → 0.1.39

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 (49) hide show
  1. package/dist/{chunk-WE3A6TOJ.js → chunk-SAZ5OK7R.js} +50 -9
  2. package/dist/chunk-SAZ5OK7R.js.map +1 -0
  3. package/dist/cli.js +4 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +3 -2
  7. package/src/cli.ts +865 -0
  8. package/src/config/defaults.ts +14 -0
  9. package/src/config/workspace-config.ts +61 -0
  10. package/src/db/connection.ts +131 -0
  11. package/src/db/migrations.ts +81 -0
  12. package/src/db/repositories.ts +353 -0
  13. package/src/db/schema.ts +24 -0
  14. package/src/discovery/classify-repository.ts +50 -0
  15. package/src/discovery/discover-repositories.ts +58 -0
  16. package/src/index.ts +13 -0
  17. package/src/indexer/incremental-index.ts +25 -0
  18. package/src/indexer/repository-indexer.ts +137 -0
  19. package/src/indexer/workspace-indexer.ts +29 -0
  20. package/src/linker/cross-repo-linker.ts +406 -0
  21. package/src/linker/dynamic-edge-resolver.ts +45 -0
  22. package/src/linker/external-http-target.ts +38 -0
  23. package/src/linker/helper-package-linker.ts +57 -0
  24. package/src/linker/odata-path-normalizer.ts +236 -0
  25. package/src/linker/operation-decorator-normalizer.ts +47 -0
  26. package/src/linker/remote-query-target.ts +39 -0
  27. package/src/linker/service-resolver.ts +223 -0
  28. package/src/output/json-output.ts +7 -0
  29. package/src/output/mermaid-output.ts +16 -0
  30. package/src/output/table-output.ts +21 -0
  31. package/src/parsers/cds-parser.ts +147 -0
  32. package/src/parsers/decorator-parser.ts +76 -0
  33. package/src/parsers/generated-constants-parser.ts +23 -0
  34. package/src/parsers/handler-registration-parser.ts +177 -0
  35. package/src/parsers/outbound-call-parser.ts +441 -0
  36. package/src/parsers/package-json-parser.ts +63 -0
  37. package/src/parsers/service-binding-parser.ts +826 -0
  38. package/src/parsers/symbol-parser.ts +328 -0
  39. package/src/parsers/ts-project.ts +13 -0
  40. package/src/trace/selectors.ts +20 -0
  41. package/src/trace/trace-engine.ts +647 -0
  42. package/src/trace/traversal.ts +4 -0
  43. package/src/types.ts +186 -0
  44. package/src/utils/diagnostics.ts +10 -0
  45. package/src/utils/hashing.ts +10 -0
  46. package/src/utils/path-utils.ts +13 -0
  47. package/src/utils/redaction.ts +20 -0
  48. package/src/version.ts +4 -0
  49. package/dist/chunk-WE3A6TOJ.js.map +0 -1
@@ -0,0 +1,236 @@
1
+ export interface NormalizedODataOperationPath {
2
+ rawOperationPath: string;
3
+ normalizedOperationPath: string;
4
+ wasInvocation: boolean;
5
+ invocationArgumentPlaceholderKeys: string[];
6
+ normalizationReason?: string;
7
+ normalizationRejectedReason?: string;
8
+ }
9
+
10
+ export type ODataPathIntentKind = 'operation_invocation' | 'entity_query' | 'entity_key_read' | 'entity_navigation_query' | 'entity_mutation' | 'entity_delete' | 'entity_media' | 'entity_candidate' | 'unknown';
11
+
12
+ export interface ODataPathIntent {
13
+ kind: ODataPathIntentKind;
14
+ rawPath: string;
15
+ method: string;
16
+ pathWithoutQuery: string;
17
+ queryString?: string;
18
+ hasQueryString: boolean;
19
+ entitySegment?: string;
20
+ placeholderKeys: string[];
21
+ keyPredicatePlaceholderKeys: string[];
22
+ invocationArgumentPlaceholderKeys: string[];
23
+ navigationSuffix?: string;
24
+ mediaOrPropertySuffix?: string;
25
+ hasEntityKeyPredicate: boolean;
26
+ hasNavigationSuffix: boolean;
27
+ hasMediaOrPropertySuffix: boolean;
28
+ topLevelOperationName?: string;
29
+ topLevelOperationNameCandidate: boolean;
30
+ topLevelOperationInvocation: boolean;
31
+ reason: string;
32
+ }
33
+
34
+ export function normalizeODataOperationInvocationPath(path: string | undefined): NormalizedODataOperationPath | undefined {
35
+ if (path === undefined) return undefined;
36
+ const raw = path.trim();
37
+ if (!raw) return undefined;
38
+ const rejected = (reason: string): NormalizedODataOperationPath => ({ rawOperationPath: raw, normalizedOperationPath: raw, wasInvocation: false, invocationArgumentPlaceholderKeys: [], normalizationRejectedReason: reason });
39
+ const open = raw.indexOf('(');
40
+ if (open < 0) return rejected('no_top_level_parenthesis');
41
+ const query = topLevelQueryIndex(raw);
42
+ if (query >= 0) return rejected('query_string_paths_are_not_operation_invocations');
43
+ if (!raw.startsWith('/')) return rejected('path_is_not_absolute');
44
+ if (raw.slice(1, open).includes('/')) return rejected('operation_segment_contains_navigation_separator');
45
+ const close = matchingClose(raw, open);
46
+ if (close === undefined) return rejected('top_level_invocation_parenthesis_is_unbalanced');
47
+ if (raw.slice(close + 1).trim().length > 0) return rejected('top_level_invocation_does_not_cover_remaining_path');
48
+ const operationSegment = raw.slice(0, open).trim();
49
+ if (operationSegment.length <= 1) return rejected('operation_segment_is_empty');
50
+ return {
51
+ rawOperationPath: raw,
52
+ normalizedOperationPath: operationSegment,
53
+ wasInvocation: true,
54
+ invocationArgumentPlaceholderKeys: [...new Set(extractTemplatePlaceholders(raw.slice(open + 1, close)))],
55
+ normalizationReason: 'balanced_top_level_operation_invocation',
56
+ };
57
+ }
58
+
59
+ export function classifyODataPathIntent(path: string | undefined, method: string | undefined): ODataPathIntent {
60
+ const rawPath = (path ?? '').trim();
61
+ const normalizedMethod = (method ?? 'GET').trim().toUpperCase() || 'GET';
62
+ const queryIndex = rawPath.indexOf('?');
63
+ const pathWithoutQuery = queryIndex >= 0 ? rawPath.slice(0, queryIndex) : rawPath;
64
+ const queryString = queryIndex >= 0 ? rawPath.slice(queryIndex + 1) : undefined;
65
+ const segments = pathWithoutQuery.replace(/^\//, '').split('/').filter(Boolean);
66
+ const firstSegment = segments[0] ?? '';
67
+ const hasNavigationSegments = segments.length > 1;
68
+ const entitySegment = entitySegmentFromPath(pathWithoutQuery);
69
+ const placeholderKeys = [...new Set(extractTemplatePlaceholders(rawPath))];
70
+ const firstOpen = firstSegment.indexOf('(');
71
+ const firstClose = firstOpen >= 0 ? matchingClose(firstSegment, firstOpen) : undefined;
72
+ const keyPredicateText = firstOpen >= 0 && firstClose !== undefined ? firstSegment.slice(firstOpen + 1, firstClose) : '';
73
+ const rawKeyPredicatePlaceholderKeys = [...new Set(extractTemplatePlaceholders(keyPredicateText))];
74
+ const navigationSuffix = hasNavigationSegments ? segments.slice(1).join('/') : undefined;
75
+ const lastSegment = segments.at(-1) ?? '';
76
+ const mediaOrPropertySuffix = hasNavigationSegments ? lastSegment : undefined;
77
+ const invocation = normalizeODataOperationInvocationPath(pathWithoutQuery);
78
+ const operationNameFromInvocation = invocation?.wasInvocation ? invocation.normalizedOperationPath.replace(/^\//, '').split('.').at(-1) : undefined;
79
+ const topLevelName = firstSegment.split('(')[0]?.replace(/^\//, '');
80
+ const topLevelOperationName = operationNameFromInvocation ?? (!hasNavigationSegments && !firstSegment.includes('(') ? topLevelName : undefined);
81
+ const topLevelOperationInvocation = Boolean(invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment));
82
+ const keyPredicatePlaceholderKeys = topLevelOperationInvocation ? [] : rawKeyPredicatePlaceholderKeys;
83
+ const topLevelOperationNameCandidate = Boolean(topLevelOperationName && !hasNavigationSegments && !firstSegment.includes('('));
84
+ const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
85
+ if (!rawPath || !rawPath.startsWith('/')) return { ...base, kind: 'unknown', reason: 'path_missing_or_not_absolute' };
86
+ const upperEntityLike = /^[A-Z][A-Za-z0-9_]*$/.test(entitySegment ?? firstSegment);
87
+ const mediaLike = isMediaOrPropertySuffix(segments.at(-1) ?? '');
88
+ if (normalizedMethod !== 'GET') {
89
+ if (invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: 'operation_invocation', reason: 'non_get_balanced_top_level_operation_invocation' };
90
+ if (mediaLike) return { ...base, kind: 'entity_media', reason: 'non_get_entity_media_stream_path' };
91
+ if (hasNavigationSegments || firstSegment.includes('(')) return { ...base, kind: normalizedMethod === 'DELETE' ? 'entity_delete' : 'entity_mutation', reason: firstSegment.includes('(') ? 'non_get_entity_key_or_navigation_path_shape' : 'non_get_entity_navigation_path_shape' };
92
+ if (upperEntityLike) return { ...base, kind: normalizedMethod === 'DELETE' ? 'entity_delete' : 'entity_mutation', reason: 'non_get_entity_path_shape' };
93
+ return { ...base, kind: 'operation_invocation', reason: 'non_get_lowercase_path_may_be_operation' };
94
+ }
95
+ if (queryIndex >= 0) {
96
+ if (hasNavigationSegments) return { ...base, kind: 'entity_navigation_query', reason: 'get_path_has_navigation_and_query_string' };
97
+ if (looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: 'unknown', reason: 'get_invocation_with_query_string_requires_indexed_operation_evidence' };
98
+ return { ...base, kind: 'entity_query', reason: 'get_collection_path_has_query_string' };
99
+ }
100
+ if (hasNavigationSegments) return mediaLike ? { ...base, kind: 'entity_media', reason: 'get_entity_media_stream_path' } : { ...base, kind: 'entity_navigation_query', reason: 'get_path_has_navigation_segments' };
101
+ if (firstSegment.includes('(')) {
102
+ if (invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment)) return { ...base, kind: 'operation_invocation', reason: 'get_balanced_top_level_operation_invocation' };
103
+ return looksLikeLowerCamelInvocation(firstSegment)
104
+ ? { ...base, kind: 'operation_invocation', reason: 'get_single_lower_camel_segment_has_top_level_invocation' }
105
+ : { ...base, kind: 'entity_key_read', reason: 'get_entity_segment_has_key_predicate' };
106
+ }
107
+ if (/^[A-Z][A-Za-z0-9_]*$/.test(firstSegment)) return { ...base, kind: 'entity_candidate', reason: 'uppercase_collection_segment_without_indexed_entity_evidence' };
108
+ return { ...base, kind: 'unknown', reason: 'get_path_has_no_query_key_or_navigation_signal' };
109
+ }
110
+
111
+ function entitySegmentFromPath(path: string): string | undefined {
112
+ const first = path.replace(/^\//, '').split('/')[0]?.trim();
113
+ if (!first) return undefined;
114
+ const open = first.indexOf('(');
115
+ const entity = (open >= 0 ? first.slice(0, open) : first).trim();
116
+ return entity || undefined;
117
+ }
118
+
119
+ function isMediaOrPropertySuffix(segment: string): boolean {
120
+ return ['file', 'content', '$value', 'metadata', 'items'].includes(segment.toLowerCase());
121
+ }
122
+
123
+ function looksLikeLowerCamelInvocation(segment: string): boolean {
124
+ const open = segment.indexOf('(');
125
+ if (open <= 0) return false;
126
+ const name = segment.slice(0, open).split('.').at(-1) ?? segment.slice(0, open);
127
+ return /^[a-z][A-Za-z0-9_]*$/.test(name);
128
+ }
129
+
130
+ function extractTemplatePlaceholders(text: string): string[] {
131
+ const keys: string[] = [];
132
+ for (let index = 0; index < text.length - 1; index += 1) {
133
+ if (text[index] !== '$' || text[index + 1] !== '{') continue;
134
+ const close = matchingPlaceholderClose(text, index + 1);
135
+ if (close === undefined) continue;
136
+ const key = text.slice(index + 2, close).trim();
137
+ if (key) keys.push(key);
138
+ index = close;
139
+ }
140
+ return keys;
141
+ }
142
+
143
+ function matchingClose(text: string, openIndex: number): number | undefined {
144
+ let depth = 0;
145
+ let quote: 'single' | 'double' | 'template' | undefined;
146
+ for (let index = openIndex; index < text.length; index += 1) {
147
+ const char = text[index];
148
+ const prev = text[index - 1];
149
+ if (quote) {
150
+ if (prev === '\\') continue;
151
+ if (quote === 'template' && char === '$' && text[index + 1] === '{') {
152
+ const close = matchingPlaceholderClose(text, index + 1);
153
+ if (close === undefined) return undefined;
154
+ index = close;
155
+ continue;
156
+ }
157
+ if ((quote === 'single' && char === "'") || (quote === 'double' && char === '"') || (quote === 'template' && char === '`')) quote = undefined;
158
+ continue;
159
+ }
160
+ if (char === '$' && text[index + 1] === '{') {
161
+ const close = matchingPlaceholderClose(text, index + 1);
162
+ if (close === undefined) return undefined;
163
+ index = close;
164
+ continue;
165
+ }
166
+ if (char === "'") { quote = 'single'; continue; }
167
+ if (char === '"') { quote = 'double'; continue; }
168
+ if (char === '`') { quote = 'template'; continue; }
169
+ if (char === '(') depth += 1;
170
+ if (char === ')') {
171
+ depth -= 1;
172
+ if (depth === 0) return index;
173
+ if (depth < 0) return undefined;
174
+ }
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ function matchingPlaceholderClose(text: string, openBraceIndex: number): number | undefined {
180
+ let depth = 0;
181
+ let quote: 'single' | 'double' | 'template' | undefined;
182
+ for (let index = openBraceIndex; index < text.length; index += 1) {
183
+ const char = text[index];
184
+ const prev = text[index - 1];
185
+ if (quote) {
186
+ if (prev === '\\') continue;
187
+ if (quote === 'template' && char === '$' && text[index + 1] === '{') {
188
+ depth += 1;
189
+ index += 1;
190
+ continue;
191
+ }
192
+ if ((quote === 'single' && char === "'") || (quote === 'double' && char === '"') || (quote === 'template' && char === '`')) quote = undefined;
193
+ continue;
194
+ }
195
+ if (char === "'") { quote = 'single'; continue; }
196
+ if (char === '"') { quote = 'double'; continue; }
197
+ if (char === '`') { quote = 'template'; continue; }
198
+ if (char === '{') depth += 1;
199
+ if (char === '}') {
200
+ depth -= 1;
201
+ if (depth === 0) return index;
202
+ if (depth < 0) return undefined;
203
+ }
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ function topLevelQueryIndex(text: string): number {
209
+ let quote: 'single' | 'double' | 'template' | undefined;
210
+ for (let index = 0; index < text.length; index += 1) {
211
+ const char = text[index];
212
+ const prev = text[index - 1];
213
+ if (quote) {
214
+ if (prev === '\\') continue;
215
+ if (quote === 'template' && char === '$' && text[index + 1] === '{') {
216
+ const close = matchingPlaceholderClose(text, index + 1);
217
+ if (close === undefined) return -1;
218
+ index = close;
219
+ continue;
220
+ }
221
+ if ((quote === 'single' && char === "'") || (quote === 'double' && char === '"') || (quote === 'template' && char === '`')) quote = undefined;
222
+ continue;
223
+ }
224
+ if (char === '$' && text[index + 1] === '{') {
225
+ const close = matchingPlaceholderClose(text, index + 1);
226
+ if (close === undefined) return -1;
227
+ index = close;
228
+ continue;
229
+ }
230
+ if (char === "'") { quote = 'single'; continue; }
231
+ if (char === '"') { quote = 'double'; continue; }
232
+ if (char === '`') { quote = 'template'; continue; }
233
+ if (char === '?') return index;
234
+ }
235
+ return -1;
236
+ }
@@ -0,0 +1,47 @@
1
+ function lowerFirst(value: string): string {
2
+ return value ? `${value[0]?.toLowerCase() ?? ''}${value.slice(1)}` : value;
3
+ }
4
+ export type DecoratorOperationSignal =
5
+ | { status: 'resolved'; operationName: string; raw?: string }
6
+ | { status: 'none'; raw?: string }
7
+ | { status: 'unsupported'; raw: string; reason: string }
8
+ | { status: 'malformed'; raw: string; reason: string };
9
+ export function normalizedOperationName(value: string): string {
10
+ return value.replace(/^\//, '');
11
+ }
12
+ function clean(value: string): string {
13
+ return value.replace(/^[`'"]|[`'"]$/g, '');
14
+ }
15
+ function generatedFromConstantName(value: string): string | undefined {
16
+ for (const prefix of ['Action', 'Func']) {
17
+ if (value.startsWith(prefix) && value.length > prefix.length && /^[A-Z]/.test(value.slice(prefix.length))) return lowerFirst(value.slice(prefix.length));
18
+ }
19
+ return undefined;
20
+ }
21
+ function resolved(value: string, raw?: string): DecoratorOperationSignal {
22
+ const literal = clean(value);
23
+ const generated = generatedFromConstantName(literal);
24
+ return { status: 'resolved', operationName: generated ?? normalizedOperationName(literal), raw };
25
+ }
26
+ export function normalizeDecoratorOperationSignal(value: string | undefined, raw: string | undefined, candidateOperation?: string): DecoratorOperationSignal {
27
+ if (value) return resolved(value, raw);
28
+ if (!raw || raw.trim().length === 0) return { status: 'none', raw };
29
+ const expression = raw.trim();
30
+ const nameMatch = /(?:^|\.)(Action[A-Z][\w$]*|Func[A-Z][\w$]*)\.name$/.exec(expression);
31
+ if (nameMatch?.[1]) return resolved(nameMatch[1], expression);
32
+ const stringMatch = /^String\(([A-Za-z_$][\w$]*)\)$/.exec(expression);
33
+ if (stringMatch?.[1]) {
34
+ const identifier = stringMatch[1];
35
+ const generated = generatedFromConstantName(identifier);
36
+ const normalizedCandidate = candidateOperation ? normalizedOperationName(candidateOperation) : undefined;
37
+ if (generated) return { status: 'resolved', operationName: generated, raw: expression };
38
+ if (normalizedCandidate && identifier === normalizedCandidate) return { status: 'resolved', operationName: identifier, raw: expression };
39
+ return { status: 'unsupported', raw: expression, reason: 'string_wrapper_identifier_not_resolved' };
40
+ }
41
+ if (/^[`'"]/.test(expression) && !/[`'"]$/.test(expression)) return { status: 'malformed', raw: expression, reason: 'unterminated_literal' };
42
+ return { status: 'unsupported', raw: expression, reason: 'unsupported_decorator_expression' };
43
+ }
44
+ export function normalizeDecoratorOperation(value: string | undefined, raw: string | undefined): string | undefined {
45
+ const signal = normalizeDecoratorOperationSignal(value, raw);
46
+ return signal.status === 'resolved' ? signal.operationName : undefined;
47
+ }
@@ -0,0 +1,39 @@
1
+ export interface RemoteQueryTargetInput {
2
+ queryEntity?: unknown;
3
+ servicePath?: string;
4
+ serviceAlias?: unknown;
5
+ serviceAliasExpr?: unknown;
6
+ destination?: string;
7
+ isDynamic?: boolean;
8
+ parserWarning?: unknown;
9
+ }
10
+
11
+ export interface RemoteQueryTarget {
12
+ toKind: 'remote_entity' | 'remote_query';
13
+ toId: string;
14
+ label: string;
15
+ evidence: Record<string, unknown>;
16
+ }
17
+
18
+ export function buildRemoteQueryTarget(input: RemoteQueryTargetInput): RemoteQueryTarget {
19
+ const entity = typeof input.queryEntity === 'string' && input.queryEntity.trim() ? input.queryEntity.trim() : undefined;
20
+ const servicePath = input.servicePath?.trim();
21
+ const prefix = servicePath ? `${servicePath}:` : '';
22
+ const label = entity ? `Remote entity: ${prefix}${entity}` : 'Remote query: unknown';
23
+ return {
24
+ toKind: entity ? 'remote_entity' : 'remote_query',
25
+ toId: entity ? `${prefix}${entity}` : 'unknown',
26
+ label,
27
+ evidence: {
28
+ remoteQueryTarget: label,
29
+ queryEntity: entity,
30
+ queryTargetKind: entity ? 'remote_entity' : 'remote_query_unknown',
31
+ queryEntityDynamic: entity ? undefined : Boolean(input.isDynamic) || undefined,
32
+ serviceAlias: input.serviceAlias,
33
+ serviceAliasExpr: input.serviceAliasExpr,
34
+ destination: input.destination,
35
+ servicePath,
36
+ parserWarning: entity ? input.parserWarning : input.parserWarning ?? { code: 'query_entity_unknown', message: 'Remote query entity is dynamic or unavailable' },
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,223 @@
1
+ import type { Db } from '../db/connection.js';
2
+ export interface OperationTarget {
3
+ operationId: number;
4
+ repoName: string;
5
+ serviceName: string;
6
+ qualifiedName: string;
7
+ servicePath: string;
8
+ operationPath: string;
9
+ operationName: string;
10
+ sourceFile: string;
11
+ sourceLine: number;
12
+ repoId?: number;
13
+ packageName?: string | null;
14
+ score: number;
15
+ reasons: string[];
16
+ }
17
+ export interface OperationResolution {
18
+ status: 'resolved' | 'ambiguous' | 'unresolved' | 'dynamic';
19
+ target?: OperationTarget;
20
+ candidates: OperationTarget[];
21
+ reasons: string[];
22
+ }
23
+ function rows(
24
+ db: Db,
25
+ operationPath: string,
26
+ workspaceId?: number,
27
+ ): OperationTarget[] {
28
+ const names = operationLookupNames(operationPath);
29
+ return db
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
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
+ 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
+ )
35
+ .all(
36
+ workspaceId,
37
+ workspaceId,
38
+ names.path,
39
+ names.simplePath,
40
+ names.name,
41
+ names.simpleName,
42
+ ) as unknown as OperationTarget[];
43
+ }
44
+ function operationLookupNames(operationPath: string): { path: string; simplePath: string; name: string; simpleName: string } {
45
+ const name = operationPath.replace(/^\//, '');
46
+ const simpleName = name.split('.').at(-1) ?? name;
47
+ return { path: operationPath, simplePath: `/${simpleName}`, name, simpleName };
48
+ }
49
+ function operationMatches(candidate: OperationTarget, operationPath: string | undefined): boolean {
50
+ if (!operationPath) return false;
51
+ const names = operationLookupNames(operationPath);
52
+ return candidate.operationPath === names.path || candidate.operationPath === names.simplePath || candidate.operationName === names.name || candidate.operationName === names.simpleName;
53
+ }
54
+ export function resolveOperation(
55
+ db: Db,
56
+ signals: {
57
+ servicePath?: string;
58
+ alias?: string;
59
+ destination?: string;
60
+ operationPath?: string;
61
+ serviceName?: string;
62
+ repoId?: number;
63
+ hasExplicitOverride?: boolean;
64
+ isDynamic?: boolean;
65
+ localServiceLookup?: string;
66
+ },
67
+ workspaceId?: number,
68
+ ): OperationResolution {
69
+ const missing = [signals.servicePath, signals.alias, signals.destination, signals.operationPath].flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim())).filter(Boolean);
70
+ if (missing.length > 0)
71
+ return {
72
+ status: 'dynamic',
73
+ candidates: signals.operationPath ? rows(db, signals.operationPath, workspaceId) : [],
74
+ reasons: [...new Set(missing)].map((name) => `missing_variable:${name}`),
75
+ };
76
+ if (!signals.operationPath)
77
+ return {
78
+ status: 'unresolved',
79
+ candidates: [],
80
+ reasons: ['missing_operation_path'],
81
+ };
82
+ const allCandidates = rows(db, signals.operationPath, workspaceId).map((c) => ({
83
+ ...c,
84
+ score: 0.2,
85
+ reasons: ['operation_path_match'],
86
+ }));
87
+ let candidates = allCandidates.filter((c) => matchesLocalRepo(db, c.operationId, signals.repoId));
88
+ if (candidates.length === 0 && signals.repoId !== undefined && signals.serviceName) {
89
+ candidates = implementationContextCandidates(db, allCandidates, signals.repoId, signals.serviceName);
90
+ if (candidates.length === 0)
91
+ return {
92
+ status: 'unresolved',
93
+ candidates: allCandidates.filter((c) => serviceMatches(c, signals.serviceName)),
94
+ reasons: allCandidates.some((c) => serviceMatches(c, signals.serviceName)) ? ['local_service_candidate_without_caller_ownership'] : ['no_operation_candidates'],
95
+ };
96
+ }
97
+ if (candidates.length === 0)
98
+ return {
99
+ status: 'unresolved',
100
+ candidates: [],
101
+ reasons: ['no_operation_candidates'],
102
+ };
103
+ const hasStrongSignal = Boolean(
104
+ signals.servicePath ||
105
+ signals.serviceName ||
106
+ signals.alias ||
107
+ signals.destination ||
108
+ signals.hasExplicitOverride,
109
+ );
110
+ for (const c of candidates) {
111
+ if (signals.servicePath && c.servicePath === signals.servicePath) {
112
+ c.score += 0.75;
113
+ c.reasons.push('exact_service_path');
114
+ }
115
+ if (signals.servicePath && c.servicePath !== signals.servicePath) {
116
+ c.score -= 0.1;
117
+ c.reasons.push('service_path_mismatch');
118
+ }
119
+ if (signals.serviceName) {
120
+ const simple = signals.serviceName.split('.').at(-1) ?? signals.serviceName;
121
+ if (c.qualifiedName === signals.serviceName) {
122
+ c.score += 0.8;
123
+ c.reasons.push('exact_local_qualified_service_name');
124
+ } else if (c.serviceName === signals.serviceName || c.serviceName === simple) {
125
+ c.score += 0.75;
126
+ c.reasons.push('exact_local_simple_service_name');
127
+ } else if (c.servicePath === signals.serviceName || c.servicePath === `/${signals.serviceName}` || c.servicePath === `/${simple}`) {
128
+ c.score += 0.7;
129
+ c.reasons.push('exact_local_service_path');
130
+ } else if (c.servicePath.endsWith(`/${simple}`)) {
131
+ c.score += candidates.filter((candidate) => candidate.servicePath.endsWith(`/${simple}`)).length === 1 ? 0.65 : 0.2;
132
+ c.reasons.push('suffix_local_service_path');
133
+ } else c.reasons.push('local_service_name_mismatch');
134
+ }
135
+ if (signals.hasExplicitOverride) {
136
+ c.score += 0.2;
137
+ c.reasons.push(signals.repoId !== undefined ? 'explicit_local_service_call' : 'explicit_dynamic_override');
138
+ }
139
+ if (signals.repoId !== undefined && candidates.length === 1 && signals.serviceName && c.reasons.includes('local_service_name_mismatch') && operationMatches(c, signals.operationPath)) {
140
+ c.score = Math.max(c.score, 0.9);
141
+ c.reasons.push('same_repo_unique_operation_path_with_lookup_mismatch');
142
+ }
143
+ }
144
+ for (const c of candidates) c.score = Math.max(0, Math.min(1, c.score));
145
+ candidates.sort(
146
+ (a, b) => b.score - a.score || a.repoName.localeCompare(b.repoName),
147
+ );
148
+ const best = candidates[0];
149
+ const second = candidates[1];
150
+ if (signals.isDynamic && !signals.hasExplicitOverride && !signals.servicePath)
151
+ return {
152
+ status: 'dynamic',
153
+ candidates,
154
+ reasons: ['dynamic_target_without_override'],
155
+ };
156
+ if (!hasStrongSignal)
157
+ return {
158
+ status: candidates.length > 1 ? 'ambiguous' : 'unresolved',
159
+ candidates,
160
+ reasons: ['operation_path_only_has_no_strong_target_signal'],
161
+ };
162
+ if (
163
+ best &&
164
+ best.score >= 0.9 &&
165
+ (best.servicePath === signals.servicePath || Boolean(signals.serviceName && (!best.reasons.includes('local_service_name_mismatch') || best.reasons.includes('same_repo_unique_operation_path_with_lookup_mismatch')))) &&
166
+ operationMatches(best, signals.operationPath) &&
167
+ (!second || best.score - second.score >= 0.25)
168
+ )
169
+ return {
170
+ status: 'resolved',
171
+ target: best,
172
+ candidates,
173
+ reasons: best.reasons,
174
+ };
175
+ return {
176
+ status: candidates.length > 1 ? 'ambiguous' : 'unresolved',
177
+ candidates,
178
+ reasons: ['candidate_score_below_resolution_threshold'],
179
+ };
180
+ }
181
+ function serviceMatches(candidate: OperationTarget, serviceName: string | undefined): boolean {
182
+ if (!serviceName) return false;
183
+ const simple = serviceName.split('.').at(-1) ?? serviceName;
184
+ return candidate.qualifiedName === serviceName || candidate.serviceName === serviceName || candidate.serviceName === simple || candidate.servicePath === serviceName || candidate.servicePath === `/${serviceName}` || candidate.servicePath === `/${simple}` || candidate.servicePath.endsWith(`/${simple}`);
185
+ }
186
+ function implementationContextCandidates(db: Db, candidates: OperationTarget[], callerRepoId: number, serviceName: string): OperationTarget[] {
187
+ const matching = candidates.filter((candidate) => serviceMatches(candidate, serviceName));
188
+ const owned = matching.map((candidate) => ownershipReason(db, candidate, callerRepoId)).filter((item): item is { candidate: OperationTarget; reason: string } => Boolean(item));
189
+ if (owned.length === 0) return [];
190
+ const direct = owned.filter((item) => item.reason !== 'caller_depends_on_model_package');
191
+ const chosen = direct.length > 0 ? direct : owned.length === 1 ? owned : [];
192
+ return chosen.map((item) => ({ ...item.candidate, score: 0.95, reasons: [...item.candidate.reasons, 'implementation_context_caller_ownership', item.reason] }));
193
+ }
194
+ function ownershipReason(db: Db, candidate: OperationTarget, callerRepoId: number): { candidate: OperationTarget; reason: string } | undefined {
195
+ const edge = db.prepare("SELECT status,evidence_json,to_id FROM graph_edges WHERE edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND from_kind='operation' AND from_id=? ORDER BY CASE status WHEN 'resolved' THEN 0 WHEN 'ambiguous' THEN 1 ELSE 2 END LIMIT 1").get(String(candidate.operationId)) as { status?: string; evidence_json?: string; to_id?: string } | undefined;
196
+ if (edge?.status === 'resolved') {
197
+ const row = db.prepare('SELECT hc.repo_id repoId FROM handler_methods hm JOIN handler_classes hc ON hc.id=hm.handler_class_id WHERE hm.id=?').get(edge.to_id) as { repoId?: number } | undefined;
198
+ if (row?.repoId === callerRepoId) return { candidate, reason: 'resolved_implementation_handler_repo_matches_caller' };
199
+ }
200
+ if (edge?.evidence_json) {
201
+ const evidence = JSON.parse(edge.evidence_json) as { candidates?: Array<{ accepted?: boolean; handlerPackage?: { id?: number }; applicationPackage?: { id?: number } }> };
202
+ const hit = evidence.candidates?.find((item) => item.accepted && (Number(item.handlerPackage?.id) === callerRepoId || Number(item.applicationPackage?.id) === callerRepoId));
203
+ if (hit) return { candidate, reason: edge.status === 'ambiguous' ? 'ambiguous_implementation_candidate_repo_matches_caller' : 'registration_package_matches_caller' };
204
+ }
205
+ const dep = db.prepare("SELECT 1 FROM graph_edges WHERE edge_type='REPO_IMPORTS_HELPER_PACKAGE' AND status='resolved' AND from_kind='repo' AND from_id=? AND to_id=?").get(String(callerRepoId), String(candidate.repoId));
206
+ if (dep) return { candidate, reason: 'caller_depends_on_model_package' };
207
+ return undefined;
208
+ }
209
+
210
+ function matchesLocalRepo(db: Db, operationId: number, repoId: number | undefined): boolean {
211
+ if (repoId === undefined) return true;
212
+ const row = db.prepare('SELECT s.repo_id repoId FROM cds_operations o JOIN cds_services s ON s.id=o.service_id WHERE o.id=?').get(operationId) as { repoId?: number } | undefined;
213
+ return row?.repoId === repoId;
214
+ }
215
+ export function findOperation(
216
+ db: Db,
217
+ servicePath: string | undefined,
218
+ operationPath: string | undefined,
219
+ workspaceId?: number,
220
+ ): OperationTarget | undefined {
221
+ return resolveOperation(db, { servicePath, operationPath }, workspaceId)
222
+ .target;
223
+ }
@@ -0,0 +1,7 @@
1
+ import type { TraceResult } from '../types.js';
2
+ export function renderJson(value: unknown): string {
3
+ return `${JSON.stringify(value, null, 2)}\n`;
4
+ }
5
+ export function renderTraceJson(trace: TraceResult): string {
6
+ return renderJson(trace);
7
+ }
@@ -0,0 +1,16 @@
1
+ import type { TraceResult } from '../types.js';
2
+ function safe(value: string): string {
3
+ return value.replace(/[^\w-]/g, '_').slice(0, 60);
4
+ }
5
+ function label(trace: TraceResult, idOrLabel: string): string {
6
+ const node = trace.nodes.find((item) => item.id === idOrLabel || item.label === idOrLabel);
7
+ return String(node?.label ?? idOrLabel);
8
+ }
9
+ export function renderMermaid(trace: TraceResult): string {
10
+ const lines = ['flowchart TD'];
11
+ for (const e of trace.edges)
12
+ lines.push(
13
+ ` ${safe(e.from)}["${label(trace, e.from)}"] -->|${e.type}| ${safe(e.to)}["${label(trace, e.to)}"]`
14
+ );
15
+ return `${lines.join('\n')}\n`;
16
+ }
@@ -0,0 +1,21 @@
1
+ import type { TraceResult } from '../types.js';
2
+
3
+ function location(evidence: Record<string, unknown>): string {
4
+ const file = evidence.file ?? evidence.sourceFile ?? evidence.handlerSourceFile ?? evidence.operationSourceFile ?? evidence.registrationSourceFile;
5
+ const line = evidence.line ?? evidence.sourceLine ?? evidence.handlerSourceLine ?? evidence.operationSourceLine ?? evidence.registrationSourceLine;
6
+ if (file || line) return `${String(file ?? '')}:${String(line ?? '')}`;
7
+ const candidates = evidence.candidates;
8
+ if (Array.isArray(candidates) && candidates.length > 0) {
9
+ const first = candidates[0] as Record<string, unknown>;
10
+ return `${String(first.sourceFile ?? '')}:${String(first.sourceLine ?? '')}`;
11
+ }
12
+ return ':';
13
+ }
14
+ export function renderTraceTable(result: TraceResult): string {
15
+ const lines = ['Step Type From To Evidence'];
16
+ for (const e of result.edges) {
17
+ lines.push(`${String(e.step).padEnd(5)} ${e.type.padEnd(20)} ${e.from.slice(0, 34).padEnd(35)} ${e.to.slice(0, 35).padEnd(36)} ${location(e.evidence)}`);
18
+ }
19
+ if (result.diagnostics.length > 0) lines.push('', 'Diagnostics:', ...result.diagnostics.map((d) => `${String(d.severity ?? 'info')} ${String(d.code ?? 'diagnostic')} ${String(d.message ?? '')}`));
20
+ return `${lines.join('\n')}\n`;
21
+ }