@saptools/service-flow 0.1.44 → 0.1.45

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-BXSCE5CR.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.45",
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
@@ -3,13 +3,16 @@ import { classifyODataPathIntent, normalizeODataOperationInvocationPath } from '
3
3
  import { ANALYZER_VERSION } from '../version.js';
4
4
 
5
5
  type Diagnostic = Record<string, unknown>;
6
+ interface DoctorOptions {
7
+ detail?: boolean;
8
+ }
6
9
 
7
10
  export function linkUpgradeWarnings(db: Db): Diagnostic[] {
8
11
  return [...schemaDriftDiagnostics(db, true), ...analyzerVersionDiagnostics(db, true)]
9
12
  .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
13
  }
11
14
 
12
- export function doctorDiagnostics(db: Db, strict: boolean): Diagnostic[] {
15
+ export function doctorDiagnostics(db: Db, strict: boolean, options: DoctorOptions = {}): Diagnostic[] {
13
16
  const diagnostics = db.prepare('SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics ORDER BY id').all() as Diagnostic[];
14
17
  return [
15
18
  ...diagnostics,
@@ -17,7 +20,7 @@ export function doctorDiagnostics(db: Db, strict: boolean): Diagnostic[] {
17
20
  ...localServiceDiagnostics(db, strict),
18
21
  ...schemaDriftDiagnostics(db, strict),
19
22
  ...analyzerVersionDiagnostics(db, strict),
20
- ...parserQualityDiagnostics(db, strict),
23
+ ...parserQualityDiagnostics(db, strict, options),
21
24
  ];
22
25
  }
23
26
 
@@ -99,7 +102,7 @@ function localServiceDiagnostics(db: Db, strict: boolean): Diagnostic[] {
99
102
  return out;
100
103
  }
101
104
 
102
- function parserQualityDiagnostics(db: Db, strict: boolean): Diagnostic[] {
105
+ function parserQualityDiagnostics(db: Db, strict: boolean, options: DoctorOptions): Diagnostic[] {
103
106
  if (!strict) return [];
104
107
  const symbol = symbolCallQuality(db);
105
108
  const dbq = dbQueryQuality(db);
@@ -108,7 +111,7 @@ function parserQualityDiagnostics(db: Db, strict: boolean): Diagnostic[] {
108
111
  identityAliasBindingQuality(db),
109
112
  remoteActionNoBindingQuality(db),
110
113
  contextualImplementationQuality(db),
111
- implementationCandidateQuality(db),
114
+ implementationCandidateQuality(db, Boolean(options.detail)),
112
115
  classInstanceNoiseQuality(db),
113
116
  contextualBindingPropagationQuality(db),
114
117
  nestedThisReceiverQuality(db),
@@ -178,20 +181,26 @@ function graphDynamicFlagQuality(db: Db): Diagnostic {
178
181
  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
182
  }
180
183
 
181
- function implementationCandidateQuality(db: Db): Diagnostic {
182
- const categories = [...implementationEdgeCategories(db), missingParameterMetadataCategory(db)].filter((item) => item.count > 0);
184
+ function implementationCandidateQuality(db: Db, detail: boolean): Diagnostic {
185
+ const categories = [...implementationEdgeCategories(db, detail), missingParameterMetadataCategory(db, detail), dynamicWrapperCategory(db, detail)].filter((item) => item.count > 0);
183
186
  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 };
187
+ return { severity: total > 0 ? 'warning' : 'info', code: 'strict_implementation_candidate_quality', message: 'Implementation candidate ambiguity and rejection aggregate', total, summary: implementationSummary(categories), categories };
185
188
  }
186
189
 
187
- function implementationEdgeCategories(db: Db): Array<Diagnostic & { count: number }> {
190
+ function implementationEdgeCategories(db: Db, detail: boolean): Array<Diagnostic & { count: number }> {
188
191
  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
192
  FROM graph_edges e JOIN cds_operations o ON o.id=CAST(e.from_id AS INTEGER)
190
193
  JOIN cds_services s ON s.id=o.service_id LEFT JOIN cds_operations base ON base.id=o.base_operation_id
191
194
  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
195
  const grouped = new Map<string, Diagnostic & { count: number; servicePaths: string[]; examples: Diagnostic[] }>();
193
196
  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) }));
197
+ return [...grouped.values()].map(({ servicePaths, ...item }) => ({
198
+ ...item,
199
+ servicePathPattern: pathPattern(servicePaths),
200
+ suggestedAction: categoryAction(String(item.category)),
201
+ examples: item.examples.slice(0, 3),
202
+ expandedExamples: detail ? item.examples : undefined,
203
+ }));
195
204
  }
196
205
 
197
206
  function addImplementationCategory(grouped: Map<string, Diagnostic & { count: number; servicePaths: string[]; examples: Diagnostic[] }>, row: Diagnostic): void {
@@ -208,16 +217,44 @@ function addImplementationCategory(grouped: Map<string, Diagnostic & { count: nu
208
217
  grouped.set(key, current);
209
218
  }
210
219
 
211
- function missingParameterMetadataCategory(db: Db): Diagnostic & { count: number } {
220
+ function missingParameterMetadataCategory(db: Db, detail = false): Diagnostic & { count: number } {
212
221
  const examples = db.prepare(`SELECT sc.source_file sourceFile,sc.source_line sourceLine,sc.callee_expression calleeExpression
213
222
  FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
214
223
  WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
215
224
  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[];
225
+ ORDER BY sc.source_file,sc.source_line`).all() as Diagnostic[];
217
226
  const row = db.prepare(`SELECT COUNT(*) count FROM symbol_calls sc JOIN symbols s ON s.id=sc.callee_symbol_id
218
227
  WHERE sc.status='resolved' AND json_extract(sc.evidence_json,'$.callArguments[0].kind') IN ('identifier','object_literal')
219
228
  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 };
229
+ 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 };
230
+ }
231
+
232
+ function dynamicWrapperCategory(db: Db, detail: boolean): Diagnostic & { count: number } {
233
+ const rows = db.prepare(`SELECT source_file sourceFile,source_line sourceLine,
234
+ json_extract(evidence_json,'$.receiver') receiverName,
235
+ COALESCE(json_extract(evidence_json,'$.missingPathIdentifier'),json_extract(evidence_json,'$.operationPathExpression')) pathIdentifier
236
+ FROM outbound_calls WHERE call_type='remote_action' AND unresolved_reason='dynamic_operation_path_identifier'
237
+ ORDER BY source_file,source_line`).all() as Diagnostic[];
238
+ 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 };
239
+ }
240
+
241
+ function implementationSummary(categories: Array<Diagnostic & { count: number }>): Diagnostic[] {
242
+ const grouped = new Map<string, Diagnostic & { count: number }>();
243
+ for (const category of categories) {
244
+ const key = [category.category, category.candidateFamily, category.reason].join('\0');
245
+ const current = grouped.get(key) ?? { category: category.category, candidateFamily: category.candidateFamily, reason: category.reason, severity: 'warning', suggestedAction: category.suggestedAction, count: 0 };
246
+ current.count += category.count;
247
+ grouped.set(key, current);
248
+ }
249
+ return [...grouped.values()].sort((left, right) => String(left.category).localeCompare(String(right.category)) || String(left.candidateFamily).localeCompare(String(right.candidateFamily)));
250
+ }
251
+
252
+ function categoryAction(category: string): string {
253
+ if (category === 'duplicate_package_name_candidates') return 'Use scoped --implementation-hint fields to select one repository for each ambiguous hop.';
254
+ if (category === 'missing_strong_ownership_evidence') return 'Add an explicit package dependency, local service-path ownership, or registration ownership evidence.';
255
+ if (category === 'missing_parameter_metadata') return 'Export a statically analyzable helper with named or destructured parameters.';
256
+ if (category === 'dynamic_wrapper_paths') return 'Pass a literal path or provide the reported runtime identifier with --var key=value.';
257
+ return 'Inspect the capped examples and add stronger implementation ownership evidence.';
221
258
  }
222
259
 
223
260
  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';
@@ -146,7 +146,29 @@ function objectJson(value: unknown): Record<string, unknown> | undefined {
146
146
  }
147
147
  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
148
  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 };
149
+ const routingPlaceholderKeys = placeholderKeys([servicePath, destination, stringValue(call.aliasExpr), stringValue(call.alias)]);
150
+ 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 };
151
+ }
152
+
153
+ function compactCandidateScores(candidates: unknown[]): Array<Record<string, unknown>> {
154
+ return candidates.flatMap((candidate): Array<Record<string, unknown>> => {
155
+ const row = objectValue(candidate);
156
+ if (!row) return [];
157
+ return [{ repo: row.repoName, servicePath: row.servicePath, operationPath: row.operationPath, score: row.score, reasons: row.reasons }];
158
+ });
159
+ }
160
+
161
+ function placeholderKeys(values: Array<string | undefined>): string[] {
162
+ const keys = values.flatMap((value) => [...(value ?? '').matchAll(/\$\{([^}]*)\}/g)].map((match) => (match[1] ?? '').trim()).filter(Boolean));
163
+ return [...new Set(keys)].sort();
164
+ }
165
+
166
+ function objectValue(value: unknown): Record<string, unknown> | undefined {
167
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
168
+ }
169
+
170
+ function stringValue(value: unknown): string | undefined {
171
+ return typeof value === 'string' ? value : undefined;
150
172
  }
151
173
 
152
174
  function linkImplementations(db: Db, workspaceId: number, generation: number): { edgeCount: number; resolvedCount: number; ambiguousCount: number; unresolvedCount: number } {
@@ -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) ?? '');
@@ -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);