diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 97b45b085..cb53f5981 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -140,6 +140,19 @@ export type RelationshipType = * Lets Cypher queries trace which beans the container injects into a given * consumer, complementing the structural `IMPLEMENTS` heritage edges. */ | 'INJECTS' + /** Spring activation constraint. Source = a conditional Bean/configuration + * Class or factory Method; target = the referenced configuration Property + * when statically identifiable, otherwise an Annotation evidence node. + * The reason records the annotation and explicitly marks activation as + * unknown because runtime environment/classpath state may override source + * configuration. */ + | 'CONDITIONAL_ON' + /** Metadata declaration/discovery relationship. Source = a metadata File; + * target = the declared candidate node. This deliberately does not claim + * that the target is active or registered at runtime. Framework-specific + * semantics belong in `reason` so the relationship can be reused by other + * metadata-driven systems. */ + | 'DECLARES' /** Vue component event system: a handler function in a parent component is * bound to an event emitted by a child component (`@event="handlerFn"`). * Source = handler Function/Method node in the parent. diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index 46b0560fc..ff3f4535b 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -70,6 +70,8 @@ export const REL_TYPES = [ 'WRAPS', 'QUERIES', 'INJECTS', + 'CONDITIONAL_ON', + 'DECLARES', // Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no // phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH → // M3/M4). REACHING_DEF's variable name rides the relation's `reason` column. diff --git a/gitnexus/bench/spring-conditionals/measure.mjs b/gitnexus/bench/spring-conditionals/measure.mjs new file mode 100644 index 000000000..31dff0099 --- /dev/null +++ b/gitnexus/bench/spring-conditionals/measure.mjs @@ -0,0 +1,263 @@ +/** + * Standalone Spring condition/auto-configuration benchmark (#2415). + * + * Wall-clock measurements intentionally live outside Vitest: shared-runner + * scheduling and machine load must not make integration tests flaky. Existing + * unit/integration suites own deterministic correctness; the assertions here + * only protect the synthetic benchmark setup while timings remain diagnostic. + * + * Run from gitnexus/: + * + * node --import tsx bench/spring-conditionals/measure.mjs + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createKnowledgeGraph } from '../../src/core/graph/graph.ts'; +import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.ts'; +import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/captures.ts'; +import { collectKotlinCaptureSideChannel } from '../../src/core/ingestion/languages/kotlin/capture-side-channel.ts'; +import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.ts'; +import { + classifySpringAutoConfigurationMetadata, + parseSpringAutoConfigurationImports, + parseSpringFactoriesAutoConfigurations, + springAutoConfigurationPhase, +} from '../../src/core/ingestion/pipeline-phases/spring-auto-configuration.ts'; +import { generateId } from '../../src/lib/utils.ts'; + +const CAPTURE_SCALES = [100, 200, 400]; +const METADATA_SCALES = [2_000, 4_000, 8_000]; +const PATH_SCALES = [50_000, 100_000, 200_000]; +const CLASS_SCALES = [10_000, 20_000, 40_000]; +const AUTO_CONFIGURATION_CANDIDATES = 2_000; +const REPETITIONS = 5; + +function denseJavaConditions(classCount) { + const classes = Array.from( + { length: classCount }, + (_, index) => ` +@Configuration +@Profile("profile-${index}") +@ConditionalOnProperty(prefix = "feature.${index}", name = "enabled") +class JavaConfig${index} { + @ConditionalOnClass(name = "com.example.Driver${index}") + Object bean${index}() { return new Object(); } +} +`, + ).join('\n'); + return `package com.example; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +${classes} +`; +} + +function denseKotlinConditions(classCount) { + const classes = Array.from( + { length: classCount }, + (_, index) => ` +@Configuration +@Profile("profile-${index}") +@ConditionalOnProperty(prefix = "feature.${index}", name = ["enabled"]) +class KotlinConfig${index} { + @ConditionalOnClass(name = ["com.example.Driver${index}"]) + fun bean${index}(): Any = Any() +} +`, + ).join('\n'); + return `package com.example +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +${classes} +`; +} + +function elapsedMs(start) { + return Number(process.hrtime.bigint() - start) / 1e6; +} + +function median(samples) { + const sorted = [...samples].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)] ?? Number.NaN; +} + +function measure(repetitions, operation) { + operation(); + const samples = []; + let value; + for (let run = 0; run < repetitions; run++) { + const start = process.hrtime.bigint(); + value = operation(); + samples.push(elapsedMs(start)); + } + return { medianMs: median(samples), samplesMs: samples, value }; +} + +function captureBenchmark(language) { + const isJava = language === 'java'; + const emit = isJava ? emitJavaScopeCaptures : emitKotlinScopeCaptures; + const collect = isJava ? collectJavaCaptureSideChannel : collectKotlinCaptureSideChannel; + const source = isJava ? denseJavaConditions : denseKotlinConditions; + const extension = isJava ? 'java' : 'kt'; + + return CAPTURE_SCALES.map((classes) => { + let run = 0; + const result = measure(REPETITIONS, () => { + const filePath = `src/SpringConditionBench${classes}_${run++}.${extension}`; + const captures = emit(source(classes), filePath); + const facts = collect(filePath)?.springConditionalFacts ?? []; + return { captures: captures.length, facts: facts.length }; + }); + assert.equal(result.value?.facts, classes * 2); + assert.ok((result.value?.captures ?? 0) > classes * (isJava ? 6 : 5)); + return { + classes, + median_ms: Number(result.medianMs.toFixed(2)), + facts: result.value.facts, + captures: result.value.captures, + }; + }); +} + +function metadataParsingBenchmark() { + return METADATA_SCALES.map((declarations) => { + const imports = Array.from( + { length: declarations }, + (_, index) => `com.example.AutoConfiguration${index}`, + ).join('\n'); + const factories = + 'org.springframework.boot.autoconfigure.EnableAutoConfiguration=' + + imports.replaceAll('\n', ','); + const result = measure(REPETITIONS, () => ({ + modern: parseSpringAutoConfigurationImports(imports).length, + legacy: parseSpringFactoriesAutoConfigurations(factories).length, + })); + assert.deepEqual(result.value, { modern: declarations, legacy: declarations }); + return { + declarations, + median_ms: Number(result.medianMs.toFixed(2)), + }; + }); +} + +function pathClassificationBenchmark() { + return PATH_SCALES.map((files) => { + const paths = Array.from( + { length: files }, + (_, index) => `module-${index}/src/main/java/com/example/Service${index}.java`, + ); + const result = measure(REPETITIONS, () => { + let matches = 0; + for (const filePath of paths) { + if (classifySpringAutoConfigurationMetadata(filePath) !== null) matches++; + } + return matches; + }); + assert.equal(result.value, 0); + return { + files, + median_ms: Number(result.medianMs.toFixed(2)), + }; + }); +} + +async function autoConfigurationResolutionBenchmark(classCount) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `spring-auto-config-bench-${classCount}-`)); + const metadataPath = + 'META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports'; + const content = Array.from( + { length: AUTO_CONFIGURATION_CANDIDATES }, + (_, index) => `com.example.AutoConfiguration${index}`, + ).join('\n'); + fs.mkdirSync(path.join(dir, path.dirname(metadataPath)), { recursive: true }); + fs.writeFileSync(path.join(dir, metadataPath), content); + + try { + const graph = createKnowledgeGraph(); + graph.addNode({ + id: generateId('File', metadataPath), + label: 'File', + properties: { name: path.basename(metadataPath), filePath: metadataPath }, + }); + for (let index = 0; index < classCount; index++) { + const qualifiedName = `com.example.AutoConfiguration${index}`; + graph.addNode({ + id: `Class:src/AutoConfiguration${index}.java:${qualifiedName}`, + label: 'Class', + properties: { + name: `AutoConfiguration${index}`, + qualifiedName, + filePath: `src/AutoConfiguration${index}.java`, + }, + }); + } + + const structure = { + scannedFiles: [{ path: metadataPath, size: Buffer.byteLength(content) }], + allPaths: [metadataPath], + allPathSet: new Set([metadataPath]), + totalFiles: 1, + }; + const deps = new Map([ + [ + 'structure', + { + phaseName: 'structure', + output: structure, + durationMs: 0, + }, + ], + ]); + const ctx = { + repoPath: dir, + graph, + onProgress: () => {}, + pipelineStart: Date.now(), + }; + + await springAutoConfigurationPhase.execute(ctx, deps); + const samples = []; + let output; + for (let run = 0; run < REPETITIONS; run++) { + const start = process.hrtime.bigint(); + output = await springAutoConfigurationPhase.execute(ctx, deps); + samples.push(elapsedMs(start)); + } + assert.equal(output?.autoConfigurations, AUTO_CONFIGURATION_CANDIDATES); + assert.equal(output?.ambiguousAutoConfigurations, 0); + return { + classes: classCount, + candidates: AUTO_CONFIGURATION_CANDIDATES, + median_ms: Number(median(samples).toFixed(2)), + }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +async function main() { + const resolution = []; + for (const classes of CLASS_SCALES) { + resolution.push(await autoConfigurationResolutionBenchmark(classes)); + } + + const results = { + capture: { + java: captureBenchmark('java'), + kotlin: captureBenchmark('kotlin'), + }, + metadata_parsing: metadataParsingBenchmark(), + unrelated_path_classification: pathClassificationBenchmark(), + class_fqn_resolution: resolution, + }; + process.stdout.write(`${JSON.stringify(results, null, 2)}\n`); +} + +await main(); diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts index 7a5dd4f36..9f2e49a56 100644 --- a/gitnexus/src/core/incremental/subgraph-extract.ts +++ b/gitnexus/src/core/incremental/subgraph-extract.ts @@ -6,8 +6,8 @@ * replaced, produce a smaller KnowledgeGraph that contains: * * - Every node whose `properties.filePath` is in `toWriteSet`. - * - Every graph-wide node (Community, Process) — these are regenerated - * each run by the communities/processes phases and must be fully + * - Every graph-wide node (Community, Process, and Spring metadata + * placeholders) — these are regenerated each run and must be fully * rewritten. * - Every relationship where AT LEAST ONE endpoint is in the writable * set above. Relationships entirely between unchanged-file nodes @@ -51,8 +51,15 @@ import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { createKnowledgeGraph } from '../graph/graph.js'; import type { KnowledgeGraph } from '../graph/types.js'; +import { + isSpringAutoConfigurationDeclaration, + isSpringAutoConfigurationSyntheticClass, +} from '../ingestion/frameworks/spring/auto-configuration.js'; -const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process'; +const isGraphWideNode = (node: GraphNode): boolean => + node.label === 'Community' || + node.label === 'Process' || + isSpringAutoConfigurationSyntheticClass(node); /** * Relationship types whose VALIDITY is a whole-program property, not a @@ -81,8 +88,17 @@ const isGraphWide = (label: string): boolean => label === 'Community' || label = // analyze, and the `incrementalInProgress` dirty flag (saved before any // delete) forces a full rebuild on the next run. Temporary absence is // possible; duplicates are not. -const isGraphWideRelType = (type: string): boolean => - type === 'TAINT_PATH' || type === 'CALL_SUMMARY' || type === 'INJECTS'; +// +// Spring auto-configuration DECLARES edges (#2415) are also recomputed from +// repository-wide metadata. A third-file class addition/removal can retarget +// an unchanged declaration, so they need the same global re-extract contract. +// DECLARES itself is generic, however: only the two Spring-owned reasons are +// graph-wide, leaving future metadata systems under their own lifecycle. +const isGraphWideRelationship = (relationship: GraphRelationship): boolean => + relationship.type === 'TAINT_PATH' || + relationship.type === 'CALL_SUMMARY' || + relationship.type === 'INJECTS' || + isSpringAutoConfigurationDeclaration(relationship); /** * Build a Map for every File-bound node in the graph. @@ -106,7 +122,7 @@ export const extractChangedSubgraph = ( fullGraph.forEachNode((n: GraphNode) => { const filePath = n.properties?.filePath as string | undefined; - const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label); + const include = (filePath && toWriteSet.has(filePath)) || isGraphWideNode(n); if (include) { sub.addNode(n); writableNodeIds.add(n.id); @@ -117,7 +133,7 @@ export const extractChangedSubgraph = ( if ( writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId) || - isGraphWideRelType(r.type) + isGraphWideRelationship(r) ) { sub.addRelationship(r); } diff --git a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts index db5cdc29a..83302e5f9 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts @@ -7,3 +7,23 @@ export const SPRING_BEAN_INVENTORY_FEATURE: AnalysisFeatureDescriptor = { version: 1, appliesTo: (filePaths) => filePaths.some(isSpringBeanCandidateSourceFile), }; + +function isSpringConditionOrAutoConfigurationFile(filePath: string): boolean { + const normalized = `/${filePath.replaceAll('\\', '/')}`.toLowerCase(); + return ( + normalized.endsWith('.java') || + normalized.endsWith('.kt') || + normalized.endsWith('.kts') || + normalized.endsWith('/meta-inf/spring.factories') || + normalized.endsWith( + '/meta-inf/spring/org.springframework.boot.autoconfigure.autoconfiguration.imports', + ) + ); +} + +/** Durable completeness contract for conditional and auto-configuration evidence. */ +export const SPRING_CONDITIONALS_FEATURE: AnalysisFeatureDescriptor = { + id: 'spring.conditionals-auto-configuration', + version: 1, + appliesTo: (filePaths) => filePaths.some(isSpringConditionOrAutoConfigurationFile), +}; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/auto-configuration.ts b/gitnexus/src/core/ingestion/frameworks/spring/auto-configuration.ts new file mode 100644 index 000000000..7289a5956 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/auto-configuration.ts @@ -0,0 +1,31 @@ +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; + +export const SPRING_AUTO_CONFIGURATION_IMPORT_REASON = 'spring-auto-configuration-import'; +export const SPRING_AUTO_CONFIGURATION_FACTORY_REASON = 'spring-auto-configuration-factory'; +export const SPRING_AUTO_CONFIGURATION_REASONS = [ + SPRING_AUTO_CONFIGURATION_IMPORT_REASON, + SPRING_AUTO_CONFIGURATION_FACTORY_REASON, +] as const; + +export const SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX = 'Class:spring-auto-configuration:'; + +export const SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION = + 'Spring Boot auto-configuration declared by metadata; implementation source unavailable'; + +export function isSpringAutoConfigurationDeclaration( + relationship: Pick, +): boolean { + return ( + relationship.type === 'DECLARES' && + (relationship.reason === SPRING_AUTO_CONFIGURATION_IMPORT_REASON || + relationship.reason === SPRING_AUTO_CONFIGURATION_FACTORY_REASON) + ); +} + +export function isSpringAutoConfigurationSyntheticClass( + node: Pick, +): boolean { + return ( + node.label === 'Class' && node.id.startsWith(SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX) + ); +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/bean-catalog.ts b/gitnexus/src/core/ingestion/frameworks/spring/bean-catalog.ts index 7bb1ed7f6..e5f2bb7a3 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/bean-catalog.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/bean-catalog.ts @@ -15,6 +15,7 @@ export const SPRING_BEAN_STEREOTYPES = new Map([ ['org.springframework.stereotype.Controller', { role: 'controller' }], ['org.springframework.web.bind.annotation.RestController', { role: 'rest-controller' }], ['org.springframework.context.annotation.Configuration', { role: 'configuration' }], + ['org.springframework.boot.autoconfigure.AutoConfiguration', { role: 'auto-configuration' }], ]); export function deriveSpringBeanMetadata( diff --git a/gitnexus/src/core/ingestion/frameworks/spring/conditionals.ts b/gitnexus/src/core/ingestion/frameworks/spring/conditionals.ts new file mode 100644 index 000000000..859feb4b9 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/conditionals.ts @@ -0,0 +1,409 @@ +import type { GraphNode, ParsedFile, ScopeId } from 'gitnexus-shared'; +import { generateId } from '../../../../lib/utils.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { + resolveCallerGraphId, + resolveDefGraphId, +} from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { stripBidiAndZeroWidth } from '../../utils/ast-helpers.js'; +import { createSpringAnnotationNameResolver } from './bean-candidates.js'; +import { SPRING_CONFIG_DESCRIPTION } from './config-bindings.js'; + +export interface SpringConditionalAnnotationFact { + readonly name: string; + readonly text: string; + readonly line: number; +} + +export interface SpringConditionalOwnerFact< + Annotation extends SpringConditionalAnnotationFact = SpringConditionalAnnotationFact, +> { + readonly ownerScopeId: ScopeId; + readonly ownerKind: 'class' | 'callable'; + readonly annotations: readonly Annotation[]; +} + +export interface SpringConditionalMetadataAdapter< + Annotation extends SpringConditionalAnnotationFact, +> { + getFacts(filePath: string): readonly SpringConditionalOwnerFact[]; + isPackageVisibilityIncomplete(filePath: string): boolean; +} + +const PROFILE_ANNOTATION = 'org.springframework.context.annotation.Profile'; +const CONDITIONAL_ANNOTATION = 'org.springframework.context.annotation.Conditional'; +const AUTO_CONFIGURATION_ANNOTATION = 'org.springframework.boot.autoconfigure.AutoConfiguration'; + +const BOOT_CONDITIONAL_ANNOTATIONS = [ + 'ConditionalOnBean', + 'ConditionalOnBooleanProperty', + 'ConditionalOnClass', + 'ConditionalOnCloudPlatform', + 'ConditionalOnExpression', + 'ConditionalOnJava', + 'ConditionalOnJndi', + 'ConditionalOnMissingBean', + 'ConditionalOnMissingClass', + 'ConditionalOnNotWebApplication', + 'ConditionalOnProperty', + 'ConditionalOnResource', + 'ConditionalOnSingleCandidate', + 'ConditionalOnThreading', + 'ConditionalOnWarDeployment', + 'ConditionalOnWebApplication', +] as const; + +const CONDITIONAL_ANNOTATIONS = new Set([ + PROFILE_ANNOTATION, + CONDITIONAL_ANNOTATION, + ...BOOT_CONDITIONAL_ANNOTATIONS.map( + (name) => `org.springframework.boot.autoconfigure.condition.${name}`, + ), +]); + +const PROPERTY_CONDITIONAL_ANNOTATIONS = new Set([ + 'org.springframework.boot.autoconfigure.condition.ConditionalOnProperty', + 'org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty', +]); + +const RESOLVABLE_SPRING_CONDITIONAL_ANNOTATIONS = new Set([ + ...CONDITIONAL_ANNOTATIONS, + AUTO_CONFIGURATION_ANNOTATION, +]); + +const CAPTURE_RELEVANT_SIMPLE_NAMES = new Set([ + 'Profile', + 'Conditional', + 'AutoConfiguration', + ...BOOT_CONDITIONAL_ANNOTATIONS, +]); + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +export function hasSpringConditionalRelevantAnnotation( + annotations: readonly Pick[], +): boolean { + return annotations.some((annotation) => + CAPTURE_RELEVANT_SIMPLE_NAMES.has(simpleName(annotation.name)), + ); +} + +function annotationArguments(text: string): string | undefined { + const start = text.indexOf('('); + const end = text.lastIndexOf(')'); + if (start === -1 || end <= start) return undefined; + const args = text.slice(start + 1, end).trim(); + return args.length === 0 ? undefined : args; +} + +function splitTopLevelArguments(value: string): string[] { + const parts: string[] = []; + let start = 0; + let quote: '"' | "'" | null = null; + let escaped = false; + let round = 0; + let square = 0; + let curly = 0; + + for (let index = 0; index < value.length; index++) { + const char = value.charAt(index); + if (quote !== null) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '(') round++; + else if (char === ')') round = Math.max(0, round - 1); + else if (char === '[') square++; + else if (char === ']') square = Math.max(0, square - 1); + else if (char === '{') curly++; + else if (char === '}') curly = Math.max(0, curly - 1); + else if (char === ',' && round === 0 && square === 0 && curly === 0) { + parts.push(value.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(value.slice(start).trim()); + return parts.filter((part) => part.length > 0); +} + +interface ParsedAnnotationArguments { + readonly positional: readonly string[]; + readonly named: ReadonlyMap; +} + +function parseArguments(text: string): ParsedAnnotationArguments { + const positional: string[] = []; + const named = new Map(); + const args = annotationArguments(text); + if (args === undefined) return { positional, named }; + for (const part of splitTopLevelArguments(args)) { + const assignment = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]+)$/.exec(part); + if (assignment === null) positional.push(part); + else { + const [, name, value] = assignment; + if (name !== undefined && value !== undefined) named.set(name, value.trim()); + } + } + return { positional, named }; +} + +function decodeStaticString(raw: string): string | undefined { + try { + return JSON.parse(raw) as string; + } catch { + return undefined; + } +} + +function staticStrings(value: string | undefined): string[] { + if (value === undefined) return []; + const values: string[] = []; + for (let index = 0; index < value.length; ) { + if (value.startsWith('"""', index)) { + const end = value.indexOf('"""', index + 3); + if (end === -1) break; + const decoded = value.slice(index + 3, end); + if (!decoded.includes('${')) values.push(decoded); + index = end + 3; + continue; + } + if (value.charAt(index) !== '"') { + index++; + continue; + } + let end = index + 1; + let escaped = false; + for (; end < value.length; end++) { + const char = value.charAt(end); + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') break; + } + if (end >= value.length) break; + const decoded = decodeStaticString(value.slice(index, end + 1)); + if (decoded !== undefined) values.push(decoded); + index = end + 1; + } + return values; +} + +function propertyConditionKeys(annotationText: string): string[] { + const args = parseArguments(annotationText); + const prefix = staticStrings(args.named.get('prefix'))[0]?.trim().replace(/\.+$/, '') ?? ''; + const names = staticStrings( + args.named.get('name') ?? + args.named.get('value') ?? + (args.positional.length > 0 ? args.positional.join(',') : undefined), + ); + return [ + ...new Set( + names + .map((name) => name.replace(/^\.+/, '').trim()) + .filter((name) => name.length > 0) + .map((name) => (prefix.length > 0 ? `${prefix}.${name}` : name)), + ), + ]; +} + +function conditionDescription(resolvedName: string, annotationText: string): string { + const args = annotationArguments(annotationText); + const renderedArgs = + args === undefined ? '' : `(${args.replace(/\s+/g, ' ').trim().slice(0, 1000)})`; + return stripBidiAndZeroWidth( + `Spring condition @${simpleName(resolvedName)}${renderedArgs}; activation unknown`, + ); +} + +function ownerGraphNode( + fact: SpringConditionalOwnerFact, + indexes: ScopeResolutionIndexes, + nodeLookup: GraphNodeLookup, + graph: KnowledgeGraph, +): GraphNode | undefined { + const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId); + if (ownerScope === undefined) return undefined; + let ownerId: string | undefined; + if (fact.ownerKind === 'class') { + const classDef = ownerScope.ownedDefs.find( + (definition) => definition.type === 'Class' || definition.type === 'Record', + ); + if (classDef !== undefined) { + ownerId = resolveDefGraphId(classDef.filePath, classDef, nodeLookup); + } + } else { + ownerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup, { + startLine: fact.annotations[0]?.line ?? ownerScope.range.startLine, + startCol: 0, + }); + } + if (ownerId === undefined) return undefined; + const owner = graph.getNode(ownerId); + if (owner === undefined || owner.label === 'File') return undefined; + return owner; +} + +function addConditionNode( + graph: KnowledgeGraph, + owner: GraphNode, + annotation: SpringConditionalAnnotationFact, + resolvedName: string, +): GraphNode { + const description = conditionDescription(resolvedName, annotation.text); + const nodeId = generateId( + 'Annotation', + `spring-condition:${owner.id}:${annotation.line}:${resolvedName}:${description}`, + ); + const conditionNode: GraphNode = { + id: nodeId, + label: 'Annotation', + properties: { + name: `@${simpleName(resolvedName)}`, + filePath: owner.properties.filePath, + startLine: annotation.line, + endLine: annotation.line, + description, + }, + }; + graph.addNode(conditionNode); + const fileId = generateId('File', owner.properties.filePath); + if (graph.getNode(fileId) !== undefined) { + graph.addRelationship({ + id: generateId('DEFINES', `${fileId}->${nodeId}`), + sourceId: fileId, + targetId: nodeId, + type: 'DEFINES', + confidence: 1, + reason: 'spring-condition:annotation', + }); + } + return conditionNode; +} + +function addConditionalRelationship( + graph: KnowledgeGraph, + owner: GraphNode, + target: GraphNode, + annotation: SpringConditionalAnnotationFact, + resolvedName: string, + detail?: string, +): void { + const reason = stripBidiAndZeroWidth( + [`spring-condition:@${simpleName(resolvedName)}`, detail, 'activation=unknown'] + .filter((part): part is string => part !== undefined && part.length > 0) + .join(' '), + ); + graph.addRelationship({ + id: generateId( + 'CONDITIONAL_ON', + `${owner.id}->${target.id}:${annotation.line}:${resolvedName}:${detail ?? ''}`, + ), + sourceId: owner.id, + targetId: target.id, + type: 'CONDITIONAL_ON', + confidence: 1, + reason, + }); +} + +const configNodesByGraph = new WeakMap>(); + +function configNodesByKey(graph: KnowledgeGraph): ReadonlyMap { + const cached = configNodesByGraph.get(graph); + if (cached !== undefined) return cached; + const byKey = new Map(); + for (const node of graph.iterNodes()) { + if ( + node.label !== 'Property' || + typeof node.properties.description !== 'string' || + !node.properties.description.startsWith(SPRING_CONFIG_DESCRIPTION) + ) { + continue; + } + const key = String(node.properties.name); + const bucket = byKey.get(key) ?? []; + bucket.push(node); + byKey.set(key, bucket); + } + configNodesByGraph.set(graph, byKey); + return byKey; +} + +/** + * Build a post-resolution Spring conditional attacher shared by language + * adapters. Adapters capture syntax and package-visibility facts; this module + * owns framework annotation semantics and graph representation. + */ +export function createSpringConditionalMetadataAttacher< + Annotation extends SpringConditionalAnnotationFact, +>(adapter: SpringConditionalMetadataAdapter) { + return ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + ): void => { + const resolveAnnotation = createSpringAnnotationNameResolver(indexes); + let propertyNodes: ReadonlyMap | undefined; + + for (const parsed of parsedFiles) { + const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath); + const resolvedAnnotations = new Map(); + for (const fact of adapter.getFacts(parsed.filePath)) { + const owner = ownerGraphNode(fact, indexes, nodeLookup, graph); + const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId); + if (owner === undefined || ownerScope === undefined) continue; + + for (const annotation of fact.annotations) { + const cacheKey = `${ownerScope.parent ?? ''}\0${annotation.name}`; + let resolved = resolvedAnnotations.get(cacheKey); + if (!resolvedAnnotations.has(cacheKey)) { + resolved = resolveAnnotation( + annotation.name, + parsed, + ownerScope.parent, + RESOLVABLE_SPRING_CONDITIONAL_ANNOTATIONS, + incomplete, + ); + resolvedAnnotations.set(cacheKey, resolved); + } + if (resolved === undefined) continue; + if (!CONDITIONAL_ANNOTATIONS.has(resolved)) continue; + + if (PROPERTY_CONDITIONAL_ANNOTATIONS.has(resolved)) { + const keys = propertyConditionKeys(annotation.text); + propertyNodes ??= configNodesByKey(graph); + let matched = false; + for (const key of keys) { + for (const property of propertyNodes.get(key) ?? []) { + matched = true; + addConditionalRelationship( + graph, + owner, + property, + annotation, + resolved, + `key=${key}`, + ); + } + } + if (matched) continue; + } + + const conditionNode = addConditionNode(graph, owner, annotation, resolved); + addConditionalRelationship(graph, owner, conditionNode, annotation, resolved); + } + } + } + }; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts b/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts index 5083fb4ed..73544548e 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts @@ -77,6 +77,7 @@ const CAPTURE_RELEVANT_ANNOTATIONS = new Set([ 'Controller', 'RestController', 'Configuration', + 'AutoConfiguration', ]); const STEREOTYPE_SIMPLE_NAMES = new Set( diff --git a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index 51a8d6b2e..3fd9db774 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -10,6 +10,7 @@ import { } from '../jvm/package-facts.js'; import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js'; import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; +import type { JavaSpringConditionalFact } from './spring-conditionals.js'; import type { JavaSpringDiClassFact } from './spring-di.js'; export type JavaClassAnnotationFact = ClassAnnotationFact; @@ -19,17 +20,20 @@ export interface JavaCaptureSideChannel { readonly packageFact: JvmPackageFact; readonly classAnnotations: readonly JavaClassAnnotationFact[]; readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; + readonly springConditionalFacts?: readonly JavaSpringConditionalFact[]; readonly springDiFacts?: readonly JavaSpringDiClassFact[]; } const classAnnotations = createClassAnnotationFactStore(); const springConfigConsumers = new Map(); +const springConditionalFacts = new Map(); const springDiFacts = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ export function clearJavaClassAnnotationFacts(): void { classAnnotations.clear(); springConfigConsumers.clear(); + springConditionalFacts.clear(); springDiFacts.clear(); } @@ -55,6 +59,20 @@ export function getJavaSpringConfigConsumerFacts( return springConfigConsumers.get(filePath) ?? []; } +export function setJavaSpringConditionalFacts( + filePath: string, + facts: readonly JavaSpringConditionalFact[], +): void { + if (facts.length === 0) springConditionalFacts.delete(filePath); + else springConditionalFacts.set(filePath, facts); +} + +export function getJavaSpringConditionalFacts( + filePath: string, +): readonly JavaSpringConditionalFact[] { + return springConditionalFacts.get(filePath) ?? []; +} + export function setJavaSpringDiFacts( filePath: string, facts: readonly JavaSpringDiClassFact[], @@ -73,11 +91,13 @@ export function collectJavaCaptureSideChannel( ): JavaCaptureSideChannel | undefined { const facts = classAnnotations.get(filePath); const configConsumers = springConfigConsumers.get(filePath) ?? []; + const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); if ( facts.length === 0 && configConsumers.length === 0 && + conditionFacts.length === 0 && diFacts.length === 0 && packageFact === undefined ) { @@ -88,6 +108,7 @@ export function collectJavaCaptureSideChannel( packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, classAnnotations: facts, ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), + ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), }; } @@ -108,6 +129,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { ) { setJavaClassAnnotationFacts(parsed.filePath, []); setJavaSpringConfigConsumerFacts(parsed.filePath, []); + setJavaSpringConditionalFacts(parsed.filePath, []); setJavaSpringDiFacts(parsed.filePath, []); setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; @@ -117,6 +139,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [], ); + setJavaSpringConditionalFacts( + parsed.filePath, + Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : [], + ); setJavaSpringDiFacts( parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : [], diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index dce0b1dca..1314dd5bc 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -36,12 +36,17 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { setJavaClassAnnotationFacts, setJavaSpringConfigConsumerFacts, + setJavaSpringConditionalFacts, setJavaSpringDiFacts, } from './capture-side-channel.js'; import { captureJavaPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js'; import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js'; +import { + captureJavaSpringConditionalFacts, + type JavaSpringConditionalFact, +} from './spring-conditionals.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -126,6 +131,7 @@ export function emitJavaScopeCaptures( const rawMatches = getJavaScopeQuery().matches(tree.rootNode); const out: CaptureMatch[] = []; const classAnnotations = new Map>(); + const springConditionalFacts: JavaSpringConditionalFact[] = []; const springDiFacts: JavaSpringDiClassFact[] = []; const springDiClassNodeIds = new Set(); @@ -150,6 +156,9 @@ export function emitJavaScopeCaptures( const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration'); if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { springDiClassNodeIds.add(springDiClassNode.id); + springConditionalFacts.push( + ...captureJavaSpringConditionalFacts(springDiClassNode, filePath), + ); const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath); if (fact !== null) springDiFacts.push(fact); } @@ -347,6 +356,7 @@ export function emitJavaScopeCaptures( filePath, captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath), ); + setJavaSpringConditionalFacts(filePath, springConditionalFacts); setJavaSpringDiFacts(filePath, springDiFacts); return [ diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index e79624fea..3b27c5d41 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -31,6 +31,7 @@ import { import { populateJavaPackageSiblings } from './package-siblings.js'; import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; +import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js'; import { attachJavaSpringDiMetadata } from './spring-di.js'; import { applyJavaCaptureSideChannel, @@ -87,6 +88,7 @@ const javaScopeResolver: ScopeResolver = { populateRangeBindings: populateJavaCrossFileReturnTypes, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => { attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); + attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); }, diff --git a/gitnexus/src/core/ingestion/languages/java/spring-conditionals.ts b/gitnexus/src/core/ingestion/languages/java/spring-conditionals.ts new file mode 100644 index 000000000..0d4145f40 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-conditionals.ts @@ -0,0 +1,61 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringConditionalMetadataAttacher, + hasSpringConditionalRelevantAnnotation, + type SpringConditionalOwnerFact, +} from '../../frameworks/spring/conditionals.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getJavaSpringConditionalFacts } from './capture-side-channel.js'; +import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spring-di.js'; + +export type JavaSpringConditionalAnnotationFact = JavaAnnotationSyntaxFact; + +export type JavaSpringConditionalFact = + SpringConditionalOwnerFact; + +function scopeId(filePath: string, node: SyntaxNode, kind: 'Class' | 'Function') { + return makeScopeId({ + filePath, + range: nodeToCapture('@spring-condition.owner', node).range, + kind, + }); +} + +/** + * Capture Spring condition syntax while Java's existing class traversal already + * has the AST node in hand. Framework/FQN semantics are resolved later. + */ +export function captureJavaSpringConditionalFacts( + classNode: SyntaxNode, + filePath: string, +): JavaSpringConditionalFact[] { + const facts: JavaSpringConditionalFact[] = []; + const classAnnotations = javaSpringAnnotationFacts(classNode); + if (hasSpringConditionalRelevantAnnotation(classAnnotations)) { + facts.push({ + ownerScopeId: scopeId(filePath, classNode, 'Class'), + ownerKind: 'class', + annotations: classAnnotations, + }); + } + + const body = classNode.childForFieldName('body'); + if (body === null) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'method_declaration') continue; + const annotations = javaSpringAnnotationFacts(member); + if (!hasSpringConditionalRelevantAnnotation(annotations)) continue; + facts.push({ + ownerScopeId: scopeId(filePath, member, 'Function'), + ownerKind: 'callable', + annotations, + }); + } + return facts; +} + +export const attachJavaSpringConditionalMetadata = createSpringConditionalMetadataAttacher({ + getFacts: getJavaSpringConditionalFacts, + isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/languages/java/spring-di.ts b/gitnexus/src/core/ingestion/languages/java/spring-di.ts index 2b106060c..ee6f55783 100644 --- a/gitnexus/src/core/ingestion/languages/java/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/java/spring-di.ts @@ -13,7 +13,9 @@ import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; import { getJavaSpringDiFacts } from './capture-side-channel.js'; -export type JavaAnnotationSyntaxFact = SpringDiAnnotationFact; +export interface JavaAnnotationSyntaxFact extends SpringDiAnnotationFact { + readonly line: number; +} export type JavaSpringDependencyFact = SpringDiDependencyFact; @@ -29,7 +31,7 @@ export type JavaSpringDiClassFact = SpringDiClassFact< JavaSpringInjectionSiteKind >; -function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] { +export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] { const facts: JavaAnnotationSyntaxFact[] = []; for (const child of node.namedChildren) { if (child.type !== 'modifiers') continue; @@ -37,7 +39,11 @@ function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] { if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue; const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild; if (nameNode === null) continue; - facts.push({ name: nameNode.text.trim(), text: modifier.text.trim() }); + facts.push({ + name: nameNode.text.trim(), + text: modifier.text.trim(), + line: modifier.startPosition.row + 1, + }); } } return facts; @@ -55,7 +61,7 @@ function dependenciesOf(callable: SyntaxNode): JavaSpringDependencyFact[] { dependencies.push({ name: nameNode.text.trim(), rawType: typeNode.text.trim(), - annotations: annotationFacts(parameter), + annotations: javaSpringAnnotationFacts(parameter), }); } return dependencies; @@ -73,14 +79,14 @@ export function captureJavaSpringDiClassFact( ): JavaSpringDiClassFact | null { const body = classNode.childForFieldName('body'); if (body === null) return null; - const classAnnotations = annotationFacts(classNode); + const classAnnotations = javaSpringAnnotationFacts(classNode); const injectionSites: JavaSpringInjectionSiteFact[] = []; const constructors = body.namedChildren.filter( (child) => child.type === 'constructor_declaration', ); for (const constructor of constructors) { - const annotations = annotationFacts(constructor); + const annotations = javaSpringAnnotationFacts(constructor); const implicitConstructor = constructors.length === 1 && hasSpringStereotypeSyntax(classAnnotations) && @@ -97,7 +103,7 @@ export function captureJavaSpringDiClassFact( for (const member of body.namedChildren) { if (member.type === 'field_declaration') { - const annotations = annotationFacts(member); + const annotations = javaSpringAnnotationFacts(member); if (!hasSpringDiRelevantAnnotation(annotations)) continue; const typeNode = member.childForFieldName('type'); if (typeNode === null) continue; @@ -120,7 +126,7 @@ export function captureJavaSpringDiClassFact( }); } } else if (member.type === 'method_declaration') { - const annotations = annotationFacts(member); + const annotations = javaSpringAnnotationFacts(member); if (!hasSpringDiRelevantAnnotation(annotations)) continue; injectionSites.push({ kind: 'method', diff --git a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts index 14ea8f122..c1e8ce4f8 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -49,9 +49,11 @@ import { } from '../jvm/package-facts.js'; import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js'; import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js'; +import type { KotlinSpringConditionalFact } from './spring-conditionals.js'; import type { KotlinSpringDiClassFact } from './spring-di.js'; const classAnnotations = createClassAnnotationFactStore(); +const springConditionalFacts = new Map(); const springDiFacts = new Map(); /** @@ -68,12 +70,15 @@ export interface KotlinCaptureSideChannel { readonly packageFact: JvmPackageFact; /** Class annotation syntax collected by the existing scope traversal. */ readonly classAnnotations: readonly ClassAnnotationFact[]; + /** Profile, conditional, and auto-configuration syntax captured per owner. */ + readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[]; /** Constructor, property, and method injection syntax captured per class. */ readonly springDiFacts?: readonly KotlinSpringDiClassFact[]; } export function clearKotlinClassAnnotationFacts(): void { classAnnotations.clear(); + springConditionalFacts.clear(); springDiFacts.clear(); } @@ -88,6 +93,20 @@ export function getKotlinClassAnnotationFacts(filePath: string): readonly ClassA return classAnnotations.get(filePath); } +export function setKotlinSpringConditionalFacts( + filePath: string, + facts: readonly KotlinSpringConditionalFact[], +): void { + if (facts.length === 0) springConditionalFacts.delete(filePath); + else springConditionalFacts.set(filePath, facts); +} + +export function getKotlinSpringConditionalFacts( + filePath: string, +): readonly KotlinSpringConditionalFact[] { + return springConditionalFacts.get(filePath) ?? []; +} + export function setKotlinSpringDiFacts( filePath: string, facts: readonly KotlinSpringDiClassFact[], @@ -110,11 +129,13 @@ export function collectKotlinCaptureSideChannel( ): KotlinCaptureSideChannel | undefined { const companionScopes = getCompanionScopesForFile(filePath); const annotationFacts = classAnnotations.get(filePath); + const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; const packageFact = getKotlinPackageFact(filePath); if ( companionScopes.length === 0 && annotationFacts.length === 0 && + conditionFacts.length === 0 && diFacts.length === 0 && packageFact === undefined ) { @@ -125,6 +146,7 @@ export function collectKotlinCaptureSideChannel( companionScopes, packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, classAnnotations: annotationFacts, + ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), }; } @@ -148,6 +170,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { !Array.isArray(data.classAnnotations) ) { classAnnotations.set(parsed.filePath, []); + setKotlinSpringConditionalFacts(parsed.filePath, []); setKotlinSpringDiFacts(parsed.filePath, []); setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; @@ -156,6 +179,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { markCompanionScope(parsed.filePath, scopeId); } classAnnotations.set(parsed.filePath, data.classAnnotations); + setKotlinSpringConditionalFacts( + parsed.filePath, + Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : [], + ); setKotlinSpringDiFacts( parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : [], diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index afcbd703e..657e6bcb6 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -18,10 +18,18 @@ import { normalizeKotlinType } from './interpret.js'; import { synthesizeKotlinReceiverBinding } from './receiver-binding.js'; import { getKotlinParser, getKotlinScopeQuery } from './query.js'; import { markCompanionScope } from './companion-scopes.js'; -import { setKotlinClassAnnotationFacts, setKotlinSpringDiFacts } from './capture-side-channel.js'; +import { + setKotlinClassAnnotationFacts, + setKotlinSpringConditionalFacts, + setKotlinSpringDiFacts, +} from './capture-side-channel.js'; import { captureKotlinPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js'; +import { + captureKotlinSpringConditionalFacts, + type KotlinSpringConditionalFact, +} from './spring-conditionals.js'; const FUNCTION_DECL_TAGS = ['@declaration.function'] as const; @@ -84,6 +92,7 @@ export function emitKotlinScopeCaptures( const out: CaptureMatch[] = []; const classAnnotations = new Map>(); + const springConditionalFacts: KotlinSpringConditionalFact[] = []; const springDiFacts: KotlinSpringDiClassFact[] = []; const springDiClassNodeIds = new Set(); const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode); @@ -112,6 +121,9 @@ export function emitKotlinScopeCaptures( const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration'); if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { springDiClassNodeIds.add(springDiClassNode.id); + springConditionalFacts.push( + ...captureKotlinSpringConditionalFacts(springDiClassNode, filePath), + ); const fact = captureKotlinSpringDiClassFact(springDiClassNode, filePath); if (fact !== null) springDiFacts.push(fact); } @@ -298,6 +310,7 @@ export function emitKotlinScopeCaptures( } setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations)); + setKotlinSpringConditionalFacts(filePath, springConditionalFacts); setKotlinSpringDiFacts(filePath, springDiFacts); out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS)); return out; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 7d3a78611..bad2b85d0 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -23,6 +23,7 @@ import { populateKotlinPackageSiblings } from './package-siblings.js'; import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; import { clearKotlinPackageFacts } from './package-facts.js'; import { attachKotlinSpringDiMetadata } from './spring-di.js'; +import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js'; /** * Kotlin scope resolver for RFC #909 Ring 3. @@ -126,6 +127,7 @@ export const kotlinScopeResolver: ScopeResolver = { populateNamespaceSiblings: populateKotlinPackageSiblings, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => { attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); + attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); }, }; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-conditionals.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-conditionals.ts new file mode 100644 index 000000000..fc6b39853 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-conditionals.ts @@ -0,0 +1,62 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringConditionalMetadataAttacher, + hasSpringConditionalRelevantAnnotation, + type SpringConditionalOwnerFact, +} from '../../frameworks/spring/conditionals.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getKotlinSpringConditionalFacts } from './capture-side-channel.js'; +import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { kotlinSpringAnnotationFacts, type KotlinAnnotationSyntaxFact } from './spring-di.js'; + +export type KotlinSpringConditionalAnnotationFact = KotlinAnnotationSyntaxFact; + +export type KotlinSpringConditionalFact = + SpringConditionalOwnerFact; + +function scopeId(filePath: string, node: SyntaxNode, kind: 'Class' | 'Function') { + return makeScopeId({ + filePath, + range: nodeToCapture('@spring-condition.owner', node).range, + kind, + }); +} + +/** + * Capture Kotlin condition syntax from the class node already surfaced by the + * scope query. Kotlin syntax stays local; shared Spring semantics are attached + * after resolution. + */ +export function captureKotlinSpringConditionalFacts( + classNode: SyntaxNode, + filePath: string, +): KotlinSpringConditionalFact[] { + const facts: KotlinSpringConditionalFact[] = []; + const classAnnotations = kotlinSpringAnnotationFacts(classNode); + if (hasSpringConditionalRelevantAnnotation(classAnnotations)) { + facts.push({ + ownerScopeId: scopeId(filePath, classNode, 'Class'), + ownerKind: 'class', + annotations: classAnnotations, + }); + } + + const body = classNode.namedChildren.find((child) => child.type === 'class_body'); + if (body === undefined) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'function_declaration') continue; + const annotations = kotlinSpringAnnotationFacts(member); + if (!hasSpringConditionalRelevantAnnotation(annotations)) continue; + facts.push({ + ownerScopeId: scopeId(filePath, member, 'Function'), + ownerKind: 'callable', + annotations, + }); + } + return facts; +} + +export const attachKotlinSpringConditionalMetadata = createSpringConditionalMetadataAttacher({ + getFacts: getKotlinSpringConditionalFacts, + isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts index 3efc60522..a4e7e0727 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts @@ -15,6 +15,7 @@ import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.j export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact { readonly useSiteTarget?: string; + readonly line: number; } export type KotlinSpringDependencyFact = SpringDiDependencyFact; @@ -57,6 +58,7 @@ function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | nu return { name: nameNode.text.trim(), text: annotation.text.trim(), + line: annotation.startPosition.row + 1, ...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }), }; } @@ -71,7 +73,7 @@ function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyn return facts; } -function annotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { +export function kotlinSpringAnnotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { const facts: KotlinAnnotationSyntaxFact[] = []; for (const child of node.namedChildren) { if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue; @@ -94,7 +96,7 @@ function parameterDependency( return { name: nameNode.text.trim(), rawType: typeNode.text.trim(), - annotations: [...precedingAnnotations, ...annotationFacts(parameter)], + annotations: [...precedingAnnotations, ...kotlinSpringAnnotationFacts(parameter)], }; } @@ -134,7 +136,7 @@ function propertyDependency(property: SyntaxNode): KotlinSpringDependencyFact | const nameNode = variable.namedChildren.find((child) => child.type === 'simple_identifier'); const typeNode = directTypeNode(variable); if (nameNode === undefined || typeNode === undefined) return null; - const annotations = annotationFacts(property); + const annotations = kotlinSpringAnnotationFacts(property); return { name: nameNode.text.trim(), rawType: typeNode.text.trim(), @@ -162,7 +164,7 @@ export function captureKotlinSpringDiClassFact( filePath: string, ): KotlinSpringDiClassFact | null { if (!isKotlinBeanCandidateClass(classNode)) return null; - const classAnnotations = annotationFacts(classNode); + const classAnnotations = kotlinSpringAnnotationFacts(classNode); const injectionSites: KotlinSpringInjectionSiteFact[] = []; const body = classNode.namedChildren.find((child) => child.type === 'class_body'); const primaryConstructor = classNode.namedChildren.find( @@ -174,7 +176,7 @@ export function captureKotlinSpringDiClassFact( (primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length; if (primaryConstructor !== undefined) { - const annotations = annotationFacts(primaryConstructor); + const annotations = kotlinSpringAnnotationFacts(primaryConstructor); const implicitConstructor = constructorCount === 1 && hasSpringStereotypeSyntax(classAnnotations) && @@ -191,7 +193,7 @@ export function captureKotlinSpringDiClassFact( } for (const constructor of secondaryConstructors) { - const annotations = annotationFacts(constructor); + const annotations = kotlinSpringAnnotationFacts(constructor); const implicitConstructor = constructorCount === 1 && hasSpringStereotypeSyntax(classAnnotations) && @@ -209,7 +211,7 @@ export function captureKotlinSpringDiClassFact( if (body !== undefined) { for (const member of body.namedChildren) { if (member.type === 'property_declaration') { - const annotations = annotationFacts(member); + const annotations = kotlinSpringAnnotationFacts(member); if (!hasSpringDiRelevantAnnotation(annotations)) continue; const dependency = propertyDependency(member); if (dependency === null) continue; @@ -221,7 +223,7 @@ export function captureKotlinSpringDiClassFact( dependencies: [dependency], }); } else if (member.type === 'function_declaration') { - const annotations = annotationFacts(member); + const annotations = kotlinSpringAnnotationFacts(member); if (!hasSpringDiRelevantAnnotation(annotations)) continue; const name = member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ?? diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index a3fb83aeb..0095c6bef 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -21,6 +21,10 @@ export { type ScopeResolutionOutput, } from '../scope-resolution/pipeline/phase.js'; export { springConfigPhase, type SpringConfigOutput } from './spring-config.js'; +export { + springAutoConfigurationPhase, + type SpringAutoConfigurationOutput, +} from './spring-auto-configuration.js'; export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js'; export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js'; export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/spring-auto-configuration.ts b/gitnexus/src/core/ingestion/pipeline-phases/spring-auto-configuration.ts new file mode 100644 index 000000000..a578bfa0d --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/spring-auto-configuration.ts @@ -0,0 +1,314 @@ +/** + * Phase: springAutoConfiguration + * + * Discovers Spring Boot auto-configuration declarations from repository + * metadata after source symbols have been resolved. Metadata-backed classes + * are linked through DECLARES; when source is unavailable, a lightweight + * synthetic Class preserves the third-party/starter contribution. + * + * @deps structure, scopeResolution + * @reads META-INF/spring.factories and AutoConfiguration.imports + * @writes Class nodes and DECLARES edges + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { GraphNode } from 'gitnexus-shared'; +import { generateId } from '../../../lib/utils.js'; +import { logger } from '../../logger.js'; +import { + SPRING_AUTO_CONFIGURATION_FACTORY_REASON, + SPRING_AUTO_CONFIGURATION_IMPORT_REASON, + SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION, +} from '../frameworks/spring/auto-configuration.js'; +import { isDev } from '../utils/env.js'; +import type { StructureOutput } from './structure.js'; +import type { PipelineContext, PipelinePhase, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; + +const MAX_SPRING_METADATA_BYTES = 2 * 1024 * 1024; +const ENABLE_AUTO_CONFIGURATION_KEY = + 'org.springframework.boot.autoconfigure.EnableAutoConfiguration'; +const AUTO_CONFIGURATION_IMPORTS = + 'META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports'; +const SPRING_FACTORIES = 'META-INF/spring.factories'; +const AUTO_CONFIGURATION_IMPORTS_LOWER = AUTO_CONFIGURATION_IMPORTS.toLowerCase(); +const SPRING_FACTORIES_LOWER = SPRING_FACTORIES.toLowerCase(); + +/** + * Case-insensitive ASCII path-suffix match without allocating a normalized or + * lower-cased copy of every scanned path. Spring metadata suffixes are ASCII; + * both POSIX and Windows separators are accepted. + */ +function hasPathSuffix(filePath: string, suffix: string): boolean { + let fileCursor = filePath.length - 1; + for (let suffixCursor = suffix.length - 1; suffixCursor >= 0; suffixCursor--, fileCursor--) { + if (fileCursor < 0) return false; + const expected = suffix.charCodeAt(suffixCursor); + const actual = filePath.charCodeAt(fileCursor); + if (expected === 47) { + if (actual !== 47 && actual !== 92) return false; + continue; + } + const lowerActual = actual >= 65 && actual <= 90 ? actual + 32 : actual; + if (lowerActual !== expected) return false; + } + if (fileCursor < 0) return true; + const boundary = filePath.charCodeAt(fileCursor); + return boundary === 47 || boundary === 92; +} + +export interface SpringAutoConfigurationEntry { + readonly className: string; + readonly line: number; +} + +type SpringAutoConfigurationMetadataKind = 'imports' | 'spring-factories'; + +interface SpringAutoConfigurationMetadataFile { + readonly filePath: string; + readonly kind: SpringAutoConfigurationMetadataKind; +} + +export interface SpringAutoConfigurationOutput { + readonly metadataFiles: number; + readonly autoConfigurations: number; + readonly ambiguousAutoConfigurations: number; +} + +export function classifySpringAutoConfigurationMetadata( + filePath: string, +): SpringAutoConfigurationMetadataFile | null { + if (hasPathSuffix(filePath, AUTO_CONFIGURATION_IMPORTS_LOWER)) { + return { filePath, kind: 'imports' }; + } + if (hasPathSuffix(filePath, SPRING_FACTORIES_LOWER)) { + return { filePath, kind: 'spring-factories' }; + } + return null; +} + +/** Parse Boot 2.7+/3.x one-class-per-line auto-configuration imports. */ +export function parseSpringAutoConfigurationImports( + content: string, +): SpringAutoConfigurationEntry[] { + const entries: SpringAutoConfigurationEntry[] = []; + const seen = new Set(); + for (const [index, rawLine] of content.split(/\r?\n/).entries()) { + const line = rawLine.replace(/\s*#.*$/, '').trim(); + if (line.length === 0 || seen.has(line)) continue; + if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$/.test(line)) continue; + seen.add(line); + entries.push({ className: line, line: index + 1 }); + } + return entries; +} + +function logicalFactoryLines(content: string): Array<{ text: string; line: number }> { + const logical: Array<{ text: string; line: number }> = []; + let current = ''; + let startLine = 1; + const physical = content.split(/\r?\n/); + for (let index = 0; index < physical.length; index++) { + const raw = physical[index] ?? ''; + if (current.length === 0) startLine = index + 1; + const trimmed = current.length === 0 ? raw.trimStart() : raw.trim(); + current += trimmed; + let trailingBackslashes = 0; + for (let cursor = current.length - 1; cursor >= 0 && current[cursor] === '\\'; cursor--) { + trailingBackslashes++; + } + if (trailingBackslashes % 2 === 1) { + current = current.slice(0, -1); + continue; + } + logical.push({ text: current, line: startLine }); + current = ''; + } + if (current.length > 0) logical.push({ text: current, line: startLine }); + return logical; +} + +/** Parse the legacy Boot 1.x/2.x EnableAutoConfiguration factory entry. */ +export function parseSpringFactoriesAutoConfigurations( + content: string, +): SpringAutoConfigurationEntry[] { + const entries: SpringAutoConfigurationEntry[] = []; + const seen = new Set(); + for (const logical of logicalFactoryLines(content)) { + const trimmed = logical.text.trim(); + if (trimmed.length === 0 || trimmed.startsWith('#') || trimmed.startsWith('!')) continue; + const separator = trimmed.search(/[:=]/); + if (separator === -1) continue; + const key = trimmed.slice(0, separator).trim(); + if (key !== ENABLE_AUTO_CONFIGURATION_KEY) continue; + const value = trimmed + .slice(separator + 1) + .replace(/\s+#.*$/, '') + .trim(); + for (const candidate of value.split(',')) { + const className = candidate.trim(); + if ( + className.length === 0 || + seen.has(className) || + !/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$/.test(className) + ) { + continue; + } + seen.add(className); + entries.push({ className, line: logical.line }); + } + } + return entries; +} + +function normalizedQualifiedName(value: string): string { + return value.replaceAll('$', '.'); +} + +function simpleClassName(qualifiedName: string): string { + const separator = Math.max(qualifiedName.lastIndexOf('.'), qualifiedName.lastIndexOf('$')); + return separator === -1 ? qualifiedName : qualifiedName.slice(separator + 1); +} + +function sourceClassIndexes( + graph: PipelineContext['graph'], +): ReadonlyMap { + const byQualifiedName = new Map(); + for (const node of graph.iterNodes()) { + if (node.label !== 'Class') continue; + const qualifiedName = + typeof node.properties.qualifiedName === 'string' + ? normalizedQualifiedName(node.properties.qualifiedName) + : undefined; + if (qualifiedName !== undefined) { + const existing = byQualifiedName.get(qualifiedName); + if (existing === undefined) { + byQualifiedName.set(qualifiedName, node); + } else if (existing !== null) { + // Only uniqueness matters. A null sentinel avoids retaining an array of + // every duplicate while preserving fail-closed ambiguity semantics. + byQualifiedName.set(qualifiedName, null); + } + } + } + return byQualifiedName; +} + +function resolveOrCreateAutoConfigurationClass( + ctx: PipelineContext, + metadata: SpringAutoConfigurationMetadataFile, + qualifiedName: string, + line: number, + classesByQualifiedName: ReturnType, +): GraphNode | undefined { + const exact = classesByQualifiedName.get(qualifiedName); + if (exact !== null && exact !== undefined) return exact; + // The runtime classpath chooses one of duplicate FQNs. GitNexus has no + // reliable module/classpath precedence here, so fail closed instead of + // guessing, fanning out, or fabricating a third candidate. + if (exact === null) return undefined; + + const nodeId = generateId('Class', `spring-auto-configuration:${qualifiedName}`); + const syntheticClass: GraphNode = { + id: nodeId, + label: 'Class', + properties: { + name: simpleClassName(qualifiedName), + qualifiedName, + filePath: metadata.filePath, + startLine: line, + endLine: line, + description: SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION, + }, + }; + ctx.graph.addNode(syntheticClass); + return syntheticClass; +} + +export const springAutoConfigurationPhase: PipelinePhase = { + name: 'springAutoConfiguration', + deps: ['structure', 'scopeResolution'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const { scannedFiles } = getPhaseOutput(deps, 'structure'); + let classesByQualifiedName: ReturnType | undefined; + const resolutions = new Map(); + const ambiguousQualifiedNames = new Set(); + let metadataFiles = 0; + let autoConfigurations = 0; + + for (const scanned of scannedFiles) { + const metadata = classifySpringAutoConfigurationMetadata(scanned.path); + if (metadata === null || scanned.size > MAX_SPRING_METADATA_BYTES) continue; + let content: string; + try { + content = await fs.readFile(path.join(ctx.repoPath, scanned.path), 'utf8'); + } catch { + continue; + } + metadataFiles++; + const entries = + metadata.kind === 'imports' + ? parseSpringAutoConfigurationImports(content) + : parseSpringFactoriesAutoConfigurations(content); + const fileId = generateId('File', metadata.filePath); + if (ctx.graph.getNode(fileId) === undefined) continue; + const declaredQualifiedNames = new Set(); + + for (const entry of entries) { + const qualifiedName = normalizedQualifiedName(entry.className); + if (declaredQualifiedNames.has(qualifiedName)) continue; + declaredQualifiedNames.add(qualifiedName); + let autoConfiguration = resolutions.get(qualifiedName); + if (autoConfiguration === undefined) { + classesByQualifiedName ??= sourceClassIndexes(ctx.graph); + autoConfiguration = + resolveOrCreateAutoConfigurationClass( + ctx, + metadata, + qualifiedName, + entry.line, + classesByQualifiedName, + ) ?? null; + resolutions.set(qualifiedName, autoConfiguration); + } + if (autoConfiguration === null) { + ambiguousQualifiedNames.add(qualifiedName); + continue; + } + ctx.graph.addRelationship({ + id: generateId( + 'DECLARES', + `${fileId}->${autoConfiguration.id}:${metadata.kind}:${entry.className}`, + ), + sourceId: fileId, + targetId: autoConfiguration.id, + type: 'DECLARES', + confidence: 1, + reason: + metadata.kind === 'imports' + ? SPRING_AUTO_CONFIGURATION_IMPORT_REASON + : SPRING_AUTO_CONFIGURATION_FACTORY_REASON, + }); + autoConfigurations++; + } + } + + if (isDev && ambiguousQualifiedNames.size > 0) { + logger.debug( + `Spring auto-configuration: skipped ${ambiguousQualifiedNames.size} ambiguous FQN(s) ` + + `because classpath precedence is unknown: ${[...ambiguousQualifiedNames].sort().join(', ')}`, + ); + } + + return { + metadataFiles, + autoConfigurations, + ambiguousAutoConfigurations: ambiguousQualifiedNames.size, + }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index eb1308710..e99515468 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -33,6 +33,7 @@ import { crossFilePhase, scopeResolutionPhase, springConfigPhase, + springAutoConfigurationPhase, pruneLocalSymbolsPhase, taintSummariesPhase, callSummariesPhase, @@ -261,7 +262,7 @@ export interface PipelineOptions { * Phase dependency graph: * * scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm] - * → crossFile → scopeResolution → pruneLocalSymbols + * → crossFile → scopeResolution → springAutoConfiguration → pruneLocalSymbols * → mro → di → communities → processes * * To add a new phase: create a file in pipeline-phases/, export the phase @@ -288,6 +289,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { .register(ormPhase) .register(crossFilePhase) .register(scopeResolutionPhase) + .register(springAutoConfigurationPhase) .register(pruneLocalSymbolsPhase) // M4 (#2084): interprocedural taint fixpoint — the first real opt-in // pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 0eafe74c5..157549366 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -1255,12 +1255,11 @@ export function findChild(node: SyntaxNode, type: string): SyntaxNode | null { return null; } -/** Remove bidi-override and zero-width control characters. Doc text is - * attacker-influenced (any indexed repo) and is returned verbatim to MCP - * clients, so strip Trojan-Source-style hidden controls from the description - * before it leaves the extractor (#2286 review). Scoped to the doc-comment path - * only — global `sanitizeUTF8` is intentionally untouched. */ -const stripBidiAndZeroWidth = (text: string): string => +/** Remove bidi-override and zero-width control characters from attacker- + * influenced repository text before it is exposed through graph descriptions + * or MCP output (#2286). Global `sanitizeUTF8` intentionally remains focused + * on encoding/control-character validity. */ +export const stripBidiAndZeroWidth = (text: string): string => Array.from(text) .filter((ch) => { const c = ch.codePointAt(0) ?? 0; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 57f388349..86f763ffa 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -61,6 +61,10 @@ import { } from './sidecar-recovery.js'; import { logger } from '../logger.js'; +import { + SPRING_AUTO_CONFIGURATION_REASONS, + SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX, +} from '../ingestion/frameworks/spring/auto-configuration.js'; // --------------------------------------------------------------------------- // Relationship CSV splitting — extracted for testability (PR #818) // --------------------------------------------------------------------------- @@ -2614,9 +2618,9 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{ /** * Shared mechanics for the delete-all-relationships-of-one-type family * ({@link deleteAllInterprocTaintPaths}, {@link deleteAllCallSummaries}, - * {@link deleteAllInjects}): count the typed CodeRelation rows, then DELETE - * them (relationship-level — these are edge types, not node labels, so - * endpoints are untouched). + * {@link deleteAllInjects}, {@link deleteSpringAutoConfigurationDeclarations}): + * count the matching CodeRelation rows, then DELETE them (relationship-level — + * these are edge types, not node labels, so endpoints are untouched). * * count + DELETE run as one critical section on the singleton connection so a * concurrent WAL-checkpoint cannot corrupt native state mid-delete (#pdg). @@ -2624,11 +2628,13 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{ * @param relType the CodeRelation `type` value to delete (e.g. 'INJECTS') * @param logTag the `[tag]` prefix on the abort error message * @param duplicateNoun what the abort message says would be duplicated + * @param exactReasons optional reason allowlist for a shared relationship type */ const deleteAllRelationshipsOfType = async ( relType: string, logTag: string, duplicateNoun: string, + exactReasons?: readonly string[], ): Promise<{ edgesDeleted: number }> => { const c = conn; if (!c) { @@ -2637,16 +2643,23 @@ const deleteAllRelationshipsOfType = async ( return withConnLock(async () => { let edgesDeleted = 0; let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined; + const reasonFilter = + exactReasons === undefined || exactReasons.length === 0 + ? '' + : ` AND (${exactReasons + .map((reason) => `r.reason = '${escapeCypherString(reason)}'`) + .join(' OR ')})`; + const predicate = `r.type = '${escapeCypherString(relType)}'${reasonFilter}`; try { countResult = await c.query( - `MATCH ()-[r:CodeRelation]->() WHERE r.type = '${relType}' RETURN count(r) AS cnt`, + `MATCH ()-[r:CodeRelation]->() WHERE ${predicate} RETURN count(r) AS cnt`, ); const result = Array.isArray(countResult) ? countResult[0] : countResult; const rows = await result.getAll(); const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); if (count > 0) { await closeQueryResults( - await c.query(`MATCH ()-[r:CodeRelation]->() WHERE r.type = '${relType}' DELETE r`), + await c.query(`MATCH ()-[r:CodeRelation]->() WHERE ${predicate} DELETE r`), ); edgesDeleted = count; } @@ -2732,6 +2745,65 @@ export const deleteAllCallSummaries = async (): Promise<{ edgesDeleted: number } export const deleteAllInjects = async (): Promise<{ edgesDeleted: number }> => deleteAllRelationshipsOfType('INJECTS', 'di', 'duplicate INJECTS edges'); +/** + * Drop Spring-owned auto-configuration `DECLARES` relationships before + * incremental writeback. `DECLARES` is generic, so exact reason filtering is + * required: other metadata systems must retain their own declarations. + */ +export const deleteSpringAutoConfigurationDeclarations = async (): Promise<{ + edgesDeleted: number; +}> => + deleteAllRelationshipsOfType( + 'DECLARES', + 'spring-auto-configuration', + 'duplicate auto-configuration declarations', + SPRING_AUTO_CONFIGURATION_REASONS, + ); + +/** + * Drop synthetic source-unavailable auto-configuration Class nodes before + * incremental writeback. The fresh full graph re-emits every still-needed + * synthetic node; deleting first also removes placeholders that became stale + * when a real source class appeared. + */ +export const deleteSpringAutoConfigurationSyntheticClasses = async (): Promise<{ + nodesDeleted: number; +}> => { + const c = conn; + if (!c) { + throw new Error('LadybugDB not initialized. Call initLbug first.'); + } + return withConnLock(async () => { + let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined; + const idPrefix = escapeCypherString(SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX); + const predicate = `n.id STARTS WITH '${idPrefix}'`; + try { + countResult = await c.query(`MATCH (n:Class) WHERE ${predicate} RETURN count(n) AS cnt`); + const result = Array.isArray(countResult) ? countResult[0] : countResult; + const rows = await result.getAll(); + const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); + if (count > 0) { + await closeQueryResults( + await c.query(`MATCH (n:Class) WHERE ${predicate} DETACH DELETE n`), + ); + } + if (countResult) await closeQueryResults(countResult); + return { nodesDeleted: count }; + } catch (err) { + if (countResult) await closeQueryResults(countResult); + if (classifyDeleteAllError(err) === 'benign-missing-table') { + return { nodesDeleted: 0 }; + } + const message = err instanceof Error ? err.message : String(err); + throw new Error( + '[spring-auto-configuration] failed to clear synthetic Class nodes before ' + + `incremental re-write (${message}) — aborting to avoid stale placeholders; ` + + 'the next run will full-rebuild', + ); + } + }); +}; + // ============================================================================ // Full-Text Search (FTS) Functions // ============================================================================ diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 4a51e39d8..027fb01ba 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -32,6 +32,8 @@ import { deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, + deleteSpringAutoConfigurationDeclarations, + deleteSpringAutoConfigurationSyntheticClasses, queryImportersBatch, loadFTSExtension, wipeLbugDbFiles, @@ -136,7 +138,10 @@ import { sanitizeDetectedBranch } from '../cli/analyze-config.js'; import { EMBEDDING_TABLE_NAME } from './lbug/schema.js'; import { STALE_HASH_SENTINEL } from './lbug/schema.js'; import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js'; -import { SPRING_BEAN_INVENTORY_FEATURE } from './ingestion/frameworks/spring/analysis-features.js'; +import { + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, +} from './ingestion/frameworks/spring/analysis-features.js'; import { SPRING_CONFIG_BINDINGS_FEATURE } from './ingestion/languages/java/analysis-features.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, @@ -152,6 +157,7 @@ import { const ANALYSIS_FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, ] as const; @@ -2010,7 +2016,16 @@ async function runFullAnalysisInner( // deleting on every non-pdg incremental run (N runs = N copies of // every INJECTS row; CodeRelation has no PK and no read-side dedup). await deleteAllInjects(); - // 2b. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on + // 2b. Drop Spring-owned DECLARES edges (#2415). The + // auto-configuration phase scans every metadata file and recomputes + // the full set each run; exact reason filtering leaves declarations + // owned by other metadata systems untouched. + await deleteSpringAutoConfigurationDeclarations(); + // 2c. Drop source-unavailable auto-configuration placeholders. Fresh + // synthetic nodes are graph-wide in extractChangedSubgraph, so this + // also removes an orphan when a newly-added real class takes over. + await deleteSpringAutoConfigurationSyntheticClasses(); + // 2d. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on // — their validity is a whole-program property (an A→C flow can be // invalidated by a change to an intermediate function on a third // file), so endpoint-writability extraction can't refresh them. @@ -2018,7 +2033,7 @@ async function runFullAnalysisInner( // graph (isGraphWideRelType), mirroring Community/Process. if (options.pdg === true) { await deleteAllInterprocTaintPaths(); - // 2c. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg` + // 2e. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg` // writeback. They are re-included from the FULL fresh graph // (isGraphWideRelType) and the callSummaries phase recomputes every // summary each run, so delete-all-then-rebuild keeps an unchanged diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index ad9caeb47..2f95c3ff1 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -303,6 +303,12 @@ export const VALID_RELATION_TYPES = new Set([ // (WRAPS/FETCHES precedent): the 0.5 unknown-type floor applies there, // and the edges carry their own confidence (0.8) in the graph. 'INJECTS', + // Conditional and metadata-declaration evidence is opt-in for impact + // traversal, like INJECTS: explicit filters can follow activation + // constraints and declarations without changing the default callgraph + // surface. + 'CONDITIONAL_ON', + 'DECLARES', ]); /** diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index d2be2e1a0..5f8584fd9 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -55,6 +55,8 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. +// v28: Java/Kotlin capture side-channels persist Spring condition facts and +// annotation-source line numbers (#2415). // v26: the enclosing-callable walk stops at class bodies and anonymous-class // construction sites (#2699 follow-up); a v25 cache replays worker results carrying the // wrong Java anonymous-class ids. Cached results are replayed verbatim — including @@ -101,7 +103,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // JLS 13.1 immediate-host chains (#2555). // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. // v16: direct callee identity. -const SCHEMA_BUMP = 27; +const SCHEMA_BUMP = 28; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/graph-emit-streaming-roundtrip.test.ts b/gitnexus/test/integration/graph-emit-streaming-roundtrip.test.ts index df8dbf1df..98c012592 100644 --- a/gitnexus/test/integration/graph-emit-streaming-roundtrip.test.ts +++ b/gitnexus/test/integration/graph-emit-streaming-roundtrip.test.ts @@ -85,6 +85,8 @@ const buildFixture = ( } relationships.push(edge('EXTENDS', `Class:${FILE_PATH}:Cls2`, `Class:${FILE_PATH}:Cls1`)); // retained relationships.push(edge('IMPORTS', `File:${FILE_PATH}`, `Class:${FILE_PATH}:Cls1`)); // streamed + relationships.push(edge('DECLARES', `File:${FILE_PATH}`, `Class:${FILE_PATH}:Cls1`)); // streamed + relationships.push(edge('CONDITIONAL_ON', `Class:${FILE_PATH}:Cls2`, `Class:${FILE_PATH}:Cls1`)); // streamed // Self-edge and an exact duplicate id — both must appear exactly once. relationships.push(edge('CALLS', `Function:${FILE_PATH}:fn0`, `Function:${FILE_PATH}:fn0`)); relationships.push(edge('CALLS', `Function:${FILE_PATH}:fn0`, `Function:${FILE_PATH}:fn1`)); diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index eb420e046..2de9538d5 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -14,6 +14,10 @@ import path from 'path'; import type { GraphRelationship } from 'gitnexus-shared'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { skipUnlessFtsAvailable } from '../helpers/fts-availability.js'; +import { + SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION, + SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX, +} from '../../src/core/ingestion/frameworks/spring/auto-configuration.js'; /** * LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()` @@ -170,6 +174,66 @@ withTestLbugDB( expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1); }); + it('deleteSpringAutoConfigurationDeclarations: removes only Spring DECLARES edges (#2415)', async () => { + const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationDeclarations } = + await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(deleteSpringAutoConfigurationDeclarations()).resolves.toEqual({ + edgesDeleted: 0, + }); + const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as Array<{ + id: string; + }>; + expect(fns.length).toBe(2); + await coreExecuteQuery( + `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` + + `CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'spring-auto-configuration-import', step: 0}]->(b)`, + ); + await coreExecuteQuery( + `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` + + `CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'spring-auto-configuration-factory', step: 0}]->(b)`, + ); + await coreExecuteQuery( + `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` + + `CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'other-metadata-system', step: 0}]->(b)`, + ); + + await expect(deleteSpringAutoConfigurationDeclarations()).resolves.toEqual({ + edgesDeleted: 2, + }); + const left = await coreExecuteQuery( + `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' RETURN count(r) AS cnt`, + ); + expect(Number((left[0] as { cnt: number }).cnt)).toBe(1); + }); + + it('deleteSpringAutoConfigurationSyntheticClasses: removes only metadata placeholders (#2415)', async () => { + const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationSyntheticClasses } = + await import('../../src/core/lbug/lbug-adapter.js'); + + await coreExecuteQuery( + `CREATE (:Class {id: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}com.example.ExternalAutoConfiguration', ` + + `name: 'ExternalAutoConfiguration', ` + + `filePath: 'META-INF/spring.factories', ` + + `description: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION}'})`, + ); + await coreExecuteQuery( + `CREATE (:Class {id: 'Class:src/Real.java:Real', name: 'Real', ` + + `filePath: 'src/Real.java', ` + + `description: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION}'})`, + ); + await expect(deleteSpringAutoConfigurationSyntheticClasses()).resolves.toEqual({ + nodesDeleted: 1, + }); + const syntheticLeft = await coreExecuteQuery( + `MATCH (n:Class) WHERE n.id STARTS WITH '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}' ` + + 'RETURN count(n) AS cnt', + ); + expect(Number((syntheticLeft[0] as { cnt: number }).cnt)).toBe(0); + const realClasses = await coreExecuteQuery('MATCH (n:Class) RETURN count(n) AS cnt'); + expect(Number((realClasses[0] as { cnt: number }).cnt)).toBe(2); + }); + describe('unhappy path', () => { it('throws on malformed Cypher query', async () => { const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js'); diff --git a/gitnexus/test/integration/spring-conditionals-pipeline.test.ts b/gitnexus/test/integration/spring-conditionals-pipeline.test.ts new file mode 100644 index 000000000..146d46399 --- /dev/null +++ b/gitnexus/test/integration/spring-conditionals-pipeline.test.ts @@ -0,0 +1,322 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; + +function writeFixture(root: string, relativePath: string, content: string): void { + const target = path.join(root, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +describe('Spring profiles, conditionals, and auto-configuration pipeline (#2415)', () => { + let dir: string; + let result: PipelineResult; + let nodes: GraphNode[]; + let conditions: GraphRelationship[]; + let declarations: GraphRelationship[]; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-spring-conditionals-')); + writeFixture( + dir, + 'src/main/resources/application.properties', + 'feature.payments.enabled=true\nfeature.search.enabled=true\n', + ); + writeFixture( + dir, + 'src/main/java/com/example/SpringConditions.java', + `package com.example; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Profile({"prod\u202e", "staging\u200b"}) +class ProfiledJavaConfig {} + +@Configuration +class JavaBeanConfig { + @Bean + @ConditionalOnProperty(prefix = "feature.payments", name = {"enabled"}, havingValue = "true") + Object paymentService() { return new Object(); } + + @Bean + @ConditionalOnBooleanProperty(prefix = "feature.search", name = "enabled") + Object booleanSearchService() { return new Object(); } + + @Bean + @ConditionalOnProperty(prefix = """ + feature.payments + """, name = """ + enabled + """) + Object textBlockPaymentService() { return new Object(); } +} + +@AutoConfiguration +@ConditionalOnClass(name = "com.acme.Driver") +class JavaAutoConfig {} + +@Configuration +class OrdinaryApplicationConfig {} +`, + ); + writeFixture( + dir, + 'src/main/kotlin/com/example/KotlinConditions.kt', + `package com.example + +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Profile("dev") +class ProfiledKotlinConfig + +@Configuration +class KotlinBeanConfig { + @Bean + @ConditionalOnProperty(prefix = """feature.search""", name = ["""enabled"""]) + fun searchService(): Any = Any() +} + +@AutoConfiguration +@ConditionalOnMissingBean(name = ["client"]) +class KotlinAutoConfig +`, + ); + writeFixture( + dir, + 'src/main/java/com/local/StarterAutoConfiguration.java', + `package com.local; +class StarterAutoConfiguration {} +`, + ); + for (const moduleName of ['module-a', 'module-b']) { + writeFixture( + dir, + `${moduleName}/src/main/java/com/duplicate/DuplicateAutoConfiguration.java`, + `package com.duplicate; +class DuplicateAutoConfiguration {} +`, + ); + } + writeFixture( + dir, + 'src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports', + `com.example.JavaAutoConfig +com.vendor.StarterAutoConfiguration +com.vendor.SharedAutoConfiguration +com.duplicate.DuplicateAutoConfiguration +`, + ); + writeFixture( + dir, + 'src/main/resources/META-INF/spring.factories', + `org.springframework.boot.autoconfigure.EnableAutoConfiguration=\\ +com.example.KotlinAutoConfig,\\ +com.vendor.LegacyStarterAutoConfiguration,\\ +com.vendor.SharedAutoConfiguration,\\ +com.duplicate.DuplicateAutoConfiguration +`, + ); + + result = await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }); + nodes = [...result.graph.iterNodes()]; + conditions = [...result.graph.iterRelationshipsByType('CONDITIONAL_ON')]; + declarations = [...result.graph.iterRelationshipsByType('DECLARES')]; + }, 60_000); + + afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const nodeNamed = (name: string): GraphNode | undefined => + nodes.find((node) => node.properties.name === name); + const nodesQualified = (qualifiedName: string): GraphNode[] => + nodes.filter((node) => node.properties.qualifiedName === qualifiedName); + + const outgoingConditions = ( + name: string, + ): Array<{ + target: string; + reason: string; + }> => { + const source = nodeNamed(name); + if (source === undefined) return []; + return conditions + .filter((edge) => edge.sourceId === source.id) + .map((edge) => ({ + target: String(result.graph.getNode(edge.targetId)?.properties.name), + reason: edge.reason, + })); + }; + + it('captures Java and Kotlin profile gates as explicit unknown-activation evidence', () => { + expect(outgoingConditions('ProfiledJavaConfig')).toEqual([ + expect.objectContaining({ + target: '@Profile', + reason: expect.stringContaining('activation=unknown'), + }), + ]); + expect(outgoingConditions('ProfiledKotlinConfig')).toEqual([ + expect.objectContaining({ + target: '@Profile', + reason: expect.stringContaining('activation=unknown'), + }), + ]); + + for (const [className, annotationLine] of [ + ['ProfiledJavaConfig', 11], + ['ProfiledKotlinConfig', 10], + ] as const) { + const owner = nodeNamed(className); + const edge = conditions.find((candidate) => candidate.sourceId === owner?.id); + const condition = edge === undefined ? undefined : result.graph.getNode(edge.targetId); + expect(condition?.properties.startLine).toBe(annotationLine); + } + }); + + it('strips Trojan-Source controls from condition descriptions before MCP exposure', () => { + const conditionDescriptions = nodes + .filter((node) => node.label === 'Annotation') + .map((node) => String(node.properties.description)); + expect(conditionDescriptions.length).toBeGreaterThan(0); + expect( + conditionDescriptions.every( + (description) => !/[\u200b-\u200d\u202a-\u202e\u2066-\u2069\ufeff]/u.test(description), + ), + ).toBe(true); + expect( + conditions.every( + (edge) => !/[\u200b-\u200d\u202a-\u202e\u2066-\u2069\ufeff]/u.test(edge.reason), + ), + ).toBe(true); + }); + + it('connects Java/Kotlin property conditions, including raw/text-block strings, to config keys', () => { + expect(outgoingConditions('paymentService')).toEqual([ + expect.objectContaining({ + target: 'feature.payments.enabled', + reason: expect.stringContaining('@ConditionalOnProperty'), + }), + ]); + expect(outgoingConditions('searchService')).toEqual([ + expect.objectContaining({ + target: 'feature.search.enabled', + reason: expect.stringContaining('@ConditionalOnProperty'), + }), + ]); + expect(outgoingConditions('booleanSearchService')).toEqual([ + expect.objectContaining({ + target: 'feature.search.enabled', + reason: expect.stringContaining('@ConditionalOnBooleanProperty'), + }), + ]); + expect(outgoingConditions('textBlockPaymentService')).toEqual([ + expect.objectContaining({ + target: 'feature.payments.enabled', + reason: expect.stringContaining('@ConditionalOnProperty'), + }), + ]); + }); + + it('preserves non-property conditional variants for both languages', () => { + expect(outgoingConditions('JavaAutoConfig')).toEqual([ + expect.objectContaining({ target: '@ConditionalOnClass' }), + ]); + expect(outgoingConditions('KotlinAutoConfig')).toEqual([ + expect.objectContaining({ target: '@ConditionalOnMissingBean' }), + ]); + }); + + it('uses metadata DECLARES evidence without claiming annotation-based registration', () => { + const targetNames = declarations + .map((edge) => String(result.graph.getNode(edge.targetId)?.properties.name)) + .sort(); + expect(targetNames).toEqual([ + 'JavaAutoConfig', + 'KotlinAutoConfig', + 'LegacyStarterAutoConfiguration', + 'SharedAutoConfiguration', + 'SharedAutoConfiguration', + 'StarterAutoConfiguration', + ]); + expect( + declarations.some( + (edge) => + result.graph.getNode(edge.targetId)?.properties.name === 'OrdinaryApplicationConfig', + ), + ).toBe(false); + expect( + declarations.every((edge) => + String(result.graph.getNode(edge.sourceId)?.properties.filePath).includes('META-INF'), + ), + ).toBe(true); + expect( + nodesQualified('com.vendor.StarterAutoConfiguration')[0]?.properties.description, + ).toContain('implementation source unavailable'); + }); + + it('never falls back from metadata FQN to an unrelated simple-name match', () => { + const declaration = declarations.find( + (edge) => + result.graph.getNode(edge.targetId)?.properties.qualifiedName === + 'com.vendor.StarterAutoConfiguration', + ); + expect(declaration).toBeDefined(); + expect(result.graph.getNode(declaration!.targetId)?.properties.filePath).toContain('META-INF'); + expect( + declarations.some( + (edge) => + result.graph.getNode(edge.targetId)?.properties.qualifiedName === + 'com.local.StarterAutoConfiguration', + ), + ).toBe(false); + }); + + it('deduplicates missing source classes by normalized FQN across metadata files', () => { + const shared = nodesQualified('com.vendor.SharedAutoConfiguration'); + expect(shared).toHaveLength(1); + expect(declarations.filter((edge) => edge.targetId === shared[0]?.id)).toHaveLength(2); + expect( + declarations + .filter((edge) => edge.targetId === shared[0]?.id) + .map((edge) => edge.reason) + .sort(), + ).toEqual(['spring-auto-configuration-factory', 'spring-auto-configuration-import']); + }); + + it('fails closed when duplicate real classes share one FQN', () => { + const duplicates = nodesQualified('com.duplicate.DuplicateAutoConfiguration'); + expect(duplicates).toHaveLength(2); + expect( + declarations.some((edge) => duplicates.some((duplicate) => duplicate.id === edge.targetId)), + ).toBe(false); + expect( + duplicates.some((node) => + String(node.properties.description).includes('implementation source unavailable'), + ), + ).toBe(false); + }); + + it('recognizes AutoConfiguration as a Spring Bean candidate in Java and Kotlin', () => { + expect(nodeNamed('JavaAutoConfig')?.properties.frameworkAnnotations).toEqual([ + 'org.springframework.boot.autoconfigure.AutoConfiguration', + ]); + expect(nodeNamed('KotlinAutoConfig')?.properties.frameworkAnnotations).toEqual([ + 'org.springframework.boot.autoconfigure.AutoConfiguration', + ]); + }); +}); diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 146965798..da1f136f6 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -5,12 +5,16 @@ import { resolveAnalysisFeatureVersions, type AnalysisFeatureDescriptor, } from '../../src/core/analysis-features.js'; -import { SPRING_BEAN_INVENTORY_FEATURE } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; +import { + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, +} from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; const FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, ] as const; @@ -22,11 +26,13 @@ describe('analysis feature versions', () => { expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, 'spring.bean-inventory': 1, + 'spring.conditionals-auto-configuration': 1, 'spring.config-bindings': 1, }); expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ 'graph.class-framework-annotations': 1, 'spring.bean-inventory': 1, + 'spring.conditionals-auto-configuration': 1, }); expect( resolveAnalysisFeatureVersions(FEATURES, [ @@ -37,6 +43,14 @@ describe('analysis feature versions', () => { 'graph.class-framework-annotations': 1, 'spring.config-bindings': 1, }); + expect( + resolveAnalysisFeatureVersions(FEATURES, [ + 'src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports', + ]), + ).toEqual({ + 'graph.class-framework-annotations': 1, + 'spring.conditionals-auto-configuration': 1, + }); }); it('requires an exact, well-formed feature set', () => { diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index fda37c3b5..489e3aeff 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -43,7 +43,11 @@ import { stampEmbeddingCount, } from '../helpers/embedding-seed.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; -import { SPRING_BEAN_INVENTORY_FEATURE } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; +import { + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, +} from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; +import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js'; import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-'); @@ -165,6 +169,38 @@ async function countInjects(repoPath: string): Promise { } } +async function countSpringAutoConfigurationDeclarations(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 ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' ` + + `AND (r.reason = 'spring-auto-configuration-import' ` + + `OR r.reason = 'spring-auto-configuration-factory') RETURN count(r) AS c`, + )) as Array<{ c: number | bigint }>; + return Number(rows[0]?.c ?? 0); + } finally { + await adapter.closeLbug(); + } +} + +async function countSpringAutoConfigurationSyntheticClasses(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 (n:Class) WHERE n.id STARTS WITH ` + + `'${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}' ` + + `RETURN count(n) AS c`, + )) as Array<{ c: number | bigint }>; + return Number(rows[0]?.c ?? 0); + } finally { + await adapter.closeLbug(); + } +} + /** Java DI fixture (#2200): `@Autowired List` + 2 implementers ⇒ exactly * 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the * spring-di-pipeline integration fixture. */ @@ -275,6 +311,7 @@ describe('runFullAnalysis — incremental orchestration', () => { expect(meta!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, + [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_BEAN_INVENTORY_FEATURE.id)); @@ -291,6 +328,7 @@ describe('runFullAnalysis — incremental orchestration', () => { expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, + [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); } finally { await repo.cleanup(); @@ -358,6 +396,7 @@ describe('runFullAnalysis — incremental orchestration', () => { expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, + [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, }); } finally { await repo.cleanup(); @@ -916,6 +955,49 @@ describe('runFullAnalysis — incremental orchestration', () => { await repo.cleanup(); } }, 600_000); + + it('incremental runs do not duplicate repository-wide Spring DECLARES edges (#2415)', async () => { + const repo = await setupMiniRepo(); + try { + const sourceDir = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example'); + const metadataDir = path.join(repo.dbPath, 'src', 'main', 'resources', 'META-INF', 'spring'); + await mkdir(sourceDir, { recursive: true }); + await mkdir(metadataDir, { recursive: true }); + await writeFile( + path.join(metadataDir, 'org.springframework.boot.autoconfigure.AutoConfiguration.imports'), + 'com.example.ExampleAutoConfiguration\n', + 'utf-8', + ); + gitCommitAll(repo.dbPath, 'add auto configuration metadata'); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1); + expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(1); + + const target = path.join(repo.dbPath, 'src', 'logger.ts'); + for (const run of [1, 2]) { + const before = await readFile(target, 'utf-8'); + await writeFile(target, `${before}\n// auto-register idempotency touch ${run}\n`, 'utf-8'); + gitCommitAll(repo.dbPath, `unrelated auto-register touch ${run}`); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1); + expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(1); + } + + await writeFile( + path.join(sourceDir, 'ExampleAutoConfiguration.java'), + 'package com.example;\npublic class ExampleAutoConfiguration {}\n', + 'utf-8', + ); + gitCommitAll(repo.dbPath, 'add source for metadata-only auto configuration'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1); + expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(0); + } finally { + await repo.cleanup(); + } + }, 600_000); }); /** diff --git a/gitnexus/test/unit/incremental-subgraph-extract.test.ts b/gitnexus/test/unit/incremental-subgraph-extract.test.ts index 667ef3399..b46e86ba4 100644 --- a/gitnexus/test/unit/incremental-subgraph-extract.test.ts +++ b/gitnexus/test/unit/incremental-subgraph-extract.test.ts @@ -17,6 +17,10 @@ import { extractChangedSubgraph, computeEffectiveWriteSet, } from '../../src/core/incremental/subgraph-extract.js'; +import { + SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION, + SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX, +} from '../../src/core/ingestion/frameworks/spring/auto-configuration.js'; const makeFileNode = (id: string, filePath: string, label = 'Function'): GraphNode => ({ @@ -37,12 +41,14 @@ const makeRel = ( sourceId: string, targetId: string, type = 'CALLS', + reason = 'test', ): GraphRelationship => ({ id, sourceId, targetId, type, + reason, properties: {}, }) as unknown as GraphRelationship; @@ -68,6 +74,25 @@ describe('extractChangedSubgraph', () => { expect(sub.nodes.map((n) => n.id).sort()).toEqual(['comm-1', 'proc-1']); }); + it('always includes Spring auto-configuration synthetic Class nodes', () => { + const g = createKnowledgeGraph(); + g.addNode({ + id: `${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}com.example.ExternalAutoConfiguration`, + label: 'Class', + properties: { + name: 'ExternalAutoConfiguration', + filePath: '/repo/META-INF/spring.factories', + description: SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION, + }, + }); + + const sub = extractChangedSubgraph(g, new Set(['/repo/unrelated.ts'])); + + expect(sub.nodes.map((node) => node.id)).toEqual([ + `${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}com.example.ExternalAutoConfiguration`, + ]); + }); + it('includes a relationship when at least one endpoint is writable', () => { const g = createKnowledgeGraph(); g.addNode(makeFileNode('a:fn', '/repo/a.ts')); @@ -130,6 +155,39 @@ describe('extractChangedSubgraph', () => { expect(sub.relationships.map((r) => r.id)).toEqual(['inj1']); }); + + it('always includes Spring DECLARES edges between unchanged metadata and classes (#2415)', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('metadata:File', '/repo/META-INF/spring.factories', 'File')); + g.addNode(makeFileNode('config:Class', '/repo/AutoConfig.java', 'Class')); + g.addRelationship( + makeRel( + 'declares1', + 'metadata:File', + 'config:Class', + 'DECLARES', + 'spring-auto-configuration-factory', + ), + ); + g.addRelationship(makeRel('call1', 'metadata:File', 'config:Class', 'CALLS')); + + const sub = extractChangedSubgraph(g, new Set(['/repo/unrelated.ts'])); + + expect(sub.relationships.map((relationship) => relationship.id)).toEqual(['declares1']); + }); + + it('does not make another metadata system graph-wide just because it uses DECLARES', () => { + const g = createKnowledgeGraph(); + g.addNode(makeFileNode('metadata:File', '/repo/META-INF/example.metadata', 'File')); + g.addNode(makeFileNode('target:Class', '/repo/Target.java', 'Class')); + g.addRelationship( + makeRel('declares1', 'metadata:File', 'target:Class', 'DECLARES', 'example-discovery'), + ); + + const sub = extractChangedSubgraph(g, new Set(['/repo/unrelated.ts'])); + + expect(sub.relationships).toEqual([]); + }); }); describe('computeEffectiveWriteSet (Finding 1)', () => { diff --git a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts index 7aeb8b431..9e204631f 100644 --- a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts +++ b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts @@ -75,6 +75,7 @@ const FULL_ORDER = [ 'orm', 'crossFile', 'scopeResolution', + 'springAutoConfiguration', 'pruneLocalSymbols', 'mro', 'di', diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 9d7206ace..4a29417c4 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -111,6 +111,12 @@ describe('LadybugDB Schema', () => { it('includes the DI collection-injection edge type (#2200)', () => { expect(REL_TYPES).toContain('INJECTS'); }); + + it('includes Spring condition and auto-configuration edge types (#2415)', () => { + expect(REL_TYPES).toContain('CONDITIONAL_ON'); + expect(REL_TYPES).toContain('DECLARES'); + expect(REL_TYPES).not.toContain('AUTO_REGISTERS'); + }); }); describe('node schema DDL', () => { diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 058139b7b..581f1b18b 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -39,6 +39,9 @@ describe('VALID_RELATION_TYPES', () => { 'WRAPS', // Spring DI @Autowired collection injection (#2200) 'INJECTS', + // Conditional activation and metadata declaration/discovery (#2415) + 'CONDITIONAL_ON', + 'DECLARES', ] as const; it('contains all expected relation types', () => { diff --git a/gitnexus/test/unit/spring-auto-configuration.test.ts b/gitnexus/test/unit/spring-auto-configuration.test.ts new file mode 100644 index 000000000..03042acaa --- /dev/null +++ b/gitnexus/test/unit/spring-auto-configuration.test.ts @@ -0,0 +1,144 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { GraphNode } from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { + classifySpringAutoConfigurationMetadata, + parseSpringAutoConfigurationImports, + parseSpringFactoriesAutoConfigurations, + springAutoConfigurationPhase, +} from '../../src/core/ingestion/pipeline-phases/spring-auto-configuration.js'; +import type { + PipelineContext, + PhaseResult, +} from '../../src/core/ingestion/pipeline-phases/types.js'; +import type { StructureOutput } from '../../src/core/ingestion/pipeline-phases/structure.js'; +import { generateId } from '../../src/lib/utils.js'; + +describe('Spring Boot auto-configuration metadata parsing', () => { + it('classifies modern imports and legacy spring.factories paths', () => { + expect( + classifySpringAutoConfigurationMetadata( + 'src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports', + ), + ).toMatchObject({ kind: 'imports' }); + expect( + classifySpringAutoConfigurationMetadata('src/main/resources/META-INF/spring.factories'), + ).toMatchObject({ kind: 'spring-factories' }); + expect( + classifySpringAutoConfigurationMetadata( + 'SRC\\MAIN\\RESOURCES\\meta-inf\\SPRING\\ORG.SPRINGFRAMEWORK.BOOT.AUTOCONFIGURE.AUTOCONFIGURATION.IMPORTS', + ), + ).toMatchObject({ kind: 'imports' }); + expect( + classifySpringAutoConfigurationMetadata( + 'not-meta-inf/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports', + ), + ).toBeNull(); + expect(classifySpringAutoConfigurationMetadata('application.properties')).toBeNull(); + }); + + it('parses, validates, and de-duplicates AutoConfiguration.imports entries', () => { + expect( + parseSpringAutoConfigurationImports(` +# comment +com.example.FirstAutoConfiguration +com.example.SecondAutoConfiguration # trailing comment +not a class +com.example.FirstAutoConfiguration +`), + ).toEqual([ + { className: 'com.example.FirstAutoConfiguration', line: 3 }, + { className: 'com.example.SecondAutoConfiguration', line: 4 }, + ]); + }); + + it('parses only EnableAutoConfiguration with properties continuations', () => { + expect( + parseSpringFactoriesAutoConfigurations(` +org.example.OtherFactory=com.example.Ignored +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\\ + com.example.LegacyOne,\\ + com.example.LegacyTwo +`), + ).toEqual([ + { className: 'com.example.LegacyOne', line: 3 }, + { className: 'com.example.LegacyTwo', line: 3 }, + ]); + }); + + it('reports duplicate-FQN ambiguity and fails closed without a synthetic third class', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spring-auto-config-ambiguity-')); + const relativePath = + 'META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports'; + const metadataPath = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(metadataPath), { recursive: true }); + const content = 'com.example.DuplicateAutoConfiguration\n'; + fs.writeFileSync(metadataPath, content); + + try { + const graph = createKnowledgeGraph(); + graph.addNode({ + id: generateId('File', relativePath), + label: 'File', + properties: { name: path.basename(relativePath), filePath: relativePath }, + }); + for (const moduleName of ['module-a', 'module-b']) { + const node: GraphNode = { + id: `Class:${moduleName}:DuplicateAutoConfiguration`, + label: 'Class', + properties: { + name: 'DuplicateAutoConfiguration', + qualifiedName: 'com.example.DuplicateAutoConfiguration', + filePath: `${moduleName}/DuplicateAutoConfiguration.java`, + }, + }; + graph.addNode(node); + } + + const structure: StructureOutput = { + scannedFiles: [{ path: relativePath, size: Buffer.byteLength(content) }], + allPaths: [relativePath], + allPathSet: new Set([relativePath]), + totalFiles: 1, + }; + const deps = new Map>([ + [ + 'structure', + { + phaseName: 'structure', + output: structure, + durationMs: 0, + }, + ], + ]); + const output = await springAutoConfigurationPhase.execute( + { + repoPath: dir, + graph, + onProgress: () => {}, + pipelineStart: performance.now(), + } as PipelineContext, + deps, + ); + + expect(output).toEqual({ + metadataFiles: 1, + autoConfigurations: 0, + ambiguousAutoConfigurations: 1, + }); + expect([...graph.iterRelationshipsByType('DECLARES')]).toEqual([]); + expect( + [...graph.iterNodes()].filter( + (node) => + node.label === 'Class' && + node.properties.qualifiedName === 'com.example.DuplicateAutoConfiguration', + ), + ).toHaveLength(2); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/spring-bean-extractor.test.ts b/gitnexus/test/unit/spring-bean-extractor.test.ts index cbc5f5c4a..2f2cc1dbc 100644 --- a/gitnexus/test/unit/spring-bean-extractor.test.ts +++ b/gitnexus/test/unit/spring-bean-extractor.test.ts @@ -68,7 +68,9 @@ describe('Java Spring injection syntax capture', () => { `); expect(facts).toHaveLength(1); - expect(facts[0].classAnnotations).toEqual([{ name: 'Service', text: '@Service("checkout")' }]); + expect(facts[0].classAnnotations).toEqual([ + { name: 'Service', text: '@Service("checkout")', line: 2 }, + ]); expect(facts[0].injectionSites).toMatchObject([ { kind: 'constructor', @@ -167,8 +169,8 @@ describe('Kotlin Spring injection syntax capture', () => { expect(facts).toHaveLength(1); expect(facts[0].classAnnotations).toEqual([ - { name: 'Service', text: '@Service("checkout")' }, - { name: 'Primary', text: '@Primary' }, + { name: 'Service', text: '@Service("checkout")', line: 2 }, + { name: 'Primary', text: '@Primary', line: 2 }, ]); expect(facts[0].injectionSites).toMatchObject([ { diff --git a/gitnexus/test/unit/spring-bean-schema.test.ts b/gitnexus/test/unit/spring-bean-schema.test.ts index 135ba71c1..b9f4dde83 100644 --- a/gitnexus/test/unit/spring-bean-schema.test.ts +++ b/gitnexus/test/unit/spring-bean-schema.test.ts @@ -4,7 +4,10 @@ import { getCopyQuery } from '../../src/core/lbug/lbug-adapter.js'; import { PARSE_CACHE_VERSION } from '../../src/storage/parse-cache.js'; import { INCREMENTAL_SCHEMA_VERSION } from '../../src/storage/repo-manager.js'; import { isSpringBeanCandidateSourceFile } from '../../src/core/ingestion/frameworks/spring/bean-catalog.js'; -import { SPRING_BEAN_INVENTORY_FEATURE } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; +import { + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, +} from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; describe('Spring Bean Class persistence schema', () => { @@ -18,10 +21,11 @@ describe('Spring Bean Class persistence schema', () => { it('meets the cache-version baselines required by the merged implementation', () => { const parseSchemaVersion = Number.parseInt(PARSE_CACHE_VERSION, 10); - expect(parseSchemaVersion).toBeGreaterThanOrEqual(20); + expect(parseSchemaVersion).toBeGreaterThanOrEqual(22); expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(8); expect(CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version).toBe(1); expect(SPRING_BEAN_INVENTORY_FEATURE.version).toBe(1); + expect(SPRING_CONDITIONALS_FEATURE.version).toBe(1); }); it('limits incremental drift queries to Java and Kotlin Bean source files', () => { diff --git a/gitnexus/test/unit/stream-graph-emit-config.test.ts b/gitnexus/test/unit/stream-graph-emit-config.test.ts index 16beb3339..53c41d67d 100644 --- a/gitnexus/test/unit/stream-graph-emit-config.test.ts +++ b/gitnexus/test/unit/stream-graph-emit-config.test.ts @@ -137,6 +137,11 @@ describe('buildPhaseList under streamGraphEmit', () => { }); describe('RETAINED_REL_TYPES tracks its readers', () => { + it('streams write-only conditional and declaration evidence', () => { + expect(RETAINED_REL_TYPES.has('CONDITIONAL_ON')).toBe(false); + expect(RETAINED_REL_TYPES.has('DECLARES')).toBe(false); + }); + it('retains every relationship type any phase reads back mid-pipeline', async () => { // The round-trip test CANNOT catch drift here: addRelationship partitions // edges between the graph and the CSVs, and a partition's union is