fix(spring): address actuator review feedback

This commit is contained in:
Shining 2026-09-01 01:07:57 +08:00
parent f899da1ee3
commit 04be1aca2a
14 changed files with 592 additions and 61 deletions

View file

@ -454,7 +454,7 @@ gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-check
# (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
```
`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning and its path is not persisted; env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`.
`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`.
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `--worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.

View file

@ -324,7 +324,7 @@ and root fields. Dynamic decorator names, anonymous operations, and ambiguous or
anchors are deliberately omitted. Add common infrastructure fields such as `/health` to
`matching.exclude_links_paths` to keep those GraphQL contracts visible without cross-linking them.
`--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning and its path is not persisted; env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`.
`--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`.
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.

View file

@ -1,8 +1,9 @@
{
"fingerprint": "c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e",
"fingerprint": "7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2",
"scaling_budget": 1.8,
"max_ms_large": 1000,
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
"_rebaselined_3107_route_runtime_evidence": "Route gained trailing runtimeConfirmed BOOLEAN, runtimeSource STRING, and runtimeStatus STRING columns in its schema, CSV header/rows, COPY statement, and graph API projection. The deterministic emit still produces the same 35 CSV files; the synthetic benchmark graph has no Route rows, so the only byte drift is the Route CSV header and no row moved or reordered. Prior c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e -> 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2. While the guard was red, scaling_ratio was 1.044 against the 1.8 budget and elapsed_ms_large was 121.08ms against the 1000ms backstop.",
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
}

View file

@ -187,14 +187,21 @@ function markRuntimeEvidence(
endpoint: ActuatorEndpoint,
target: GraphNode,
status: string = 'runtime-confirmed',
confirmed: boolean = true,
): void {
target.properties.runtimeConfirmed = true;
target.properties.runtimeSource = 'spring-actuator';
const previousStatus = safeText(target.properties.runtimeStatus);
target.properties.runtimeStatus =
previousStatus === undefined || previousStatus === status
? status
: [previousStatus, status].sort().join(',');
// Only Route declares structured runtime columns in the persisted schema.
// Other labels retain the same evidence durably through their description
// plus the DECLARES edge below; setting undeclared properties would make the
// in-memory graph promise data that CSV/LadybugDB silently drops.
if (target.label === 'Route') {
target.properties.runtimeConfirmed = confirmed;
target.properties.runtimeSource = 'spring-actuator';
const previousStatus = safeText(target.properties.runtimeStatus);
target.properties.runtimeStatus =
previousStatus === undefined || previousStatus === status
? status
: [previousStatus, status].sort().join(',');
}
const marker = `Spring Actuator ${endpoint} ${status}`;
appendRuntimeMarker(target, marker);
@ -218,7 +225,7 @@ function normalizedQualifiedName(value: string): string {
function uniqueIndexAdd(index: Map<string, GraphNode | null>, key: string, node: GraphNode): void {
const existing = index.get(key);
if (existing === undefined) index.set(key, node);
else if (existing.id !== node.id) index.set(key, null);
else if (existing !== null && existing.id !== node.id) index.set(key, null);
}
interface RuntimeNodeIndexes {
@ -226,6 +233,7 @@ interface RuntimeNodeIndexes {
readonly classesBySimpleName: Map<string, GraphNode | null>;
readonly beanProvidersByName: Map<string, GraphNode | null>;
readonly methodsByOwnerId: Map<string, GraphNode[]>;
readonly routeOwnerFileIdsByRouteId: Map<string, Set<string>>;
}
function buildRuntimeNodeIndexes(graph: KnowledgeGraph): RuntimeNodeIndexes {
@ -235,6 +243,7 @@ function buildRuntimeNodeIndexes(graph: KnowledgeGraph): RuntimeNodeIndexes {
const beanProvidersByName = new Map<string, GraphNode | null>();
const nodesById = new Map(allNodes.map((node) => [node.id, node]));
const methodsByOwnerId = new Map<string, GraphNode[]>();
const routeOwnerFileIdsByRouteId = new Map<string, Set<string>>();
for (const node of allNodes) {
if (node.label === 'Class' || node.label === 'Record') {
const qualified = safeText(node.properties.qualifiedName);
@ -255,11 +264,17 @@ function buildRuntimeNodeIndexes(graph: KnowledgeGraph): RuntimeNodeIndexes {
methods.push(method);
methodsByOwnerId.set(relationship.sourceId, methods);
}
for (const relationship of graph.iterRelationshipsByType('HANDLES_ROUTE')) {
const owners = routeOwnerFileIdsByRouteId.get(relationship.targetId) ?? new Set<string>();
owners.add(relationship.sourceId);
routeOwnerFileIdsByRouteId.set(relationship.targetId, owners);
}
return {
classesByQualifiedName,
classesBySimpleName,
beanProvidersByName,
methodsByOwnerId,
routeOwnerFileIdsByRouteId,
};
}
@ -271,6 +286,10 @@ function resolveClass(
const type = normalizedQualifiedName(rawType.replace(/\[\]$/, ''));
const exact = indexes.classesByQualifiedName.get(type);
if (exact !== null && exact !== undefined) return exact;
// A qualified runtime name is authoritative. Falling back to a unique class
// with the same simple name can bind a stale snapshot to a different package
// and then mint confidence-1 handler evidence for the wrong source.
if (type.includes('.')) return undefined;
const simple = type.slice(type.lastIndexOf('.') + 1);
const fallback = indexes.classesBySimpleName.get(simple);
return fallback === null ? undefined : fallback;
@ -381,6 +400,7 @@ function importMappings(
const fallbackId = generateId('Route', url);
let route = graph.getNode(exactId) ?? graph.getNode(fallbackId);
if (route?.label !== 'Route') route = undefined;
const routeWasPresent = route !== undefined;
if (route === undefined) {
route = {
id: exactId,
@ -393,16 +413,39 @@ function importMappings(
},
};
graph.addNode(route);
} else if (handler !== undefined && route.properties.handlerSymbolId === undefined) {
}
const existingHandlerId = safeText(route.properties.handlerSymbolId);
const handlerFileId =
handler === undefined
? undefined
: generateId('File', String(handler.properties.filePath));
const staticOwnerFileIds = indexes.routeOwnerFileIdsByRouteId.get(route.id);
const conflictsWithStaticOwner =
routeWasPresent &&
handlerFileId !== undefined &&
staticOwnerFileIds !== undefined &&
[...staticOwnerFileIds].some((ownerFileId) => ownerFileId !== handlerFileId);
if (
handler !== undefined &&
((existingHandlerId !== undefined && existingHandlerId !== handler.id) ||
conflictsWithStaticOwner)
) {
// Static ownership and runtime ownership disagree. Preserve the
// static handler, persist an explicit conflict, and do not mint an
// authoritative HANDLES_ROUTE edge from the runtime candidate.
markRuntimeEvidence(graph, 'mappings', route, 'handler-conflict', false);
imported++;
continue;
}
if (handler !== undefined && existingHandlerId === undefined) {
route.properties.handlerSymbolId = handler.id;
}
markRuntimeEvidence(graph, 'mappings', route);
if (handler !== undefined) {
const fileId = generateId('File', String(handler.properties.filePath));
if (graph.getNode(fileId) !== undefined) {
if (handler !== undefined && handlerFileId !== undefined) {
if (graph.getNode(handlerFileId) !== undefined) {
graph.addRelationship({
id: generateId('HANDLES_ROUTE', `${fileId}->${route.id}`),
sourceId: fileId,
id: generateId('HANDLES_ROUTE', `${handlerFileId}->${route.id}`),
sourceId: handlerFileId,
targetId: route.id,
type: 'HANDLES_ROUTE',
confidence: 1,
@ -496,15 +539,6 @@ function importConditions(
payload: JsonObject,
indexes: RuntimeNodeIndexes,
): number {
const targetsByOwner = new Map<string, GraphNode[]>();
for (const relationship of graph.iterRelationshipsByType('CONDITIONAL_ON')) {
const target = graph.getNode(relationship.targetId);
if (target === undefined) continue;
const targets = targetsByOwner.get(relationship.sourceId) ?? [];
targets.push(target);
targetsByOwner.set(relationship.sourceId, targets);
}
let imported = 0;
const seen = new Set<string>();
for (const context of contextObjects(payload)) {
@ -521,10 +555,11 @@ function importConditions(
seen.add(`${status}:${name}`);
const owner = resolveConditionOwner(indexes, name);
if (owner === undefined) continue;
// Actuator reports this status for the aggregate owner entry. Its child
// details may contain a mix of matched and not-matched conditions, but
// do not carry a stable identifier that maps to our CONDITIONAL_ON
// targets. Keep the aggregate on the owner instead of guessing.
markRuntimeEvidence(graph, 'conditions', owner, status);
for (const target of targetsByOwner.get(owner.id) ?? []) {
markRuntimeEvidence(graph, 'conditions', target, status);
}
imported++;
}
}

View file

@ -4,7 +4,8 @@
* Detects execution flows (processes) and creates Process nodes +
* STEP_IN_PROCESS edges. Also links Route/Tool nodes to processes.
*
* @deps communities, routes, tools, pruneLocalSymbols, structure, parse
* @deps communities, routes, tools, springAutoConfiguration,
* pruneLocalSymbols, structure, parse
* @reads graph (all nodes and relationships), communityResult, routeRegistry,
* toolDefs, parse's allFetchCalls + allORMQueries (R3-6 sink sites)
* @writes graph (Process nodes, STEP_IN_PROCESS edges, ENTRY_POINT_OF edges)
@ -52,7 +53,15 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
// sinks rather than failing the phase. `pruneLocalSymbols` is declared
// explicitly so process extraction always reads the trimmed graph even if a
// future option drops the intervening `mro`/`communities` phases.
deps: ['communities', 'routes', 'tools', 'pruneLocalSymbols', 'structure', 'parse'],
deps: [
'communities',
'routes',
'tools',
'springAutoConfiguration',
'pruneLocalSymbols',
'structure',
'parse',
],
async execute(
ctx: PipelineContext,
@ -212,8 +221,38 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
});
});
// The static registry is finalized before Spring runtime enrichment. Merge
// runtime-confirmed Route nodes from the graph after the explicit
// springAutoConfiguration dependency has completed, so Actuator-only
// mappings participate in the same process-linking path.
const processRouteRegistry = new Map(routeRegistry);
ctx.graph.forEachNode((node) => {
if (
node.label !== 'Route' ||
node.properties.runtimeSource !== 'spring-actuator' ||
node.properties.runtimeConfirmed !== true
) {
return;
}
const url = typeof node.properties.name === 'string' ? node.properties.name : undefined;
const filePath =
typeof node.properties.filePath === 'string' ? node.properties.filePath : undefined;
const method =
typeof node.properties.method === 'string' ? node.properties.method : undefined;
if (url === undefined || filePath === undefined) return;
const key = routeNodeKey(method, url);
if (!processRouteRegistry.has(key)) {
processRouteRegistry.set(key, {
filePath,
source: 'spring-actuator-runtime',
url,
...(method === undefined ? {} : { method }),
});
}
});
// Link Route and Tool nodes to Processes
if (routeRegistry.size > 0 || toolDefs.length > 0) {
if (processRouteRegistry.size > 0 || toolDefs.length > 0) {
// Two-tier route lookup, mirroring the tool tables 10 lines below.
// Routes whose handler resolved key by `handlerSymbolId` (read from
// the Route node's graph properties — routes.ts stamps it there) and
@ -228,7 +267,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
// routes phase stamps on the Route node was never consulted.
const routesByHandlerId = new Map<string, string[]>();
const routesWithoutHandlerByFile = new Map<string, string[]>();
for (const [, entry] of routeRegistry) {
for (const [, entry] of processRouteRegistry) {
// Push the Route node identity (`routeNodeKey`), not the bare URL, so the
// ENTRY_POINT_OF edge targets the same node id the routes phase created
// (#2289: a same-URL GET/POST pair is two distinct Route nodes).

View file

@ -48,10 +48,19 @@ function excludesRuntimeInput(repoPath: string, filePath: string, inputPath: str
SPRING_ACTUATOR_ENDPOINT_FILES.has(path.basename(candidateRelativeToRepo).toLowerCase())
);
}
if (inputRelativeToRepo.startsWith('..') || path.isAbsolute(inputRelativeToRepo)) return false;
if (
inputRelativeToRepo === '..' ||
inputRelativeToRepo.startsWith(`..${path.sep}`) ||
path.isAbsolute(inputRelativeToRepo)
) {
return false;
}
const relative = path.relative(input, candidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
return (
relative === '' ||
(relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
);
}
export const scanPhase: PipelinePhase<ScanOutput> = {
@ -65,14 +74,17 @@ export const scanPhase: PipelinePhase<ScanOutput> = {
message: 'Scanning repository...',
});
const springActuatorPath = ctx.options?.springActuatorPath;
const springActuatorPaths = [
...(ctx.options?.springActuatorPath === undefined ? [] : [ctx.options.springActuatorPath]),
...(ctx.options?.springActuatorScanExclusions ?? []),
];
let scannedFiles;
try {
scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
const scanProgress = Math.round((current / total) * 15);
const isRuntimeInput =
springActuatorPath !== undefined &&
excludesRuntimeInput(ctx.repoPath, filePath, springActuatorPath);
const isRuntimeInput = springActuatorPaths.some((inputPath) =>
excludesRuntimeInput(ctx.repoPath, filePath, inputPath),
);
ctx.onProgress({
phase: 'extracting',
percent: scanProgress,
@ -95,9 +107,12 @@ export const scanPhase: PipelinePhase<ScanOutput> = {
throw err;
}
if (springActuatorPath !== undefined) {
if (springActuatorPaths.length > 0) {
scannedFiles = scannedFiles.filter(
(file) => !excludesRuntimeInput(ctx.repoPath, file.path, springActuatorPath),
(file) =>
!springActuatorPaths.some((inputPath) =>
excludesRuntimeInput(ctx.repoPath, file.path, inputPath),
),
);
}

View file

@ -71,6 +71,8 @@ export interface PipelineOptions {
* Undefined keeps runtime enrichment completely disabled.
*/
springActuatorPath?: string;
/** Repo-relative Actuator inputs retained only for a cleanup scan. */
springActuatorScanExclusions?: readonly string[];
/** Per-advice Spring AOP candidate inspection cap. `0` disables this cap. */
springAopMaxCandidateInspectionsPerAdvice?: number;
/** Aggregate Spring AOP candidate inspection cap for one analysis. `0` disables this cap. */

View file

@ -126,12 +126,13 @@ import { PDG_EDGE_TYPES } from './pdg-emit-sink.js';
* DEFINES - local-symbol-pruner's isFileDefinesEdge test
* INJECTS - di phase fan-out
* CONDITIONAL_ON - Spring Actuator runtime condition enrichment
* HANDLES_ROUTE - Spring Actuator runtime handler conflict detection
*
* Deliberately NOT retained: STEP_IN_PROCESS / ENTRY_POINT_OF / MEMBER_OF
* (written only by the `processes` / `communities` phases, which the streaming
* flag disables), TAINT_PATH / CALL_SUMMARY (their phases are likewise gated
* off under the flag), and HANDLES_ROUTE / HANDLES_TOOL (written by
* `routes`/`tools`, never read back mid-pipeline).
* off under the flag), and HANDLES_TOOL (written by `tools`, never read back
* mid-pipeline).
*
* Adding a relationship type that a phase reads back WITHOUT adding it here is
* a silent-wrong-graph bug, not a crash. The differential round-trip test cannot:
@ -152,6 +153,7 @@ export const RETAINED_REL_TYPES: ReadonlySet<RelationshipType> = new Set<Relatio
'DEFINES',
'INJECTS',
'CONDITIONAL_ON',
'HANDLES_ROUTE',
// springAopInheritance reads direct behavior evidence after MRO.
'ADVISED_BY',
]);

View file

@ -1664,15 +1664,51 @@ async function runFullAnalysisInner(
// removed so stale runtime-only evidence is cleared from the index.
const springActuatorRequested = options.springActuatorPath !== undefined;
const springActuatorPreviouslyEnabled = existingMeta?.springActuator?.enabled === true;
let springActuatorRepoRelativeInput: string | null | undefined;
const previousActuatorInputs = (
existingMeta?.springActuator as { repoRelativeInputs?: unknown } | undefined
)?.repoRelativeInputs;
const retainedActuatorInputs = Array.isArray(previousActuatorInputs)
? previousActuatorInputs.filter((input): input is string => typeof input === 'string')
: [];
if (springActuatorRequested) {
const resolvedRepo = path.resolve(repoPath);
const resolvedInput = path.resolve(repoPath, options.springActuatorPath!);
const relativeInput = path.relative(resolvedRepo, resolvedInput);
springActuatorRepoRelativeInput =
relativeInput === ''
? '.'
: relativeInput === '..' ||
relativeInput.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeInput)
? null
: relativeInput.split(path.sep).join('/');
if (
springActuatorRepoRelativeInput !== null &&
!retainedActuatorInputs.includes(springActuatorRepoRelativeInput)
) {
retainedActuatorInputs.push(springActuatorRepoRelativeInput);
}
if (!options.force) {
log('Spring Actuator runtime enrichment requested; forcing a full rebuild.');
}
options = { ...options, force: true };
} else if (springActuatorPreviouslyEnabled) {
if (
!Array.isArray(previousActuatorInputs) ||
previousActuatorInputs.some((input) => typeof input !== 'string')
) {
throw new Error(
'Cannot safely disable Spring Actuator runtime enrichment because the previous ' +
'index did not record whether its snapshot was inside the repository. Re-run once ' +
'with the previous --spring-actuator path, then run again without it.',
);
}
log('Spring Actuator runtime enrichment disabled; rebuilding to remove runtime evidence.');
options = { ...options, force: true };
}
const springActuatorScanExclusions =
retainedActuatorInputs.length === 0 ? undefined : retainedActuatorInputs;
// ── Early-return: already up to date ──────────────────────────────
if (
@ -1980,6 +2016,7 @@ async function runFullAnalysisInner(
fetchWrappers: options.fetchWrappers,
skipDerivedGraphPhases,
springActuatorPath: options.springActuatorPath,
springActuatorScanExclusions,
},
);
@ -3707,9 +3744,24 @@ async function runFullAnalysisInner(
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
runnerIdentity,
// Records only the mode, never the input path or payload. The next run
// uses this to remove runtime evidence when the opt-in is omitted.
...(springActuatorRequested ? { springActuator: { enabled: true as const } } : {}),
// Persist only normalized repo-relative exclusions, never absolute paths
// or payloads. Keep them after runtime enrichment is disabled so a later
// ordinary scan cannot rediscover an unchanged snapshot as source/FTS.
...(springActuatorRequested
? {
springActuator: {
enabled: true,
repoRelativeInputs: retainedActuatorInputs,
},
}
: retainedActuatorInputs.length > 0
? {
springActuator: {
enabled: false,
repoRelativeInputs: retainedActuatorInputs,
},
}
: {}),
// Branch identity this index represents (#2106). Recorded for the flat
// slot too (so resolveBranchPlacement knows which branch owns it). When
// the label is null (detached HEAD / non-git re-analyze) we PRESERVE an

View file

@ -343,6 +343,24 @@ export const writeNdjsonRecord = async (
}
};
const LEGACY_ROUTE_NODE_QUERY =
'MATCH (n:`Route`) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, ' +
'n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware';
const isMissingRouteRuntimePropertyError = (err: unknown): boolean => {
const message = err instanceof Error ? err.message : String(err);
const mentionsRuntimeProperty = ['runtimeConfirmed', 'runtimeSource', 'runtimeStatus'].some(
(property) => message.includes(property),
);
return (
mentionsRuntimeProperty &&
(/cannot find property/i.test(message) ||
/property .* does not exist/i.test(message) ||
/property .* not found/i.test(message))
);
};
const buildGraph = async (
includeContent = false,
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
@ -354,6 +372,13 @@ const buildGraph = async (
nodes.push(mapGraphNodeRow(table, row, includeContent));
}
} catch (err) {
if (table === 'Route' && isMissingRouteRuntimePropertyError(err)) {
const rows = await executeQuery(LEGACY_ROUTE_NODE_QUERY);
for (const row of rows) {
nodes.push(mapGraphNodeRow(table, row, includeContent));
}
continue;
}
if (!isIgnorableGraphQueryError(err)) {
throw err;
}
@ -473,6 +498,19 @@ export const streamGraphNdjson = async (
);
});
} catch (err) {
if (table === 'Route' && isMissingRouteRuntimePropertyError(err)) {
await streamQuery(LEGACY_ROUTE_NODE_QUERY, async (row) => {
await writeNdjsonRecord(
res,
{
type: 'node',
data: mapGraphNodeRow(table, row, includeContent),
},
signal,
);
});
continue;
}
if (!isIgnorableGraphQueryError(err)) {
throw err;
}

View file

@ -87,10 +87,17 @@ export interface RepoMeta {
lastCommit: string;
indexedAt: string;
/**
* Runtime enrichment mode for this index. Only the enabled bit is persisted:
* input paths and Actuator payload data are deliberately excluded from meta.
* Runtime enrichment mode plus redacted scan exclusions. Payload data and
* absolute/external paths are deliberately excluded from metadata.
*/
springActuator?: { enabled: true };
springActuator?: {
enabled: boolean;
/**
* Normalized repo-relative inputs that must remain excluded from all future
* source scans. The list is empty when every configured input was external.
*/
repoRelativeInputs: string[];
};
/**
* Analyzer/runtime receipt for the successful run represented by this
* metadata. Optional so indexes written by older GitNexus releases remain

View file

@ -37,7 +37,9 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
class OrderController {
@GetMapping("/orders")
String list() { return "ok"; }
String list() { return helper(); }
String helper() { return leaf(); }
String leaf() { return "ok"; }
}
class SiblingController {
@ -240,16 +242,25 @@ describe('Spring Boot Actuator runtime enrichment (#2418)', () => {
result.graph.getNode(edge.targetId)?.properties.name === 'list',
)?.targetId;
expect(nodeNamed('/runtime-bound', 'Route')?.properties.handlerSymbolId).toBe(ownerMethodId);
expect(nodeNamed('BillingService', 'Class')?.properties.runtimeConfirmed).toBe(true);
expect(nodeNamed('runtimeOnlyBean', 'CodeElement')?.properties.runtimeConfirmed).toBe(true);
expect(nodeNamed('BillingService', 'Class')?.properties.runtimeConfirmed).toBeUndefined();
expect(nodeNamed('BillingService', 'Class')?.properties.description).toContain(
'Spring Actuator beans runtime-confirmed',
);
expect(
nodeNamed('runtimeOnlyBean', 'CodeElement')?.properties.runtimeConfirmed,
).toBeUndefined();
expect(nodeNamed('runtimeOnlyBean', 'CodeElement')?.properties.description).toContain(
'Spring Actuator beans runtime-confirmed',
);
const configOwner = nodeNamed('RuntimeConfig', 'Class');
const configMethodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find(
(edge) =>
edge.sourceId === configOwner?.id &&
result.graph.getNode(edge.targetId)?.properties.name === 'billingService',
)?.targetId;
expect(result.graph.getNode(configMethodId ?? '')?.properties.runtimeStatus).toContain(
'matched',
expect(result.graph.getNode(configMethodId ?? '')?.properties.runtimeStatus).toBeUndefined();
expect(result.graph.getNode(configMethodId ?? '')?.properties.description).toContain(
'Spring Actuator conditions matched',
);
const siblingOwner = nodeNamed('SiblingConfig', 'Class');
const siblingMethodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find(
@ -258,8 +269,14 @@ describe('Spring Boot Actuator runtime enrichment (#2418)', () => {
result.graph.getNode(edge.targetId)?.properties.name === 'billingService',
)?.targetId;
expect(result.graph.getNode(siblingMethodId ?? '')?.properties.runtimeStatus).toBeUndefined();
expect(nodeNamed('app.billing.url', 'Property')?.properties.runtimeConfirmed).toBe(true);
expect(nodeNamed('db.password', 'Property')?.properties.runtimeConfirmed).toBe(true);
expect(nodeNamed('app.billing.url', 'Property')?.properties.runtimeConfirmed).toBeUndefined();
expect(nodeNamed('app.billing.url', 'Property')?.properties.description).toContain(
'Spring Actuator configprops runtime-confirmed',
);
expect(nodeNamed('db.password', 'Property')?.properties.runtimeConfirmed).toBeUndefined();
expect(nodeNamed('db.password', 'Property')?.properties.description).toContain(
'Spring Actuator env runtime-confirmed',
);
const graphText = JSON.stringify({
nodes,
@ -325,8 +342,8 @@ describe('Spring Boot Actuator runtime enrichment (#2418)', () => {
expect(
nodes.find(
(node) => node.label === 'Property' && node.properties.name === 'bundle.secret-key',
)?.properties.runtimeConfirmed,
).toBe(true);
)?.properties.description,
).toContain('Spring Actuator env runtime-confirmed');
expect(nodes.some((node) => node.properties.filePath === 'runtime-snapshot.json')).toBe(false);
expect(JSON.stringify(nodes)).not.toContain(SECRET_ENV_VALUE);
expect(JSON.stringify(nodes)).not.toContain('bundle-source');
@ -367,4 +384,206 @@ describe('Spring Boot Actuator runtime enrichment (#2418)', () => {
),
).toBe(true);
}, 60_000);
it('links an Actuator-only route with a resolved source handler to its execution flow', async () => {
const { repo, actuatorDir } = runtimeFixture();
tempRepos.push(repo);
const result = await runPipelineFromRepo(repo, () => {}, {
springActuatorPath: actuatorDir,
});
const route = [...result.graph.iterNodes()].find(
(node) => node.label === 'Route' && node.properties.name === '/runtime-bound',
);
const entryEdges = [...result.graph.iterRelationshipsByType('ENTRY_POINT_OF')].filter(
(edge) => edge.sourceId === route?.id,
);
expect(route?.properties.handlerSymbolId).toEqual(expect.any(String));
expect(entryEdges.length).toBeGreaterThan(0);
expect(
entryEdges.some(
(edge) =>
result.graph.getNode(edge.targetId)?.properties.entryPointId ===
route?.properties.handlerSymbolId,
),
).toBe(true);
}, 60_000);
it('keeps triple duplicate simple names ambiguous and rejects stale qualified packages', async () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-actuator-ambiguous-'));
tempRepos.push(repo);
for (const pkg of ['one', 'two', 'three']) {
writeFixture(
repo,
`src/main/java/${pkg}/DuplicateController.java`,
`package ${pkg}; class DuplicateController { String run() { return "${pkg}"; } }\n`,
);
}
writeFixture(
repo,
'src/main/java/source/AdminController.java',
'package source; class AdminController { String run() { return "source"; } }\n',
);
writeJson(repo, 'actuator/mappings.json', {
contexts: {
application: {
mappings: {
dispatcherServlets: {
dispatcherServlet: [
{
predicate: '{GET [/ambiguous]}',
details: {
handlerMethod: {
className: 'DuplicateController',
name: 'run',
descriptor: '()Ljava/lang/String;',
},
requestMappingConditions: { methods: ['GET'], patterns: ['/ambiguous'] },
},
},
{
predicate: '{GET [/stale-package]}',
details: {
handlerMethod: {
className: 'runtime.AdminController',
name: 'run',
descriptor: '()Ljava/lang/String;',
},
requestMappingConditions: {
methods: ['GET'],
patterns: ['/stale-package'],
},
},
},
],
},
},
},
},
});
const result = await runPipelineFromRepo(repo, () => {}, {
skipGraphPhases: true,
springActuatorPath: 'actuator',
});
const routes = [...result.graph.iterNodes()].filter((node) => node.label === 'Route');
expect(
routes.find((node) => node.properties.name === '/ambiguous')?.properties,
).not.toHaveProperty('handlerSymbolId');
expect(
routes.find((node) => node.properties.name === '/stale-package')?.properties,
).not.toHaveProperty('handlerSymbolId');
}, 60_000);
it('records a static/runtime handler conflict without confirming the wrong owner', async () => {
const { repo } = runtimeFixture();
tempRepos.push(repo);
writeFixture(
repo,
'src/main/java/com/example/OtherController.java',
'package com.example; class OtherController { String otherList() { return "other"; } }\n',
);
writeJson(repo, 'runtime-actuator/mappings.json', {
contexts: {
application: {
mappings: {
dispatcherServlets: {
dispatcherServlet: [
{
predicate: '{GET [/orders]}',
details: {
handlerMethod: {
className: 'com.example.OtherController',
name: 'otherList',
descriptor: '()Ljava/lang/String;',
},
requestMappingConditions: { methods: ['GET'], patterns: ['/orders'] },
},
},
],
},
},
},
},
});
const result = await runPipelineFromRepo(repo, () => {}, {
skipGraphPhases: true,
springActuatorPath: 'runtime-actuator',
});
const route = [...result.graph.iterNodes()].find(
(node) => node.label === 'Route' && node.properties.name === '/orders',
);
const otherFile = [...result.graph.iterNodes()].find(
(node) => node.label === 'File' && node.properties.filePath.endsWith('OtherController.java'),
);
expect(route?.properties.filePath).toContain('RuntimeApplication.java');
expect(route?.properties.handlerSymbolId).not.toBe(
[...result.graph.iterNodes()].find(
(node) =>
node.label === 'Method' &&
node.properties.name === 'otherList' &&
node.properties.filePath.endsWith('OtherController.java'),
)?.id,
);
expect(route?.properties).toMatchObject({
runtimeConfirmed: false,
runtimeSource: 'spring-actuator',
runtimeStatus: 'handler-conflict',
});
expect(
[...result.graph.iterRelationshipsByType('HANDLES_ROUTE')].some(
(edge) => edge.sourceId === otherFile?.id && edge.targetId === route?.id,
),
).toBe(false);
}, 60_000);
it('keeps aggregate negative condition status on the owner instead of every child target', async () => {
const { repo } = runtimeFixture();
tempRepos.push(repo);
writeJson(repo, 'runtime-actuator/conditions.json', {
contexts: {
application: {
positiveMatches: {},
negativeMatches: {
'com.example.RuntimeConfig#billingService': {
notMatched: [{ condition: 'OnPropertyCondition', message: 'did not match' }],
matched: [{ condition: 'OnClassCondition', message: 'did match' }],
},
},
},
},
});
const result = await runPipelineFromRepo(repo, () => {}, {
skipGraphPhases: true,
springActuatorPath: 'runtime-actuator',
});
const owner = [...result.graph.iterNodes()].find(
(node) => node.label === 'Class' && node.properties.name === 'RuntimeConfig',
);
const methodId = [...result.graph.iterRelationshipsByType('HAS_METHOD')].find(
(edge) =>
edge.sourceId === owner?.id &&
result.graph.getNode(edge.targetId)?.properties.name === 'billingService',
)?.targetId;
const conditionTargets = [...result.graph.iterRelationshipsByType('CONDITIONAL_ON')]
.filter((edge) => edge.sourceId === methodId)
.map((edge) => result.graph.getNode(edge.targetId));
expect(result.graph.getNode(methodId ?? '')?.properties.description).toContain(
'Spring Actuator conditions not-matched',
);
expect(conditionTargets.length).toBeGreaterThan(0);
expect(
conditionTargets.some((node) =>
String(node?.properties.description ?? '').includes(
'Spring Actuator conditions not-matched',
),
),
).toBe(false);
}, 60_000);
});

View file

@ -242,6 +242,51 @@ describe('streamGraphNdjson', () => {
});
});
it('retries Route streaming with the legacy projection when runtime columns are absent', async () => {
let modernRouteAttempts = 0;
let legacyRouteAttempts = 0;
lbugMocks.streamQuery.mockImplementation(
async (query: string, onRow: (row: any) => Promise<void>) => {
if (!query.includes('MATCH (n:`Route`)')) return 0;
if (query.includes('runtimeConfirmed')) {
modernRouteAttempts++;
throw new Error('Binder exception: Cannot find property runtimeConfirmed for n.');
}
legacyRouteAttempts++;
await onRow({
id: 'Route:GET /legacy',
name: '/legacy',
filePath: 'src/LegacyController.java',
responseKeys: [],
errorKeys: [],
middleware: [],
});
return 1;
},
);
const writes: string[] = [];
const response = createMockResponse((chunk) => {
writes.push(chunk);
return true;
});
await expect(streamGraphNdjson(response, false)).resolves.toBeUndefined();
expect(modernRouteAttempts).toBe(1);
expect(legacyRouteAttempts).toBe(1);
const routeRecord = writes
.map((chunk) => JSON.parse(chunk))
.find((record) => record.data?.id === 'Route:GET /legacy');
expect(routeRecord).toMatchObject({
type: 'node',
data: { id: 'Route:GET /legacy' },
});
expect(routeRecord.data.properties).not.toHaveProperty('runtimeConfirmed');
expect(routeRecord.data.properties).not.toHaveProperty('runtimeSource');
expect(routeRecord.data.properties).not.toHaveProperty('runtimeStatus');
});
// Taint/PDG substrate (#2080): BasicBlock has no name/content columns, so its
// getNodeQuery projects none — mapGraphNodeRow must still yield a `string`
// name (NodeProperties.name contract) or the web layer derefs undefined.

View file

@ -246,6 +246,53 @@ async function readSpringConfigPropertyNames(repoPath: string): Promise<string[]
}
}
async function readActuatorSnapshotLeakRows(
repoPath: string,
snapshotPath: string,
secretValue: string,
): Promise<Array<{ filePath?: unknown; content?: unknown }>> {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { lbugPath } = getStoragePaths(repoPath);
await adapter.initLbug(lbugPath);
try {
const rows = (await adapter.executeQuery(
`MATCH (f:File) RETURN f.filePath AS filePath, f.content AS content`,
)) as Array<{ filePath?: unknown; content?: unknown }>;
return rows.filter(
(row) =>
row.filePath === snapshotPath ||
(typeof row.content === 'string' && row.content.includes(secretValue)),
);
} finally {
await adapter.closeLbug();
}
}
async function readRuntimePropertyEvidence(repoPath: string): Promise<{
description: string;
reasons: string[];
}> {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { lbugPath } = getStoragePaths(repoPath);
await adapter.initLbug(lbugPath);
try {
const propertyRows = (await adapter.executeQuery(
`MATCH (p:Property) WHERE p.name = 'runtime.secret' ` +
`RETURN p.description AS description LIMIT 1`,
)) as Array<{ description?: unknown }>;
const relationshipRows = (await adapter.executeQuery(
`MATCH (:File)-[r:CodeRelation]->(p:Property) WHERE p.name = 'runtime.secret' ` +
`AND r.type = 'DECLARES' RETURN r.reason AS reason ORDER BY reason`,
)) as Array<{ reason?: unknown }>;
return {
description: String(propertyRows[0]?.description ?? ''),
reasons: relationshipRows.map((row) => String(row.reason ?? '')),
};
} finally {
await adapter.closeLbug();
}
}
async function countStatusImplementsNamed(repoPath: string): Promise<number> {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { lbugPath } = getStoragePaths(repoPath);
@ -517,10 +564,19 @@ describe('runFullAnalysis — incremental orchestration', () => {
const { storagePath } = getStoragePaths(repo.dbPath);
const enabledMeta = await loadMeta(storagePath);
expect(enabledMeta?.springActuator).toEqual({ enabled: true });
expect(JSON.stringify(enabledMeta)).not.toContain(runtimeInput);
expect(enabledMeta?.springActuator).toEqual({
enabled: true,
repoRelativeInputs: [runtimeInput],
});
expect(JSON.stringify(enabledMeta)).not.toContain(secretValue);
expect(Object.keys(enabledMeta?.fileHashes ?? {})).not.toContain(`${runtimeInput}/env.json`);
expect(await readRuntimePropertyEvidence(repo.dbPath)).toEqual({
description: expect.stringContaining('Spring Actuator env runtime-confirmed'),
reasons: ['spring-actuator:env:runtime-confirmed'],
});
expect(
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
).toEqual([]);
const disableLogs: string[] = [];
const disabled = await runFullAnalysis(
@ -532,7 +588,13 @@ describe('runFullAnalysis — incremental orchestration', () => {
expect(disableLogs.join('\n')).toContain(
'Spring Actuator runtime enrichment disabled; rebuilding to remove runtime evidence.',
);
expect((await loadMeta(storagePath))?.springActuator).toBeUndefined();
expect((await loadMeta(storagePath))?.springActuator).toEqual({
enabled: false,
repoRelativeInputs: [runtimeInput],
});
expect(
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
).toEqual([]);
const steady = await runFullAnalysis(
repo.dbPath,
@ -540,6 +602,20 @@ describe('runFullAnalysis — incremental orchestration', () => {
{ onProgress: () => {} },
);
expect(steady.alreadyUpToDate).toBe(true);
const forcedSteady = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, force: true },
{ onProgress: () => {} },
);
expect(forcedSteady.alreadyUpToDate).toBeUndefined();
expect(
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
).toEqual([]);
expect((await loadMeta(storagePath))?.springActuator).toEqual({
enabled: false,
repoRelativeInputs: [runtimeInput],
});
} finally {
await repo.cleanup();
}