@saptools/service-flow 0.1.50 → 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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -3
  3. package/TECHNICAL-NOTE.md +10 -1
  4. package/dist/{chunk-52OUS3MO.js → chunk-PTLDSHRC.js} +2663 -363
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +318 -510
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +59 -17
  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 +97 -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/index.ts +1 -1
  18. package/src/indexer/repository-indexer.ts +57 -29
  19. package/src/indexer/workspace-indexer.ts +84 -6
  20. package/src/linker/cross-repo-linker.ts +22 -2
  21. package/src/linker/service-resolver.ts +15 -4
  22. package/src/output/table-output.ts +56 -3
  23. package/src/parsers/cds-parser.ts +8 -2
  24. package/src/parsers/decorator-parser.ts +382 -48
  25. package/src/parsers/handler-registration-parser.ts +38 -21
  26. package/src/parsers/imported-wrapper-parser.ts +18 -5
  27. package/src/parsers/outbound-call-parser.ts +25 -8
  28. package/src/parsers/package-json-parser.ts +36 -11
  29. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  30. package/src/parsers/service-binding-parser.ts +6 -3
  31. package/src/parsers/symbol-parser.ts +13 -3
  32. package/src/parsers/ts-project.ts +54 -0
  33. package/src/trace/000-dynamic-target-types.ts +84 -0
  34. package/src/trace/001-dynamic-identity.ts +280 -0
  35. package/src/trace/002-trace-diagnostics.ts +54 -0
  36. package/src/trace/003-dynamic-references.ts +82 -0
  37. package/src/trace/dynamic-branches.ts +45 -0
  38. package/src/trace/dynamic-targets.ts +654 -0
  39. package/src/trace/evidence.ts +391 -22
  40. package/src/trace/selectors.ts +483 -0
  41. package/src/trace/trace-engine.ts +101 -94
  42. package/src/types.ts +39 -1
  43. package/dist/chunk-52OUS3MO.js.map +0 -1
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import ts from 'typescript';
5
5
  import type { HandlerRegistrationFact } from '../types.js';
6
6
  import { normalizePath } from '../utils/path-utils.js';
7
+ import type { RepositorySourceContext } from './ts-project.js';
7
8
 
8
9
  interface ImportEvidence { importedName: string; source: string }
9
10
  interface ClassEvidence { className: string; importSource?: string }
@@ -26,16 +27,26 @@ function importSourceFor(identifier: string, imports: Map<string, ImportEvidence
26
27
  return evidence ? `${evidence.source}#${evidence.importedName}` : undefined;
27
28
  }
28
29
 
29
- export async function parseHandlerRegistrations(repoPath: string, filePath: string): Promise<HandlerRegistrationFact[]> {
30
+ export async function parseHandlerRegistrations(
31
+ repoPath: string,
32
+ filePath: string,
33
+ context?: RepositorySourceContext,
34
+ ): Promise<HandlerRegistrationFact[]> {
30
35
  const absolutePath = path.join(repoPath, filePath);
31
- const text = await fs.readFile(absolutePath, 'utf8');
32
- const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
36
+ const snapshot = context?.get(filePath);
37
+ const text = snapshot?.text ?? await fs.readFile(absolutePath, 'utf8');
38
+ const sourceFile = snapshot?.sourceFile() ?? ts.createSourceFile(
39
+ filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS,
40
+ );
33
41
  const imports = collectImports(sourceFile);
34
- const localArrays = collectLocalArrays(sourceFile, imports, new Map(), repoPath, filePath);
42
+ const localArrays = collectLocalArrays(
43
+ sourceFile, imports, new Map(), repoPath, filePath, context,
44
+ );
35
45
  const out: HandlerRegistrationFact[] = [];
36
-
37
46
  function emitFromExpression(expression: ts.Expression, call: ts.CallExpression): void {
38
- const classes = resolveArrayExpression(expression, localArrays, imports, repoPath, filePath, new Set());
47
+ const classes = resolveArrayExpression(
48
+ expression, localArrays, imports, repoPath, filePath, new Set(), context,
49
+ );
39
50
  for (const cls of classes) {
40
51
  out.push({
41
52
  className: cls.className,
@@ -55,7 +66,6 @@ export async function parseHandlerRegistrations(repoPath: string, filePath: stri
55
66
  });
56
67
  }
57
68
  }
58
-
59
69
  function visit(node: ts.Node): void {
60
70
  if (ts.isCallExpression(node) && isRegistrationCall(node)) {
61
71
  const handlerExpr = handlerExpression(node, sourceFile);
@@ -98,45 +108,47 @@ function collectImports(sourceFile: ts.SourceFile): Map<string, ImportEvidence>
98
108
  }
99
109
  return imports;
100
110
  }
101
- function collectLocalArrays(sourceFile: ts.SourceFile, imports: Map<string, ImportEvidence>, seed: Map<string, ClassEvidence[]>, repoPath = '', fromFile = ''): Map<string, ClassEvidence[]> {
111
+ function collectLocalArrays(sourceFile: ts.SourceFile, imports: Map<string, ImportEvidence>, seed: Map<string, ClassEvidence[]>, repoPath = '', fromFile = '', context?: RepositorySourceContext): Map<string, ClassEvidence[]> {
102
112
  const arrays = new Map(seed);
103
113
  for (const statement of sourceFile.statements) {
104
114
  if (ts.isVariableStatement(statement)) {
105
115
  for (const decl of statement.declarationList.declarations) {
106
116
  if (ts.isIdentifier(decl.name) && decl.initializer && ts.isArrayLiteralExpression(decl.initializer)) {
107
- arrays.set(decl.name.text, resolveArrayLiteral(decl.initializer, arrays, imports, repoPath, fromFile, new Set()));
117
+ arrays.set(decl.name.text, resolveArrayLiteral(
118
+ decl.initializer, arrays, imports, repoPath, fromFile, new Set(), context,
119
+ ));
108
120
  }
109
121
  }
110
122
  }
111
123
  }
112
124
  return arrays;
113
125
  }
114
- function resolveArrayExpression(expr: ts.Expression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>): ClassEvidence[] {
115
- if (ts.isArrayLiteralExpression(expr)) return resolveArrayLiteral(expr, arrays, imports, repoPath, fromFile, seen);
126
+ function resolveArrayExpression(expr: ts.Expression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>, context?: RepositorySourceContext): ClassEvidence[] {
127
+ if (ts.isArrayLiteralExpression(expr)) return resolveArrayLiteral(expr, arrays, imports, repoPath, fromFile, seen, context);
116
128
  if (ts.isIdentifier(expr)) {
117
129
  const local = arrays.get(expr.text);
118
130
  if (local) return local;
119
131
  const evidence = imports.get(expr.text);
120
- if (evidence && isRelative(evidence.source)) return resolveImportedArray(repoPath, fromFile, evidence, seen);
132
+ if (evidence && isRelative(evidence.source)) return resolveImportedArray(repoPath, fromFile, evidence, seen, context);
121
133
  if (evidence) return [{ className: evidence.importedName === 'default' ? expr.text : evidence.importedName, importSource: `${evidence.source}#${evidence.importedName}` }];
122
134
  }
123
135
  return [];
124
136
  }
125
- function resolveArrayLiteral(array: ts.ArrayLiteralExpression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>): ClassEvidence[] {
137
+ function resolveArrayLiteral(array: ts.ArrayLiteralExpression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>, context?: RepositorySourceContext): ClassEvidence[] {
126
138
  const out: ClassEvidence[] = [];
127
139
  for (const element of array.elements) {
128
- if (ts.isSpreadElement(element)) out.push(...resolveArrayExpression(element.expression, arrays, imports, repoPath, fromFile, seen));
140
+ if (ts.isSpreadElement(element)) out.push(...resolveArrayExpression(element.expression, arrays, imports, repoPath, fromFile, seen, context));
129
141
  else if (ts.isIdentifier(element)) out.push({ className: element.text, importSource: importSourceFor(element.text, imports) });
130
142
  }
131
143
  return out;
132
144
  }
133
- function resolveImportedArray(repoPath: string, fromFile: string, evidence: ImportEvidence, seen: Set<string>): ClassEvidence[] {
145
+ function resolveImportedArray(repoPath: string, fromFile: string, evidence: ImportEvidence, seen: Set<string>, context?: RepositorySourceContext): ClassEvidence[] {
134
146
  const moduleFile = resolveRelativeModule(repoPath, fromFile, evidence.source);
135
147
  if (!moduleFile) return [];
136
148
  const key = `${moduleFile}:${evidence.importedName}`;
137
149
  if (seen.has(key) || seen.size > MAX_EXPORT_DEPTH) return [];
138
150
  seen.add(key);
139
- const exports = readExports(repoPath, moduleFile, seen);
151
+ const exports = readExports(repoPath, moduleFile, seen, context);
140
152
  if (evidence.importedName === 'default') return exports.defaultArray ?? [];
141
153
  return exports.arrays.get(evidence.importedName) ?? exports.arrays.get(exports.aliases.get(evidence.importedName) ?? evidence.importedName) ?? [];
142
154
  }
@@ -150,13 +162,16 @@ function resolveRelativeModule(repoPath: string, fromFile: string, specifier: st
150
162
  }
151
163
  return undefined;
152
164
  }
153
- function readExports(repoPath: string, filePath: string, seen: Set<string>): FileExports {
165
+ function readExports(repoPath: string, filePath: string, seen: Set<string>, context?: RepositorySourceContext): FileExports {
154
166
  const absolute = path.join(repoPath, filePath);
155
167
  let text: string;
156
- try { text = fsSync.readFileSync(absolute, 'utf8'); } catch { return { arrays: new Map(), aliases: new Map() }; }
157
- const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
168
+ const snapshot = context?.get(filePath);
169
+ try { text = snapshot?.text ?? fsSync.readFileSync(absolute, 'utf8'); } catch { return { arrays: new Map(), aliases: new Map() }; }
170
+ const sourceFile = snapshot?.sourceFile() ?? ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
158
171
  const imports = collectImports(sourceFile);
159
- const arrays = collectLocalArrays(sourceFile, imports, new Map(), repoPath, filePath);
172
+ const arrays = collectLocalArrays(
173
+ sourceFile, imports, new Map(), repoPath, filePath, context,
174
+ );
160
175
  const aliases = new Map<string, string>();
161
176
  let defaultArray: ClassEvidence[] | undefined;
162
177
  for (const statement of sourceFile.statements) {
@@ -167,7 +182,9 @@ function readExports(repoPath: string, filePath: string, seen: Set<string>): Fil
167
182
  const local = element.propertyName?.text ?? element.name.text;
168
183
  aliases.set(element.name.text, local);
169
184
  if (module && isRelative(module)) {
170
- const imported = resolveImportedArray(repoPath, filePath, { source: module, importedName: local }, seen);
185
+ const imported = resolveImportedArray(
186
+ repoPath, filePath, { source: module, importedName: local }, seen, context,
187
+ );
171
188
  if (imported.length > 0) arrays.set(element.name.text, imported);
172
189
  }
173
190
  }
@@ -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
+ }