microservice-kg 0.1.0
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/LICENSE +21 -0
- package/README.md +161 -0
- package/package.json +44 -0
- package/src/analyzer.mjs +1137 -0
- package/src/cli.mjs +70 -0
- package/src/export-obsidian.mjs +226 -0
- package/src/graph-query.mjs +292 -0
- package/src/mcp-server.mjs +260 -0
package/src/analyzer.mjs
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const IGNORED_DIR_NAMES = new Set([
|
|
5
|
+
".git",
|
|
6
|
+
".idea",
|
|
7
|
+
".gradle",
|
|
8
|
+
".microservice-kg",
|
|
9
|
+
".gitnexus",
|
|
10
|
+
".claude",
|
|
11
|
+
"build",
|
|
12
|
+
"dist",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"out",
|
|
15
|
+
"target",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const CONTROL_FLOW_KEYWORDS = new Set([
|
|
19
|
+
"if",
|
|
20
|
+
"for",
|
|
21
|
+
"while",
|
|
22
|
+
"switch",
|
|
23
|
+
"catch",
|
|
24
|
+
"try",
|
|
25
|
+
"return",
|
|
26
|
+
"throw",
|
|
27
|
+
"new",
|
|
28
|
+
"super",
|
|
29
|
+
"this",
|
|
30
|
+
"synchronized",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const HTTP_METHOD_BY_ANNOTATION = {
|
|
34
|
+
GetMapping: "GET",
|
|
35
|
+
PostMapping: "POST",
|
|
36
|
+
PutMapping: "PUT",
|
|
37
|
+
DeleteMapping: "DELETE",
|
|
38
|
+
PatchMapping: "PATCH",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export async function analyzeWorkspace(inputDir) {
|
|
42
|
+
const serviceRoots = await discoverServiceRoots(inputDir);
|
|
43
|
+
const services = [];
|
|
44
|
+
|
|
45
|
+
for (const serviceRoot of serviceRoots) {
|
|
46
|
+
const service = await analyzeService(inputDir, serviceRoot);
|
|
47
|
+
if (service) {
|
|
48
|
+
services.push(service);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const serviceEdges = buildServiceEdges(services);
|
|
53
|
+
const graph = {
|
|
54
|
+
version: 1,
|
|
55
|
+
generatedAt: new Date().toISOString(),
|
|
56
|
+
inputDir,
|
|
57
|
+
serviceCount: services.length,
|
|
58
|
+
serviceEdges,
|
|
59
|
+
services,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return graph;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function writeGraphArtifacts(graph, outputDir) {
|
|
66
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
67
|
+
await fs.writeFile(
|
|
68
|
+
path.join(outputDir, "service-graph.json"),
|
|
69
|
+
`${JSON.stringify(graph, null, 2)}\n`,
|
|
70
|
+
"utf8",
|
|
71
|
+
);
|
|
72
|
+
await fs.writeFile(
|
|
73
|
+
path.join(outputDir, "summary.md"),
|
|
74
|
+
`${renderSummary(graph)}\n`,
|
|
75
|
+
"utf8",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function discoverServiceRoots(rootDir) {
|
|
80
|
+
const results = [];
|
|
81
|
+
await walkDirectories(rootDir, async (dirPath, dirents) => {
|
|
82
|
+
const names = new Set(dirents.map((dirent) => dirent.name));
|
|
83
|
+
const hasSource = names.has("src");
|
|
84
|
+
const hasBuildFile = names.has("build.gradle") || names.has("pom.xml");
|
|
85
|
+
if (!hasSource || !hasBuildFile) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const javaRoot = path.join(dirPath, "src", "main", "java");
|
|
90
|
+
if (!(await pathExists(javaRoot))) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const resourcesRoot = path.join(dirPath, "src", "main", "resources");
|
|
95
|
+
const hasAppConfig = await hasApplicationConfig(resourcesRoot);
|
|
96
|
+
const hasSpringBootApp = await hasSpringBootApplication(javaRoot);
|
|
97
|
+
if (!hasAppConfig && !hasSpringBootApp) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
results.push(dirPath);
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
results.sort((left, right) => left.localeCompare(right));
|
|
106
|
+
return results;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function analyzeService(inputDir, serviceRoot) {
|
|
110
|
+
const javaRoot = path.join(serviceRoot, "src", "main", "java");
|
|
111
|
+
const resourcesRoot = path.join(serviceRoot, "src", "main", "resources");
|
|
112
|
+
const propertyMap = await loadServiceProperties(resourcesRoot);
|
|
113
|
+
const javaFiles = await listFiles(javaRoot, (entry) => entry.isFile() && entry.name.endsWith(".java"));
|
|
114
|
+
|
|
115
|
+
const parsedFiles = [];
|
|
116
|
+
for (const filePath of javaFiles) {
|
|
117
|
+
parsedFiles.push(await parseJavaFile(filePath, serviceRoot, propertyMap));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const serviceNameFromConfig = firstDefined(
|
|
121
|
+
propertyMap["spring.application.name"],
|
|
122
|
+
propertyMap["spring.application.name[0]"],
|
|
123
|
+
);
|
|
124
|
+
const serviceId = path.basename(serviceRoot);
|
|
125
|
+
const aliases = deriveServiceAliases([
|
|
126
|
+
serviceId,
|
|
127
|
+
serviceNameFromConfig,
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const classes = [];
|
|
131
|
+
const methods = [];
|
|
132
|
+
const endpoints = [];
|
|
133
|
+
const clients = [];
|
|
134
|
+
const fields = [];
|
|
135
|
+
for (const parsedFile of parsedFiles) {
|
|
136
|
+
classes.push(...parsedFile.classes);
|
|
137
|
+
methods.push(...parsedFile.methods);
|
|
138
|
+
endpoints.push(...parsedFile.endpoints);
|
|
139
|
+
clients.push(...parsedFile.clients);
|
|
140
|
+
fields.push(...parsedFile.fields);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const methodInteractions = buildMethodInteractions(methods, fields);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
id: serviceId,
|
|
147
|
+
name: serviceNameFromConfig || serviceId,
|
|
148
|
+
rootDir: serviceRoot,
|
|
149
|
+
relativeRootDir: path.relative(inputDir, serviceRoot),
|
|
150
|
+
aliases,
|
|
151
|
+
properties: propertyMap,
|
|
152
|
+
classes,
|
|
153
|
+
fields,
|
|
154
|
+
methods,
|
|
155
|
+
endpoints,
|
|
156
|
+
clients,
|
|
157
|
+
methodInteractions,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildMethodInteractions(methods, fields) {
|
|
162
|
+
const fieldMapByClass = new Map();
|
|
163
|
+
for (const field of fields) {
|
|
164
|
+
const key = field.className;
|
|
165
|
+
if (!fieldMapByClass.has(key)) {
|
|
166
|
+
fieldMapByClass.set(key, new Map());
|
|
167
|
+
}
|
|
168
|
+
fieldMapByClass.get(key).set(field.name, field.type);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const methodMapByClass = new Map();
|
|
172
|
+
for (const method of methods) {
|
|
173
|
+
if (!methodMapByClass.has(method.className)) {
|
|
174
|
+
methodMapByClass.set(method.className, new Set());
|
|
175
|
+
}
|
|
176
|
+
methodMapByClass.get(method.className).add(method.name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const interactions = [];
|
|
180
|
+
for (const method of methods) {
|
|
181
|
+
const fieldMap = fieldMapByClass.get(method.className) || new Map();
|
|
182
|
+
const localMethods = methodMapByClass.get(method.className) || new Set();
|
|
183
|
+
|
|
184
|
+
for (const call of method.calls) {
|
|
185
|
+
if (call.receiver && fieldMap.has(call.receiver)) {
|
|
186
|
+
interactions.push({
|
|
187
|
+
type: "field-call",
|
|
188
|
+
sourceMethodId: method.id,
|
|
189
|
+
sourceClassName: method.className,
|
|
190
|
+
sourceMethodName: method.name,
|
|
191
|
+
targetClassName: sanitizeJavaType(fieldMap.get(call.receiver)),
|
|
192
|
+
targetMethodName: call.method,
|
|
193
|
+
filePath: method.filePath,
|
|
194
|
+
line: call.line,
|
|
195
|
+
});
|
|
196
|
+
} else if (!call.receiver && localMethods.has(call.method) && call.method !== method.name) {
|
|
197
|
+
interactions.push({
|
|
198
|
+
type: "local-call",
|
|
199
|
+
sourceMethodId: method.id,
|
|
200
|
+
sourceClassName: method.className,
|
|
201
|
+
sourceMethodName: method.name,
|
|
202
|
+
targetClassName: method.className,
|
|
203
|
+
targetMethodName: call.method,
|
|
204
|
+
filePath: method.filePath,
|
|
205
|
+
line: call.line,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return dedupeBy(interactions, (interaction) => [
|
|
212
|
+
interaction.type,
|
|
213
|
+
interaction.sourceMethodId,
|
|
214
|
+
interaction.targetClassName,
|
|
215
|
+
interaction.targetMethodName,
|
|
216
|
+
interaction.line,
|
|
217
|
+
].join("|"));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function buildServiceEdges(services) {
|
|
221
|
+
const endpointsByMethodAndPath = new Map();
|
|
222
|
+
const aliasIndex = new Map();
|
|
223
|
+
|
|
224
|
+
for (const service of services) {
|
|
225
|
+
for (const alias of service.aliases) {
|
|
226
|
+
aliasIndex.set(alias, service.id);
|
|
227
|
+
}
|
|
228
|
+
for (const endpoint of service.endpoints) {
|
|
229
|
+
const key = `${endpoint.httpMethod}:${normalizePath(endpoint.fullPath)}`;
|
|
230
|
+
if (!endpointsByMethodAndPath.has(key)) {
|
|
231
|
+
endpointsByMethodAndPath.set(key, []);
|
|
232
|
+
}
|
|
233
|
+
endpointsByMethodAndPath.get(key).push({
|
|
234
|
+
serviceId: service.id,
|
|
235
|
+
endpoint,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const edges = new Map();
|
|
241
|
+
|
|
242
|
+
for (const service of services) {
|
|
243
|
+
for (const client of service.clients) {
|
|
244
|
+
const targetServiceId = resolveTargetServiceId(client, services, aliasIndex, endpointsByMethodAndPath);
|
|
245
|
+
if (!targetServiceId || targetServiceId === service.id) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const provider = resolveProviderEndpoint(targetServiceId, client, services, endpointsByMethodAndPath);
|
|
250
|
+
const callSites = findCallSitesForClientMethod(service, client);
|
|
251
|
+
const edgeKey = `${service.id}->${targetServiceId}`;
|
|
252
|
+
if (!edges.has(edgeKey)) {
|
|
253
|
+
edges.set(edgeKey, {
|
|
254
|
+
id: edgeKey,
|
|
255
|
+
sourceServiceId: service.id,
|
|
256
|
+
targetServiceId,
|
|
257
|
+
protocol: "http",
|
|
258
|
+
reasons: [],
|
|
259
|
+
calls: [],
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const edge = edges.get(edgeKey);
|
|
264
|
+
edge.reasons.push({
|
|
265
|
+
type: "feign-client",
|
|
266
|
+
clientClassName: client.className,
|
|
267
|
+
clientMethodName: client.methodName,
|
|
268
|
+
httpMethod: client.httpMethod,
|
|
269
|
+
path: client.fullPath,
|
|
270
|
+
});
|
|
271
|
+
edge.calls.push({
|
|
272
|
+
httpMethod: client.httpMethod,
|
|
273
|
+
path: client.fullPath,
|
|
274
|
+
sourceClassName: client.className,
|
|
275
|
+
sourceMethodName: client.methodName,
|
|
276
|
+
sourceFilePath: client.filePath,
|
|
277
|
+
callSites,
|
|
278
|
+
provider: provider
|
|
279
|
+
? {
|
|
280
|
+
targetClassName: provider.className,
|
|
281
|
+
targetMethodName: provider.methodName,
|
|
282
|
+
targetFilePath: provider.filePath,
|
|
283
|
+
targetLine: provider.line,
|
|
284
|
+
}
|
|
285
|
+
: null,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return Array.from(edges.values())
|
|
291
|
+
.map((edge) => ({
|
|
292
|
+
...edge,
|
|
293
|
+
reasons: dedupeBy(edge.reasons, (reason) => JSON.stringify(reason)),
|
|
294
|
+
calls: dedupeBy(edge.calls, (call) => JSON.stringify(call)),
|
|
295
|
+
}))
|
|
296
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function findCallSitesForClientMethod(service, clientMethod) {
|
|
300
|
+
const sites = [];
|
|
301
|
+
const fieldsByClass = new Map();
|
|
302
|
+
for (const field of service.fields) {
|
|
303
|
+
if (!fieldsByClass.has(field.className)) {
|
|
304
|
+
fieldsByClass.set(field.className, []);
|
|
305
|
+
}
|
|
306
|
+
fieldsByClass.get(field.className).push(field);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const method of service.methods) {
|
|
310
|
+
const classFields = fieldsByClass.get(method.className) || [];
|
|
311
|
+
const receiverNames = classFields
|
|
312
|
+
.filter((field) => sanitizeJavaType(field.type) === clientMethod.className)
|
|
313
|
+
.map((field) => field.name);
|
|
314
|
+
|
|
315
|
+
for (const call of method.calls) {
|
|
316
|
+
if (
|
|
317
|
+
call.receiver &&
|
|
318
|
+
receiverNames.includes(call.receiver) &&
|
|
319
|
+
call.method === clientMethod.methodName
|
|
320
|
+
) {
|
|
321
|
+
sites.push({
|
|
322
|
+
className: method.className,
|
|
323
|
+
methodName: method.name,
|
|
324
|
+
filePath: method.filePath,
|
|
325
|
+
line: call.line,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return sites;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function resolveProviderEndpoint(targetServiceId, client, services, endpointsByMethodAndPath) {
|
|
335
|
+
const key = `${client.httpMethod}:${normalizePath(client.fullPath)}`;
|
|
336
|
+
const providerEntries = endpointsByMethodAndPath.get(key) || [];
|
|
337
|
+
const provider = providerEntries.find((entry) => entry.serviceId === targetServiceId);
|
|
338
|
+
if (provider?.endpoint) {
|
|
339
|
+
return provider.endpoint;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const targetService = services.find((service) => service.id === targetServiceId);
|
|
343
|
+
if (!targetService) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const clientPath = normalizePath(client.fullPath);
|
|
348
|
+
return targetService.endpoints.find((endpoint) => {
|
|
349
|
+
if (endpoint.httpMethod !== client.httpMethod) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
const endpointPath = normalizePath(endpoint.fullPath);
|
|
353
|
+
return (
|
|
354
|
+
clientPath.endsWith(endpointPath)
|
|
355
|
+
|| endpointPath.endsWith(clientPath)
|
|
356
|
+
);
|
|
357
|
+
}) || null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function resolveTargetServiceId(client, services, aliasIndex, endpointsByMethodAndPath) {
|
|
361
|
+
const candidateStrings = [
|
|
362
|
+
client.feignName,
|
|
363
|
+
client.urlExpression,
|
|
364
|
+
client.resolvedBaseUrl,
|
|
365
|
+
client.pathExpression,
|
|
366
|
+
client.resolvedPath,
|
|
367
|
+
].filter(Boolean);
|
|
368
|
+
|
|
369
|
+
const candidateScores = new Map();
|
|
370
|
+
for (const candidateString of candidateStrings) {
|
|
371
|
+
const haystack = candidateString.toLowerCase();
|
|
372
|
+
for (const service of services) {
|
|
373
|
+
for (const alias of service.aliases) {
|
|
374
|
+
if (haystack.includes(alias)) {
|
|
375
|
+
candidateScores.set(service.id, (candidateScores.get(service.id) || 0) + alias.length);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (candidateScores.size > 0) {
|
|
382
|
+
return Array.from(candidateScores.entries())
|
|
383
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0][0];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const key = `${client.httpMethod}:${normalizePath(client.fullPath)}`;
|
|
387
|
+
const providers = endpointsByMethodAndPath.get(key) || [];
|
|
388
|
+
if (providers.length === 1) {
|
|
389
|
+
return providers[0].serviceId;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return aliasIndex.get(normalizeAlias(client.feignName || "")) || null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function parseJavaFile(filePath, serviceRoot, propertyMap) {
|
|
396
|
+
const text = await fs.readFile(filePath, "utf8");
|
|
397
|
+
const lines = text.split(/\r?\n/);
|
|
398
|
+
const packageMatch = text.match(/^\s*package\s+([\w.]+)\s*;/m);
|
|
399
|
+
const imports = lines
|
|
400
|
+
.filter((line) => line.trim().startsWith("import "))
|
|
401
|
+
.map((line) => line.trim().replace(/^import\s+/, "").replace(/;$/, ""));
|
|
402
|
+
|
|
403
|
+
const parsed = {
|
|
404
|
+
filePath,
|
|
405
|
+
relativeFilePath: path.relative(serviceRoot, filePath),
|
|
406
|
+
packageName: packageMatch?.[1] || null,
|
|
407
|
+
imports,
|
|
408
|
+
classes: [],
|
|
409
|
+
fields: [],
|
|
410
|
+
methods: [],
|
|
411
|
+
endpoints: [],
|
|
412
|
+
clients: [],
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
let braceDepth = 0;
|
|
416
|
+
let currentClass = null;
|
|
417
|
+
let currentMethod = null;
|
|
418
|
+
let declarationBuffer = [];
|
|
419
|
+
|
|
420
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
421
|
+
const lineNumber = index + 1;
|
|
422
|
+
const line = lines[index];
|
|
423
|
+
const trimmed = line.trim();
|
|
424
|
+
const nextBraceDepth = braceDepth + countBraces(line);
|
|
425
|
+
|
|
426
|
+
if (currentMethod) {
|
|
427
|
+
currentMethod.bodyLines.push({ lineNumber, line });
|
|
428
|
+
currentMethod.calls.push(...extractCallsFromLine(line, lineNumber));
|
|
429
|
+
braceDepth = nextBraceDepth;
|
|
430
|
+
if (braceDepth < currentMethod.bodyBraceDepth) {
|
|
431
|
+
finalizeMethod(currentMethod);
|
|
432
|
+
parsed.methods.push(currentMethod);
|
|
433
|
+
currentMethod = null;
|
|
434
|
+
}
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (trimmed) {
|
|
439
|
+
declarationBuffer.push({ lineNumber, line });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const declarationComplete = Boolean(trimmed) && (trimmed.endsWith("{") || trimmed.endsWith(";"));
|
|
443
|
+
if (declarationComplete && declarationBuffer.length > 0) {
|
|
444
|
+
const declarationText = declarationBuffer.map((entry) => entry.line.trim()).join(" ");
|
|
445
|
+
if (looksLikeClassDeclaration(declarationText)) {
|
|
446
|
+
currentClass = parseClassDeclaration(
|
|
447
|
+
declarationText,
|
|
448
|
+
declarationBuffer[0].lineNumber,
|
|
449
|
+
filePath,
|
|
450
|
+
nextBraceDepth,
|
|
451
|
+
propertyMap,
|
|
452
|
+
);
|
|
453
|
+
if (currentClass) {
|
|
454
|
+
parsed.classes.push({
|
|
455
|
+
id: `${currentClass.kind}:${filePath}:${currentClass.name}`,
|
|
456
|
+
name: currentClass.name,
|
|
457
|
+
kind: currentClass.kind,
|
|
458
|
+
filePath,
|
|
459
|
+
line: currentClass.line,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
} else if (currentClass && looksLikeMethodDeclaration(declarationText)) {
|
|
463
|
+
const method = parseMethodDeclaration(
|
|
464
|
+
declarationText,
|
|
465
|
+
declarationBuffer[0].lineNumber,
|
|
466
|
+
filePath,
|
|
467
|
+
currentClass,
|
|
468
|
+
propertyMap,
|
|
469
|
+
);
|
|
470
|
+
if (method) {
|
|
471
|
+
if (method.endpoint) {
|
|
472
|
+
parsed.endpoints.push(method.endpoint);
|
|
473
|
+
}
|
|
474
|
+
if (method.clientMethod) {
|
|
475
|
+
parsed.clients.push(method.clientMethod);
|
|
476
|
+
}
|
|
477
|
+
if (declarationText.endsWith("{")) {
|
|
478
|
+
currentMethod = {
|
|
479
|
+
...method,
|
|
480
|
+
bodyBraceDepth: nextBraceDepth,
|
|
481
|
+
bodyLines: [],
|
|
482
|
+
calls: [],
|
|
483
|
+
};
|
|
484
|
+
} else {
|
|
485
|
+
parsed.methods.push({
|
|
486
|
+
...method,
|
|
487
|
+
calls: [],
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
} else if (currentClass) {
|
|
492
|
+
const constant = parseStringConstant(declarationText);
|
|
493
|
+
if (constant) {
|
|
494
|
+
currentClass.constants.set(constant.name, constant.value);
|
|
495
|
+
}
|
|
496
|
+
const field = parseFieldDeclaration(declarationText, filePath, currentClass.name, declarationBuffer[0].lineNumber);
|
|
497
|
+
if (field) {
|
|
498
|
+
parsed.fields.push(field);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
declarationBuffer = [];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
braceDepth = nextBraceDepth;
|
|
505
|
+
if (currentClass && braceDepth < currentClass.bodyBraceDepth) {
|
|
506
|
+
currentClass = null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return parsed;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function parseClassDeclaration(declarationText, lineNumber, filePath, bodyBraceDepth, propertyMap) {
|
|
514
|
+
const match = declarationText.match(/\b(class|interface|enum)\s+([A-Za-z_]\w*)\b/);
|
|
515
|
+
if (!match) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
const annotations = declarationText;
|
|
519
|
+
const requestMapping = parseRequestMapping(annotations, null, propertyMap, new Map());
|
|
520
|
+
const feignClient = parseFeignClient(annotations, propertyMap);
|
|
521
|
+
return {
|
|
522
|
+
name: match[2],
|
|
523
|
+
kind: match[1],
|
|
524
|
+
line: lineNumber,
|
|
525
|
+
filePath,
|
|
526
|
+
bodyBraceDepth,
|
|
527
|
+
basePath: requestMapping?.fullPath || "",
|
|
528
|
+
feignClient,
|
|
529
|
+
constants: new Map(),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function parseMethodDeclaration(declarationText, lineNumber, filePath, currentClass, propertyMap) {
|
|
534
|
+
const methodName = extractMethodName(declarationText, currentClass.name);
|
|
535
|
+
if (!methodName) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const endpoint = parseRequestMapping(
|
|
540
|
+
declarationText,
|
|
541
|
+
currentClass.basePath,
|
|
542
|
+
propertyMap,
|
|
543
|
+
currentClass.constants,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
const id = `Method:${filePath}:${currentClass.name}:${methodName}:${lineNumber}`;
|
|
547
|
+
const method = {
|
|
548
|
+
id,
|
|
549
|
+
className: currentClass.name,
|
|
550
|
+
name: methodName,
|
|
551
|
+
filePath,
|
|
552
|
+
line: lineNumber,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
if (endpoint) {
|
|
556
|
+
method.endpoint = {
|
|
557
|
+
id: `Endpoint:${filePath}:${currentClass.name}:${methodName}:${lineNumber}`,
|
|
558
|
+
servicePathId: `${endpoint.httpMethod}:${normalizePath(endpoint.fullPath)}`,
|
|
559
|
+
className: currentClass.name,
|
|
560
|
+
methodName,
|
|
561
|
+
filePath,
|
|
562
|
+
line: lineNumber,
|
|
563
|
+
...endpoint,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (currentClass.feignClient) {
|
|
568
|
+
const clientMapping = parseRequestMapping(
|
|
569
|
+
declarationText,
|
|
570
|
+
currentClass.feignClient.path || "",
|
|
571
|
+
propertyMap,
|
|
572
|
+
currentClass.constants,
|
|
573
|
+
);
|
|
574
|
+
if (clientMapping) {
|
|
575
|
+
method.clientMethod = {
|
|
576
|
+
id: `ClientMethod:${filePath}:${currentClass.name}:${methodName}:${lineNumber}`,
|
|
577
|
+
className: currentClass.name,
|
|
578
|
+
methodName,
|
|
579
|
+
filePath,
|
|
580
|
+
line: lineNumber,
|
|
581
|
+
feignName: currentClass.feignClient.name,
|
|
582
|
+
urlExpression: currentClass.feignClient.urlExpression,
|
|
583
|
+
resolvedBaseUrl: currentClass.feignClient.resolvedBaseUrl,
|
|
584
|
+
pathExpression: clientMapping.rawPath,
|
|
585
|
+
resolvedPath: clientMapping.resolvedPath,
|
|
586
|
+
fullPath: clientMapping.fullPath,
|
|
587
|
+
httpMethod: clientMapping.httpMethod,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return method;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function finalizeMethod(method) {
|
|
596
|
+
delete method.bodyBraceDepth;
|
|
597
|
+
delete method.bodyLines;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function parseFieldDeclaration(declarationText, filePath, className, lineNumber) {
|
|
601
|
+
const fieldMatch = declarationText.match(
|
|
602
|
+
/(?:private|protected|public)?\s*(?:static\s+)?(?:final\s+)?([A-Za-z_][\w.$<>\[\], ?]+)\s+([a-zA-Z_]\w*)\s*(?:=.*)?;$/,
|
|
603
|
+
);
|
|
604
|
+
if (!fieldMatch) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const fieldName = fieldMatch[2];
|
|
609
|
+
if (fieldName.toUpperCase() === fieldName) {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
id: `Field:${filePath}:${className}:${fieldName}:${lineNumber}`,
|
|
615
|
+
className,
|
|
616
|
+
name: fieldName,
|
|
617
|
+
type: sanitizeJavaType(fieldMatch[1]),
|
|
618
|
+
filePath,
|
|
619
|
+
line: lineNumber,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function parseStringConstant(declarationText) {
|
|
624
|
+
const match = declarationText.match(
|
|
625
|
+
/(?:private|protected|public)?\s*(?:static\s+)?(?:final\s+)?String\s+([A-Z0-9_]+)\s*=\s*"([^"]*)";$/,
|
|
626
|
+
);
|
|
627
|
+
if (!match) {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
name: match[1],
|
|
633
|
+
value: match[2],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function looksLikeClassDeclaration(declarationText) {
|
|
638
|
+
return /\b(class|interface|enum)\s+[A-Za-z_]\w*\b/.test(declarationText);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function looksLikeMethodDeclaration(declarationText) {
|
|
642
|
+
if (
|
|
643
|
+
!declarationText.includes("(") ||
|
|
644
|
+
(!declarationText.endsWith("{") && !declarationText.endsWith(";"))
|
|
645
|
+
) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
if (looksLikeClassDeclaration(declarationText)) {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
const lowered = declarationText.toLowerCase();
|
|
652
|
+
for (const keyword of CONTROL_FLOW_KEYWORDS) {
|
|
653
|
+
if (lowered.startsWith(`${keyword} `) || lowered.startsWith(`${keyword}(`)) {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function extractMethodName(declarationText, className) {
|
|
661
|
+
const constructorMatch = declarationText.match(
|
|
662
|
+
new RegExp(`\\b${className}\\s*\\(([^)]*)\\)\\s*(?:throws [^;{]+)?[;{]$`),
|
|
663
|
+
);
|
|
664
|
+
if (constructorMatch) {
|
|
665
|
+
return className;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const methodMatch = declarationText.match(
|
|
669
|
+
/(?:public|protected|private|static|final|native|synchronized|abstract|default|strictfp|\s)+(?:<[^>]+>\s*)?(?:[\w.$<>\[\],?@]+\s+)+([A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?:throws [^;{]+)?[;{]$/,
|
|
670
|
+
);
|
|
671
|
+
return methodMatch?.[1] || null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function parseFeignClient(declarationText, propertyMap) {
|
|
675
|
+
const annotationArgs = extractAnnotationArguments(declarationText, "FeignClient");
|
|
676
|
+
if (!annotationArgs) {
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const urlExpression = extractAnnotationValue(annotationArgs, "url");
|
|
681
|
+
const pathExpression = extractAnnotationValue(annotationArgs, "path")
|
|
682
|
+
|| extractAnnotationValue(annotationArgs, "value")
|
|
683
|
+
|| "";
|
|
684
|
+
return {
|
|
685
|
+
name: extractAnnotationValue(annotationArgs, "name")
|
|
686
|
+
|| extractAnnotationValue(annotationArgs, "value")
|
|
687
|
+
|| null,
|
|
688
|
+
urlExpression,
|
|
689
|
+
resolvedBaseUrl: resolveConfigValue(urlExpression || "", propertyMap),
|
|
690
|
+
path: normalizePath(resolveConfigValue(pathExpression || "", propertyMap)),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function parseRequestMapping(declarationText, basePath, propertyMap, constants) {
|
|
695
|
+
for (const [annotationName, httpMethod] of Object.entries(HTTP_METHOD_BY_ANNOTATION)) {
|
|
696
|
+
const annotationArgs = extractAnnotationArguments(declarationText, annotationName);
|
|
697
|
+
if (annotationArgs) {
|
|
698
|
+
const rawPath = extractMappingPath(annotationArgs, constants);
|
|
699
|
+
const resolvedPath = resolveConfigValue(rawPath || "", propertyMap);
|
|
700
|
+
return {
|
|
701
|
+
annotationName,
|
|
702
|
+
httpMethod,
|
|
703
|
+
rawPath,
|
|
704
|
+
resolvedPath,
|
|
705
|
+
fullPath: joinPaths(basePath, resolvedPath),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const requestMappingArgs = extractAnnotationArguments(declarationText, "RequestMapping");
|
|
711
|
+
if (!requestMappingArgs) {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const rawPath = extractMappingPath(requestMappingArgs, constants);
|
|
716
|
+
const resolvedPath = resolveConfigValue(rawPath || "", propertyMap);
|
|
717
|
+
const methodValue = extractAnnotationRequestMethod(requestMappingArgs) || "GET";
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
annotationName: "RequestMapping",
|
|
721
|
+
httpMethod: methodValue,
|
|
722
|
+
rawPath,
|
|
723
|
+
resolvedPath,
|
|
724
|
+
fullPath: joinPaths(basePath, resolvedPath),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function extractAnnotationArguments(text, annotationName) {
|
|
729
|
+
const regex = new RegExp(`@${annotationName}\\(([^)]*)\\)`);
|
|
730
|
+
const match = text.match(regex);
|
|
731
|
+
return match?.[1] || null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function extractAnnotationRequestMethod(annotationArgs) {
|
|
735
|
+
const match = annotationArgs.match(/RequestMethod\.([A-Z]+)/);
|
|
736
|
+
return match?.[1] || null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function extractAnnotationValue(annotationArgs, key) {
|
|
740
|
+
const namedMatch = annotationArgs.match(new RegExp(`${key}\\s*=\\s*("([^"]*)"|'([^']*)'|\\$\\{[^}]+\\}|[A-Za-z0-9_.-]+)`));
|
|
741
|
+
if (namedMatch) {
|
|
742
|
+
return stripQuotes(namedMatch[1]);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (key === "value") {
|
|
746
|
+
const positionalMatch = annotationArgs.match(/^("([^"]*)"|'([^']*)'|\$\{[^}]+\}|[A-Za-z0-9_.-]+)/);
|
|
747
|
+
if (positionalMatch) {
|
|
748
|
+
return stripQuotes(positionalMatch[1]);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function extractMappingPath(annotationArgs, constants) {
|
|
756
|
+
const value = extractAnnotationValue(annotationArgs, "path")
|
|
757
|
+
|| extractAnnotationValue(annotationArgs, "value");
|
|
758
|
+
if (!value) {
|
|
759
|
+
return "";
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (constants.has(value)) {
|
|
763
|
+
return constants.get(value);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function extractCallsFromLine(line, lineNumber) {
|
|
770
|
+
const calls = [];
|
|
771
|
+
const receiverRegex = /\b([A-Za-z_]\w*)\s*\.\s*([A-Za-z_]\w*)\s*\(/g;
|
|
772
|
+
for (const match of line.matchAll(receiverRegex)) {
|
|
773
|
+
calls.push({
|
|
774
|
+
receiver: match[1],
|
|
775
|
+
method: match[2],
|
|
776
|
+
line: lineNumber,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const directRegex = /(^|[^.])\b([A-Za-z_]\w*)\s*\(/g;
|
|
781
|
+
for (const match of line.matchAll(directRegex)) {
|
|
782
|
+
const methodName = match[2];
|
|
783
|
+
if (CONTROL_FLOW_KEYWORDS.has(methodName)) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
calls.push({
|
|
787
|
+
receiver: null,
|
|
788
|
+
method: methodName,
|
|
789
|
+
line: lineNumber,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return dedupeBy(calls, (call) => `${call.receiver || ""}:${call.method}:${call.line}`);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function loadServiceProperties(resourcesRoot) {
|
|
797
|
+
if (!(await pathExists(resourcesRoot))) {
|
|
798
|
+
return {};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const configFiles = await listFiles(resourcesRoot, (entry) =>
|
|
802
|
+
entry.isFile() && /^(application|bootstrap).*\.(properties|yml|yaml)$/.test(entry.name),
|
|
803
|
+
);
|
|
804
|
+
configFiles.sort((left, right) => left.localeCompare(right));
|
|
805
|
+
|
|
806
|
+
const propertyMap = {};
|
|
807
|
+
for (const filePath of configFiles) {
|
|
808
|
+
const text = await fs.readFile(filePath, "utf8");
|
|
809
|
+
const parsed =
|
|
810
|
+
filePath.endsWith(".properties")
|
|
811
|
+
? parseProperties(text)
|
|
812
|
+
: parseYamlLike(text);
|
|
813
|
+
Object.assign(propertyMap, parsed);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return propertyMap;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function parseProperties(text) {
|
|
820
|
+
const properties = {};
|
|
821
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
822
|
+
const line = rawLine.trim();
|
|
823
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) {
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const separatorIndex = line.indexOf("=");
|
|
828
|
+
const fallbackSeparatorIndex = line.indexOf(":");
|
|
829
|
+
const index =
|
|
830
|
+
separatorIndex >= 0
|
|
831
|
+
? separatorIndex
|
|
832
|
+
: fallbackSeparatorIndex >= 0
|
|
833
|
+
? fallbackSeparatorIndex
|
|
834
|
+
: -1;
|
|
835
|
+
if (index === -1) {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const key = line.slice(0, index).trim();
|
|
840
|
+
const value = line.slice(index + 1).trim();
|
|
841
|
+
properties[key] = stripQuotes(value);
|
|
842
|
+
}
|
|
843
|
+
return properties;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function parseYamlLike(text) {
|
|
847
|
+
const properties = {};
|
|
848
|
+
const stack = [{ indent: -1, key: "" }];
|
|
849
|
+
|
|
850
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
851
|
+
if (!rawLine.trim() || rawLine.trim().startsWith("#")) {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const indent = rawLine.match(/^\s*/)?.[0]?.length || 0;
|
|
856
|
+
const trimmed = rawLine.trim();
|
|
857
|
+
if (trimmed.startsWith("- ")) {
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const separatorIndex = trimmed.indexOf(":");
|
|
862
|
+
if (separatorIndex === -1) {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
867
|
+
const rawValue = trimmed.slice(separatorIndex + 1).trim();
|
|
868
|
+
|
|
869
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
870
|
+
stack.pop();
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const parentPath = stack[stack.length - 1].key;
|
|
874
|
+
const fullKey = parentPath ? `${parentPath}.${key}` : key;
|
|
875
|
+
|
|
876
|
+
if (!rawValue) {
|
|
877
|
+
stack.push({ indent, key: fullKey });
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
properties[fullKey] = stripQuotes(rawValue);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return properties;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function renderSummary(graph) {
|
|
888
|
+
const lines = [
|
|
889
|
+
"# Microservice KG Summary",
|
|
890
|
+
"",
|
|
891
|
+
`- Generated at: ${graph.generatedAt}`,
|
|
892
|
+
`- Input directory: ${graph.inputDir}`,
|
|
893
|
+
`- Services discovered: ${graph.serviceCount}`,
|
|
894
|
+
`- Service edges discovered: ${graph.serviceEdges.length}`,
|
|
895
|
+
"",
|
|
896
|
+
"## Service Graph",
|
|
897
|
+
"",
|
|
898
|
+
"```mermaid",
|
|
899
|
+
"graph LR",
|
|
900
|
+
];
|
|
901
|
+
|
|
902
|
+
for (const edge of graph.serviceEdges) {
|
|
903
|
+
lines.push(` ${safeMermaidId(edge.sourceServiceId)}["${edge.sourceServiceId}"] -->|HTTP| ${safeMermaidId(edge.targetServiceId)}["${edge.targetServiceId}"]`);
|
|
904
|
+
}
|
|
905
|
+
lines.push("```", "", "## Services", "");
|
|
906
|
+
|
|
907
|
+
for (const service of graph.services) {
|
|
908
|
+
lines.push(`### ${service.id}`);
|
|
909
|
+
lines.push(`- Root: ${service.relativeRootDir}`);
|
|
910
|
+
lines.push(`- Endpoints: ${service.endpoints.length}`);
|
|
911
|
+
lines.push(`- Clients: ${service.clients.length}`);
|
|
912
|
+
lines.push(`- Method interactions: ${service.methodInteractions.length}`);
|
|
913
|
+
const outgoingEdges = graph.serviceEdges.filter((edge) => edge.sourceServiceId === service.id);
|
|
914
|
+
if (outgoingEdges.length > 0) {
|
|
915
|
+
lines.push(`- Outgoing services: ${outgoingEdges.map((edge) => edge.targetServiceId).join(", ")}`);
|
|
916
|
+
}
|
|
917
|
+
lines.push("");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
lines.push("## Edge Evidence", "");
|
|
921
|
+
for (const edge of graph.serviceEdges) {
|
|
922
|
+
lines.push(`### ${edge.sourceServiceId} -> ${edge.targetServiceId}`);
|
|
923
|
+
for (const call of edge.calls) {
|
|
924
|
+
lines.push(`- ${call.httpMethod} ${call.path}`);
|
|
925
|
+
lines.push(`- Client: ${call.sourceClassName}.${call.sourceMethodName}`);
|
|
926
|
+
if (call.provider) {
|
|
927
|
+
lines.push(`- Provider: ${call.provider.targetClassName}.${call.provider.targetMethodName}`);
|
|
928
|
+
}
|
|
929
|
+
if (call.callSites.length > 0) {
|
|
930
|
+
lines.push(`- Call sites: ${call.callSites.map((site) => `${site.className}.${site.methodName}`).join(", ")}`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
lines.push("");
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return lines.join("\n");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function safeMermaidId(value) {
|
|
940
|
+
return value.replace(/[^A-Za-z0-9_]/g, "_");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function deriveServiceAliases(values) {
|
|
944
|
+
const aliases = new Set();
|
|
945
|
+
for (const value of values.filter(Boolean)) {
|
|
946
|
+
const normalized = normalizeAlias(value);
|
|
947
|
+
if (!normalized) {
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
aliases.add(normalized);
|
|
951
|
+
const strippedPrefixes = normalized.replace(/^(fin|cf)-/, "");
|
|
952
|
+
aliases.add(strippedPrefixes);
|
|
953
|
+
const strippedService = strippedPrefixes.replace(/-service$/, "");
|
|
954
|
+
aliases.add(strippedService);
|
|
955
|
+
aliases.add(`${strippedService}-service`);
|
|
956
|
+
}
|
|
957
|
+
return Array.from(aliases).filter(Boolean).sort((left, right) => left.localeCompare(right));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function normalizeAlias(value) {
|
|
961
|
+
return String(value || "")
|
|
962
|
+
.trim()
|
|
963
|
+
.toLowerCase()
|
|
964
|
+
.replace(/_/g, "-")
|
|
965
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
966
|
+
.replace(/-+/g, "-")
|
|
967
|
+
.replace(/^-|-$/g, "");
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function joinPaths(basePath, pathValue) {
|
|
971
|
+
const normalizedBase = normalizePath(basePath || "");
|
|
972
|
+
const normalizedPath = normalizePath(pathValue || "");
|
|
973
|
+
if (!normalizedBase) {
|
|
974
|
+
return normalizedPath || "/";
|
|
975
|
+
}
|
|
976
|
+
if (!normalizedPath) {
|
|
977
|
+
return normalizedBase || "/";
|
|
978
|
+
}
|
|
979
|
+
return normalizePath(`${normalizedBase}/${normalizedPath}`);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function normalizePath(value) {
|
|
983
|
+
if (!value) {
|
|
984
|
+
return "";
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
let result = stripQuotes(value.trim());
|
|
988
|
+
if (!result) {
|
|
989
|
+
return "";
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
result = result.replace(/^https?:\/\/[^/]+/i, "");
|
|
993
|
+
if (!result.startsWith("/")) {
|
|
994
|
+
result = `/${result}`;
|
|
995
|
+
}
|
|
996
|
+
result = result.replace(/\/+/g, "/");
|
|
997
|
+
result = result.replace(/\{[^}]+\}/g, "{}");
|
|
998
|
+
if (result.length > 1 && result.endsWith("/")) {
|
|
999
|
+
result = result.slice(0, -1);
|
|
1000
|
+
}
|
|
1001
|
+
return result;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function resolveConfigValue(rawValue, propertyMap) {
|
|
1005
|
+
if (!rawValue) {
|
|
1006
|
+
return rawValue;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
let result = rawValue;
|
|
1010
|
+
for (let iteration = 0; iteration < 8; iteration += 1) {
|
|
1011
|
+
const replaced = result.replace(/\$\{([^}:]+)(?::[^}]*)?\}/g, (match, key) => {
|
|
1012
|
+
return propertyMap[key] ?? match;
|
|
1013
|
+
});
|
|
1014
|
+
if (replaced === result) {
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
result = replaced;
|
|
1018
|
+
}
|
|
1019
|
+
return stripQuotes(result);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function stripQuotes(value) {
|
|
1023
|
+
return String(value || "").replace(/^['"]|['"]$/g, "");
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function sanitizeJavaType(value) {
|
|
1027
|
+
return String(value || "")
|
|
1028
|
+
.replace(/<.*>/g, "")
|
|
1029
|
+
.replace(/\[\]/g, "")
|
|
1030
|
+
.split(".")
|
|
1031
|
+
.pop()
|
|
1032
|
+
.trim();
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function countBraces(line) {
|
|
1036
|
+
const sanitized = line
|
|
1037
|
+
.replace(/"([^"\\]|\\.)*"/g, "")
|
|
1038
|
+
.replace(/'([^'\\]|\\.)*'/g, "")
|
|
1039
|
+
.replace(/\/\/.*$/g, "");
|
|
1040
|
+
let count = 0;
|
|
1041
|
+
for (const character of sanitized) {
|
|
1042
|
+
if (character === "{") {
|
|
1043
|
+
count += 1;
|
|
1044
|
+
} else if (character === "}") {
|
|
1045
|
+
count -= 1;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return count;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function walkDirectories(rootDir, visitor) {
|
|
1052
|
+
const dirents = await fs.readdir(rootDir, { withFileTypes: true });
|
|
1053
|
+
const shouldStop = await visitor(rootDir, dirents);
|
|
1054
|
+
if (shouldStop) {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
for (const dirent of dirents) {
|
|
1059
|
+
if (!dirent.isDirectory()) {
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (IGNORED_DIR_NAMES.has(dirent.name)) {
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
await walkDirectories(path.join(rootDir, dirent.name), visitor);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
async function listFiles(rootDir, predicate) {
|
|
1070
|
+
const files = [];
|
|
1071
|
+
if (!(await pathExists(rootDir))) {
|
|
1072
|
+
return files;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function walk(currentDir) {
|
|
1076
|
+
const dirents = await fs.readdir(currentDir, { withFileTypes: true });
|
|
1077
|
+
for (const dirent of dirents) {
|
|
1078
|
+
if (dirent.isDirectory()) {
|
|
1079
|
+
if (IGNORED_DIR_NAMES.has(dirent.name)) {
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
await walk(path.join(currentDir, dirent.name));
|
|
1083
|
+
} else if (predicate(dirent)) {
|
|
1084
|
+
files.push(path.join(currentDir, dirent.name));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
await walk(rootDir);
|
|
1090
|
+
return files;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function hasApplicationConfig(resourcesRoot) {
|
|
1094
|
+
if (!(await pathExists(resourcesRoot))) {
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
const files = await fs.readdir(resourcesRoot);
|
|
1098
|
+
return files.some((fileName) => /^(application|bootstrap).*\.(properties|yml|yaml)$/.test(fileName));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function hasSpringBootApplication(javaRoot) {
|
|
1102
|
+
const javaFiles = await listFiles(javaRoot, (entry) => entry.isFile() && entry.name.endsWith(".java"));
|
|
1103
|
+
for (const filePath of javaFiles.slice(0, 200)) {
|
|
1104
|
+
const text = await fs.readFile(filePath, "utf8");
|
|
1105
|
+
if (text.includes("@SpringBootApplication")) {
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async function pathExists(targetPath) {
|
|
1113
|
+
try {
|
|
1114
|
+
await fs.access(targetPath);
|
|
1115
|
+
return true;
|
|
1116
|
+
} catch {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function dedupeBy(values, keyFn) {
|
|
1122
|
+
const seen = new Set();
|
|
1123
|
+
const result = [];
|
|
1124
|
+
for (const value of values) {
|
|
1125
|
+
const key = keyFn(value);
|
|
1126
|
+
if (seen.has(key)) {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
seen.add(key);
|
|
1130
|
+
result.push(value);
|
|
1131
|
+
}
|
|
1132
|
+
return result;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function firstDefined(...values) {
|
|
1136
|
+
return values.find((value) => value !== undefined && value !== null && value !== "");
|
|
1137
|
+
}
|