@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.
package/dist/index.d.ts CHANGED
@@ -121,6 +121,14 @@ interface TraceStart {
121
121
  operationPath?: string;
122
122
  handler?: string;
123
123
  }
124
+ interface ImplementationHint {
125
+ servicePath?: string;
126
+ operationPath?: string;
127
+ packageName?: string;
128
+ repositoryName?: string;
129
+ candidateFamily?: string;
130
+ implementationRepo: string;
131
+ }
124
132
  interface TraceEdge {
125
133
  step: number;
126
134
  type: string;
@@ -206,9 +214,12 @@ declare function trace(db: Db, start: TraceStart, options: {
206
214
  includeDb?: boolean;
207
215
  includeAsync?: boolean;
208
216
  implementationRepo?: string;
217
+ implementationHints?: ImplementationHint[];
209
218
  }): TraceResult;
210
219
 
220
+ declare function parseImplementationHint(value: string): ImplementationHint;
221
+
211
222
  declare function redactText(text: string): string;
212
223
  declare function redactValue(value: unknown): unknown;
213
224
 
214
- export { type RuntimeSubstitution, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
225
+ export { type ImplementationHint, type RuntimeSubstitution, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseImplementationHint, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  parseCdsFile,
9
9
  parseDecorators,
10
10
  parseHandlerRegistrations,
11
+ parseImplementationHint,
11
12
  parseOutboundCalls,
12
13
  parsePackageJson,
13
14
  parseServiceBindings,
@@ -16,7 +17,7 @@ import {
16
17
  stripQuotes,
17
18
  substituteVariables,
18
19
  trace
19
- } from "./chunk-UKNPHTUS.js";
20
+ } from "./chunk-EGY2A4AT.js";
20
21
 
21
22
  // src/parsers/generated-constants-parser.ts
22
23
  import fs from "fs/promises";
@@ -46,6 +47,7 @@ export {
46
47
  parseDecorators,
47
48
  parseGeneratedConstants,
48
49
  parseHandlerRegistrations,
50
+ parseImplementationHint,
49
51
  parseOutboundCalls,
50
52
  parsePackageJson,
51
53
  parseServiceBindings,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/parsers/generated-constants-parser.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { GeneratedConstantFact } from '../types.js';\nimport { normalizePath, stripQuotes } from '../utils/path-utils.js';\nfunction lineOf(text: string, idx: number): number {\n return text.slice(0, idx).split('\\n').length;\n}\nexport async function parseGeneratedConstants(\n repoPath: string,\n filePath: string\n): Promise<GeneratedConstantFact[]> {\n const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');\n return [\n ...text.matchAll(\n /(?:export\\s+)?(?:const|static\\s+readonly)\\s+(\\w+)\\s*=\\s*(['\"])([^'\"]+)\\2/g\n )\n ].map((m) => ({\n name: m[1] ?? 'constant',\n value: stripQuotes(m[3] ?? ''),\n sourceFile: normalizePath(filePath),\n sourceLine: lineOf(text, m.index ?? 0)\n }));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAGjB,SAAS,OAAO,MAAc,KAAqB;AACjD,SAAO,KAAK,MAAM,GAAG,GAAG,EAAE,MAAM,IAAI,EAAE;AACxC;AACA,eAAsB,wBACpB,UACA,UACkC;AAClC,QAAM,OAAO,MAAM,GAAG,SAAS,KAAK,KAAK,UAAU,QAAQ,GAAG,MAAM;AACpE,SAAO;AAAA,IACL,GAAG,KAAK;AAAA,MACN;AAAA,IACF;AAAA,EACF,EAAE,IAAI,CAAC,OAAO;AAAA,IACZ,MAAM,EAAE,CAAC,KAAK;AAAA,IACd,OAAO,YAAY,EAAE,CAAC,KAAK,EAAE;AAAA,IAC7B,YAAY,cAAc,QAAQ;AAAA,IAClC,YAAY,OAAO,MAAM,EAAE,SAAS,CAAC;AAAA,EACvC,EAAE;AACJ;","names":[]}
1
+ {"version":3,"sources":["../src/parsers/generated-constants-parser.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { GeneratedConstantFact } from '../types.js';\nimport { normalizePath, stripQuotes } from '../utils/path-utils.js';\nfunction lineOf(text: string, idx: number): number {\n return text.slice(0, idx).split('\\n').length;\n}\nexport async function parseGeneratedConstants(\n repoPath: string,\n filePath: string\n): Promise<GeneratedConstantFact[]> {\n const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');\n return [\n ...text.matchAll(\n /(?:export\\s+)?(?:const|static\\s+readonly)\\s+(\\w+)\\s*=\\s*(['\"])([^'\"]+)\\2/g\n )\n ].map((m) => ({\n name: m[1] ?? 'constant',\n value: stripQuotes(m[3] ?? ''),\n sourceFile: normalizePath(filePath),\n sourceLine: lineOf(text, m.index ?? 0)\n }));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAGjB,SAAS,OAAO,MAAc,KAAqB;AACjD,SAAO,KAAK,MAAM,GAAG,GAAG,EAAE,MAAM,IAAI,EAAE;AACxC;AACA,eAAsB,wBACpB,UACA,UACkC;AAClC,QAAM,OAAO,MAAM,GAAG,SAAS,KAAK,KAAK,UAAU,QAAQ,GAAG,MAAM;AACpE,SAAO;AAAA,IACL,GAAG,KAAK;AAAA,MACN;AAAA,IACF;AAAA,EACF,EAAE,IAAI,CAAC,OAAO;AAAA,IACZ,MAAM,EAAE,CAAC,KAAK;AAAA,IACd,OAAO,YAAY,EAAE,CAAC,KAAK,EAAE;AAAA,IAC7B,YAAY,cAAc,QAAQ;AAAA,IAClC,YAAY,OAAO,MAAM,EAAE,SAAS,CAAC;AAAA,EACvC,EAAE;AACJ;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "description": "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/src/cli/doctor.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  import type { Db } from '../db/connection.js';
2
2
  import { classifyODataPathIntent, normalizeODataOperationInvocationPath } from '../linker/odata-path-normalizer.js';
3
+ import { implementationHintSuggestions } from '../trace/implementation-hints.js';
3
4
  import { ANALYZER_VERSION } from '../version.js';
4
5
 
5
6
  type Diagnostic = Record<string, unknown>;
7
+ interface DoctorOptions {
8
+ detail?: boolean;
9
+ }
6
10
 
7
11
  export function linkUpgradeWarnings(db: Db): Diagnostic[] {
8
12
  return [...schemaDriftDiagnostics(db, true), ...analyzerVersionDiagnostics(db, true)]
9
13
  .filter((item) => ['schema_legacy_columns_present', 'external_target_columns_missing_data', 'reindex_required_after_upgrade', 'reindex_required_after_analyzer_upgrade'].includes(String(item.code)));
10
14
  }
11
15
 
12
- export function doctorDiagnostics(db: Db, strict: boolean): Diagnostic[] {
16
+ export function doctorDiagnostics(db: Db, strict: boolean, options: DoctorOptions = {}): Diagnostic[] {
13
17
  const diagnostics = db.prepare('SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics ORDER BY id').all() as Diagnostic[];
14
18
  return [
15
19
  ...diagnostics,
@@ -17,7 +21,7 @@ export function doctorDiagnostics(db: Db, strict: boolean): Diagnostic[] {
17
21
  ...localServiceDiagnostics(db, strict),
18
22
  ...schemaDriftDiagnostics(db, strict),
19
23
  ...analyzerVersionDiagnostics(db, strict),
20
- ...parserQualityDiagnostics(db, strict),
24
+ ...parserQualityDiagnostics(db, strict, options),
21
25
  ];
22
26
  }
23
27
 
@@ -99,7 +103,7 @@ function localServiceDiagnostics(db: Db, strict: boolean): Diagnostic[] {
99
103
  return out;
100
104
  }
101
105
 
102
- function parserQualityDiagnostics(db: Db, strict: boolean): Diagnostic[] {
106
+ function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOptions): Diagnostic[] {
103
107
  if (!strict) return [];
104
108
  const symbol = symbolCallQuality(db);
105
109
  const dbq = dbQueryQuality(db);
@@ -108,7 +112,7 @@ function parserQualityDiagnostics(db: Db, strict: boolean): Diagnostic[] {
108
112
  identityAliasBindingQuality(db),
109
113
  remoteActionNoBindingQuality(db),
110
114
  contextualImplementationQuality(db),
111
- implementationCandidateQuality(db),
115
+ implementationCandidateQuality(db, Boolean(options.detail)),
112
116
  classInstanceNoiseQuality(db),
113
117
  contextualBindingPropagationQuality(db),
114
118
  nestedThisReceiverQuality(db),
@@ -178,20 +182,27 @@ function graphDynamicFlagQuality(db: Db): Diagnostic {
178
182
  return { severity: Number(row.count ?? 0) > 0 ? 'warning' : 'info', code: 'strict_graph_dynamic_flag_consistency', message: 'Graph dynamic flag consistency aggregate', dynamicTerminalEdges: Number(row.count ?? 0) };
179
183
  }
180
184
 
181
- function implementationCandidateQuality(db: Db): Diagnostic {
182
- const categories = [...implementationEdgeCategories(db), missingParameterMetadataCategory(db)].filter((item) => item.count > 0);
185
+ function implementationCandidateQuality(db: Db, detail: boolean): Diagnostic {
186
+ const categories = [...implementationEdgeCategories(db, detail), missingParameterMetadataCategory(db, detail), dynamicWrapperCategory(db, detail)].filter((item) => item.count > 0);
183
187
  const total = categories.reduce((sum, item) => sum + item.count, 0);
184
- return { severity: total > 0 ? 'warning' : 'info', code: 'strict_implementation_candidate_quality', message: 'Implementation candidate ambiguity and rejection aggregate', total, categories };
188
+ return { severity: total > 0 ? 'warning' : 'info', code: 'strict_implementation_candidate_quality', message: 'Implementation candidate ambiguity and rejection aggregate', total, summary: implementationSummary(categories), categories };
185
189
  }
186
190
 
187
- function implementationEdgeCategories(db: Db): Array<Diagnostic & { count: number }> {
191
+ function implementationEdgeCategories(db: Db, detail: boolean): Array<Diagnostic & { count: number }> {
188
192
  const rows = db.prepare(`SELECT e.status,e.unresolved_reason unresolvedReason,e.evidence_json evidenceJson,o.operation_name operationName,base.operation_name baseOperation,s.service_path servicePath
189
193
  FROM graph_edges e JOIN cds_operations o ON o.id=CAST(e.from_id AS INTEGER)
190
194
  JOIN cds_services s ON s.id=o.service_id LEFT JOIN cds_operations base ON base.id=o.base_operation_id
191
195
  WHERE e.edge_type='OPERATION_IMPLEMENTED_BY_HANDLER' AND e.status IN ('ambiguous','unresolved') ORDER BY s.service_path,o.operation_name,e.id`).all() as Diagnostic[];
192
196
  const grouped = new Map<string, Diagnostic & { count: number; servicePaths: string[]; examples: Diagnostic[] }>();
193
197
  for (const row of rows) addImplementationCategory(grouped, row);
194
- return [...grouped.values()].map(({ servicePaths, ...item }) => ({ ...item, servicePathPattern: pathPattern(servicePaths), examples: item.examples.slice(0, 3) }));
198
+ return [...grouped.values()].map(({ servicePaths, ...item }) => ({
199
+ ...item,
200
+ servicePathPattern: pathPattern(servicePaths),
201
+ suggestedAction: categoryAction(String(item.category)),
202
+ suggestedHints: suggestedHints(item.examples),
203
+ examples: item.examples.slice(0, 3),
204
+ expandedExamples: detail ? item.examples : undefined,
205
+ }));
195
206
  }
196
207
 
197
208
  function addImplementationCategory(grouped: Map<string, Diagnostic & { count: number; servicePaths: string[]; examples: Diagnostic[] }>, row: Diagnostic): void {
@@ -202,22 +213,65 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
202
213
  const baseOperation = String(row.baseOperation ?? row.operationName ?? evidence.operationName ?? 'unknown');
203
214
  const key = [category, baseOperation, reason, family].join('\0');
204
215
  const current = grouped.get(key) ?? { category, baseOperation, reason, candidateFamily: family, count: 0, servicePaths: [], examples: [] };
216
+ const hintSuggestions = implementationSuggestions(evidence);
205
217
  current.count += 1;
206
218
  current.servicePaths.push(String(row.servicePath ?? evidence.servicePath ?? ''));
207
- current.examples.push({ servicePath: row.servicePath, operation: row.operationName, status: row.status, reason: row.unresolvedReason });
219
+ current.examples.push({ servicePath: row.servicePath, operation: row.operationName, status: row.status, reason: row.unresolvedReason, implementationHintSuggestions: hintSuggestions });
208
220
  grouped.set(key, current);
209
221
  }
210
222
 
211
- function missingParameterMetadataCategory(db: Db): Diagnostic & { count: number } {
223
+ function implementationSuggestions(evidence: Diagnostic): Diagnostic[] | undefined {
224
+ const persisted = asRecords(evidence.implementationHintSuggestions);
225
+ const suggestions = persisted.length ? persisted : implementationHintSuggestions(evidence);
226
+ return suggestions.length ? suggestions : undefined;
227
+ }
228
+
229
+ function suggestedHints(examples: Diagnostic[]): string[] | undefined {
230
+ const hints = examples.flatMap((example) =>
231
+ asRecords(example.implementationHintSuggestions)
232
+ .flatMap((suggestion) => typeof suggestion.cli === 'string' ? [String(suggestion.cli)] : []));
233
+ const unique = [...new Set(hints)].slice(0, 3);
234
+ return unique.length ? unique : undefined;
235
+ }
236
+
237
+ function missingParameterMetadataCategory(db: Db, detail = false): Diagnostic & { count: number } {
212
238
  const examples = db.prepare(`SELECT sc.source_file sourceFile,sc.source_line sourceLine,sc.callee_expression calleeExpression
213
239
  FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
214
240
  WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
215
241
  AND (s.evidence_json IS NULL OR json_extract(s.evidence_json,'$.parameterBindings') IS NULL)
216
- ORDER BY sc.source_file,sc.source_line LIMIT 3`).all() as Diagnostic[];
242
+ ORDER BY sc.source_file,sc.source_line`).all() as Diagnostic[];
217
243
  const row = db.prepare(`SELECT COUNT(*) count FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
218
244
  WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
219
245
  AND (s.evidence_json IS NULL OR json_extract(s.evidence_json,'$.parameterBindings') IS NULL)`).get() as { count?: number };
220
- return { category: 'missing_parameter_metadata', reason: 'callee symbol is missing parameter binding metadata', candidateFamily: 'symbol_parameter_metadata', count: Number(row.count ?? 0), examples };
246
+ return { category: 'missing_parameter_metadata', reason: 'callee symbol is missing parameter binding metadata', candidateFamily: 'symbol_parameter_metadata', count: Number(row.count ?? 0), suggestedAction: categoryAction('missing_parameter_metadata'), examples: examples.slice(0, 3), expandedExamples: detail ? examples : undefined };
247
+ }
248
+
249
+ function dynamicWrapperCategory(db: Db, detail: boolean): Diagnostic & { count: number } {
250
+ const rows = db.prepare(`SELECT source_file sourceFile,source_line sourceLine,
251
+ json_extract(evidence_json,'$.receiver') receiverName,
252
+ COALESCE(json_extract(evidence_json,'$.missingPathIdentifier'),json_extract(evidence_json,'$.operationPathExpression')) pathIdentifier
253
+ FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier'
254
+ ORDER BY source_file,source_line`).all() as Diagnostic[];
255
+ return { category: 'dynamic_wrapper_paths', reason: 'wrapper path cannot be proven statically', candidateFamily: 'wrapper_path', count: rows.length, suggestedAction: categoryAction('dynamic_wrapper_paths'), examples: rows.slice(0, 3), expandedExamples: detail ? rows : undefined };
256
+ }
257
+
258
+ function implementationSummary(categories: Array<Diagnostic & { count: number }>): Diagnostic[] {
259
+ const grouped = new Map<string, Diagnostic & { count: number }>();
260
+ for (const category of categories) {
261
+ const key = [category.category, category.candidateFamily, category.reason].join('\0');
262
+ const current = grouped.get(key) ?? { category: category.category, candidateFamily: category.candidateFamily, reason: category.reason, severity: 'warning', suggestedAction: category.suggestedAction, count: 0 };
263
+ current.count += category.count;
264
+ grouped.set(key, current);
265
+ }
266
+ return [...grouped.values()].sort((left, right) => String(left.category).localeCompare(String(right.category)) || String(left.candidateFamily).localeCompare(String(right.candidateFamily)));
267
+ }
268
+
269
+ function categoryAction(category: string): string {
270
+ if (category === 'duplicate_package_name_candidates') return 'Use scoped --implementation-hint fields to select one repository for each ambiguous hop.';
271
+ if (category === 'missing_strong_ownership_evidence') return 'Add an explicit package dependency, local service-path ownership, or registration ownership evidence.';
272
+ if (category === 'missing_parameter_metadata') return 'Export a statically analyzable helper with named or destructured parameters.';
273
+ if (category === 'dynamic_wrapper_paths') return 'Pass a literal path or provide the reported runtime identifier with --var key=value.';
274
+ return 'Inspect the capped examples and add stronger implementation ownership evidence.';
221
275
  }
222
276
 
223
277
  function implementationCategory(row: Diagnostic, evidence: Diagnostic): string {
package/src/cli.ts CHANGED
@@ -24,6 +24,7 @@ import { linkWorkspace } from './linker/cross-repo-linker.js';
24
24
  import { doctorDiagnostics, linkUpgradeWarnings } from './cli/doctor.js';
25
25
  import { trace } from './trace/trace-engine.js';
26
26
  import { parseVars } from './trace/selectors.js';
27
+ import { parseImplementationHint } from './trace/implementation-hints.js';
27
28
  import { renderTraceTable } from './output/table-output.js';
28
29
  import { renderTraceJson, renderJson } from './output/json-output.js';
29
30
  import { renderMermaid } from './output/mermaid-output.js';
@@ -152,6 +153,7 @@ export function createProgram(): Command {
152
153
  .option('--include-db')
153
154
  .option('--include-async')
154
155
  .option('--implementation-repo <name>')
156
+ .option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
155
157
  .option('--var <key=value>', 'dynamic variable', collect, [])
156
158
  .action(
157
159
  (opts: {
@@ -167,6 +169,7 @@ export function createProgram(): Command {
167
169
  includeDb?: boolean;
168
170
  includeAsync?: boolean;
169
171
  implementationRepo?: string;
172
+ implementationHint: string[];
170
173
  var: string[];
171
174
  }) =>
172
175
  void withReadOnlyWorkspace(opts.workspace, (db) => {
@@ -186,6 +189,7 @@ export function createProgram(): Command {
186
189
  includeDb: Boolean(opts.includeDb),
187
190
  includeAsync: Boolean(opts.includeAsync),
188
191
  implementationRepo: opts.implementationRepo,
192
+ implementationHints: opts.implementationHint.map(parseImplementationHint),
189
193
  },
190
194
  );
191
195
  process.stdout.write(
@@ -293,6 +297,7 @@ export function createProgram(): Command {
293
297
  .option('--path <operationPath>')
294
298
  .option('--format <format>', 'mermaid|json', 'mermaid')
295
299
  .option('--implementation-repo <name>')
300
+ .option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
296
301
  .option('--var <key=value>', 'dynamic variable', collect, [])
297
302
  .action(
298
303
  (opts: {
@@ -303,6 +308,7 @@ export function createProgram(): Command {
303
308
  path?: string;
304
309
  format: string;
305
310
  implementationRepo?: string;
311
+ implementationHint: string[];
306
312
  var: string[];
307
313
  }) =>
308
314
  void withReadOnlyWorkspace(opts.workspace, (db) => {
@@ -321,6 +327,7 @@ export function createProgram(): Command {
321
327
  includeExternal: true,
322
328
  vars: parseVars(opts.var),
323
329
  implementationRepo: opts.implementationRepo,
330
+ implementationHints: opts.implementationHint.map(parseImplementationHint),
324
331
  },
325
332
  );
326
333
  process.stdout.write(
@@ -362,10 +369,11 @@ export function createProgram(): Command {
362
369
  .command('doctor')
363
370
  .option('--workspace <path>')
364
371
  .option('--strict')
372
+ .option('--detail')
365
373
  .action(
366
- (opts: { workspace?: string; strict?: boolean }) =>
374
+ (opts: { workspace?: string; strict?: boolean; detail?: boolean }) =>
367
375
  void withReadOnlyWorkspace(opts.workspace, (db) => {
368
- const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict));
376
+ const allDiagnostics = doctorDiagnostics(db, Boolean(opts.strict), { detail: Boolean(opts.detail) });
369
377
  process.stdout.write(
370
378
  allDiagnostics.length
371
379
  ? renderJson(allDiagnostics)
package/src/index.ts CHANGED
@@ -10,4 +10,6 @@ export { linkWorkspace } from './linker/cross-repo-linker.js';
10
10
  export { applyVariables, extractPlaceholders, substituteVariables } from './linker/dynamic-edge-resolver.js';
11
11
  export type { RuntimeSubstitution } from './linker/dynamic-edge-resolver.js';
12
12
  export { trace } from './trace/trace-engine.js';
13
+ export { parseImplementationHint } from './trace/implementation-hints.js';
14
+ export type { ImplementationHint } from './types.js';
13
15
  export { redactValue, redactText } from './utils/redaction.js';
@@ -6,6 +6,7 @@ import { resolveOperation } from './service-resolver.js';
6
6
  import { linkHelperPackages } from './helper-package-linker.js';
7
7
  import { normalizeDecoratorOperationSignal, normalizedOperationName } from './operation-decorator-normalizer.js';
8
8
  import { externalHttpTarget } from './external-http-target.js';
9
+ import { implementationHintSuggestions } from '../trace/implementation-hints.js';
9
10
  export interface LinkWorkspaceResult {
10
11
  edgeCount: number;
11
12
  unresolvedCount: number;
@@ -146,7 +147,29 @@ function objectJson(value: unknown): Record<string, unknown> | undefined {
146
147
  }
147
148
  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> {
148
149
  const bindingHasDynamicExpression = Boolean(Number(call.isDynamic ?? 0));
149
- 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, invocationArgumentPlaceholderKeys: normalized?.invocationArgumentPlaceholderKeys.length ? normalized.invocationArgumentPlaceholderKeys : 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, 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 };
150
+ const routingPlaceholderKeys = placeholderKeys([servicePath, destination, stringValue(call.aliasExpr), stringValue(call.alias)]);
151
+ 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 };
152
+ }
153
+
154
+ function compactCandidateScores(candidates: unknown[]): Array<Record<string, unknown>> {
155
+ return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
156
+ const row = objectValue(candidate);
157
+ if (!row) return [];
158
+ return [{ repo: row.repoName, servicePath: row.servicePath, operationPath: row.operationPath, score: row.score, reasons: row.reasons }];
159
+ });
160
+ }
161
+
162
+ function placeholderKeys(values: Array<string | undefined>): string[] {
163
+ const keys = values.flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim()).filter(Boolean));
164
+ return [...new Set(keys)].sort();
165
+ }
166
+
167
+ function objectValue(value: unknown): Record<string, unknown> | undefined {
168
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
169
+ }
170
+
171
+ function stringValue(value: unknown): string | undefined {
172
+ return typeof value === 'string' ? value : undefined;
150
173
  }
151
174
 
152
175
  function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
@@ -179,13 +202,14 @@ function linkImplementations(db: Db, workspaceId: number, generation: number): {
179
202
  candidateFamilies: duplicateFamilies,
180
203
  candidates: candidates.map((candidate, index) => candidateEvidence(candidate, index + 1)),
181
204
  };
205
+ const evidenceWithHints = unique ? evidence : { ...evidence, implementationHintSuggestions: implementationHintSuggestions(evidence) };
182
206
  if (accepted.length === 0) {
183
- 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(evidence), 0, 'No implementation candidate passed policy', generation);
207
+ 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);
184
208
  edgeCount += 1;
185
209
  unresolvedCount += 1;
186
210
  continue;
187
211
  }
188
- 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(evidence), 0, unique ? null : 'Ambiguous registered handler implementation candidates', generation);
212
+ 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);
189
213
  edgeCount += 1;
190
214
  if (unique) resolvedCount += 1;
191
215
  else ambiguousCount += 1;
@@ -2,6 +2,7 @@ export interface NormalizedODataOperationPath {
2
2
  rawOperationPath: string;
3
3
  normalizedOperationPath: string;
4
4
  wasInvocation: boolean;
5
+ invocationArguments?: string;
5
6
  invocationArgumentPlaceholderKeys: string[];
6
7
  normalizationReason?: string;
7
8
  normalizationRejectedReason?: string;
@@ -20,6 +21,7 @@ export interface ODataPathIntent {
20
21
  placeholderKeys: string[];
21
22
  keyPredicatePlaceholderKeys: string[];
22
23
  invocationArgumentPlaceholderKeys: string[];
24
+ invocationArguments?: string;
23
25
  navigationSuffix?: string;
24
26
  mediaOrPropertySuffix?: string;
25
27
  hasEntityKeyPredicate: boolean;
@@ -51,6 +53,7 @@ export function normalizeODataOperationInvocationPath(path: string | undefined):
51
53
  rawOperationPath: raw,
52
54
  normalizedOperationPath: operationSegment,
53
55
  wasInvocation: true,
56
+ invocationArguments: raw.slice(open + 1, close),
54
57
  invocationArgumentPlaceholderKeys: [...new Set(extractTemplatePlaceholders(raw.slice(open + 1, close)))],
55
58
  normalizationReason: 'balanced_top_level_operation_invocation',
56
59
  };
@@ -81,7 +84,7 @@ export function classifyODataPathIntent(path: string | undefined, method: string
81
84
  const topLevelOperationInvocation = Boolean(invocation?.wasInvocation && looksLikeLowerCamelInvocation(firstSegment));
82
85
  const keyPredicatePlaceholderKeys = topLevelOperationInvocation ? [] : rawKeyPredicatePlaceholderKeys;
83
86
  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 };
87
+ const base = { rawPath, method: normalizedMethod, pathWithoutQuery, queryString, hasQueryString: queryIndex >= 0, entitySegment, placeholderKeys, keyPredicatePlaceholderKeys, invocationArguments: invocation?.invocationArguments, invocationArgumentPlaceholderKeys: invocation?.invocationArgumentPlaceholderKeys ?? [], navigationSuffix, mediaOrPropertySuffix, hasEntityKeyPredicate: firstOpen >= 0 && firstClose !== undefined, hasNavigationSuffix: hasNavigationSegments, hasMediaOrPropertySuffix: Boolean(mediaOrPropertySuffix && isMediaOrPropertySuffix(mediaOrPropertySuffix)), topLevelOperationName, topLevelOperationNameCandidate, topLevelOperationInvocation };
85
88
  if (!rawPath || !rawPath.startsWith('/')) return { ...base, kind: 'unknown', reason: 'path_missing_or_not_absolute' };
86
89
  const upperEntityLike = /^[A-Z][A-Za-z0-9_]*$/.test(entitySegment ?? firstSegment);
87
90
  const mediaLike = isMediaOrPropertySuffix(segments.at(-1) ?? '');
@@ -15,7 +15,22 @@ export function renderTraceTable(result: TraceResult): string {
15
15
  const lines = ['Step Type From To Evidence'];
16
16
  for (const e of result.edges) {
17
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
+ const hint = firstHint(e.evidence);
19
+ if (e.unresolvedReason && hint) lines.push(` try ${hint}`);
18
20
  }
19
- if (result.diagnostics.length > 0) lines.push('', 'Diagnostics:', ...result.diagnostics.map((d) => `${String(d.severity ?? 'info')} ${String(d.code ?? 'diagnostic')} ${String(d.message ?? '')}`));
21
+ if (result.diagnostics.length > 0) lines.push('', 'Diagnostics:', ...result.diagnostics.flatMap(diagnosticLines));
20
22
  return `${lines.join('\n')}\n`;
21
23
  }
24
+
25
+ function diagnosticLines(diagnostic: Record<string, unknown>): string[] {
26
+ const first = `${String(diagnostic.severity ?? 'info')} ${String(diagnostic.code ?? 'diagnostic')} ${String(diagnostic.message ?? '')}`;
27
+ const hint = firstHint(diagnostic);
28
+ return hint ? [first, ` try ${hint}`] : [first];
29
+ }
30
+
31
+ function firstHint(evidence: Record<string, unknown>): string | undefined {
32
+ const suggestions = evidence.implementationHintSuggestions;
33
+ if (!Array.isArray(suggestions)) return undefined;
34
+ const first = suggestions.find((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object');
35
+ return typeof first?.cli === 'string' ? first.cli : undefined;
36
+ }