@saptools/service-flow 0.1.44 → 0.1.46

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.
@@ -0,0 +1,262 @@
1
+ import path from 'node:path';
2
+ import ts from 'typescript';
3
+ import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
4
+ import type { OutboundCallFact } from '../types.js';
5
+ import { normalizePath } from '../utils/path-utils.js';
6
+ import { importsFor, lineOf, readSource, type ImportBinding } from './service-binding-parser-helpers.js';
7
+
8
+ interface WrapperSpec {
9
+ clientIndex: number;
10
+ pathIndex: number;
11
+ methodIndex?: number;
12
+ methodLiteral?: string;
13
+ sourceFile: string;
14
+ sourceLine: number;
15
+ chain: string[];
16
+ }
17
+
18
+ interface PathValue {
19
+ value?: string;
20
+ sourceKind: string;
21
+ rawExpression: string;
22
+ }
23
+
24
+ export async function parseImportedWrapperCalls(
25
+ repoPath: string,
26
+ filePath: string,
27
+ source: ts.SourceFile,
28
+ serviceBindings: Set<string>,
29
+ ): Promise<OutboundCallFact[]> {
30
+ const imports = await importsFor(repoPath, filePath, source);
31
+ const importedByLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
32
+ const calls = collectImportedCalls(source, importedByLocal);
33
+ const out: OutboundCallFact[] = [];
34
+ const cache = new Map<string, Promise<WrapperSpec | undefined>>();
35
+ for (const call of calls) {
36
+ if (!ts.isIdentifier(call.expression)) continue;
37
+ const imported = importedByLocal.get(call.expression.text);
38
+ if (!imported?.sourceFile) continue;
39
+ const spec = await loadWrapperSpec(repoPath, imported, cache, 0);
40
+ const fact = spec ? wrapperCallFact(source, filePath, call, spec, serviceBindings) : undefined;
41
+ if (fact) out.push(fact);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function collectImportedCalls(source: ts.SourceFile, imports: Map<string, ImportBinding>): ts.CallExpression[] {
47
+ const calls: ts.CallExpression[] = [];
48
+ const visit = (node: ts.Node): void => {
49
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && imports.has(node.expression.text)) calls.push(node);
50
+ ts.forEachChild(node, visit);
51
+ };
52
+ visit(source);
53
+ return calls;
54
+ }
55
+
56
+ async function loadWrapperSpec(
57
+ repoPath: string,
58
+ imported: ImportBinding,
59
+ cache: Map<string, Promise<WrapperSpec | undefined>>,
60
+ depth: number,
61
+ ): Promise<WrapperSpec | undefined> {
62
+ if (!imported.sourceFile || depth > 5) return undefined;
63
+ const key = `${imported.sourceFile}#${imported.exportedName}`;
64
+ const existing = cache.get(key);
65
+ if (existing) return existing;
66
+ const pending = inspectWrapper(repoPath, imported.sourceFile, imported.exportedName, cache, depth);
67
+ cache.set(key, pending);
68
+ return pending;
69
+ }
70
+
71
+ async function inspectWrapper(
72
+ repoPath: string,
73
+ sourceFile: string,
74
+ exportedName: string,
75
+ cache: Map<string, Promise<WrapperSpec | undefined>>,
76
+ depth: number,
77
+ ): Promise<WrapperSpec | undefined> {
78
+ const source = await readSource(path.join(repoPath, sourceFile));
79
+ if (!source) return undefined;
80
+ const named = findFunction(source, exportedName);
81
+ if (!named) return undefined;
82
+ const direct = directSendSpec(source, sourceFile, named.name, named.fn);
83
+ if (direct) return direct;
84
+ return nestedSendSpec(repoPath, sourceFile, source, named.name, named.fn, cache, depth);
85
+ }
86
+
87
+ function directSendSpec(source: ts.SourceFile, sourceFile: string, name: string, fn: ts.FunctionLikeDeclaration): WrapperSpec | undefined {
88
+ const params = parameterNames(fn);
89
+ const sends: ts.CallExpression[] = [];
90
+ visitFunctionBody(fn, (node) => {
91
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'send') sends.push(node);
92
+ });
93
+ if (sends.length !== 1) return undefined;
94
+ const send = sends[0];
95
+ const receiver = send && ts.isPropertyAccessExpression(send.expression) && ts.isIdentifier(send.expression.expression) ? send.expression.expression.text : undefined;
96
+ const object = send?.arguments[0];
97
+ if (!receiver || !object || !ts.isObjectLiteralExpression(object)) return undefined;
98
+ const pathExpr = propertyExpression(object, 'path');
99
+ const methodExpr = propertyExpression(object, 'method');
100
+ const pathName = pathExpr && ts.isIdentifier(pathExpr) ? pathExpr.text : undefined;
101
+ const clientIndex = params.indexOf(receiver);
102
+ const pathIndex = pathName ? params.indexOf(pathName) : -1;
103
+ if (clientIndex < 0 || pathIndex < 0) return undefined;
104
+ const methodName = methodExpr && ts.isIdentifier(methodExpr) ? methodExpr.text : undefined;
105
+ const methodIndex = methodName ? params.indexOf(methodName) : -1;
106
+ return { clientIndex, pathIndex, methodIndex: methodIndex >= 0 ? methodIndex : undefined, methodLiteral: literal(methodExpr), sourceFile, sourceLine: lineOf(source, fn), chain: [name] };
107
+ }
108
+
109
+ async function nestedSendSpec(
110
+ repoPath: string,
111
+ sourceFile: string,
112
+ source: ts.SourceFile,
113
+ name: string,
114
+ fn: ts.FunctionLikeDeclaration,
115
+ cache: Map<string, Promise<WrapperSpec | undefined>>,
116
+ depth: number,
117
+ ): Promise<WrapperSpec | undefined> {
118
+ const imports = await importsFor(repoPath, sourceFile, source);
119
+ const byLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
120
+ const calls: ts.CallExpression[] = [];
121
+ visitFunctionBody(fn, (node) => {
122
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && byLocal.has(node.expression.text)) calls.push(node);
123
+ });
124
+ if (calls.length !== 1) return undefined;
125
+ const call = calls[0];
126
+ const imported = call && ts.isIdentifier(call.expression) ? byLocal.get(call.expression.text) : undefined;
127
+ const nested = imported ? await loadWrapperSpec(repoPath, imported, cache, depth + 1) : undefined;
128
+ if (!call || !nested) return undefined;
129
+ const params = parameterNames(fn);
130
+ const clientIndex = mappedParameterIndex(call.arguments[nested.clientIndex], params);
131
+ const pathIndex = mappedParameterIndex(call.arguments[nested.pathIndex], params);
132
+ if (clientIndex < 0 || pathIndex < 0) return undefined;
133
+ const methodIndex = nested.methodIndex === undefined ? undefined : mappedParameterIndex(call.arguments[nested.methodIndex], params);
134
+ return { clientIndex, pathIndex, methodIndex: methodIndex !== undefined && methodIndex >= 0 ? methodIndex : undefined, methodLiteral: nested.methodLiteral, sourceFile, sourceLine: lineOf(source, fn), chain: [name, ...nested.chain] };
135
+ }
136
+
137
+ function wrapperCallFact(
138
+ source: ts.SourceFile,
139
+ filePath: string,
140
+ call: ts.CallExpression,
141
+ spec: WrapperSpec,
142
+ serviceBindings: Set<string>,
143
+ ): OutboundCallFact | undefined {
144
+ const client = call.arguments[spec.clientIndex];
145
+ if (!client || !ts.isIdentifier(client) || !serviceBindings.has(client.text)) return undefined;
146
+ const pathValue = resolveCallerPath(call.arguments[spec.pathIndex], call);
147
+ const methodValue = spec.methodIndex === undefined ? spec.methodLiteral : literal(call.arguments[spec.methodIndex]);
148
+ const method = (methodValue ?? 'POST').toUpperCase();
149
+ const rawPath = pathValue.rawExpression;
150
+ const operationPathExpr = pathValue.value ? normalizeOperationPath(pathValue.value) : runtimePathExpression(rawPath);
151
+ return {
152
+ callType: 'remote_action',
153
+ serviceVariableName: client.text,
154
+ method,
155
+ operationPathExpr,
156
+ payloadSummary: call.getText(source),
157
+ sourceFile: normalizePath(filePath),
158
+ sourceLine: lineOf(source, call),
159
+ confidence: operationPathExpr ? 0.85 : 0.5,
160
+ unresolvedReason: pathValue.value ? undefined : 'dynamic_operation_path_identifier',
161
+ evidence: {
162
+ parser: 'typescript_ast',
163
+ classifier: pathValue.value ? 'imported_wrapper_literal_path' : 'imported_wrapper_dynamic_path',
164
+ receiver: client.text,
165
+ wrapperFunction: spec.chain[0],
166
+ wrapperChain: spec.chain,
167
+ callerSite: { sourceFile: normalizePath(filePath), sourceLine: lineOf(source, call) },
168
+ calleeSite: { sourceFile: spec.sourceFile, sourceLine: spec.sourceLine },
169
+ rawPathExpression: rawPath,
170
+ missingPathIdentifier: pathValue.value ? undefined : rawPath,
171
+ odataPathIntent: pathValue.value ? classifyODataPathIntent(operationPathExpr, method) : undefined,
172
+ },
173
+ };
174
+ }
175
+
176
+ function resolveCallerPath(expr: ts.Expression | undefined, use: ts.Node): PathValue {
177
+ if (!expr) return { sourceKind: 'missing', rawExpression: '' };
178
+ if (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { value: expr.text, sourceKind: 'literal', rawExpression: expr.getText() };
179
+ if (ts.isTemplateExpression(expr)) return { value: expr.getText().slice(1, -1), sourceKind: 'template', rawExpression: expr.getText() };
180
+ if (ts.isIdentifier(expr)) {
181
+ const initializer = constInitializer(expr.text, use);
182
+ if (initializer) {
183
+ const resolved = resolveCallerPath(initializer, initializer);
184
+ return { ...resolved, sourceKind: 'const', rawExpression: expr.text };
185
+ }
186
+ }
187
+ return { sourceKind: 'dynamic', rawExpression: expr.getText() };
188
+ }
189
+
190
+ function constInitializer(name: string, use: ts.Node): ts.Expression | undefined {
191
+ let found: ts.Expression | undefined;
192
+ const source = use.getSourceFile();
193
+ const visit = (node: ts.Node): void => {
194
+ if (node.getStart(source) >= use.getStart(source)) return;
195
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer && (node.parent.flags & ts.NodeFlags.Const) !== 0) found = node.initializer;
196
+ ts.forEachChild(node, visit);
197
+ };
198
+ visit(source);
199
+ return found;
200
+ }
201
+
202
+ function findFunction(source: ts.SourceFile, exportedName: string): { name: string; fn: ts.FunctionLikeDeclaration } | undefined {
203
+ const localName = exportedLocalName(source, exportedName);
204
+ for (const statement of source.statements) {
205
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === localName) return { name: exportedName, fn: statement };
206
+ if (!ts.isVariableStatement(statement)) continue;
207
+ for (const declaration of statement.declarationList.declarations) {
208
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === localName && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return { name: exportedName, fn: declaration.initializer };
209
+ }
210
+ }
211
+ return undefined;
212
+ }
213
+
214
+ function exportedLocalName(source: ts.SourceFile, exportedName: string): string {
215
+ for (const statement of source.statements) {
216
+ if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue;
217
+ const match = statement.exportClause.elements.find((item) => item.name.text === exportedName);
218
+ if (match) return match.propertyName?.text ?? match.name.text;
219
+ }
220
+ return exportedName;
221
+ }
222
+
223
+ function visitFunctionBody(fn: ts.FunctionLikeDeclaration, visitor: (node: ts.Node) => void): void {
224
+ const visit = (node: ts.Node): void => {
225
+ if (node !== fn && ts.isFunctionLike(node)) return;
226
+ visitor(node);
227
+ ts.forEachChild(node, visit);
228
+ };
229
+ if (fn.body) visit(fn.body);
230
+ }
231
+
232
+ function parameterNames(fn: ts.FunctionLikeDeclaration): string[] {
233
+ return fn.parameters.map((parameter) => ts.isIdentifier(parameter.name) ? parameter.name.text : '');
234
+ }
235
+
236
+ function mappedParameterIndex(expr: ts.Expression | undefined, parameters: string[]): number {
237
+ return expr && ts.isIdentifier(expr) ? parameters.indexOf(expr.text) : -1;
238
+ }
239
+
240
+ function propertyExpression(object: ts.ObjectLiteralExpression, key: string): ts.Expression | undefined {
241
+ for (const property of object.properties) {
242
+ if (ts.isPropertyAssignment(property) && propertyName(property.name) === key) return property.initializer;
243
+ if (ts.isShorthandPropertyAssignment(property) && property.name.text === key) return property.name;
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ function propertyName(name: ts.PropertyName): string | undefined {
249
+ return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : undefined;
250
+ }
251
+
252
+ function literal(expr: ts.Expression | undefined): string | undefined {
253
+ return expr && (ts.isStringLiteralLike(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) ? expr.text : undefined;
254
+ }
255
+
256
+ function normalizeOperationPath(value: string): string {
257
+ return value.startsWith('/') ? value : `/${value}`;
258
+ }
259
+
260
+ function runtimePathExpression(value: string): string | undefined {
261
+ return value ? `\${${value}}` : undefined;
262
+ }
@@ -6,6 +6,8 @@ import type { OutboundCallFact } from '../types.js';
6
6
  import { normalizePath, stripQuotes } from '../utils/path-utils.js';
7
7
  import { summarizeExpression } from '../utils/redaction.js';
8
8
  import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
9
+ import { parseServiceBindings } from './service-binding-parser.js';
10
+ import { parseImportedWrapperCalls } from './imported-wrapper-parser.js';
9
11
  function lineOf(text: string, idx: number): number {
10
12
  return text.slice(0, idx).split('\n').length;
11
13
  }
@@ -501,7 +503,9 @@ export async function parseOutboundCalls(
501
503
  ): Promise<OutboundCallFact[]> {
502
504
  const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
503
505
  const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
504
- return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...parseLocalServiceCalls(text, filePath)];
506
+ const bindingNames = new Set((await parseServiceBindings(repoPath, filePath)).map((binding) => binding.variableName));
507
+ const importedWrappers = await parseImportedWrapperCalls(repoPath, filePath, source, bindingNames);
508
+ return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...importedWrappers, ...parseLocalServiceCalls(text, filePath)];
505
509
  }
506
510
  function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFact[] {
507
511
  const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
@@ -42,7 +42,8 @@ function isObjectFunction(node: ts.Node): boolean {
42
42
  }
43
43
  type ParameterBinding =
44
44
  | { index: number; kind: 'identifier'; name: string }
45
- | { index: number; kind: 'object_pattern'; properties: Array<{ property: string; local: string }> };
45
+ | { index: number; kind: 'object_pattern'; properties: Array<{ property: string; local: string }> }
46
+ | { index: number; kind: 'array_pattern'; elements: Array<{ index: number; local: string }> };
46
47
  type ParameterPropertyAlias = { parameter: string; property: string; local: string; kind: 'object_parameter_destructure'; line: number };
47
48
  const commonTerminalMembers = new Set(['push', 'includes', 'find', 'findIndex', 'map', 'filter', 'reduce', 'forEach', 'some', 'every', 'toUpperCase', 'toLowerCase', 'trim', 'split', 'join', 'get', 'set', 'has']);
48
49
  const loggerMembers = new Set(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'log']);
@@ -99,6 +100,11 @@ function argumentEvidence(args: ts.NodeArray<ts.Expression>, source: ts.SourceFi
99
100
  }
100
101
  return { kind: 'object_literal', properties };
101
102
  }
103
+ if (ts.isArrayLiteralExpression(arg)) {
104
+ const elements = arg.elements.flatMap((element, index): Array<Record<string, unknown>> =>
105
+ ts.isIdentifier(element) ? [{ index, kind: 'identifier', name: element.text }] : []);
106
+ return { kind: 'array_literal', elements };
107
+ }
102
108
  return { kind: 'unsupported', expression: arg.getText(source) };
103
109
  });
104
110
  }
@@ -141,15 +147,24 @@ function parameterPropertyAliases(fn: ts.FunctionLikeDeclaration, source: ts.Sou
141
147
  function parameterBindings(params: ts.NodeArray<ts.ParameterDeclaration>): ParameterBinding[] {
142
148
  return params.flatMap((param, index): ParameterBinding[] => {
143
149
  if (ts.isIdentifier(param.name)) return [{ index, kind: 'identifier', name: param.name.text }];
144
- if (!ts.isObjectBindingPattern(param.name)) return [];
145
- const properties = param.name.elements.flatMap((element): Array<{ property: string; local: string }> => {
146
- if (element.dotDotDotToken || ts.isObjectBindingPattern(element.name) || ts.isArrayBindingPattern(element.name)) return [];
147
- const property = element.propertyName ? nameOf(element.propertyName) : nameOf(element.name);
148
- if (!property) return [];
149
- const local = bindingLocalName(element.name, element.initializer);
150
- return local ? [{ property, local }] : [];
151
- });
152
- return properties.length > 0 ? [{ index, kind: 'object_pattern', properties }] : [];
150
+ if (ts.isObjectBindingPattern(param.name)) {
151
+ const properties = param.name.elements.flatMap((element): Array<{ property: string; local: string }> => {
152
+ if (element.dotDotDotToken || ts.isObjectBindingPattern(element.name) || ts.isArrayBindingPattern(element.name)) return [];
153
+ const property = element.propertyName ? nameOf(element.propertyName) : nameOf(element.name);
154
+ if (!property) return [];
155
+ const local = bindingLocalName(element.name, element.initializer);
156
+ return local ? [{ property, local }] : [];
157
+ });
158
+ return properties.length > 0 ? [{ index, kind: 'object_pattern', properties }] : [];
159
+ }
160
+ if (ts.isArrayBindingPattern(param.name)) {
161
+ const elements = param.name.elements.flatMap((element, elementIndex): Array<{ index: number; local: string }> =>
162
+ ts.isBindingElement(element) && !element.dotDotDotToken && ts.isIdentifier(element.name)
163
+ ? [{ index: elementIndex, local: element.name.text }]
164
+ : []);
165
+ return elements.length > 0 ? [{ index, kind: 'array_pattern', elements }] : [];
166
+ }
167
+ return [];
153
168
  });
154
169
  }
155
170
  export async function parseExecutableSymbols(repoPath: string, filePath: string): Promise<{ symbols: ExecutableSymbolFact[]; calls: SymbolCallFact[] }> {
@@ -1,5 +1,6 @@
1
1
  import type { Db } from '../db/connection.js';
2
2
  import { extractPlaceholders, substituteVariables, type RuntimeSubstitution } from '../linker/dynamic-edge-resolver.js';
3
+ import { normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
3
4
  import { resolveOperation, type OperationTarget } from '../linker/service-resolver.js';
4
5
 
5
6
  export interface TraceGraphRow extends Record<string, unknown> {
@@ -154,7 +155,11 @@ function withEffectiveResolution(
154
155
 
155
156
  function resolveRuntimeOperation(db: Db, evidence: Record<string, unknown>, workspaceId: number | undefined): ReturnType<typeof resolveOperation> {
156
157
  const servicePath = stringValue(evidence.servicePath);
157
- const operationPath = stringValue(evidence.normalizedOperationPath ?? evidence.operationPath);
158
+ const rawOperationPath = stringValue(evidence.operationPath);
159
+ const normalized = normalizeODataOperationInvocationPath(rawOperationPath);
160
+ const operationPath = normalized?.wasInvocation
161
+ ? normalized.normalizedOperationPath
162
+ : stringValue(evidence.normalizedOperationPath) ?? rawOperationPath;
158
163
  const alias = stringValue(evidence.serviceAliasExpr ?? evidence.serviceAlias);
159
164
  const destination = stringValue(evidence.destination);
160
165
  return resolveOperation(db, { servicePath, operationPath, alias, destination, hasExplicitOverride: true, isDynamic: true }, workspaceId);
@@ -0,0 +1,223 @@
1
+ import type { ImplementationHint } from '../types.js';
2
+
3
+ interface Candidate {
4
+ accepted?: boolean;
5
+ methodId?: number;
6
+ sourceFile?: string;
7
+ handlerPackage?: { name?: string; packageName?: string };
8
+ modelPackage?: { name?: string; packageName?: string };
9
+ servicePath?: string;
10
+ operationPath?: string;
11
+ }
12
+
13
+ interface EdgeEvidence {
14
+ servicePath?: string;
15
+ operationPath?: string;
16
+ ambiguityReasons?: string[];
17
+ candidateFamilies?: Array<{ packageName?: string }>;
18
+ candidates?: Candidate[];
19
+ modelPackage?: { name?: string; packageName?: string };
20
+ }
21
+
22
+ export interface ImplementationSelection {
23
+ methodId?: string;
24
+ blocksAutomatic: boolean;
25
+ evidence: Record<string, unknown>;
26
+ }
27
+
28
+ export function parseImplementationHint(value: string): ImplementationHint {
29
+ const hint: Partial<ImplementationHint> = {};
30
+ for (const part of value.split(',')) {
31
+ const separator = part.indexOf('=');
32
+ if (separator <= 0 || separator === part.length - 1) throw new Error(`Invalid implementation hint field: ${part}`);
33
+ assignHintField(hint, part.slice(0, separator).trim(), part.slice(separator + 1).trim());
34
+ }
35
+ if (!hint.implementationRepo) throw new Error('Scoped implementation hint requires an implementation repo selection');
36
+ return { ...hint, implementationRepo: hint.implementationRepo };
37
+ }
38
+
39
+ export function selectImplementation(
40
+ rawEvidence: Record<string, unknown>,
41
+ hints: ImplementationHint[] | undefined,
42
+ legacyRepo: string | undefined,
43
+ ): ImplementationSelection {
44
+ const evidence = asEvidence(rawEvidence);
45
+ const scoped = hints ?? [];
46
+ const matchingHints = scoped.filter((hint) => hintMatchesEdge(hint, evidence));
47
+ if (matchingHints.length === 0) {
48
+ if (legacyRepo) return selectCandidate(evidence, legacyHint(legacyRepo), 'implementation_repo_hint');
49
+ const reason = scoped.length > 0 ? 'no_scoped_hint_matched_edge' : 'no_implementation_hint_supplied';
50
+ return { blocksAutomatic: false, evidence: { status: 'not_matched', reason, strategy: 'scoped_implementation_hint' } };
51
+ }
52
+ if (matchingHints.length > 1) {
53
+ return {
54
+ blocksAutomatic: true,
55
+ evidence: {
56
+ status: 'tied',
57
+ reason: 'multiple_scoped_hints_matched_edge',
58
+ strategy: 'scoped_implementation_hint',
59
+ matchedHints: matchingHints,
60
+ candidateCount: matchingHints.length,
61
+ },
62
+ };
63
+ }
64
+ const hint = matchingHints[0];
65
+ return hint ? selectCandidate(evidence, hint, 'scoped_implementation_hint') : { blocksAutomatic: false, evidence: { status: 'not_matched' } };
66
+ }
67
+
68
+ export function implementationHintDiagnostic(
69
+ selection: ImplementationSelection,
70
+ suggestions?: unknown,
71
+ ): Record<string, unknown> | undefined {
72
+ if (!selection.blocksAutomatic || selection.methodId) return undefined;
73
+ return {
74
+ severity: 'warning',
75
+ code: 'implementation_hint_mismatch',
76
+ message: 'Implementation hint did not select exactly one viable candidate',
77
+ hintStatus: selection.evidence.status,
78
+ candidateCount: selection.evidence.candidateCount,
79
+ implementationHintSuggestions: Array.isArray(suggestions) && suggestions.length > 0 ? suggestions : undefined,
80
+ implementationSelection: selection.evidence,
81
+ };
82
+ }
83
+
84
+ export function implementationHintSuggestions(rawEvidence: Record<string, unknown>): Array<Record<string, unknown>> {
85
+ const evidence = asEvidence(rawEvidence);
86
+ const accepted = (evidence.candidates ?? []).filter((candidate) => candidate.accepted);
87
+ if (accepted.length < 2) return [];
88
+ const repos = selectableRepositories(accepted);
89
+ return accepted
90
+ .flatMap((candidate) => {
91
+ const repo = candidate.handlerPackage?.name;
92
+ if (!repo || !repos.includes(repo)) return [];
93
+ const hint = suggestionHint(evidence, candidate, repo);
94
+ return [{
95
+ servicePath: hint.servicePath,
96
+ operationPath: hint.operationPath,
97
+ ambiguityReason: evidence.ambiguityReasons?.[0],
98
+ candidateFamily: hint.candidateFamily,
99
+ selectableImplementationRepositories: repos,
100
+ implementationRepo: repo,
101
+ hint,
102
+ cli: `--implementation-hint ${hintString(hint)}`,
103
+ }];
104
+ });
105
+ }
106
+
107
+ function selectableRepositories(candidates: Candidate[]): string[] {
108
+ const repos = new Set(candidates.flatMap((candidate) => candidate.handlerPackage?.name ? [candidate.handlerPackage.name] : []));
109
+ return [...repos]
110
+ .filter((repo) => candidates.filter((candidate) => candidateMatchesRepo(candidate, repo)).length === 1)
111
+ .sort();
112
+ }
113
+
114
+ function assignHintField(hint: Partial<ImplementationHint>, key: string, value: string): void {
115
+ if (key === 'service' || key === 'servicePath') hint.servicePath = value;
116
+ else if (key === 'operation' || key === 'operationPath') hint.operationPath = value;
117
+ else if (key === 'package' || key === 'packageName') hint.packageName = value;
118
+ else if (key === 'repository' || key === 'repositoryName') hint.repositoryName = value;
119
+ else if (key === 'family' || key === 'candidateFamily') hint.candidateFamily = value;
120
+ else if (key === 'repo' || key === 'implementationRepo' || key === 'select') hint.implementationRepo = value;
121
+ else throw new Error(`Unknown implementation hint field: ${key}`);
122
+ }
123
+
124
+ function selectCandidate(evidence: EdgeEvidence, hint: ImplementationHint, strategy: string): ImplementationSelection {
125
+ const matches = (evidence.candidates ?? []).filter((candidate) =>
126
+ candidate.accepted && candidateMatchesRepo(candidate, hint.implementationRepo));
127
+ const selected = matches.length === 1 ? matches[0] : undefined;
128
+ if (!selected || selected.methodId === undefined) {
129
+ return {
130
+ blocksAutomatic: true,
131
+ evidence: {
132
+ status: matches.length > 1 ? 'tied' : 'not_matched',
133
+ reason: matches.length > 1 ? 'hint_matched_multiple_candidates' : 'hint_matched_zero_candidates',
134
+ strategy,
135
+ matchedHint: hint,
136
+ selectedRepo: hint.implementationRepo,
137
+ candidateCount: matches.length,
138
+ },
139
+ };
140
+ }
141
+ return {
142
+ methodId: String(selected.methodId),
143
+ blocksAutomatic: false,
144
+ evidence: {
145
+ status: 'selected',
146
+ guided: true,
147
+ strategy,
148
+ matchedHint: hint,
149
+ selectedRepo: hint.implementationRepo,
150
+ selectedMethodId: selected.methodId,
151
+ ambiguityReason: evidence.ambiguityReasons?.[0],
152
+ },
153
+ };
154
+ }
155
+
156
+ function suggestionHint(evidence: EdgeEvidence, candidate: Candidate, repo: string): ImplementationHint {
157
+ const servicePath = evidence.servicePath ?? candidate.servicePath;
158
+ const operationPath = evidence.operationPath ?? candidate.operationPath;
159
+ const family = usefulCandidateFamily(evidence, candidate);
160
+ return {
161
+ ...(servicePath ? { servicePath } : {}),
162
+ ...(operationPath ? { operationPath } : {}),
163
+ ...(evidence.modelPackage?.packageName ? { packageName: evidence.modelPackage.packageName } : {}),
164
+ ...(evidence.modelPackage?.name ? { repositoryName: evidence.modelPackage.name } : {}),
165
+ ...(family ? { candidateFamily: family } : {}),
166
+ implementationRepo: repo,
167
+ };
168
+ }
169
+
170
+ function usefulCandidateFamily(evidence: EdgeEvidence, candidate: Candidate): string | undefined {
171
+ const family = candidate.handlerPackage?.packageName;
172
+ if (!family) return undefined;
173
+ if ((evidence.candidateFamilies ?? []).some((item) => item.packageName === family)) return family;
174
+ const acceptedFamilies = new Set(
175
+ (evidence.candidates ?? [])
176
+ .filter((item) => item.accepted)
177
+ .flatMap((item) => item.handlerPackage?.packageName ? [item.handlerPackage.packageName] : []),
178
+ );
179
+ return acceptedFamilies.size > 1 ? family : undefined;
180
+ }
181
+
182
+ function hintString(hint: ImplementationHint): string {
183
+ const fields = [
184
+ ['service', hint.servicePath],
185
+ ['operation', hint.operationPath],
186
+ ['package', hint.packageName],
187
+ ['repository', hint.repositoryName],
188
+ ['family', hint.candidateFamily],
189
+ ['repo', hint.implementationRepo],
190
+ ];
191
+ return fields.flatMap(([key, value]) => value ? [`${key}=${value}`] : []).join(',');
192
+ }
193
+
194
+ function hintMatchesEdge(hint: ImplementationHint, evidence: EdgeEvidence): boolean {
195
+ const model = evidence.modelPackage ?? evidence.candidates?.[0]?.modelPackage;
196
+ const familyNames = new Set([
197
+ ...(evidence.candidateFamilies ?? []).flatMap((family) => family.packageName ? [family.packageName] : []),
198
+ ...(evidence.candidates ?? []).flatMap((candidate) => candidate.handlerPackage?.packageName ? [candidate.handlerPackage.packageName] : []),
199
+ ]);
200
+ return matches(hint.servicePath, evidence.servicePath ?? evidence.candidates?.[0]?.servicePath)
201
+ && matches(hint.operationPath, evidence.operationPath ?? evidence.candidates?.[0]?.operationPath)
202
+ && matches(hint.packageName, model?.packageName)
203
+ && matches(hint.repositoryName, model?.name)
204
+ && (!hint.candidateFamily || familyNames.has(hint.candidateFamily));
205
+ }
206
+
207
+ function candidateMatchesRepo(candidate: Candidate, value: string): boolean {
208
+ return candidate.handlerPackage?.name === value
209
+ || candidate.handlerPackage?.packageName === value
210
+ || candidate.sourceFile?.startsWith(value) === true;
211
+ }
212
+
213
+ function matches(expected: string | undefined, actual: string | undefined): boolean {
214
+ return expected === undefined || expected === actual;
215
+ }
216
+
217
+ function legacyHint(implementationRepo: string): ImplementationHint {
218
+ return { implementationRepo };
219
+ }
220
+
221
+ function asEvidence(value: Record<string, unknown>): EdgeEvidence {
222
+ return value as EdgeEvidence;
223
+ }