gitnexus 1.6.11-rc.21 → 1.6.11-rc.23

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 (64) hide show
  1. package/README.md +24 -21
  2. package/dist/_shared/graph/types.d.ts +6 -0
  3. package/dist/_shared/graph/types.d.ts.map +1 -1
  4. package/dist/cli/ai-context.d.ts +4 -0
  5. package/dist/cli/ai-context.js +5 -2
  6. package/dist/cli/analyze-config.js +14 -0
  7. package/dist/cli/analyze-options.d.ts +5 -0
  8. package/dist/cli/analyze.js +2 -0
  9. package/dist/cli/index.js +2 -0
  10. package/dist/cli/watch.js +2 -0
  11. package/dist/core/group/cross-impact.d.ts +3 -0
  12. package/dist/core/group/cross-impact.js +26 -4
  13. package/dist/core/group/extractors/http-patterns/java.js +18 -4
  14. package/dist/core/group/extractors/http-patterns/kotlin.js +2 -0
  15. package/dist/core/group/normalization.d.ts +38 -1
  16. package/dist/core/group/normalization.js +59 -3
  17. package/dist/core/group/service.d.ts +11 -0
  18. package/dist/core/group/service.js +8 -0
  19. package/dist/core/group/sync.d.ts +21 -0
  20. package/dist/core/group/sync.js +15 -3
  21. package/dist/core/group/types.d.ts +13 -0
  22. package/dist/core/ingestion/frameworks/spring/actuator-runtime.d.ts +26 -0
  23. package/dist/core/ingestion/frameworks/spring/actuator-runtime.js +843 -0
  24. package/dist/core/ingestion/language-provider.d.ts +38 -1
  25. package/dist/core/ingestion/language-provider.js +22 -0
  26. package/dist/core/ingestion/languages/java/spring-actuator.d.ts +2 -0
  27. package/dist/core/ingestion/languages/java/spring-actuator.js +62 -0
  28. package/dist/core/ingestion/languages/java.js +8 -7
  29. package/dist/core/ingestion/languages/kotlin/spring-actuator.d.ts +4 -0
  30. package/dist/core/ingestion/languages/kotlin/spring-actuator.js +172 -0
  31. package/dist/core/ingestion/languages/kotlin.js +3 -0
  32. package/dist/core/ingestion/pipeline-phases/parse-impl.js +6 -1
  33. package/dist/core/ingestion/pipeline-phases/processes.d.ts +2 -1
  34. package/dist/core/ingestion/pipeline-phases/processes.js +39 -4
  35. package/dist/core/ingestion/pipeline-phases/scan.js +82 -1
  36. package/dist/core/ingestion/pipeline-phases/spring-auto-configuration.d.ts +2 -0
  37. package/dist/core/ingestion/pipeline-phases/spring-auto-configuration.js +8 -0
  38. package/dist/core/ingestion/pipeline.d.ts +8 -0
  39. package/dist/core/ingestion/route-extractors/constant-resolver.d.ts +16 -0
  40. package/dist/core/ingestion/route-extractors/constant-resolver.js +10 -0
  41. package/dist/core/ingestion/route-extractors/java-const-resolver.d.ts +27 -1
  42. package/dist/core/ingestion/route-extractors/java-const-resolver.js +161 -13
  43. package/dist/core/ingestion/route-extractors/kotlin-const-resolver.d.ts +7 -8
  44. package/dist/core/ingestion/route-extractors/kotlin-const-resolver.js +209 -23
  45. package/dist/core/ingestion/workers/parse-worker.js +5 -1
  46. package/dist/core/lbug/csv-generator.js +4 -1
  47. package/dist/core/lbug/graph-emit-sink.d.ts +3 -2
  48. package/dist/core/lbug/graph-emit-sink.js +4 -2
  49. package/dist/core/lbug/lbug-adapter.js +1 -1
  50. package/dist/core/lbug/schema.d.ts +1 -1
  51. package/dist/core/lbug/schema.js +3 -0
  52. package/dist/core/run-analyze.d.ts +5 -0
  53. package/dist/core/run-analyze.js +56 -0
  54. package/dist/mcp/local/local-backend.js +46 -3
  55. package/dist/mcp/resources.js +1 -0
  56. package/dist/mcp/tools.js +4 -4
  57. package/dist/server/analyze-launch.d.ts +1 -0
  58. package/dist/server/analyze-launch.js +1 -0
  59. package/dist/server/api.d.ts +4 -0
  60. package/dist/server/api.js +46 -4
  61. package/dist/storage/parse-cache.js +8 -1
  62. package/dist/storage/repo-meta.d.ts +12 -0
  63. package/package.json +1 -1
  64. package/skills/gitnexus-cli.md +3 -0
@@ -0,0 +1,843 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { generateId } from '../../../../lib/utils.js';
4
+ import { SPRING_DI_PROVIDER_PROPERTY } from '../../di-extractors/spring.js';
5
+ import { normalizeExtractedRoutePath, normalizeRouteMethod, routeNodeKey, } from '../../route-extractors/route-path.js';
6
+ import { stripBidiAndZeroWidth } from '../../utils/ast-helpers.js';
7
+ import { SPRING_CONFIG_DESCRIPTION } from './config-bindings.js';
8
+ import { getProviderForFile } from '../../languages/index.js';
9
+ export const ACTUATOR_ENDPOINTS = [
10
+ 'mappings',
11
+ 'beans',
12
+ 'conditions',
13
+ 'configprops',
14
+ 'env',
15
+ ];
16
+ const MAX_ACTUATOR_PAYLOAD_BYTES = 16 * 1024 * 1024;
17
+ export const MAX_RUNTIME_RECORDS = 50_000;
18
+ const MAX_RUNTIME_DEPTH = 64;
19
+ const RUNTIME_FILE_PREFIX = 'spring-actuator:';
20
+ export class SpringActuatorImportError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'SpringActuatorImportError';
24
+ }
25
+ }
26
+ function objectValue(value) {
27
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
28
+ ? value
29
+ : undefined;
30
+ }
31
+ function safeText(value, maxLength = 1024) {
32
+ if (typeof value !== 'string')
33
+ return undefined;
34
+ const sanitized = stripBidiAndZeroWidth(value)
35
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
36
+ .replace(/\s+/g, ' ')
37
+ .trim();
38
+ return sanitized.length === 0 ? undefined : sanitized.slice(0, maxLength);
39
+ }
40
+ function safeStrings(value, limit = 100) {
41
+ if (!Array.isArray(value))
42
+ return [];
43
+ const strings = [];
44
+ for (const item of value.slice(0, limit)) {
45
+ const text = safeText(item);
46
+ if (text !== undefined)
47
+ strings.push(text);
48
+ }
49
+ return strings;
50
+ }
51
+ async function readPayloadFile(filePath, label) {
52
+ // Size gate and read share one handle so both observe the same inode.
53
+ // Re-resolving the path for the read would let a swapped file bypass the
54
+ // payload cap (CodeQL js/file-system-race).
55
+ let handle;
56
+ let raw;
57
+ try {
58
+ handle = await fs.open(filePath, 'r');
59
+ const stat = await handle.stat();
60
+ if (!stat.isFile()) {
61
+ throw new SpringActuatorImportError(`Spring Actuator ${label} input must be a JSON file.`);
62
+ }
63
+ if (stat.size > MAX_ACTUATOR_PAYLOAD_BYTES) {
64
+ throw new SpringActuatorImportError(`Spring Actuator ${label} payload exceeds the ${MAX_ACTUATOR_PAYLOAD_BYTES / 1024 / 1024} MiB limit.`);
65
+ }
66
+ const buffer = Buffer.alloc(MAX_ACTUATOR_PAYLOAD_BYTES + 1);
67
+ let bytesRead = 0;
68
+ while (bytesRead < buffer.length) {
69
+ const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
70
+ if (result.bytesRead === 0)
71
+ break;
72
+ bytesRead += result.bytesRead;
73
+ }
74
+ if (bytesRead > MAX_ACTUATOR_PAYLOAD_BYTES) {
75
+ throw new SpringActuatorImportError(`Spring Actuator ${label} payload exceeds the ${MAX_ACTUATOR_PAYLOAD_BYTES / 1024 / 1024} MiB limit.`);
76
+ }
77
+ raw = buffer.subarray(0, bytesRead).toString('utf8');
78
+ }
79
+ catch (err) {
80
+ if (err instanceof SpringActuatorImportError)
81
+ throw err;
82
+ throw new SpringActuatorImportError(`Spring Actuator ${label} input could not be read.`);
83
+ }
84
+ finally {
85
+ await handle?.close().catch(() => { });
86
+ }
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(raw);
90
+ }
91
+ catch {
92
+ // Do not include JSON.parse's message: newer runtimes may quote source text,
93
+ // which could disclose an env/configprops value in CLI output.
94
+ throw new SpringActuatorImportError(`Spring Actuator ${label} payload is not valid JSON.`);
95
+ }
96
+ const object = objectValue(parsed);
97
+ if (object === undefined) {
98
+ throw new SpringActuatorImportError(`Spring Actuator ${label} payload must be a JSON object.`);
99
+ }
100
+ return object;
101
+ }
102
+ async function loadPayloads(repoPath, configuredPath) {
103
+ const inputPath = path.resolve(repoPath, configuredPath);
104
+ let stat;
105
+ try {
106
+ stat = await fs.stat(inputPath);
107
+ }
108
+ catch {
109
+ throw new SpringActuatorImportError('Spring Actuator input path does not exist or is unreadable.');
110
+ }
111
+ const payloads = new Map();
112
+ if (stat.isDirectory()) {
113
+ for (const endpoint of ACTUATOR_ENDPOINTS) {
114
+ const filePath = path.join(inputPath, `${endpoint}.json`);
115
+ try {
116
+ const endpointStat = await fs.stat(filePath);
117
+ if (!endpointStat.isFile())
118
+ continue;
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ payloads.set(endpoint, await readPayloadFile(filePath, endpoint));
124
+ }
125
+ }
126
+ else if (stat.isFile()) {
127
+ const parsed = await readPayloadFile(inputPath, 'bundle');
128
+ const endpointFromName = ACTUATOR_ENDPOINTS.find((endpoint) => path.basename(inputPath).toLowerCase() === `${endpoint}.json`);
129
+ if (endpointFromName !== undefined) {
130
+ payloads.set(endpointFromName, parsed);
131
+ }
132
+ else {
133
+ for (const endpoint of ACTUATOR_ENDPOINTS) {
134
+ const payload = objectValue(parsed[endpoint]);
135
+ if (payload !== undefined)
136
+ payloads.set(endpoint, payload);
137
+ }
138
+ }
139
+ }
140
+ else {
141
+ throw new SpringActuatorImportError('Spring Actuator input must be a JSON bundle or a directory of endpoint JSON files.');
142
+ }
143
+ if (payloads.size === 0) {
144
+ throw new SpringActuatorImportError('Spring Actuator input contains none of mappings, beans, conditions, configprops, or env.');
145
+ }
146
+ return payloads;
147
+ }
148
+ function evidenceFile(graph, endpoint) {
149
+ const filePath = `${RUNTIME_FILE_PREFIX}${endpoint}`;
150
+ const id = generateId('File', filePath);
151
+ const existing = graph.getNode(id);
152
+ if (existing !== undefined)
153
+ return existing;
154
+ const node = {
155
+ id,
156
+ label: 'File',
157
+ properties: { name: `${endpoint}.json`, filePath },
158
+ };
159
+ graph.addNode(node);
160
+ return node;
161
+ }
162
+ function appendRuntimeMarker(node, marker) {
163
+ const current = typeof node.properties.description === 'string' ? node.properties.description : '';
164
+ if (current.includes(marker))
165
+ return;
166
+ node.properties.description = current.length === 0 ? marker : `${current}; ${marker}`;
167
+ }
168
+ function markRuntimeEvidence(graph, endpoint, target, status = 'runtime-confirmed', confirmed = true) {
169
+ // Only Route declares structured runtime columns in the persisted schema.
170
+ // Other labels retain the same evidence durably through their description
171
+ // plus the DECLARES edge below; setting undeclared properties would make the
172
+ // in-memory graph promise data that CSV/LadybugDB silently drops.
173
+ if (target.label === 'Route') {
174
+ // Confirmation is conflict-dominant. Once any runtime observation
175
+ // disagrees with static or runtime ownership, a later duplicate must not
176
+ // restore authoritative status.
177
+ target.properties.runtimeConfirmed =
178
+ target.properties.runtimeConfirmed === false ? false : confirmed;
179
+ // Source records provenance, not authority. Consumers MUST use
180
+ // runtimeConfirmed === true before treating runtime evidence as confirmed.
181
+ target.properties.runtimeSource = 'spring-actuator';
182
+ const previousStatus = safeText(target.properties.runtimeStatus);
183
+ target.properties.runtimeStatus = [...new Set([...(previousStatus?.split(',') ?? []), status])]
184
+ .sort()
185
+ .join(',');
186
+ }
187
+ const marker = `Spring Actuator ${endpoint} ${status}`;
188
+ appendRuntimeMarker(target, marker);
189
+ const evidence = evidenceFile(graph, endpoint);
190
+ graph.addRelationship({
191
+ id: generateId('DECLARES', `${evidence.id}->${target.id}:${status}`),
192
+ sourceId: evidence.id,
193
+ targetId: target.id,
194
+ type: 'DECLARES',
195
+ confidence: 1,
196
+ reason: `spring-actuator:${endpoint}:${status}`,
197
+ });
198
+ }
199
+ function normalizedQualifiedName(value) {
200
+ return value
201
+ .replace(/\$\$(?:SpringCGLIB|EnhancerBySpringCGLIB|FastClassBySpringCGLIB).*$/, '')
202
+ .replaceAll('$', '.');
203
+ }
204
+ function uniqueIndexAdd(index, key, node) {
205
+ const existing = index.get(key);
206
+ if (existing === undefined)
207
+ index.set(key, node);
208
+ else if (existing !== null && existing.id !== node.id)
209
+ index.set(key, null);
210
+ }
211
+ function addRuntimeCallable(index, ownerName, node) {
212
+ const normalizedOwner = normalizedQualifiedName(ownerName);
213
+ const nodes = index.get(normalizedOwner) ?? [];
214
+ if (!nodes.some((candidate) => candidate.id === node.id))
215
+ nodes.push(node);
216
+ index.set(normalizedOwner, nodes);
217
+ }
218
+ function buildRuntimeNodeIndexes(graph) {
219
+ const allNodes = [...graph.iterNodes()];
220
+ const classesByQualifiedName = new Map();
221
+ const classesByRuntimeAlias = new Map();
222
+ const classesBySimpleName = new Map();
223
+ const beanProvidersByName = new Map();
224
+ const nodesById = new Map(allNodes.map((node) => [node.id, node]));
225
+ const methodsByOwnerId = new Map();
226
+ const callablesByRuntimeOwner = new Map();
227
+ const routeOwnerFileIdsByRouteId = new Map();
228
+ for (const node of allNodes) {
229
+ if (node.label === 'Class' || node.label === 'Record') {
230
+ const qualified = safeText(node.properties.qualifiedName);
231
+ if (qualified !== undefined) {
232
+ uniqueIndexAdd(classesByQualifiedName, normalizedQualifiedName(qualified), node);
233
+ }
234
+ uniqueIndexAdd(classesBySimpleName, String(node.properties.name), node);
235
+ }
236
+ const provider = objectValue(node.properties[SPRING_DI_PROVIDER_PROPERTY]);
237
+ for (const name of safeStrings(provider?.names))
238
+ uniqueIndexAdd(beanProvidersByName, name, node);
239
+ }
240
+ const ownedNodeIds = new Set();
241
+ for (const relationshipType of ['HAS_METHOD', 'HAS_PROPERTY']) {
242
+ for (const relationship of graph.iterRelationshipsByType(relationshipType)) {
243
+ const member = nodesById.get(relationship.targetId);
244
+ const owner = nodesById.get(relationship.sourceId);
245
+ if (member === undefined ||
246
+ !['Method', 'Function', 'Property'].includes(member.label) ||
247
+ owner === undefined) {
248
+ continue;
249
+ }
250
+ ownedNodeIds.add(member.id);
251
+ if (member.label === 'Method' || member.label === 'Function') {
252
+ const methods = methodsByOwnerId.get(relationship.sourceId) ?? [];
253
+ methods.push(member);
254
+ methodsByOwnerId.set(relationship.sourceId, methods);
255
+ }
256
+ const ownerQualifiedName = safeText(owner.properties.qualifiedName);
257
+ if (ownerQualifiedName !== undefined) {
258
+ addRuntimeCallable(callablesByRuntimeOwner, ownerQualifiedName, member);
259
+ }
260
+ const strategy = getProviderForFile(String(member.properties.filePath))?.runtimeSymbolStrategy;
261
+ for (const alias of strategy?.callableOwnerAliases?.(member, owner) ?? []) {
262
+ addRuntimeCallable(callablesByRuntimeOwner, alias, member);
263
+ if ((owner.label === 'Class' || owner.label === 'Record') &&
264
+ ownerQualifiedName !== undefined &&
265
+ normalizedQualifiedName(alias) !== normalizedQualifiedName(ownerQualifiedName)) {
266
+ uniqueIndexAdd(classesByRuntimeAlias, normalizedQualifiedName(alias), owner);
267
+ }
268
+ }
269
+ }
270
+ }
271
+ for (const node of allNodes) {
272
+ if (ownedNodeIds.has(node.id) ||
273
+ (node.label !== 'Function' && node.label !== 'Method' && node.label !== 'Property')) {
274
+ continue;
275
+ }
276
+ const strategy = getProviderForFile(String(node.properties.filePath))?.runtimeSymbolStrategy;
277
+ for (const alias of strategy?.callableOwnerAliases?.(node, undefined) ?? []) {
278
+ addRuntimeCallable(callablesByRuntimeOwner, alias, node);
279
+ }
280
+ }
281
+ for (const relationship of graph.iterRelationshipsByType('HANDLES_ROUTE')) {
282
+ const owners = routeOwnerFileIdsByRouteId.get(relationship.targetId) ?? new Set();
283
+ owners.add(relationship.sourceId);
284
+ routeOwnerFileIdsByRouteId.set(relationship.targetId, owners);
285
+ }
286
+ return {
287
+ classesByQualifiedName,
288
+ classesByRuntimeAlias,
289
+ classesBySimpleName,
290
+ beanProvidersByName,
291
+ methodsByOwnerId,
292
+ callablesByRuntimeOwner,
293
+ routeOwnerFileIdsByRouteId,
294
+ };
295
+ }
296
+ function resolveClass(indexes, rawType) {
297
+ if (rawType === undefined)
298
+ return undefined;
299
+ const type = normalizedQualifiedName(rawType.replace(/\[\]$/, ''));
300
+ const exact = indexes.classesByQualifiedName.get(type);
301
+ if (exact !== null && exact !== undefined)
302
+ return exact;
303
+ const alias = indexes.classesByRuntimeAlias.get(type);
304
+ if (alias !== null && alias !== undefined)
305
+ return alias;
306
+ // A qualified runtime name is authoritative. Falling back to a unique class
307
+ // with the same simple name can bind a stale snapshot to a different package
308
+ // and then mint confidence-1 handler evidence for the wrong source.
309
+ if (type.includes('.'))
310
+ return undefined;
311
+ const simple = type.slice(type.lastIndexOf('.') + 1);
312
+ const fallback = indexes.classesBySimpleName.get(simple);
313
+ return fallback === null ? undefined : fallback;
314
+ }
315
+ function providerMatchesRuntimeType(indexes, providerNode, runtimeType) {
316
+ if (runtimeType === undefined)
317
+ return true;
318
+ const provider = objectValue(providerNode.properties[SPRING_DI_PROVIDER_PROPERTY]);
319
+ const providerType = safeText(provider?.providedTypeName) ??
320
+ (providerNode.label === 'Class' || providerNode.label === 'Record'
321
+ ? safeText(providerNode.properties.qualifiedName)
322
+ : undefined);
323
+ if (providerType === undefined)
324
+ return true;
325
+ const providerClass = resolveClass(indexes, providerType);
326
+ const runtimeClass = resolveClass(indexes, runtimeType);
327
+ if (providerClass !== undefined && runtimeClass !== undefined) {
328
+ return providerClass.id === runtimeClass.id;
329
+ }
330
+ const normalizedProvider = normalizedQualifiedName(providerType);
331
+ const normalizedRuntime = normalizedQualifiedName(runtimeType);
332
+ if (normalizedProvider.includes('.'))
333
+ return normalizedProvider === normalizedRuntime;
334
+ return normalizedProvider === normalizedRuntime.slice(normalizedRuntime.lastIndexOf('.') + 1);
335
+ }
336
+ function descriptorParameterTypes(descriptor) {
337
+ if (descriptor === undefined || descriptor.charAt(0) !== '(')
338
+ return undefined;
339
+ const types = [];
340
+ for (let index = 1; index < descriptor.length && descriptor.charAt(index) !== ')';) {
341
+ let arrayDimensions = 0;
342
+ while (descriptor.charAt(index) === '[') {
343
+ arrayDimensions++;
344
+ index++;
345
+ }
346
+ const arraySuffix = '[]'.repeat(arrayDimensions);
347
+ if (descriptor.charAt(index) === 'L') {
348
+ const end = descriptor.indexOf(';', index);
349
+ if (end === -1)
350
+ return undefined;
351
+ types.push(`${descriptor.slice(index + 1, end)}${arraySuffix}`);
352
+ index = end + 1;
353
+ }
354
+ else {
355
+ const primitive = descriptor.charAt(index);
356
+ if (!'BCDFIJSZ'.includes(primitive))
357
+ return undefined;
358
+ types.push(`${primitive}${arraySuffix}`);
359
+ index++;
360
+ }
361
+ }
362
+ return descriptor.includes(')') ? types : undefined;
363
+ }
364
+ function matchesRuntimeCallable(node, runtime) {
365
+ const strategy = getProviderForFile(String(node.properties.filePath))?.runtimeSymbolStrategy;
366
+ if (strategy !== undefined)
367
+ return strategy.matchesCallable(node, runtime);
368
+ return ((node.label === 'Method' || node.label === 'Function') &&
369
+ node.properties.name === runtime.name &&
370
+ (runtime.descriptorParameterTypes === undefined ||
371
+ node.properties.parameterCount === runtime.descriptorParameterTypes.length));
372
+ }
373
+ function resolveHandlerNode(indexes, handlerMethod) {
374
+ const className = safeText(handlerMethod?.className);
375
+ const methodName = safeText(handlerMethod?.name);
376
+ if (methodName === undefined)
377
+ return resolveClass(indexes, className);
378
+ if (className === undefined)
379
+ return undefined;
380
+ const owner = resolveClass(indexes, className);
381
+ const runtime = {
382
+ name: methodName,
383
+ descriptorParameterTypes: descriptorParameterTypes(safeText(handlerMethod?.descriptor)),
384
+ };
385
+ const ownerCandidates = owner === undefined ? [] : (indexes.methodsByOwnerId.get(owner.id) ?? []);
386
+ const aliasCandidates = indexes.callablesByRuntimeOwner.get(normalizedQualifiedName(className)) ?? [];
387
+ const candidates = [...ownerCandidates, ...aliasCandidates]
388
+ .filter((node, index, all) => all.findIndex((candidate) => candidate.id === node.id) === index)
389
+ .filter((node) => matchesRuntimeCallable(node, runtime));
390
+ return candidates.length === 1 ? candidates[0] : undefined;
391
+ }
392
+ function predicateParts(predicate) {
393
+ if (predicate === undefined)
394
+ return { methods: [], patterns: [] };
395
+ const methodListEnd = predicate.indexOf('[');
396
+ const methodRegion = methodListEnd === -1 ? predicate : predicate.slice(0, methodListEnd);
397
+ const methods = [
398
+ ...methodRegion.matchAll(/\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)\b/g),
399
+ ]
400
+ .map((match) => match[1])
401
+ .filter((method) => method !== undefined);
402
+ const patterns = [...predicate.matchAll(/(?:^|[\s[(])((?:\/)[^\s\]),}]+)/g)]
403
+ .map((match) => safeText(match[1]))
404
+ .filter((pattern) => pattern !== undefined);
405
+ return { methods: [...new Set(methods)], patterns: [...new Set(patterns)] };
406
+ }
407
+ function mappingEntries(payload) {
408
+ const entries = [];
409
+ const contexts = objectValue(payload.contexts);
410
+ if (contexts === undefined)
411
+ return { entries, truncated: false };
412
+ for (const context of Object.values(contexts)) {
413
+ const mappings = objectValue(objectValue(context)?.mappings);
414
+ if (mappings === undefined)
415
+ continue;
416
+ for (const groupName of ['dispatcherServlets', 'dispatcherHandlers']) {
417
+ const groups = objectValue(mappings[groupName]);
418
+ if (groups === undefined)
419
+ continue;
420
+ for (const group of Object.values(groups)) {
421
+ if (!Array.isArray(group))
422
+ continue;
423
+ for (const entry of group) {
424
+ const object = objectValue(entry);
425
+ if (object !== undefined)
426
+ entries.push(object);
427
+ if (entries.length > MAX_RUNTIME_RECORDS) {
428
+ entries.pop();
429
+ return { entries, truncated: true };
430
+ }
431
+ }
432
+ }
433
+ }
434
+ }
435
+ return { entries, truncated: false };
436
+ }
437
+ function importMappings(graph, payload, indexes) {
438
+ let imported = 0;
439
+ const payloadEntries = mappingEntries(payload);
440
+ let truncated = payloadEntries.truncated;
441
+ const candidatesByKey = new Map();
442
+ for (const entry of payloadEntries.entries) {
443
+ const details = objectValue(entry.details);
444
+ const conditions = objectValue(details?.requestMappingConditions);
445
+ const predicate = predicateParts(safeText(entry.predicate));
446
+ const patterns = safeStrings(conditions?.patterns);
447
+ const methods = safeStrings(conditions?.methods)
448
+ .map(normalizeRouteMethod)
449
+ .filter((method) => method !== undefined);
450
+ const effectivePatterns = patterns.length > 0 ? patterns : predicate.patterns;
451
+ const effectiveMethods = methods.length > 0 ? methods : predicate.methods;
452
+ if (effectivePatterns.length === 0)
453
+ continue;
454
+ const handler = resolveHandlerNode(indexes, objectValue(details?.handlerMethod));
455
+ for (const rawPattern of effectivePatterns) {
456
+ const url = normalizeExtractedRoutePath(rawPattern, null);
457
+ for (const method of effectiveMethods.length > 0 ? effectiveMethods : [undefined]) {
458
+ const normalizedMethod = normalizeRouteMethod(method);
459
+ const key = routeNodeKey(normalizedMethod, url);
460
+ const candidate = { key, method: normalizedMethod, url, handler };
461
+ const existing = candidatesByKey.get(key);
462
+ if (existing === undefined) {
463
+ if (candidatesByKey.size >= MAX_RUNTIME_RECORDS) {
464
+ truncated = true;
465
+ continue;
466
+ }
467
+ candidatesByKey.set(key, [candidate]);
468
+ }
469
+ else {
470
+ existing.push(candidate);
471
+ }
472
+ }
473
+ }
474
+ }
475
+ for (const candidates of candidatesByKey.values()) {
476
+ const first = candidates[0];
477
+ if (first === undefined)
478
+ continue;
479
+ const { key, method: normalizedMethod, url } = first;
480
+ const resolvedHandlers = new Map(candidates
481
+ .map((candidate) => candidate.handler)
482
+ .filter((handler) => handler !== undefined)
483
+ .map((handler) => [handler.id, handler]));
484
+ const runtimeHandlerConflict = resolvedHandlers.size > 1;
485
+ const handler = runtimeHandlerConflict ? undefined : resolvedHandlers.values().next().value;
486
+ const exactId = generateId('Route', key);
487
+ const fallbackId = generateId('Route', url);
488
+ let route = graph.getNode(exactId) ?? graph.getNode(fallbackId);
489
+ if (route?.label !== 'Route')
490
+ route = undefined;
491
+ const routeWasPresent = route !== undefined;
492
+ if (route === undefined) {
493
+ route = {
494
+ id: exactId,
495
+ label: 'Route',
496
+ properties: {
497
+ name: url,
498
+ filePath: handler?.properties.filePath ?? `${RUNTIME_FILE_PREFIX}mappings`,
499
+ ...(normalizedMethod === undefined ? {} : { method: normalizedMethod }),
500
+ ...(handler === undefined ? {} : { handlerSymbolId: handler.id }),
501
+ },
502
+ };
503
+ graph.addNode(route);
504
+ }
505
+ const existingHandlerId = safeText(route.properties.handlerSymbolId);
506
+ const handlerFilePath = handler !== undefined && typeof handler.properties.filePath === 'string'
507
+ ? handler.properties.filePath
508
+ : undefined;
509
+ const handlerFileId = handlerFilePath === undefined ? undefined : generateId('File', handlerFilePath);
510
+ const staticOwnerFileIds = indexes.routeOwnerFileIdsByRouteId.get(route.id);
511
+ const conflictsWithStaticOwner = routeWasPresent &&
512
+ handlerFileId !== undefined &&
513
+ staticOwnerFileIds !== undefined &&
514
+ [...staticOwnerFileIds].some((ownerFileId) => ownerFileId !== handlerFileId);
515
+ if (runtimeHandlerConflict ||
516
+ (handler !== undefined &&
517
+ ((existingHandlerId !== undefined && existingHandlerId !== handler.id) ||
518
+ conflictsWithStaticOwner))) {
519
+ // Static ownership and runtime ownership disagree. Preserve the
520
+ // static handler, persist an explicit conflict, and do not mint an
521
+ // authoritative HANDLES_ROUTE edge from the runtime candidate.
522
+ markRuntimeEvidence(graph, 'mappings', route, 'handler-conflict', false);
523
+ imported++;
524
+ continue;
525
+ }
526
+ if (handler !== undefined && existingHandlerId === undefined) {
527
+ route.properties.handlerSymbolId = handler.id;
528
+ }
529
+ markRuntimeEvidence(graph, 'mappings', route);
530
+ if (handler !== undefined && handlerFileId !== undefined) {
531
+ if (graph.getNode(handlerFileId) !== undefined) {
532
+ graph.addRelationship({
533
+ id: generateId('HANDLES_ROUTE', `${handlerFileId}->${route.id}`),
534
+ sourceId: handlerFileId,
535
+ targetId: route.id,
536
+ type: 'HANDLES_ROUTE',
537
+ confidence: 1,
538
+ reason: 'spring-actuator:runtime-confirmed',
539
+ });
540
+ }
541
+ }
542
+ imported++;
543
+ }
544
+ return { count: imported, truncated };
545
+ }
546
+ function contextObjects(payload) {
547
+ const contexts = objectValue(payload.contexts);
548
+ if (contexts === undefined)
549
+ return [];
550
+ return Object.values(contexts)
551
+ .map(objectValue)
552
+ .filter((context) => context !== undefined);
553
+ }
554
+ function importBeans(graph, payload, indexes) {
555
+ let imported = 0;
556
+ const seen = new Set();
557
+ for (const [contextIndex, context] of contextObjects(payload).entries()) {
558
+ const beans = objectValue(context.beans);
559
+ if (beans === undefined)
560
+ continue;
561
+ for (const [rawBeanName, rawBean] of Object.entries(beans)) {
562
+ if (imported >= MAX_RUNTIME_RECORDS)
563
+ return { count: imported, truncated: true };
564
+ const beanName = safeText(rawBeanName, 512);
565
+ const bean = objectValue(rawBean);
566
+ if (beanName === undefined || bean === undefined)
567
+ continue;
568
+ const identity = `${contextIndex}:${beanName}`;
569
+ if (seen.has(identity))
570
+ continue;
571
+ seen.add(identity);
572
+ const type = safeText(bean.type, 1024);
573
+ const named = indexes.beanProvidersByName.get(beanName);
574
+ let target = named === null ? undefined : named;
575
+ if (target !== undefined && !providerMatchesRuntimeType(indexes, target, type)) {
576
+ target = undefined;
577
+ }
578
+ target ??= resolveClass(indexes, type);
579
+ if (target === undefined) {
580
+ const id = generateId('CodeElement', `spring-runtime-bean:${identity}`);
581
+ target = graph.getNode(id);
582
+ if (target === undefined) {
583
+ const scope = safeText(bean.scope, 128);
584
+ target = {
585
+ id,
586
+ label: 'CodeElement',
587
+ properties: {
588
+ name: beanName,
589
+ filePath: `${RUNTIME_FILE_PREFIX}beans`,
590
+ description: `Spring runtime Bean ${beanName}` +
591
+ (type === undefined ? '' : ` of type ${type}`) +
592
+ (scope === undefined ? '' : ` (${scope})`),
593
+ ...(type === undefined ? {} : { qualifiedName: normalizedQualifiedName(type) }),
594
+ },
595
+ };
596
+ graph.addNode(target);
597
+ }
598
+ }
599
+ markRuntimeEvidence(graph, 'beans', target);
600
+ imported++;
601
+ }
602
+ }
603
+ return { count: imported, truncated: false };
604
+ }
605
+ function resolveConditionOwner(indexes, rawName) {
606
+ const separator = rawName.lastIndexOf('#');
607
+ return resolveHandlerNode(indexes, {
608
+ className: separator === -1 ? rawName : rawName.slice(0, separator),
609
+ ...(separator === -1 ? {} : { name: rawName.slice(separator + 1) }),
610
+ });
611
+ }
612
+ function importConditions(graph, payload, indexes) {
613
+ let imported = 0;
614
+ const seen = new Set();
615
+ for (const context of contextObjects(payload)) {
616
+ for (const [field, status] of [
617
+ ['positiveMatches', 'matched'],
618
+ ['negativeMatches', 'not-matched'],
619
+ ]) {
620
+ const matches = objectValue(context[field]);
621
+ if (matches === undefined)
622
+ continue;
623
+ for (const rawName of Object.keys(matches)) {
624
+ if (imported >= MAX_RUNTIME_RECORDS)
625
+ return { count: imported, truncated: true };
626
+ const name = safeText(rawName);
627
+ if (name === undefined || seen.has(`${status}:${name}`))
628
+ continue;
629
+ seen.add(`${status}:${name}`);
630
+ const owner = resolveConditionOwner(indexes, name);
631
+ if (owner === undefined)
632
+ continue;
633
+ // Actuator reports this status for the aggregate owner entry. Its child
634
+ // details may contain a mix of matched and not-matched conditions, but
635
+ // do not carry a stable identifier that maps to our CONDITIONAL_ON
636
+ // targets. Keep the aggregate on the owner instead of guessing.
637
+ markRuntimeEvidence(graph, 'conditions', owner, status);
638
+ imported++;
639
+ }
640
+ }
641
+ }
642
+ return { count: imported, truncated: false };
643
+ }
644
+ function relaxedPropertyName(value) {
645
+ return value.toLowerCase().replace(/[-_.\[\]]/g, '');
646
+ }
647
+ function buildRuntimePropertyIndex(graph) {
648
+ const exact = new Map();
649
+ const relaxed = new Map();
650
+ for (const node of graph.iterNodes()) {
651
+ if (node.label !== 'Property')
652
+ continue;
653
+ const description = safeText(node.properties.description) ?? '';
654
+ if (!description.startsWith(SPRING_CONFIG_DESCRIPTION) && !node.id.includes('spring-runtime')) {
655
+ continue;
656
+ }
657
+ const name = String(node.properties.name);
658
+ uniqueIndexAdd(exact, name, node);
659
+ uniqueIndexAdd(relaxed, relaxedPropertyName(name), node);
660
+ }
661
+ return { exact, relaxed };
662
+ }
663
+ function ensureRuntimeProperty(graph, index, endpoint, rawName) {
664
+ const name = safeText(rawName, 1024);
665
+ if (name === undefined)
666
+ return undefined;
667
+ const exact = index.exact.get(name);
668
+ let node = exact === null ? undefined : exact;
669
+ if (node === undefined) {
670
+ const relaxed = index.relaxed.get(relaxedPropertyName(name));
671
+ node = relaxed === null ? undefined : relaxed;
672
+ }
673
+ if (node === undefined) {
674
+ const id = generateId('Property', `spring-runtime-config:${name}`);
675
+ node = graph.getNode(id);
676
+ if (node === undefined) {
677
+ node = {
678
+ id,
679
+ label: 'Property',
680
+ properties: {
681
+ name,
682
+ filePath: `${RUNTIME_FILE_PREFIX}${endpoint}`,
683
+ description: `${SPRING_CONFIG_DESCRIPTION}; imported from Spring Actuator ${endpoint}`,
684
+ },
685
+ };
686
+ graph.addNode(node);
687
+ }
688
+ uniqueIndexAdd(index.exact, name, node);
689
+ uniqueIndexAdd(index.relaxed, relaxedPropertyName(name), node);
690
+ }
691
+ markRuntimeEvidence(graph, endpoint, node);
692
+ return node;
693
+ }
694
+ function configInputPaths(inputs) {
695
+ const out = [];
696
+ const stack = [
697
+ { value: inputs, prefix: '', depth: 0 },
698
+ ];
699
+ while (stack.length > 0 && out.length < MAX_RUNTIME_RECORDS) {
700
+ const current = stack.pop();
701
+ if (current === undefined || current.depth > MAX_RUNTIME_DEPTH)
702
+ continue;
703
+ const object = objectValue(current.value);
704
+ if (object === undefined) {
705
+ if (current.prefix.length > 0)
706
+ out.push(current.prefix);
707
+ continue;
708
+ }
709
+ const keys = Object.keys(object);
710
+ const metadataLeaf = keys.length === 0 || keys.every((key) => key === 'value' || key === 'origin');
711
+ if (metadataLeaf) {
712
+ if (current.prefix.length > 0)
713
+ out.push(current.prefix);
714
+ continue;
715
+ }
716
+ for (let index = keys.length - 1; index >= 0; index--) {
717
+ const rawKey = keys[index];
718
+ if (rawKey === undefined)
719
+ continue;
720
+ const key = safeText(rawKey, 256);
721
+ if (key === undefined)
722
+ continue;
723
+ stack.push({
724
+ value: object[rawKey],
725
+ prefix: current.prefix.length === 0 ? key : `${current.prefix}.${key}`,
726
+ depth: current.depth + 1,
727
+ });
728
+ }
729
+ }
730
+ return { paths: out, truncated: stack.length > 0 };
731
+ }
732
+ function importConfigProperties(graph, payload, propertyIndex) {
733
+ let imported = 0;
734
+ let truncated = false;
735
+ const seen = new Set();
736
+ for (const context of contextObjects(payload)) {
737
+ const beans = objectValue(context.beans);
738
+ if (beans === undefined)
739
+ continue;
740
+ for (const rawBean of Object.values(beans)) {
741
+ const bean = objectValue(rawBean);
742
+ const prefix = safeText(bean?.prefix, 512)?.replace(/\.+$/, '');
743
+ if (bean === undefined || prefix === undefined)
744
+ continue;
745
+ const inputPaths = configInputPaths(bean.inputs);
746
+ truncated ||= inputPaths.truncated;
747
+ const names = inputPaths.paths.length === 0
748
+ ? [prefix]
749
+ : inputPaths.paths.map((entry) => `${prefix}.${entry}`);
750
+ for (const name of names) {
751
+ if (imported >= MAX_RUNTIME_RECORDS)
752
+ return { count: imported, truncated: true };
753
+ if (seen.has(name))
754
+ continue;
755
+ seen.add(name);
756
+ if (ensureRuntimeProperty(graph, propertyIndex, 'configprops', name) !== undefined)
757
+ imported++;
758
+ }
759
+ }
760
+ }
761
+ return { count: imported, truncated };
762
+ }
763
+ function importEnvironmentProperties(graph, payload, propertyIndex) {
764
+ let imported = 0;
765
+ const seen = new Set();
766
+ if (!Array.isArray(payload.propertySources))
767
+ return { count: imported, truncated: false };
768
+ for (const rawSource of payload.propertySources) {
769
+ const properties = objectValue(objectValue(rawSource)?.properties);
770
+ if (properties === undefined)
771
+ continue;
772
+ // Deliberately enumerate keys only. Never read, retain, interpolate, or log
773
+ // the corresponding {value, origin} objects.
774
+ for (const rawName of Object.keys(properties)) {
775
+ if (imported >= MAX_RUNTIME_RECORDS)
776
+ return { count: imported, truncated: true };
777
+ const name = safeText(rawName, 1024);
778
+ if (name === undefined || seen.has(name))
779
+ continue;
780
+ seen.add(name);
781
+ if (ensureRuntimeProperty(graph, propertyIndex, 'env', name) !== undefined)
782
+ imported++;
783
+ }
784
+ }
785
+ return { count: imported, truncated: false };
786
+ }
787
+ /**
788
+ * Import explicitly supplied Spring Boot Actuator snapshots. Runtime evidence
789
+ * is additive: it confirms existing static nodes where possible and creates
790
+ * conservative synthetic Route/Bean/Property nodes otherwise. Raw payloads,
791
+ * condition messages, config values, env values, origins, and source names are
792
+ * never copied into graph properties or logs.
793
+ */
794
+ export async function importSpringActuatorRuntime(graph, repoPath, configuredPath) {
795
+ const payloads = await loadPayloads(repoPath, configuredPath);
796
+ const stats = {
797
+ payloads: payloads.size,
798
+ mappings: 0,
799
+ beans: 0,
800
+ conditions: 0,
801
+ configProperties: 0,
802
+ environmentProperties: 0,
803
+ truncatedEndpoints: [],
804
+ };
805
+ const indexes = buildRuntimeNodeIndexes(graph);
806
+ const propertyIndex = buildRuntimePropertyIndex(graph);
807
+ const mappings = payloads.get('mappings');
808
+ if (mappings !== undefined) {
809
+ const result = importMappings(graph, mappings, indexes);
810
+ stats.mappings = result.count;
811
+ if (result.truncated)
812
+ stats.truncatedEndpoints.push('mappings');
813
+ }
814
+ const beans = payloads.get('beans');
815
+ if (beans !== undefined) {
816
+ const result = importBeans(graph, beans, indexes);
817
+ stats.beans = result.count;
818
+ if (result.truncated)
819
+ stats.truncatedEndpoints.push('beans');
820
+ }
821
+ const conditions = payloads.get('conditions');
822
+ if (conditions !== undefined) {
823
+ const result = importConditions(graph, conditions, indexes);
824
+ stats.conditions = result.count;
825
+ if (result.truncated)
826
+ stats.truncatedEndpoints.push('conditions');
827
+ }
828
+ const configprops = payloads.get('configprops');
829
+ if (configprops !== undefined) {
830
+ const result = importConfigProperties(graph, configprops, propertyIndex);
831
+ stats.configProperties = result.count;
832
+ if (result.truncated)
833
+ stats.truncatedEndpoints.push('configprops');
834
+ }
835
+ const env = payloads.get('env');
836
+ if (env !== undefined) {
837
+ const result = importEnvironmentProperties(graph, env, propertyIndex);
838
+ stats.environmentProperties = result.count;
839
+ if (result.truncated)
840
+ stats.truncatedEndpoints.push('env');
841
+ }
842
+ return stats;
843
+ }