@saptools/service-flow 0.1.51 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +10 -5
  3. package/TECHNICAL-NOTE.md +8 -4
  4. package/dist/{chunk-YZJKE5UX.js → chunk-PTLDSHRC.js} +2412 -553
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +280 -506
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +45 -7
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/000-clean.ts +82 -0
  12. package/src/cli.ts +75 -57
  13. package/src/db/connection.ts +1 -1
  14. package/src/db/migrations.ts +5 -3
  15. package/src/db/repositories.ts +120 -22
  16. package/src/db/schema.ts +7 -2
  17. package/src/indexer/repository-indexer.ts +57 -29
  18. package/src/indexer/workspace-indexer.ts +84 -6
  19. package/src/linker/cross-repo-linker.ts +13 -1
  20. package/src/output/table-output.ts +29 -3
  21. package/src/parsers/cds-parser.ts +8 -2
  22. package/src/parsers/decorator-parser.ts +382 -48
  23. package/src/parsers/handler-registration-parser.ts +38 -21
  24. package/src/parsers/imported-wrapper-parser.ts +18 -5
  25. package/src/parsers/outbound-call-parser.ts +25 -8
  26. package/src/parsers/package-json-parser.ts +36 -11
  27. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  28. package/src/parsers/service-binding-parser.ts +6 -3
  29. package/src/parsers/symbol-parser.ts +13 -3
  30. package/src/parsers/ts-project.ts +54 -0
  31. package/src/trace/000-dynamic-target-types.ts +84 -0
  32. package/src/trace/001-dynamic-identity.ts +280 -0
  33. package/src/trace/002-trace-diagnostics.ts +54 -0
  34. package/src/trace/003-dynamic-references.ts +82 -0
  35. package/src/trace/dynamic-targets.ts +459 -229
  36. package/src/trace/evidence.ts +305 -46
  37. package/src/trace/selectors.ts +483 -0
  38. package/src/trace/trace-engine.ts +90 -83
  39. package/src/types.ts +27 -1
  40. package/dist/chunk-YZJKE5UX.js.map +0 -1
@@ -4,6 +4,7 @@ import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
4
4
  import type { OutboundCallFact } from '../types.js';
5
5
  import { normalizePath } from '../utils/path-utils.js';
6
6
  import { importsFor, lineOf, readSource, type ImportBinding } from './service-binding-parser-helpers.js';
7
+ import type { RepositorySourceContext } from './ts-project.js';
7
8
  import {
8
9
  analyzeOperationPath,
9
10
  operationPathExpression,
@@ -25,6 +26,7 @@ export async function parseImportedWrapperCalls(
25
26
  filePath: string,
26
27
  source: ts.SourceFile,
27
28
  serviceBindings: Set<string>,
29
+ context?: RepositorySourceContext,
28
30
  ): Promise<OutboundCallFact[]> {
29
31
  const imports = await importsFor(repoPath, filePath, source);
30
32
  const importedByLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
@@ -35,7 +37,7 @@ export async function parseImportedWrapperCalls(
35
37
  if (!ts.isIdentifier(call.expression)) continue;
36
38
  const imported = importedByLocal.get(call.expression.text);
37
39
  if (!imported?.sourceFile) continue;
38
- const spec = await loadWrapperSpec(repoPath, imported, cache, 0);
40
+ const spec = await loadWrapperSpec(repoPath, imported, cache, 0, context);
39
41
  const fact = spec ? wrapperCallFact(source, filePath, call, spec, serviceBindings) : undefined;
40
42
  if (fact) out.push(fact);
41
43
  }
@@ -57,12 +59,15 @@ async function loadWrapperSpec(
57
59
  imported: ImportBinding,
58
60
  cache: Map<string, Promise<WrapperSpec | undefined>>,
59
61
  depth: number,
62
+ context?: RepositorySourceContext,
60
63
  ): Promise<WrapperSpec | undefined> {
61
64
  if (!imported.sourceFile || depth > 5) return undefined;
62
65
  const key = `${imported.sourceFile}#${imported.exportedName}`;
63
66
  const existing = cache.get(key);
64
67
  if (existing) return existing;
65
- const pending = inspectWrapper(repoPath, imported.sourceFile, imported.exportedName, cache, depth);
68
+ const pending = inspectWrapper(
69
+ repoPath, imported.sourceFile, imported.exportedName, cache, depth, context,
70
+ );
66
71
  cache.set(key, pending);
67
72
  return pending;
68
73
  }
@@ -73,14 +78,19 @@ async function inspectWrapper(
73
78
  exportedName: string,
74
79
  cache: Map<string, Promise<WrapperSpec | undefined>>,
75
80
  depth: number,
81
+ context?: RepositorySourceContext,
76
82
  ): Promise<WrapperSpec | undefined> {
77
- const source = await readSource(path.join(repoPath, sourceFile));
83
+ const source = await readSource(
84
+ path.join(repoPath, sourceFile), context, sourceFile,
85
+ );
78
86
  if (!source) return undefined;
79
87
  const named = findFunction(source, exportedName);
80
88
  if (!named) return undefined;
81
89
  const direct = directSendSpec(source, sourceFile, named.name, named.fn);
82
90
  if (direct) return direct;
83
- return nestedSendSpec(repoPath, sourceFile, source, named.name, named.fn, cache, depth);
91
+ return nestedSendSpec(
92
+ repoPath, sourceFile, source, named.name, named.fn, cache, depth, context,
93
+ );
84
94
  }
85
95
 
86
96
  function directSendSpec(source: ts.SourceFile, sourceFile: string, name: string, fn: ts.FunctionLikeDeclaration): WrapperSpec | undefined {
@@ -113,6 +123,7 @@ async function nestedSendSpec(
113
123
  fn: ts.FunctionLikeDeclaration,
114
124
  cache: Map<string, Promise<WrapperSpec | undefined>>,
115
125
  depth: number,
126
+ context?: RepositorySourceContext,
116
127
  ): Promise<WrapperSpec | undefined> {
117
128
  const imports = await importsFor(repoPath, sourceFile, source);
118
129
  const byLocal = new Map(imports.filter((item) => item.sourceFile).map((item) => [item.localName, item]));
@@ -123,7 +134,9 @@ async function nestedSendSpec(
123
134
  if (calls.length !== 1) return undefined;
124
135
  const call = calls[0];
125
136
  const imported = call && ts.isIdentifier(call.expression) ? byLocal.get(call.expression.text) : undefined;
126
- const nested = imported ? await loadWrapperSpec(repoPath, imported, cache, depth + 1) : undefined;
137
+ const nested = imported
138
+ ? await loadWrapperSpec(repoPath, imported, cache, depth + 1, context)
139
+ : undefined;
127
140
  if (!call || !nested) return undefined;
128
141
  const params = parameterNames(fn);
129
142
  const clientIndex = mappedParameterIndex(call.arguments[nested.clientIndex], params);
@@ -8,6 +8,7 @@ import { summarizeExpression } from '../utils/redaction.js';
8
8
  import { classifyODataPathIntent } from '../linker/odata-path-normalizer.js';
9
9
  import { parseServiceBindings } from './service-binding-parser.js';
10
10
  import { parseImportedWrapperCalls } from './imported-wrapper-parser.js';
11
+ import type { RepositorySourceContext } from './ts-project.js';
11
12
  import {
12
13
  analyzeOperationPath,
13
14
  operationPathExpression,
@@ -510,15 +511,31 @@ export function containsSupportedOutboundCall(node: ts.Node): boolean {
510
511
  export async function parseOutboundCalls(
511
512
  repoPath: string,
512
513
  filePath: string,
514
+ context?: RepositorySourceContext,
513
515
  ): Promise<OutboundCallFact[]> {
514
- const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
515
- const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
516
- const bindingNames = new Set((await parseServiceBindings(repoPath, filePath)).map((binding) => binding.variableName));
517
- const importedWrappers = await parseImportedWrapperCalls(repoPath, filePath, source, bindingNames);
518
- return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...importedWrappers, ...parseLocalServiceCalls(text, filePath)];
519
- }
520
- function parseLocalServiceCalls(text: string, filePath: string): OutboundCallFact[] {
521
- const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
516
+ const snapshot = context?.get(filePath);
517
+ const text = snapshot?.text
518
+ ?? await fs.readFile(path.join(repoPath, filePath), 'utf8');
519
+ const source = snapshot?.sourceFile() ?? ts.createSourceFile(
520
+ filePath, text, ts.ScriptTarget.Latest, true,
521
+ filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS,
522
+ );
523
+ const bindingNames = new Set((await parseServiceBindings(
524
+ repoPath, filePath, context,
525
+ )).map((binding) => binding.variableName));
526
+ const importedWrappers = await parseImportedWrapperCalls(
527
+ repoPath, filePath, source, bindingNames, context,
528
+ );
529
+ return [...classifyOutboundCallsInSource(source, filePath).map((call) => call.fact), ...importedWrappers, ...parseLocalServiceCalls(text, filePath, source)];
530
+ }
531
+ function parseLocalServiceCalls(
532
+ text: string,
533
+ filePath: string,
534
+ source = ts.createSourceFile(
535
+ filePath, text, ts.ScriptTarget.Latest, true,
536
+ filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS,
537
+ ),
538
+ ): OutboundCallFact[] {
522
539
  const aliases = new Map<string, { service: string; lookup: string; chain: string[] }>();
523
540
  const calls: OutboundCallFact[] = [];
524
541
  const visit = (node: ts.Node): void => {
@@ -1,6 +1,17 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import type { CdsRequire, PackageFacts } from '../types.js';
4
+ interface ParsePackageJsonOptions {
5
+ strict?: boolean;
6
+ allowMissing?: boolean;
7
+ }
8
+ export interface PackageJsonSnapshot {
9
+ facts: PackageFacts;
10
+ rawText: string;
11
+ }
12
+ function emptyPackageFacts(): PackageFacts {
13
+ return { dependencies: {}, cdsRequires: [], scripts: {} };
14
+ }
4
15
  function recordOfString(value: unknown): Record<string, string> {
5
16
  if (!value || typeof value !== 'object') return {};
6
17
  return Object.fromEntries(
@@ -41,23 +52,37 @@ function readRequires(cds: unknown): CdsRequire[] {
41
52
  });
42
53
  }
43
54
  export async function parsePackageJson(
44
- repoPath: string
55
+ repoPath: string,
56
+ options: ParsePackageJsonOptions = {},
45
57
  ): Promise<PackageFacts> {
58
+ return (await loadPackageJsonSnapshot(repoPath, options)).facts;
59
+ }
60
+ export async function loadPackageJsonSnapshot(
61
+ repoPath: string,
62
+ options: ParsePackageJsonOptions = {},
63
+ ): Promise<PackageJsonSnapshot> {
46
64
  try {
47
65
  const raw = await fs.readFile(path.join(repoPath, 'package.json'), 'utf8');
48
66
  const json = JSON.parse(raw) as Record<string, unknown>;
49
67
  return {
50
- packageName: typeof json.name === 'string' ? json.name : undefined,
51
- packageVersion:
52
- typeof json.version === 'string' ? json.version : undefined,
53
- dependencies: {
54
- ...recordOfString(json.dependencies),
55
- ...recordOfString(json.devDependencies)
68
+ rawText: raw,
69
+ facts: {
70
+ packageName: typeof json.name === 'string' ? json.name : undefined,
71
+ packageVersion:
72
+ typeof json.version === 'string' ? json.version : undefined,
73
+ dependencies: {
74
+ ...recordOfString(json.dependencies),
75
+ ...recordOfString(json.devDependencies),
76
+ },
77
+ cdsRequires: readRequires(json.cds),
78
+ scripts: recordOfString(json.scripts),
56
79
  },
57
- cdsRequires: readRequires(json.cds),
58
- scripts: recordOfString(json.scripts)
59
80
  };
60
- } catch {
61
- return { dependencies: {}, cdsRequires: [], scripts: {} };
81
+ } catch (error) {
82
+ const missing = typeof error === 'object' && error !== null
83
+ && 'code' in error && error.code === 'ENOENT';
84
+ if (!options.strict || (options.allowMissing && missing))
85
+ return { facts: emptyPackageFacts(), rawText: '' };
86
+ throw error;
62
87
  }
63
88
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import ts from 'typescript';
4
4
  import { normalizePath } from '../utils/path-utils.js';
5
+ import type { RepositorySourceContext } from './ts-project.js';
5
6
 
6
7
  export interface HelperBinding {
7
8
  exportedName: string;
@@ -123,7 +124,13 @@ export function findConnectInExpression(expr: ts.Expression): Omit<HelperBinding
123
124
  return found;
124
125
  }
125
126
 
126
- export async function readSource(abs: string): Promise<ts.SourceFile | undefined> {
127
+ export async function readSource(
128
+ abs: string,
129
+ context?: RepositorySourceContext,
130
+ filePath?: string,
131
+ ): Promise<ts.SourceFile | undefined> {
132
+ const snapshot = filePath ? context?.get(filePath) : undefined;
133
+ if (snapshot) return snapshot.sourceFile();
127
134
  try {
128
135
  const text = await fs.readFile(abs, 'utf8');
129
136
  return ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
@@ -15,6 +15,7 @@ import {
15
15
  type HelperBinding,
16
16
  type ImportBinding,
17
17
  } from './service-binding-parser-helpers.js';
18
+ import type { RepositorySourceContext } from './ts-project.js';
18
19
 
19
20
  function collectLocalBindingFacts(
20
21
  fn: ts.FunctionLikeDeclaration,
@@ -125,8 +126,9 @@ function exportedLocalNames(sf: ts.SourceFile): Map<string, string> {
125
126
  async function helperBindings(
126
127
  repoPath: string,
127
128
  filePath: string,
129
+ context?: RepositorySourceContext,
128
130
  ): Promise<HelperBinding[]> {
129
- const sf = await readSource(path.join(repoPath, filePath));
131
+ const sf = await readSource(path.join(repoPath, filePath), context, filePath);
130
132
  if (!sf) return [];
131
133
  const sourceFileAst = sf;
132
134
  const out: HelperBinding[] = [];
@@ -195,8 +197,9 @@ async function helperBindings(
195
197
  export async function parseServiceBindings(
196
198
  repoPath: string,
197
199
  filePath: string,
200
+ context?: RepositorySourceContext,
198
201
  ): Promise<ServiceBindingFact[]> {
199
- const sf = await readSource(path.join(repoPath, filePath));
202
+ const sf = await readSource(path.join(repoPath, filePath), context, filePath);
200
203
  if (!sf) return [];
201
204
  const sourceFileAst = sf;
202
205
  const out: ServiceBindingFact[] = [];
@@ -237,7 +240,7 @@ export async function parseServiceBindings(
237
240
  if (!helperCache.has(imp.sourceFile))
238
241
  helperCache.set(
239
242
  imp.sourceFile,
240
- await helperBindings(repoPath, imp.sourceFile),
243
+ await helperBindings(repoPath, imp.sourceFile, context),
241
244
  );
242
245
  return (helperCache.get(imp.sourceFile) ?? [])
243
246
  .filter((h) => h.exportedName === imp.exportedName)
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import ts from 'typescript';
4
4
  import type { ExecutableSymbolFact, SymbolCallFact } from '../types.js';
5
5
  import { containsSupportedOutboundCall } from './outbound-call-parser.js';
6
+ import type { RepositorySourceContext } from './ts-project.js';
6
7
  import { normalizePath } from '../utils/path-utils.js';
7
8
 
8
9
  function lineOf(source: ts.SourceFile, pos: number): number {
@@ -167,9 +168,18 @@ function parameterBindings(params: ts.NodeArray<ts.ParameterDeclaration>): Param
167
168
  return [];
168
169
  });
169
170
  }
170
- export async function parseExecutableSymbols(repoPath: string, filePath: string): Promise<{ symbols: ExecutableSymbolFact[]; calls: SymbolCallFact[] }> {
171
- const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
172
- const source = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
171
+ export async function parseExecutableSymbols(
172
+ repoPath: string,
173
+ filePath: string,
174
+ context?: RepositorySourceContext,
175
+ ): Promise<{ symbols: ExecutableSymbolFact[]; calls: SymbolCallFact[] }> {
176
+ const snapshot = context?.get(filePath);
177
+ const text = snapshot?.text
178
+ ?? await fs.readFile(path.join(repoPath, filePath), 'utf8');
179
+ const source = snapshot?.sourceFile() ?? ts.createSourceFile(
180
+ filePath, text, ts.ScriptTarget.Latest, true,
181
+ filePath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS,
182
+ );
173
183
  const sourceFile = normalizePath(filePath);
174
184
  const symbols: ExecutableSymbolFact[] = [];
175
185
  const calls: SymbolCallFact[] = [];
@@ -1,4 +1,58 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
1
3
  import ts from 'typescript';
4
+ import { normalizePath } from '../utils/path-utils.js';
5
+
6
+ export interface SourceContextInstrumentation {
7
+ onSourceRead?: (
8
+ repoPath: string,
9
+ filePath: string,
10
+ ) => void | Promise<void>;
11
+ onAstCreated?: (repoPath: string, filePath: string) => void;
12
+ }
13
+
14
+ export interface SourceFileSnapshot {
15
+ repoPath: string;
16
+ filePath: string;
17
+ text: string;
18
+ sizeBytes: number;
19
+ sourceFile: () => ts.SourceFile;
20
+ }
21
+
22
+ export interface RepositorySourceContext {
23
+ get: (filePath: string) => SourceFileSnapshot | undefined;
24
+ entries: () => SourceFileSnapshot[];
25
+ }
26
+
27
+ export async function loadRepositorySourceContext(
28
+ repoPath: string,
29
+ filePaths: string[],
30
+ instrumentation?: SourceContextInstrumentation,
31
+ ): Promise<RepositorySourceContext> {
32
+ const snapshots = new Map<string, SourceFileSnapshot>();
33
+ for (const inputPath of filePaths) {
34
+ const filePath = normalizePath(inputPath);
35
+ await instrumentation?.onSourceRead?.(repoPath, filePath);
36
+ const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
37
+ let ast: ts.SourceFile | undefined;
38
+ snapshots.set(filePath, {
39
+ repoPath,
40
+ filePath,
41
+ text,
42
+ sizeBytes: Buffer.byteLength(text),
43
+ sourceFile: () => {
44
+ if (ast) return ast;
45
+ instrumentation?.onAstCreated?.(repoPath, filePath);
46
+ ast = createSourceFile(filePath, text);
47
+ return ast;
48
+ },
49
+ });
50
+ }
51
+ return {
52
+ get: (filePath) => snapshots.get(normalizePath(filePath)),
53
+ entries: () => [...snapshots.values()],
54
+ };
55
+ }
2
56
  export function createSourceFile(
3
57
  filePath: string,
4
58
  text: string
@@ -0,0 +1,84 @@
1
+ import type { DynamicMode } from '../types.js';
2
+
3
+ export interface DynamicTemplates {
4
+ servicePath?: string;
5
+ operationPath?: string;
6
+ alias?: string;
7
+ destination?: string;
8
+ }
9
+
10
+ export interface DynamicVariableProvenance extends Record<string, unknown> {
11
+ sourceKind: string;
12
+ value: string;
13
+ rule: string;
14
+ template?: string;
15
+ matchedName?: string;
16
+ normalizedForm?: string;
17
+ sourceRepo?: string;
18
+ sourceFile?: string;
19
+ sourceLine?: number;
20
+ }
21
+
22
+ export interface DynamicVariableConflict {
23
+ key: string;
24
+ values: string[];
25
+ reason: string;
26
+ sources: string[];
27
+ }
28
+
29
+ export interface DynamicTargetCandidate {
30
+ candidateOperationId: number;
31
+ repoId?: number;
32
+ repoName: string;
33
+ packageName?: string;
34
+ serviceName: string;
35
+ qualifiedName: string;
36
+ servicePath: string;
37
+ operationPath: string;
38
+ operationName: string;
39
+ sourceFile: string;
40
+ sourceLine: number;
41
+ originalTemplates: DynamicTemplates;
42
+ effectiveValues: DynamicTemplates;
43
+ requiredVariables: string[];
44
+ requiredVariableSources: Record<string, string[]>;
45
+ suppliedVariables: Record<string, string>;
46
+ completeVariables: Record<string, string>;
47
+ derivedVariables: Record<string, string>;
48
+ derivedVariableSources: Record<string, DynamicVariableProvenance>;
49
+ derivationProvenance: Record<string, DynamicVariableProvenance[]>;
50
+ missingVariables: string[];
51
+ conflicts: DynamicVariableConflict[];
52
+ score: number;
53
+ explicitSignalStrength: number;
54
+ reasons: string[];
55
+ rejectedReasons: string[];
56
+ inferenceBlockReasons: string[];
57
+ viable: boolean;
58
+ rejected: boolean;
59
+ selected: boolean;
60
+ exploratory: boolean;
61
+ cli?: string;
62
+ }
63
+
64
+ export interface DynamicTargetAnalysis {
65
+ mode: DynamicMode;
66
+ maxCandidates: number;
67
+ candidateCount: number;
68
+ viableCandidateCount: number;
69
+ rejectedCandidateCount: number;
70
+ shownCandidateCount: number;
71
+ omittedCandidateCount: number;
72
+ shownRejectedCandidateCount: number;
73
+ omittedRejectedCandidateCount: number;
74
+ missingVariables: string[];
75
+ requiredVariables: string[];
76
+ suppliedVariables: Record<string, string>;
77
+ appliedSuppliedVariables: Record<string, string>;
78
+ substitutedSignals: DynamicTemplates;
79
+ candidates: DynamicTargetCandidate[];
80
+ shownCandidates: DynamicTargetCandidate[];
81
+ rejectedCandidates: DynamicTargetCandidate[];
82
+ suggestedVarSets: Array<{ variables: Record<string, string>; cli: string }>;
83
+ inference: Record<string, unknown>;
84
+ }