From 04be1aca2a9d64bec4cedc273f3c04d17e1f67c5 Mon Sep 17 00:00:00 2001 From: Shining Date: Tue, 1 Sep 2026 01:07:57 +0800 Subject: [PATCH] fix(spring): address actuator review feedback --- README.md | 2 +- gitnexus/README.md | 2 +- .../bench/emit-persistence/baselines.json | 3 +- .../frameworks/spring/actuator-runtime.ts | 87 +++++-- .../ingestion/pipeline-phases/processes.ts | 47 +++- .../core/ingestion/pipeline-phases/scan.ts | 31 ++- gitnexus/src/core/ingestion/pipeline.ts | 2 + gitnexus/src/core/lbug/graph-emit-sink.ts | 6 +- gitnexus/src/core/run-analyze.ts | 58 ++++- gitnexus/src/server/api.ts | 38 +++ gitnexus/src/storage/repo-meta.ts | 13 +- .../spring-actuator-runtime-pipeline.test.ts | 237 +++++++++++++++++- .../test/unit/api-graph-streaming.test.ts | 45 ++++ .../unit/incremental-orchestration.test.ts | 82 +++++- 14 files changed, 592 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index d3ceb8d0f..af0d89549 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/gitnexus/README.md b/gitnexus/README.md index 524499b16..6826534c1 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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. diff --git a/gitnexus/bench/emit-persistence/baselines.json b/gitnexus/bench/emit-persistence/baselines.json index 5eeceb02b..6bc38401e 100644 --- a/gitnexus/bench/emit-persistence/baselines.json +++ b/gitnexus/bench/emit-persistence/baselines.json @@ -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_` 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`." } diff --git a/gitnexus/src/core/ingestion/frameworks/spring/actuator-runtime.ts b/gitnexus/src/core/ingestion/frameworks/spring/actuator-runtime.ts index 2778bd8c5..2fb6374c4 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/actuator-runtime.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/actuator-runtime.ts @@ -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, 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; readonly beanProvidersByName: Map; readonly methodsByOwnerId: Map; + readonly routeOwnerFileIdsByRouteId: Map>; } function buildRuntimeNodeIndexes(graph: KnowledgeGraph): RuntimeNodeIndexes { @@ -235,6 +243,7 @@ function buildRuntimeNodeIndexes(graph: KnowledgeGraph): RuntimeNodeIndexes { const beanProvidersByName = new Map(); const nodesById = new Map(allNodes.map((node) => [node.id, node])); const methodsByOwnerId = new Map(); + const routeOwnerFileIdsByRouteId = new Map>(); 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(); + 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(); - 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(); 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++; } } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index 24117d532..e34e79d35 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -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 = { // 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 = { }); }); + // 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 = { // routes phase stamps on the Route node was never consulted. const routesByHandlerId = new Map(); const routesWithoutHandlerByFile = new Map(); - 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). diff --git a/gitnexus/src/core/ingestion/pipeline-phases/scan.ts b/gitnexus/src/core/ingestion/pipeline-phases/scan.ts index dce5ec82b..74b81fa8e 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/scan.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/scan.ts @@ -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 = { @@ -65,14 +74,17 @@ export const scanPhase: PipelinePhase = { 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 = { 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), + ), ); } diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 2aeddc926..ca5911f72 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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. */ diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index 6c4279cc5..1f4e21be6 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -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 = new Set 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 diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 4ad794569..c97ffaf30 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -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; } diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index ad3a50be5..3d6c7d376 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -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 diff --git a/gitnexus/test/integration/spring-actuator-runtime-pipeline.test.ts b/gitnexus/test/integration/spring-actuator-runtime-pipeline.test.ts index d84cd3dbc..cfd8a4a9c 100644 --- a/gitnexus/test/integration/spring-actuator-runtime-pipeline.test.ts +++ b/gitnexus/test/integration/spring-actuator-runtime-pipeline.test.ts @@ -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); }); diff --git a/gitnexus/test/unit/api-graph-streaming.test.ts b/gitnexus/test/unit/api-graph-streaming.test.ts index 4be079872..548ddcb21 100644 --- a/gitnexus/test/unit/api-graph-streaming.test.ts +++ b/gitnexus/test/unit/api-graph-streaming.test.ts @@ -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) => { + 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. diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 8305ae79c..c8888af96 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -246,6 +246,53 @@ async function readSpringConfigPropertyNames(repoPath: string): Promise> { + 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 { 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(); }