@saptools/service-flow 0.1.36 → 0.1.38
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/{chunk-2UOIY2NI.js → chunk-WE3A6TOJ.js} +59 -38
- package/dist/chunk-WE3A6TOJ.js.map +1 -0
- package/dist/cli.js +4 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -2
- package/src/cli.ts +865 -0
- package/src/config/defaults.ts +14 -0
- package/src/config/workspace-config.ts +61 -0
- package/src/db/connection.ts +131 -0
- package/src/db/migrations.ts +81 -0
- package/src/db/repositories.ts +353 -0
- package/src/db/schema.ts +24 -0
- package/src/discovery/classify-repository.ts +50 -0
- package/src/discovery/discover-repositories.ts +58 -0
- package/src/index.ts +13 -0
- package/src/indexer/incremental-index.ts +25 -0
- package/src/indexer/repository-indexer.ts +137 -0
- package/src/indexer/workspace-indexer.ts +29 -0
- package/src/linker/cross-repo-linker.ts +406 -0
- package/src/linker/dynamic-edge-resolver.ts +45 -0
- package/src/linker/external-http-target.ts +38 -0
- package/src/linker/helper-package-linker.ts +57 -0
- package/src/linker/odata-path-normalizer.ts +236 -0
- package/src/linker/operation-decorator-normalizer.ts +47 -0
- package/src/linker/remote-query-target.ts +39 -0
- package/src/linker/service-resolver.ts +223 -0
- package/src/output/json-output.ts +7 -0
- package/src/output/mermaid-output.ts +16 -0
- package/src/output/table-output.ts +21 -0
- package/src/parsers/cds-parser.ts +147 -0
- package/src/parsers/decorator-parser.ts +76 -0
- package/src/parsers/generated-constants-parser.ts +23 -0
- package/src/parsers/handler-registration-parser.ts +177 -0
- package/src/parsers/outbound-call-parser.ts +398 -0
- package/src/parsers/package-json-parser.ts +63 -0
- package/src/parsers/service-binding-parser.ts +826 -0
- package/src/parsers/symbol-parser.ts +328 -0
- package/src/parsers/ts-project.ts +13 -0
- package/src/trace/selectors.ts +20 -0
- package/src/trace/trace-engine.ts +647 -0
- package/src/trace/traversal.ts +4 -0
- package/src/types.ts +186 -0
- package/src/utils/diagnostics.ts +10 -0
- package/src/utils/hashing.ts +10 -0
- package/src/utils/path-utils.ts +13 -0
- package/src/utils/redaction.ts +20 -0
- package/src/version.ts +4 -0
- package/dist/chunk-2UOIY2NI.js.map +0 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { CdsOperationFact, CdsServiceFact } from '../types.js';
|
|
4
|
+
import { ensureLeadingSlash, normalizePath } from '../utils/path-utils.js';
|
|
5
|
+
|
|
6
|
+
function lineOf(text: string, index: number): number {
|
|
7
|
+
return text.slice(0, index).split('\n').length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function maskCommentsAndStrings(text: string): string {
|
|
11
|
+
let out = '';
|
|
12
|
+
let mode: 'code' | 'line' | 'block' | 'single' | 'double' | 'template' =
|
|
13
|
+
'code';
|
|
14
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
15
|
+
const c = text[i] ?? '';
|
|
16
|
+
const n = text[i + 1] ?? '';
|
|
17
|
+
if (mode === 'code' && c === '/' && n === '/') {
|
|
18
|
+
mode = 'line';
|
|
19
|
+
out += ' ';
|
|
20
|
+
i += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (mode === 'code' && c === '/' && n === '*') {
|
|
24
|
+
mode = 'block';
|
|
25
|
+
out += ' ';
|
|
26
|
+
i += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (mode === 'line' && c === '\n') mode = 'code';
|
|
30
|
+
if (mode === 'block' && c === '*' && n === '/') {
|
|
31
|
+
mode = 'code';
|
|
32
|
+
out += ' ';
|
|
33
|
+
i += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (mode === 'code' && (c === "'" || c === '"' || c === '`')) {
|
|
37
|
+
mode = c === "'" ? 'single' : c === '"' ? 'double' : 'template';
|
|
38
|
+
out += ' ';
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if ((mode === 'single' && c === "'") || (mode === 'double' && c === '"') || (mode === 'template' && c === '`'))
|
|
42
|
+
mode = 'code';
|
|
43
|
+
out += mode === 'code' || c === '\n' ? c : ' ';
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readAnnotation(text: string, index: number): { end: number; raw: string } | undefined {
|
|
49
|
+
if (text[index] !== '@') return undefined;
|
|
50
|
+
let i = index + 1;
|
|
51
|
+
while (/\s/.test(text[i] ?? '')) i += 1;
|
|
52
|
+
if (text[i] !== '(') return undefined;
|
|
53
|
+
let depth = 0;
|
|
54
|
+
for (; i < text.length; i += 1) {
|
|
55
|
+
if (text[i] === '(') depth += 1;
|
|
56
|
+
if (text[i] === ')') depth -= 1;
|
|
57
|
+
if (depth === 0) return { end: i + 1, raw: text.slice(index, i + 1) };
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collectAnnotations(text: string, index: number): { end: number; raw: string } {
|
|
63
|
+
let i = index;
|
|
64
|
+
let raw = '';
|
|
65
|
+
while (i < text.length) {
|
|
66
|
+
while (/\s/.test(text[i] ?? '')) i += 1;
|
|
67
|
+
const annotation = readAnnotation(text, i);
|
|
68
|
+
if (!annotation) break;
|
|
69
|
+
raw += annotation.raw;
|
|
70
|
+
i = annotation.end;
|
|
71
|
+
}
|
|
72
|
+
return { end: i, raw };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pathAnnotation(raw: string): string | undefined {
|
|
76
|
+
return /path\s*:\s*['"]([^'"]+)['"]/s.exec(raw)?.[1];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function matchingBrace(text: string, open: number): number {
|
|
80
|
+
let depth = 0;
|
|
81
|
+
for (let i = open; i < text.length; i += 1) {
|
|
82
|
+
if (text[i] === '{') depth += 1;
|
|
83
|
+
if (text[i] === '}') depth -= 1;
|
|
84
|
+
if (depth === 0) return i;
|
|
85
|
+
}
|
|
86
|
+
return text.length - 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function operationsFromBody(text: string, maskedBody: string, bodyOffset: number, filePath: string): CdsOperationFact[] {
|
|
90
|
+
return [...maskedBody.matchAll(/\b(action|function|event)\s+(\w+)\s*(?:\(([^)]*)\))?\s*(?:returns\s+([^;{]+))?/g)].map((m) => ({
|
|
91
|
+
operationType: (m[1] as 'action' | 'function' | 'event') ?? 'action',
|
|
92
|
+
operationName: m[2] ?? 'unknown',
|
|
93
|
+
operationPath: ensureLeadingSlash(m[2] ?? 'unknown'),
|
|
94
|
+
paramsJson: JSON.stringify((m[3] ?? '').split(',').map((part) => part.trim()).filter(Boolean)),
|
|
95
|
+
returnType: m[4]?.trim(),
|
|
96
|
+
sourceFile: normalizePath(filePath),
|
|
97
|
+
sourceLine: lineOf(text, bodyOffset + (m.index ?? 0))
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function parseCdsFile(repoPath: string, filePath: string): Promise<CdsServiceFact[]> {
|
|
102
|
+
const absolute = path.join(repoPath, filePath);
|
|
103
|
+
const text = await fs.readFile(absolute, 'utf8');
|
|
104
|
+
const masked = maskCommentsAndStrings(text);
|
|
105
|
+
const namespace = /namespace\s+([\w.]+)\s*;/.exec(masked)?.[1];
|
|
106
|
+
const services: CdsServiceFact[] = [];
|
|
107
|
+
const pendingAnnotations: Array<{ end: number; raw: string }> = [];
|
|
108
|
+
for (const a of masked.matchAll(/@\s*\(/g)) {
|
|
109
|
+
const annotation = collectAnnotations(masked, a.index ?? 0);
|
|
110
|
+
pendingAnnotations.push(annotation);
|
|
111
|
+
}
|
|
112
|
+
const serviceRegex = /\b(extend\s+)?service\s+([\w.]+)\b/g;
|
|
113
|
+
let match: RegExpExecArray | null;
|
|
114
|
+
while ((match = serviceRegex.exec(masked))) {
|
|
115
|
+
const afterName = collectAnnotations(masked, serviceRegex.lastIndex);
|
|
116
|
+
const open = masked.indexOf('{', afterName.end);
|
|
117
|
+
if (open === -1) continue;
|
|
118
|
+
const matchIndex = match.index;
|
|
119
|
+
const prefix = pendingAnnotations
|
|
120
|
+
.filter((a) => a.end <= matchIndex && matchIndex - a.end < 8)
|
|
121
|
+
.map((a) => a.raw)
|
|
122
|
+
.join('');
|
|
123
|
+
const annotations = `${prefix}${afterName.raw}`;
|
|
124
|
+
const end = matchingBrace(masked, open);
|
|
125
|
+
const body = masked.slice(open + 1, end);
|
|
126
|
+
const name = match[2] ?? 'UnknownService';
|
|
127
|
+
const serviceName = name.split('.').pop() ?? name;
|
|
128
|
+
const servicePath = ensureLeadingSlash(pathAnnotation(annotations) ?? serviceName);
|
|
129
|
+
services.push({
|
|
130
|
+
namespace,
|
|
131
|
+
serviceName,
|
|
132
|
+
qualifiedName: name.includes('.') ? name : namespace ? `${namespace}.${name}` : name,
|
|
133
|
+
servicePath,
|
|
134
|
+
isExtend: Boolean(match[1]),
|
|
135
|
+
sourceFile: normalizePath(filePath),
|
|
136
|
+
sourceLine: lineOf(text, match.index),
|
|
137
|
+
operations: operationsFromBody(text, body, open + 1, filePath)
|
|
138
|
+
});
|
|
139
|
+
serviceRegex.lastIndex = end + 1;
|
|
140
|
+
}
|
|
141
|
+
const baseOps = new Map(services.filter((s) => !s.isExtend).map((s) => [s.qualifiedName, s.operations]));
|
|
142
|
+
for (const service of services.filter((s) => s.isExtend && s.operations.length === 0)) {
|
|
143
|
+
const inherited = baseOps.get(service.qualifiedName) ?? baseOps.get(service.serviceName);
|
|
144
|
+
if (inherited) service.operations = inherited.map((op) => ({ ...op, sourceFile: service.sourceFile, sourceLine: service.sourceLine }));
|
|
145
|
+
}
|
|
146
|
+
return services;
|
|
147
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
import type { HandlerClassFact } from '../types.js';
|
|
5
|
+
import { createSourceFile } from './ts-project.js';
|
|
6
|
+
import { normalizePath, stripQuotes } from '../utils/path-utils.js';
|
|
7
|
+
function line(sf: ts.SourceFile, pos: number): number {
|
|
8
|
+
return sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
9
|
+
}
|
|
10
|
+
function decs(node: ts.Node): ts.Decorator[] {
|
|
11
|
+
return ts.canHaveDecorators(node) ? [...(ts.getDecorators(node) ?? [])] : [];
|
|
12
|
+
}
|
|
13
|
+
function callName(d: ts.Decorator): string {
|
|
14
|
+
const e = d.expression;
|
|
15
|
+
return ts.isCallExpression(e) ? e.expression.getText() : e.getText();
|
|
16
|
+
}
|
|
17
|
+
function firstArg(d: ts.Decorator): string {
|
|
18
|
+
const e = d.expression;
|
|
19
|
+
return ts.isCallExpression(e) && e.arguments[0]
|
|
20
|
+
? e.arguments[0].getText()
|
|
21
|
+
: '';
|
|
22
|
+
}
|
|
23
|
+
export async function parseDecorators(
|
|
24
|
+
repoPath: string,
|
|
25
|
+
filePath: string
|
|
26
|
+
): Promise<HandlerClassFact[]> {
|
|
27
|
+
const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
28
|
+
const sf = createSourceFile(filePath, text);
|
|
29
|
+
const constants = new Map<string, string>();
|
|
30
|
+
const handlers: HandlerClassFact[] = [];
|
|
31
|
+
function visit(node: ts.Node): void {
|
|
32
|
+
if (
|
|
33
|
+
ts.isVariableDeclaration(node) &&
|
|
34
|
+
ts.isIdentifier(node.name) &&
|
|
35
|
+
node.initializer &&
|
|
36
|
+
ts.isStringLiteralLike(node.initializer)
|
|
37
|
+
)
|
|
38
|
+
constants.set(node.name.text, node.initializer.text);
|
|
39
|
+
if (ts.isClassDeclaration(node)) {
|
|
40
|
+
const className = node.name?.text ?? 'AnonymousHandler';
|
|
41
|
+
const hasHandler = decs(node).some((d) => callName(d) === 'Handler');
|
|
42
|
+
const methods = node.members.filter(ts.isMethodDeclaration).flatMap((m) =>
|
|
43
|
+
decs(m)
|
|
44
|
+
.filter((d) =>
|
|
45
|
+
['Func', 'Action', 'On', 'Event'].includes(callName(d))
|
|
46
|
+
)
|
|
47
|
+
.map((d) => {
|
|
48
|
+
const raw = firstArg(d);
|
|
49
|
+
const value =
|
|
50
|
+
raw.startsWith('"') || raw.startsWith("'") || raw.startsWith('`')
|
|
51
|
+
? stripQuotes(raw)
|
|
52
|
+
: (constants.get(raw) ??
|
|
53
|
+
(raw.endsWith('.name') ? raw.split('.').at(-2) : undefined));
|
|
54
|
+
return {
|
|
55
|
+
methodName: m.name.getText(),
|
|
56
|
+
decoratorKind: callName(d),
|
|
57
|
+
decoratorValue: value,
|
|
58
|
+
decoratorRawExpression: raw,
|
|
59
|
+
sourceFile: normalizePath(filePath),
|
|
60
|
+
sourceLine: line(sf, m.getStart())
|
|
61
|
+
};
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
if (hasHandler || methods.length > 0)
|
|
65
|
+
handlers.push({
|
|
66
|
+
className,
|
|
67
|
+
sourceFile: normalizePath(filePath),
|
|
68
|
+
sourceLine: line(sf, node.getStart()),
|
|
69
|
+
methods
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
ts.forEachChild(node, visit);
|
|
73
|
+
}
|
|
74
|
+
visit(sf);
|
|
75
|
+
return handlers;
|
|
76
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { GeneratedConstantFact } from '../types.js';
|
|
4
|
+
import { normalizePath, stripQuotes } from '../utils/path-utils.js';
|
|
5
|
+
function lineOf(text: string, idx: number): number {
|
|
6
|
+
return text.slice(0, idx).split('\n').length;
|
|
7
|
+
}
|
|
8
|
+
export async function parseGeneratedConstants(
|
|
9
|
+
repoPath: string,
|
|
10
|
+
filePath: string
|
|
11
|
+
): Promise<GeneratedConstantFact[]> {
|
|
12
|
+
const text = await fs.readFile(path.join(repoPath, filePath), 'utf8');
|
|
13
|
+
return [
|
|
14
|
+
...text.matchAll(
|
|
15
|
+
/(?:export\s+)?(?:const|static\s+readonly)\s+(\w+)\s*=\s*(['"])([^'"]+)\2/g
|
|
16
|
+
)
|
|
17
|
+
].map((m) => ({
|
|
18
|
+
name: m[1] ?? 'constant',
|
|
19
|
+
value: stripQuotes(m[3] ?? ''),
|
|
20
|
+
sourceFile: normalizePath(filePath),
|
|
21
|
+
sourceLine: lineOf(text, m.index ?? 0)
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fsSync from 'node:fs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
import type { HandlerRegistrationFact } from '../types.js';
|
|
6
|
+
import { normalizePath } from '../utils/path-utils.js';
|
|
7
|
+
|
|
8
|
+
interface ImportEvidence { importedName: string; source: string }
|
|
9
|
+
interface ClassEvidence { className: string; importSource?: string }
|
|
10
|
+
interface FileExports { arrays: Map<string, ClassEvidence[]>; defaultArray?: ClassEvidence[]; aliases: Map<string, string> }
|
|
11
|
+
|
|
12
|
+
const MAX_EXPORT_DEPTH = 5;
|
|
13
|
+
|
|
14
|
+
function lineOf(sourceFile: ts.SourceFile, node: ts.Node): number {
|
|
15
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
16
|
+
}
|
|
17
|
+
function isRelative(source: string): boolean {
|
|
18
|
+
return source.startsWith('./') || source.startsWith('../');
|
|
19
|
+
}
|
|
20
|
+
function sourceText(node: ts.PropertyName, sourceFile: ts.SourceFile): string | undefined {
|
|
21
|
+
if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return node.text;
|
|
22
|
+
return node.getText(sourceFile);
|
|
23
|
+
}
|
|
24
|
+
function importSourceFor(identifier: string, imports: Map<string, ImportEvidence>): string | undefined {
|
|
25
|
+
const evidence = imports.get(identifier);
|
|
26
|
+
return evidence ? `${evidence.source}#${evidence.importedName}` : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function parseHandlerRegistrations(repoPath: string, filePath: string): Promise<HandlerRegistrationFact[]> {
|
|
30
|
+
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);
|
|
33
|
+
const imports = collectImports(sourceFile);
|
|
34
|
+
const localArrays = collectLocalArrays(sourceFile, imports, new Map(), repoPath, filePath);
|
|
35
|
+
const out: HandlerRegistrationFact[] = [];
|
|
36
|
+
|
|
37
|
+
function emitFromExpression(expression: ts.Expression, call: ts.CallExpression): void {
|
|
38
|
+
const classes = resolveArrayExpression(expression, localArrays, imports, repoPath, filePath, new Set());
|
|
39
|
+
for (const cls of classes) {
|
|
40
|
+
out.push({
|
|
41
|
+
className: cls.className,
|
|
42
|
+
importSource: cls.importSource,
|
|
43
|
+
registrationFile: normalizePath(filePath),
|
|
44
|
+
registrationLine: lineOf(sourceFile, call),
|
|
45
|
+
registrationKind: 'combined-handler-class',
|
|
46
|
+
confidence: 0.95,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (classes.length === 0) {
|
|
50
|
+
out.push({
|
|
51
|
+
registrationFile: normalizePath(filePath),
|
|
52
|
+
registrationLine: lineOf(sourceFile, call),
|
|
53
|
+
registrationKind: 'combined-handler',
|
|
54
|
+
confidence: 0.75,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function visit(node: ts.Node): void {
|
|
60
|
+
if (ts.isCallExpression(node) && isRegistrationCall(node)) {
|
|
61
|
+
const handlerExpr = handlerExpression(node, sourceFile);
|
|
62
|
+
if (handlerExpr) emitFromExpression(handlerExpr, node);
|
|
63
|
+
else out.push({ registrationFile: normalizePath(filePath), registrationLine: lineOf(sourceFile, node), registrationKind: 'combined-handler', confidence: 0.75 });
|
|
64
|
+
}
|
|
65
|
+
ts.forEachChild(node, visit);
|
|
66
|
+
}
|
|
67
|
+
visit(sourceFile);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isRegistrationCall(call: ts.CallExpression): boolean {
|
|
72
|
+
const text = call.expression.getText();
|
|
73
|
+
return text.endsWith('createCombinedHandler') || text.endsWith('srv.prepend') || text.endsWith('cds.serve');
|
|
74
|
+
}
|
|
75
|
+
function handlerExpression(call: ts.CallExpression, sourceFile: ts.SourceFile): ts.Expression | undefined {
|
|
76
|
+
for (const arg of call.arguments) {
|
|
77
|
+
if (!ts.isObjectLiteralExpression(arg)) continue;
|
|
78
|
+
for (const prop of arg.properties) {
|
|
79
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
80
|
+
if (sourceText(prop.name, sourceFile) === 'handler') return prop.initializer;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
function collectImports(sourceFile: ts.SourceFile): Map<string, ImportEvidence> {
|
|
86
|
+
const imports = new Map<string, ImportEvidence>();
|
|
87
|
+
for (const statement of sourceFile.statements) {
|
|
88
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
89
|
+
const source = statement.moduleSpecifier.text;
|
|
90
|
+
const clause = statement.importClause;
|
|
91
|
+
if (!clause) continue;
|
|
92
|
+
if (clause.name) imports.set(clause.name.text, { importedName: 'default', source });
|
|
93
|
+
const named = clause.namedBindings;
|
|
94
|
+
if (named && ts.isNamedImports(named)) {
|
|
95
|
+
for (const element of named.elements) imports.set(element.name.text, { importedName: element.propertyName?.text ?? element.name.text, source });
|
|
96
|
+
}
|
|
97
|
+
if (named && ts.isNamespaceImport(named)) imports.set(named.name.text, { importedName: '*', source });
|
|
98
|
+
}
|
|
99
|
+
return imports;
|
|
100
|
+
}
|
|
101
|
+
function collectLocalArrays(sourceFile: ts.SourceFile, imports: Map<string, ImportEvidence>, seed: Map<string, ClassEvidence[]>, repoPath = '', fromFile = ''): Map<string, ClassEvidence[]> {
|
|
102
|
+
const arrays = new Map(seed);
|
|
103
|
+
for (const statement of sourceFile.statements) {
|
|
104
|
+
if (ts.isVariableStatement(statement)) {
|
|
105
|
+
for (const decl of statement.declarationList.declarations) {
|
|
106
|
+
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()));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return arrays;
|
|
113
|
+
}
|
|
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);
|
|
116
|
+
if (ts.isIdentifier(expr)) {
|
|
117
|
+
const local = arrays.get(expr.text);
|
|
118
|
+
if (local) return local;
|
|
119
|
+
const evidence = imports.get(expr.text);
|
|
120
|
+
if (evidence && isRelative(evidence.source)) return resolveImportedArray(repoPath, fromFile, evidence, seen);
|
|
121
|
+
if (evidence) return [{ className: evidence.importedName === 'default' ? expr.text : evidence.importedName, importSource: `${evidence.source}#${evidence.importedName}` }];
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
function resolveArrayLiteral(array: ts.ArrayLiteralExpression, arrays: Map<string, ClassEvidence[]>, imports: Map<string, ImportEvidence>, repoPath: string, fromFile: string, seen: Set<string>): ClassEvidence[] {
|
|
126
|
+
const out: ClassEvidence[] = [];
|
|
127
|
+
for (const element of array.elements) {
|
|
128
|
+
if (ts.isSpreadElement(element)) out.push(...resolveArrayExpression(element.expression, arrays, imports, repoPath, fromFile, seen));
|
|
129
|
+
else if (ts.isIdentifier(element)) out.push({ className: element.text, importSource: importSourceFor(element.text, imports) });
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function resolveImportedArray(repoPath: string, fromFile: string, evidence: ImportEvidence, seen: Set<string>): ClassEvidence[] {
|
|
134
|
+
const moduleFile = resolveRelativeModule(repoPath, fromFile, evidence.source);
|
|
135
|
+
if (!moduleFile) return [];
|
|
136
|
+
const key = `${moduleFile}:${evidence.importedName}`;
|
|
137
|
+
if (seen.has(key) || seen.size > MAX_EXPORT_DEPTH) return [];
|
|
138
|
+
seen.add(key);
|
|
139
|
+
const exports = readExports(repoPath, moduleFile, seen);
|
|
140
|
+
if (evidence.importedName === 'default') return exports.defaultArray ?? [];
|
|
141
|
+
return exports.arrays.get(evidence.importedName) ?? exports.arrays.get(exports.aliases.get(evidence.importedName) ?? evidence.importedName) ?? [];
|
|
142
|
+
}
|
|
143
|
+
function resolveRelativeModule(repoPath: string, fromFile: string, specifier: string): string | undefined {
|
|
144
|
+
const base = path.resolve(repoPath, path.dirname(fromFile), specifier);
|
|
145
|
+
for (const candidate of [base, `${base}.ts`, `${base}.js`, path.join(base, 'index.ts'), path.join(base, 'index.js')]) {
|
|
146
|
+
try {
|
|
147
|
+
const stat = fsSync.statSync(candidate);
|
|
148
|
+
if (stat.isFile()) return normalizePath(path.relative(repoPath, candidate));
|
|
149
|
+
} catch { /* ignore missing candidate */ }
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
function readExports(repoPath: string, filePath: string, seen: Set<string>): FileExports {
|
|
154
|
+
const absolute = path.join(repoPath, filePath);
|
|
155
|
+
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);
|
|
158
|
+
const imports = collectImports(sourceFile);
|
|
159
|
+
const arrays = collectLocalArrays(sourceFile, imports, new Map(), repoPath, filePath);
|
|
160
|
+
const aliases = new Map<string, string>();
|
|
161
|
+
let defaultArray: ClassEvidence[] | undefined;
|
|
162
|
+
for (const statement of sourceFile.statements) {
|
|
163
|
+
if (ts.isExportAssignment(statement) && ts.isIdentifier(statement.expression)) defaultArray = arrays.get(statement.expression.text);
|
|
164
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
165
|
+
const module = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : undefined;
|
|
166
|
+
for (const element of statement.exportClause.elements) {
|
|
167
|
+
const local = element.propertyName?.text ?? element.name.text;
|
|
168
|
+
aliases.set(element.name.text, local);
|
|
169
|
+
if (module && isRelative(module)) {
|
|
170
|
+
const imported = resolveImportedArray(repoPath, filePath, { source: module, importedName: local }, seen);
|
|
171
|
+
if (imported.length > 0) arrays.set(element.name.text, imported);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { arrays, defaultArray, aliases };
|
|
177
|
+
}
|