mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-13 03:58:33 +00:00
fix(spring): address actuator review feedback
This commit is contained in:
parent
f899da1ee3
commit
04be1aca2a
14 changed files with 592 additions and 61 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue