feat(spring): model profiles, conditions, and auto-configuration (#2678)

* feat(spring): model conditions and auto-configuration

* fix(spring): align auto-configuration declarations

* perf(spring): streamline auto-configuration indexing

* test(spring): move timing benchmark out of vitest

---------

Co-authored-by: Shining <xuenning@qiyi.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
MyShining 2026-07-28 14:05:41 +08:00 committed by GitHub
parent e307286d52
commit ff86ccf1e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2135 additions and 47 deletions

View file

@ -140,6 +140,19 @@ export type RelationshipType =
* Lets Cypher queries trace which beans the container injects into a given * Lets Cypher queries trace which beans the container injects into a given
* consumer, complementing the structural `IMPLEMENTS` heritage edges. */ * consumer, complementing the structural `IMPLEMENTS` heritage edges. */
| 'INJECTS' | '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 /** Vue component event system: a handler function in a parent component is
* bound to an event emitted by a child component (`@event="handlerFn"`). * bound to an event emitted by a child component (`@event="handlerFn"`).
* Source = handler Function/Method node in the parent. * Source = handler Function/Method node in the parent.

View file

@ -70,6 +70,8 @@ export const REL_TYPES = [
'WRAPS', 'WRAPS',
'QUERIES', 'QUERIES',
'INJECTS', 'INJECTS',
'CONDITIONAL_ON',
'DECLARES',
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no // Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH → // phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column. // M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.

View file

@ -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();

View file

@ -6,8 +6,8 @@
* replaced, produce a smaller KnowledgeGraph that contains: * replaced, produce a smaller KnowledgeGraph that contains:
* *
* - Every node whose `properties.filePath` is in `toWriteSet`. * - Every node whose `properties.filePath` is in `toWriteSet`.
* - Every graph-wide node (Community, Process) these are regenerated * - Every graph-wide node (Community, Process, and Spring metadata
* each run by the communities/processes phases and must be fully * placeholders) these are regenerated each run and must be fully
* rewritten. * rewritten.
* - Every relationship where AT LEAST ONE endpoint is in the writable * - Every relationship where AT LEAST ONE endpoint is in the writable
* set above. Relationships entirely between unchanged-file nodes * set above. Relationships entirely between unchanged-file nodes
@ -51,8 +51,15 @@
import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { createKnowledgeGraph } from '../graph/graph.js'; import { createKnowledgeGraph } from '../graph/graph.js';
import type { KnowledgeGraph } from '../graph/types.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 * 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 // analyze, and the `incrementalInProgress` dirty flag (saved before any
// delete) forces a full rebuild on the next run. Temporary absence is // delete) forces a full rebuild on the next run. Temporary absence is
// possible; duplicates are not. // 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<nodeId, filePath> for every File-bound node in the graph. * Build a Map<nodeId, filePath> for every File-bound node in the graph.
@ -106,7 +122,7 @@ export const extractChangedSubgraph = (
fullGraph.forEachNode((n: GraphNode) => { fullGraph.forEachNode((n: GraphNode) => {
const filePath = n.properties?.filePath as string | undefined; 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) { if (include) {
sub.addNode(n); sub.addNode(n);
writableNodeIds.add(n.id); writableNodeIds.add(n.id);
@ -117,7 +133,7 @@ export const extractChangedSubgraph = (
if ( if (
writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.sourceId) ||
writableNodeIds.has(r.targetId) || writableNodeIds.has(r.targetId) ||
isGraphWideRelType(r.type) isGraphWideRelationship(r)
) { ) {
sub.addRelationship(r); sub.addRelationship(r);
} }

View file

@ -7,3 +7,23 @@ export const SPRING_BEAN_INVENTORY_FEATURE: AnalysisFeatureDescriptor = {
version: 1, version: 1,
appliesTo: (filePaths) => filePaths.some(isSpringBeanCandidateSourceFile), 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),
};

View file

@ -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<GraphRelationship, 'type' | 'reason'>,
): boolean {
return (
relationship.type === 'DECLARES' &&
(relationship.reason === SPRING_AUTO_CONFIGURATION_IMPORT_REASON ||
relationship.reason === SPRING_AUTO_CONFIGURATION_FACTORY_REASON)
);
}
export function isSpringAutoConfigurationSyntheticClass(
node: Pick<GraphNode, 'id' | 'label'>,
): boolean {
return (
node.label === 'Class' && node.id.startsWith(SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX)
);
}

View file

@ -15,6 +15,7 @@ export const SPRING_BEAN_STEREOTYPES = new Map<string, SpringBeanStereotype>([
['org.springframework.stereotype.Controller', { role: 'controller' }], ['org.springframework.stereotype.Controller', { role: 'controller' }],
['org.springframework.web.bind.annotation.RestController', { role: 'rest-controller' }], ['org.springframework.web.bind.annotation.RestController', { role: 'rest-controller' }],
['org.springframework.context.annotation.Configuration', { role: 'configuration' }], ['org.springframework.context.annotation.Configuration', { role: 'configuration' }],
['org.springframework.boot.autoconfigure.AutoConfiguration', { role: 'auto-configuration' }],
]); ]);
export function deriveSpringBeanMetadata( export function deriveSpringBeanMetadata(

View file

@ -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<Annotation>[];
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<string>([
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<string>([
...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<SpringConditionalAnnotationFact, 'name'>[],
): 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<string, string>;
}
function parseArguments(text: string): ParsedAnnotationArguments {
const positional: string[] = [];
const named = new Map<string, string>();
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<KnowledgeGraph, ReadonlyMap<string, readonly GraphNode[]>>();
function configNodesByKey(graph: KnowledgeGraph): ReadonlyMap<string, readonly GraphNode[]> {
const cached = configNodesByGraph.get(graph);
if (cached !== undefined) return cached;
const byKey = new Map<string, GraphNode[]>();
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<Annotation>) {
return (
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
indexes: ScopeResolutionIndexes,
): void => {
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
let propertyNodes: ReadonlyMap<string, readonly GraphNode[]> | undefined;
for (const parsed of parsedFiles) {
const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath);
const resolvedAnnotations = new Map<string, string | undefined>();
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 ?? '<root>'}\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);
}
}
}
};
}

View file

@ -77,6 +77,7 @@ const CAPTURE_RELEVANT_ANNOTATIONS = new Set([
'Controller', 'Controller',
'RestController', 'RestController',
'Configuration', 'Configuration',
'AutoConfiguration',
]); ]);
const STEREOTYPE_SIMPLE_NAMES = new Set( const STEREOTYPE_SIMPLE_NAMES = new Set(

View file

@ -10,6 +10,7 @@ import {
} from '../jvm/package-facts.js'; } from '../jvm/package-facts.js';
import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js'; import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js';
import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js';
import type { JavaSpringConditionalFact } from './spring-conditionals.js';
import type { JavaSpringDiClassFact } from './spring-di.js'; import type { JavaSpringDiClassFact } from './spring-di.js';
export type JavaClassAnnotationFact = ClassAnnotationFact; export type JavaClassAnnotationFact = ClassAnnotationFact;
@ -19,17 +20,20 @@ export interface JavaCaptureSideChannel {
readonly packageFact: JvmPackageFact; readonly packageFact: JvmPackageFact;
readonly classAnnotations: readonly JavaClassAnnotationFact[]; readonly classAnnotations: readonly JavaClassAnnotationFact[];
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
readonly springConditionalFacts?: readonly JavaSpringConditionalFact[];
readonly springDiFacts?: readonly JavaSpringDiClassFact[]; readonly springDiFacts?: readonly JavaSpringDiClassFact[];
} }
const classAnnotations = createClassAnnotationFactStore(); const classAnnotations = createClassAnnotationFactStore();
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>(); const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
const springConditionalFacts = new Map<string, readonly JavaSpringConditionalFact[]>();
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>(); const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
/** Clear facts retained by a prior workspace pass in a long-lived process. */ /** Clear facts retained by a prior workspace pass in a long-lived process. */
export function clearJavaClassAnnotationFacts(): void { export function clearJavaClassAnnotationFacts(): void {
classAnnotations.clear(); classAnnotations.clear();
springConfigConsumers.clear(); springConfigConsumers.clear();
springConditionalFacts.clear();
springDiFacts.clear(); springDiFacts.clear();
} }
@ -55,6 +59,20 @@ export function getJavaSpringConfigConsumerFacts(
return springConfigConsumers.get(filePath) ?? []; 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( export function setJavaSpringDiFacts(
filePath: string, filePath: string,
facts: readonly JavaSpringDiClassFact[], facts: readonly JavaSpringDiClassFact[],
@ -73,11 +91,13 @@ export function collectJavaCaptureSideChannel(
): JavaCaptureSideChannel | undefined { ): JavaCaptureSideChannel | undefined {
const facts = classAnnotations.get(filePath); const facts = classAnnotations.get(filePath);
const configConsumers = springConfigConsumers.get(filePath) ?? []; const configConsumers = springConfigConsumers.get(filePath) ?? [];
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
const diFacts = springDiFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? [];
const packageFact = getJavaPackageFact(filePath); const packageFact = getJavaPackageFact(filePath);
if ( if (
facts.length === 0 && facts.length === 0 &&
configConsumers.length === 0 && configConsumers.length === 0 &&
conditionFacts.length === 0 &&
diFacts.length === 0 && diFacts.length === 0 &&
packageFact === undefined packageFact === undefined
) { ) {
@ -88,6 +108,7 @@ export function collectJavaCaptureSideChannel(
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
classAnnotations: facts, classAnnotations: facts,
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
}; };
} }
@ -108,6 +129,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
) { ) {
setJavaClassAnnotationFacts(parsed.filePath, []); setJavaClassAnnotationFacts(parsed.filePath, []);
setJavaSpringConfigConsumerFacts(parsed.filePath, []); setJavaSpringConfigConsumerFacts(parsed.filePath, []);
setJavaSpringConditionalFacts(parsed.filePath, []);
setJavaSpringDiFacts(parsed.filePath, []); setJavaSpringDiFacts(parsed.filePath, []);
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
return; return;
@ -117,6 +139,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
parsed.filePath, parsed.filePath,
Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [], Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [],
); );
setJavaSpringConditionalFacts(
parsed.filePath,
Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : [],
);
setJavaSpringDiFacts( setJavaSpringDiFacts(
parsed.filePath, parsed.filePath,
Array.isArray(data.springDiFacts) ? data.springDiFacts : [], Array.isArray(data.springDiFacts) ? data.springDiFacts : [],

View file

@ -36,12 +36,17 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { import {
setJavaClassAnnotationFacts, setJavaClassAnnotationFacts,
setJavaSpringConfigConsumerFacts, setJavaSpringConfigConsumerFacts,
setJavaSpringConditionalFacts,
setJavaSpringDiFacts, setJavaSpringDiFacts,
} from './capture-side-channel.js'; } from './capture-side-channel.js';
import { captureJavaPackageFact } from './package-facts.js'; import { captureJavaPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js'; import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.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. */ /** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
@ -126,6 +131,7 @@ export function emitJavaScopeCaptures(
const rawMatches = getJavaScopeQuery().matches(tree.rootNode); const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = []; const out: CaptureMatch[] = [];
const classAnnotations = new Map<ScopeId, Set<string>>(); const classAnnotations = new Map<ScopeId, Set<string>>();
const springConditionalFacts: JavaSpringConditionalFact[] = [];
const springDiFacts: JavaSpringDiClassFact[] = []; const springDiFacts: JavaSpringDiClassFact[] = [];
const springDiClassNodeIds = new Set<number>(); const springDiClassNodeIds = new Set<number>();
@ -150,6 +156,9 @@ export function emitJavaScopeCaptures(
const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration'); const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration');
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
springDiClassNodeIds.add(springDiClassNode.id); springDiClassNodeIds.add(springDiClassNode.id);
springConditionalFacts.push(
...captureJavaSpringConditionalFacts(springDiClassNode, filePath),
);
const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath); const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath);
if (fact !== null) springDiFacts.push(fact); if (fact !== null) springDiFacts.push(fact);
} }
@ -347,6 +356,7 @@ export function emitJavaScopeCaptures(
filePath, filePath,
captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath), captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath),
); );
setJavaSpringConditionalFacts(filePath, springConditionalFacts);
setJavaSpringDiFacts(filePath, springDiFacts); setJavaSpringDiFacts(filePath, springDiFacts);
return [ return [

View file

@ -31,6 +31,7 @@ import {
import { populateJavaPackageSiblings } from './package-siblings.js'; import { populateJavaPackageSiblings } from './package-siblings.js';
import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js';
import { attachJavaSpringDiMetadata } from './spring-di.js'; import { attachJavaSpringDiMetadata } from './spring-di.js';
import { import {
applyJavaCaptureSideChannel, applyJavaCaptureSideChannel,
@ -87,6 +88,7 @@ const javaScopeResolver: ScopeResolver = {
populateRangeBindings: populateJavaCrossFileReturnTypes, populateRangeBindings: populateJavaCrossFileReturnTypes,
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => { emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => {
attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
}, },

View file

@ -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<JavaSpringConditionalAnnotationFact>;
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,
});

View file

@ -13,7 +13,9 @@ import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
import { getJavaSpringDiFacts } from './capture-side-channel.js'; import { getJavaSpringDiFacts } from './capture-side-channel.js';
export type JavaAnnotationSyntaxFact = SpringDiAnnotationFact; export interface JavaAnnotationSyntaxFact extends SpringDiAnnotationFact {
readonly line: number;
}
export type JavaSpringDependencyFact = SpringDiDependencyFact<JavaAnnotationSyntaxFact>; export type JavaSpringDependencyFact = SpringDiDependencyFact<JavaAnnotationSyntaxFact>;
@ -29,7 +31,7 @@ export type JavaSpringDiClassFact = SpringDiClassFact<
JavaSpringInjectionSiteKind JavaSpringInjectionSiteKind
>; >;
function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] { export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
const facts: JavaAnnotationSyntaxFact[] = []; const facts: JavaAnnotationSyntaxFact[] = [];
for (const child of node.namedChildren) { for (const child of node.namedChildren) {
if (child.type !== 'modifiers') continue; if (child.type !== 'modifiers') continue;
@ -37,7 +39,11 @@ function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue; if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue;
const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild; const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild;
if (nameNode === null) continue; 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; return facts;
@ -55,7 +61,7 @@ function dependenciesOf(callable: SyntaxNode): JavaSpringDependencyFact[] {
dependencies.push({ dependencies.push({
name: nameNode.text.trim(), name: nameNode.text.trim(),
rawType: typeNode.text.trim(), rawType: typeNode.text.trim(),
annotations: annotationFacts(parameter), annotations: javaSpringAnnotationFacts(parameter),
}); });
} }
return dependencies; return dependencies;
@ -73,14 +79,14 @@ export function captureJavaSpringDiClassFact(
): JavaSpringDiClassFact | null { ): JavaSpringDiClassFact | null {
const body = classNode.childForFieldName('body'); const body = classNode.childForFieldName('body');
if (body === null) return null; if (body === null) return null;
const classAnnotations = annotationFacts(classNode); const classAnnotations = javaSpringAnnotationFacts(classNode);
const injectionSites: JavaSpringInjectionSiteFact[] = []; const injectionSites: JavaSpringInjectionSiteFact[] = [];
const constructors = body.namedChildren.filter( const constructors = body.namedChildren.filter(
(child) => child.type === 'constructor_declaration', (child) => child.type === 'constructor_declaration',
); );
for (const constructor of constructors) { for (const constructor of constructors) {
const annotations = annotationFacts(constructor); const annotations = javaSpringAnnotationFacts(constructor);
const implicitConstructor = const implicitConstructor =
constructors.length === 1 && constructors.length === 1 &&
hasSpringStereotypeSyntax(classAnnotations) && hasSpringStereotypeSyntax(classAnnotations) &&
@ -97,7 +103,7 @@ export function captureJavaSpringDiClassFact(
for (const member of body.namedChildren) { for (const member of body.namedChildren) {
if (member.type === 'field_declaration') { if (member.type === 'field_declaration') {
const annotations = annotationFacts(member); const annotations = javaSpringAnnotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue; if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const typeNode = member.childForFieldName('type'); const typeNode = member.childForFieldName('type');
if (typeNode === null) continue; if (typeNode === null) continue;
@ -120,7 +126,7 @@ export function captureJavaSpringDiClassFact(
}); });
} }
} else if (member.type === 'method_declaration') { } else if (member.type === 'method_declaration') {
const annotations = annotationFacts(member); const annotations = javaSpringAnnotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue; if (!hasSpringDiRelevantAnnotation(annotations)) continue;
injectionSites.push({ injectionSites.push({
kind: 'method', kind: 'method',

View file

@ -49,9 +49,11 @@ import {
} from '../jvm/package-facts.js'; } from '../jvm/package-facts.js';
import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js'; import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js';
import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js'; import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js';
import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
import type { KotlinSpringDiClassFact } from './spring-di.js'; import type { KotlinSpringDiClassFact } from './spring-di.js';
const classAnnotations = createClassAnnotationFactStore(); const classAnnotations = createClassAnnotationFactStore();
const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalFact[]>();
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>(); const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
/** /**
@ -68,12 +70,15 @@ export interface KotlinCaptureSideChannel {
readonly packageFact: JvmPackageFact; readonly packageFact: JvmPackageFact;
/** Class annotation syntax collected by the existing scope traversal. */ /** Class annotation syntax collected by the existing scope traversal. */
readonly classAnnotations: readonly ClassAnnotationFact[]; 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. */ /** Constructor, property, and method injection syntax captured per class. */
readonly springDiFacts?: readonly KotlinSpringDiClassFact[]; readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
} }
export function clearKotlinClassAnnotationFacts(): void { export function clearKotlinClassAnnotationFacts(): void {
classAnnotations.clear(); classAnnotations.clear();
springConditionalFacts.clear();
springDiFacts.clear(); springDiFacts.clear();
} }
@ -88,6 +93,20 @@ export function getKotlinClassAnnotationFacts(filePath: string): readonly ClassA
return classAnnotations.get(filePath); 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( export function setKotlinSpringDiFacts(
filePath: string, filePath: string,
facts: readonly KotlinSpringDiClassFact[], facts: readonly KotlinSpringDiClassFact[],
@ -110,11 +129,13 @@ export function collectKotlinCaptureSideChannel(
): KotlinCaptureSideChannel | undefined { ): KotlinCaptureSideChannel | undefined {
const companionScopes = getCompanionScopesForFile(filePath); const companionScopes = getCompanionScopesForFile(filePath);
const annotationFacts = classAnnotations.get(filePath); const annotationFacts = classAnnotations.get(filePath);
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
const diFacts = springDiFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? [];
const packageFact = getKotlinPackageFact(filePath); const packageFact = getKotlinPackageFact(filePath);
if ( if (
companionScopes.length === 0 && companionScopes.length === 0 &&
annotationFacts.length === 0 && annotationFacts.length === 0 &&
conditionFacts.length === 0 &&
diFacts.length === 0 && diFacts.length === 0 &&
packageFact === undefined packageFact === undefined
) { ) {
@ -125,6 +146,7 @@ export function collectKotlinCaptureSideChannel(
companionScopes, companionScopes,
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT, packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
classAnnotations: annotationFacts, classAnnotations: annotationFacts,
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
}; };
} }
@ -148,6 +170,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
!Array.isArray(data.classAnnotations) !Array.isArray(data.classAnnotations)
) { ) {
classAnnotations.set(parsed.filePath, []); classAnnotations.set(parsed.filePath, []);
setKotlinSpringConditionalFacts(parsed.filePath, []);
setKotlinSpringDiFacts(parsed.filePath, []); setKotlinSpringDiFacts(parsed.filePath, []);
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
return; return;
@ -156,6 +179,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
markCompanionScope(parsed.filePath, scopeId); markCompanionScope(parsed.filePath, scopeId);
} }
classAnnotations.set(parsed.filePath, data.classAnnotations); classAnnotations.set(parsed.filePath, data.classAnnotations);
setKotlinSpringConditionalFacts(
parsed.filePath,
Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : [],
);
setKotlinSpringDiFacts( setKotlinSpringDiFacts(
parsed.filePath, parsed.filePath,
Array.isArray(data.springDiFacts) ? data.springDiFacts : [], Array.isArray(data.springDiFacts) ? data.springDiFacts : [],

View file

@ -18,10 +18,18 @@ import { normalizeKotlinType } from './interpret.js';
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js'; import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
import { getKotlinParser, getKotlinScopeQuery } from './query.js'; import { getKotlinParser, getKotlinScopeQuery } from './query.js';
import { markCompanionScope } from './companion-scopes.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 { captureKotlinPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.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; const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
@ -84,6 +92,7 @@ export function emitKotlinScopeCaptures(
const out: CaptureMatch[] = []; const out: CaptureMatch[] = [];
const classAnnotations = new Map<ScopeId, Set<string>>(); const classAnnotations = new Map<ScopeId, Set<string>>();
const springConditionalFacts: KotlinSpringConditionalFact[] = [];
const springDiFacts: KotlinSpringDiClassFact[] = []; const springDiFacts: KotlinSpringDiClassFact[] = [];
const springDiClassNodeIds = new Set<number>(); const springDiClassNodeIds = new Set<number>();
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode); const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
@ -112,6 +121,9 @@ export function emitKotlinScopeCaptures(
const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration'); const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration');
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) { if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
springDiClassNodeIds.add(springDiClassNode.id); springDiClassNodeIds.add(springDiClassNode.id);
springConditionalFacts.push(
...captureKotlinSpringConditionalFacts(springDiClassNode, filePath),
);
const fact = captureKotlinSpringDiClassFact(springDiClassNode, filePath); const fact = captureKotlinSpringDiClassFact(springDiClassNode, filePath);
if (fact !== null) springDiFacts.push(fact); if (fact !== null) springDiFacts.push(fact);
} }
@ -298,6 +310,7 @@ export function emitKotlinScopeCaptures(
} }
setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations)); setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations));
setKotlinSpringConditionalFacts(filePath, springConditionalFacts);
setKotlinSpringDiFacts(filePath, springDiFacts); setKotlinSpringDiFacts(filePath, springDiFacts);
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS)); out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
return out; return out;

View file

@ -23,6 +23,7 @@ import { populateKotlinPackageSiblings } from './package-siblings.js';
import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
import { clearKotlinPackageFacts } from './package-facts.js'; import { clearKotlinPackageFacts } from './package-facts.js';
import { attachKotlinSpringDiMetadata } from './spring-di.js'; import { attachKotlinSpringDiMetadata } from './spring-di.js';
import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
/** /**
* Kotlin scope resolver for RFC #909 Ring 3. * Kotlin scope resolver for RFC #909 Ring 3.
@ -126,6 +127,7 @@ export const kotlinScopeResolver: ScopeResolver = {
populateNamespaceSiblings: populateKotlinPackageSiblings, populateNamespaceSiblings: populateKotlinPackageSiblings,
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => { emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => {
attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
}, },
}; };

View file

@ -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<KotlinSpringConditionalAnnotationFact>;
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,
});

View file

@ -15,6 +15,7 @@ import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.j
export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact { export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact {
readonly useSiteTarget?: string; readonly useSiteTarget?: string;
readonly line: number;
} }
export type KotlinSpringDependencyFact = SpringDiDependencyFact<KotlinAnnotationSyntaxFact>; export type KotlinSpringDependencyFact = SpringDiDependencyFact<KotlinAnnotationSyntaxFact>;
@ -57,6 +58,7 @@ function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | nu
return { return {
name: nameNode.text.trim(), name: nameNode.text.trim(),
text: annotation.text.trim(), text: annotation.text.trim(),
line: annotation.startPosition.row + 1,
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }), ...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
}; };
} }
@ -71,7 +73,7 @@ function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyn
return facts; return facts;
} }
function annotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { export function kotlinSpringAnnotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
const facts: KotlinAnnotationSyntaxFact[] = []; const facts: KotlinAnnotationSyntaxFact[] = [];
for (const child of node.namedChildren) { for (const child of node.namedChildren) {
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue; if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
@ -94,7 +96,7 @@ function parameterDependency(
return { return {
name: nameNode.text.trim(), name: nameNode.text.trim(),
rawType: typeNode.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 nameNode = variable.namedChildren.find((child) => child.type === 'simple_identifier');
const typeNode = directTypeNode(variable); const typeNode = directTypeNode(variable);
if (nameNode === undefined || typeNode === undefined) return null; if (nameNode === undefined || typeNode === undefined) return null;
const annotations = annotationFacts(property); const annotations = kotlinSpringAnnotationFacts(property);
return { return {
name: nameNode.text.trim(), name: nameNode.text.trim(),
rawType: typeNode.text.trim(), rawType: typeNode.text.trim(),
@ -162,7 +164,7 @@ export function captureKotlinSpringDiClassFact(
filePath: string, filePath: string,
): KotlinSpringDiClassFact | null { ): KotlinSpringDiClassFact | null {
if (!isKotlinBeanCandidateClass(classNode)) return null; if (!isKotlinBeanCandidateClass(classNode)) return null;
const classAnnotations = annotationFacts(classNode); const classAnnotations = kotlinSpringAnnotationFacts(classNode);
const injectionSites: KotlinSpringInjectionSiteFact[] = []; const injectionSites: KotlinSpringInjectionSiteFact[] = [];
const body = classNode.namedChildren.find((child) => child.type === 'class_body'); const body = classNode.namedChildren.find((child) => child.type === 'class_body');
const primaryConstructor = classNode.namedChildren.find( const primaryConstructor = classNode.namedChildren.find(
@ -174,7 +176,7 @@ export function captureKotlinSpringDiClassFact(
(primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length; (primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length;
if (primaryConstructor !== undefined) { if (primaryConstructor !== undefined) {
const annotations = annotationFacts(primaryConstructor); const annotations = kotlinSpringAnnotationFacts(primaryConstructor);
const implicitConstructor = const implicitConstructor =
constructorCount === 1 && constructorCount === 1 &&
hasSpringStereotypeSyntax(classAnnotations) && hasSpringStereotypeSyntax(classAnnotations) &&
@ -191,7 +193,7 @@ export function captureKotlinSpringDiClassFact(
} }
for (const constructor of secondaryConstructors) { for (const constructor of secondaryConstructors) {
const annotations = annotationFacts(constructor); const annotations = kotlinSpringAnnotationFacts(constructor);
const implicitConstructor = const implicitConstructor =
constructorCount === 1 && constructorCount === 1 &&
hasSpringStereotypeSyntax(classAnnotations) && hasSpringStereotypeSyntax(classAnnotations) &&
@ -209,7 +211,7 @@ export function captureKotlinSpringDiClassFact(
if (body !== undefined) { if (body !== undefined) {
for (const member of body.namedChildren) { for (const member of body.namedChildren) {
if (member.type === 'property_declaration') { if (member.type === 'property_declaration') {
const annotations = annotationFacts(member); const annotations = kotlinSpringAnnotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue; if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const dependency = propertyDependency(member); const dependency = propertyDependency(member);
if (dependency === null) continue; if (dependency === null) continue;
@ -221,7 +223,7 @@ export function captureKotlinSpringDiClassFact(
dependencies: [dependency], dependencies: [dependency],
}); });
} else if (member.type === 'function_declaration') { } else if (member.type === 'function_declaration') {
const annotations = annotationFacts(member); const annotations = kotlinSpringAnnotationFacts(member);
if (!hasSpringDiRelevantAnnotation(annotations)) continue; if (!hasSpringDiRelevantAnnotation(annotations)) continue;
const name = const name =
member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ?? member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ??

View file

@ -21,6 +21,10 @@ export {
type ScopeResolutionOutput, type ScopeResolutionOutput,
} from '../scope-resolution/pipeline/phase.js'; } from '../scope-resolution/pipeline/phase.js';
export { springConfigPhase, type SpringConfigOutput } from './spring-config.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 { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js';
export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js'; export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js'; export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js';

View file

@ -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<string>();
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<string>();
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<string, GraphNode | null> {
const byQualifiedName = new Map<string, GraphNode | null>();
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<typeof sourceClassIndexes>,
): 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<SpringAutoConfigurationOutput> = {
name: 'springAutoConfiguration',
deps: ['structure', 'scopeResolution'],
async execute(
ctx: PipelineContext,
deps: ReadonlyMap<string, PhaseResult<unknown>>,
): Promise<SpringAutoConfigurationOutput> {
const { scannedFiles } = getPhaseOutput<StructureOutput>(deps, 'structure');
let classesByQualifiedName: ReturnType<typeof sourceClassIndexes> | undefined;
const resolutions = new Map<string, GraphNode | null>();
const ambiguousQualifiedNames = new Set<string>();
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<string>();
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,
};
},
};

View file

@ -33,6 +33,7 @@ import {
crossFilePhase, crossFilePhase,
scopeResolutionPhase, scopeResolutionPhase,
springConfigPhase, springConfigPhase,
springAutoConfigurationPhase,
pruneLocalSymbolsPhase, pruneLocalSymbolsPhase,
taintSummariesPhase, taintSummariesPhase,
callSummariesPhase, callSummariesPhase,
@ -261,7 +262,7 @@ export interface PipelineOptions {
* Phase dependency graph: * Phase dependency graph:
* *
* scan structure [springConfig, markdown, cobol] parse [routes, tools, orm] * scan structure [springConfig, markdown, cobol] parse [routes, tools, orm]
* crossFile scopeResolution pruneLocalSymbols * crossFile scopeResolution springAutoConfiguration pruneLocalSymbols
* mro di communities processes * mro di communities processes
* *
* To add a new phase: create a file in pipeline-phases/, export the phase * 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(ormPhase)
.register(crossFilePhase) .register(crossFilePhase)
.register(scopeResolutionPhase) .register(scopeResolutionPhase)
.register(springAutoConfigurationPhase)
.register(pruneLocalSymbolsPhase) .register(pruneLocalSymbolsPhase)
// M4 (#2084): interprocedural taint fixpoint — the first real opt-in // M4 (#2084): interprocedural taint fixpoint — the first real opt-in
// pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on // pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on

View file

@ -1255,12 +1255,11 @@ export function findChild(node: SyntaxNode, type: string): SyntaxNode | null {
return null; return null;
} }
/** Remove bidi-override and zero-width control characters. Doc text is /** Remove bidi-override and zero-width control characters from attacker-
* attacker-influenced (any indexed repo) and is returned verbatim to MCP * influenced repository text before it is exposed through graph descriptions
* clients, so strip Trojan-Source-style hidden controls from the description * or MCP output (#2286). Global `sanitizeUTF8` intentionally remains focused
* before it leaves the extractor (#2286 review). Scoped to the doc-comment path * on encoding/control-character validity. */
* only global `sanitizeUTF8` is intentionally untouched. */ export const stripBidiAndZeroWidth = (text: string): string =>
const stripBidiAndZeroWidth = (text: string): string =>
Array.from(text) Array.from(text)
.filter((ch) => { .filter((ch) => {
const c = ch.codePointAt(0) ?? 0; const c = ch.codePointAt(0) ?? 0;

View file

@ -61,6 +61,10 @@ import {
} from './sidecar-recovery.js'; } from './sidecar-recovery.js';
import { logger } from '../logger.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) // 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 * Shared mechanics for the delete-all-relationships-of-one-type family
* ({@link deleteAllInterprocTaintPaths}, {@link deleteAllCallSummaries}, * ({@link deleteAllInterprocTaintPaths}, {@link deleteAllCallSummaries},
* {@link deleteAllInjects}): count the typed CodeRelation rows, then DELETE * {@link deleteAllInjects}, {@link deleteSpringAutoConfigurationDeclarations}):
* them (relationship-level these are edge types, not node labels, so * count the matching CodeRelation rows, then DELETE them (relationship-level
* endpoints are untouched). * these are edge types, not node labels, so endpoints are untouched).
* *
* count + DELETE run as one critical section on the singleton connection so a * count + DELETE run as one critical section on the singleton connection so a
* concurrent WAL-checkpoint cannot corrupt native state mid-delete (#pdg). * 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 relType the CodeRelation `type` value to delete (e.g. 'INJECTS')
* @param logTag the `[tag]` prefix on the abort error message * @param logTag the `[tag]` prefix on the abort error message
* @param duplicateNoun what the abort message says would be duplicated * @param duplicateNoun what the abort message says would be duplicated
* @param exactReasons optional reason allowlist for a shared relationship type
*/ */
const deleteAllRelationshipsOfType = async ( const deleteAllRelationshipsOfType = async (
relType: string, relType: string,
logTag: string, logTag: string,
duplicateNoun: string, duplicateNoun: string,
exactReasons?: readonly string[],
): Promise<{ edgesDeleted: number }> => { ): Promise<{ edgesDeleted: number }> => {
const c = conn; const c = conn;
if (!c) { if (!c) {
@ -2637,16 +2643,23 @@ const deleteAllRelationshipsOfType = async (
return withConnLock(async () => { return withConnLock(async () => {
let edgesDeleted = 0; let edgesDeleted = 0;
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined; 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 { try {
countResult = await c.query( 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 result = Array.isArray(countResult) ? countResult[0] : countResult;
const rows = await result.getAll(); const rows = await result.getAll();
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0); const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
if (count > 0) { if (count > 0) {
await closeQueryResults( 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; edgesDeleted = count;
} }
@ -2732,6 +2745,65 @@ export const deleteAllCallSummaries = async (): Promise<{ edgesDeleted: number }
export const deleteAllInjects = async (): Promise<{ edgesDeleted: number }> => export const deleteAllInjects = async (): Promise<{ edgesDeleted: number }> =>
deleteAllRelationshipsOfType('INJECTS', 'di', 'duplicate INJECTS edges'); 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 // Full-Text Search (FTS) Functions
// ============================================================================ // ============================================================================

View file

@ -32,6 +32,8 @@ import {
deleteAllInterprocTaintPaths, deleteAllInterprocTaintPaths,
deleteAllCallSummaries, deleteAllCallSummaries,
deleteAllInjects, deleteAllInjects,
deleteSpringAutoConfigurationDeclarations,
deleteSpringAutoConfigurationSyntheticClasses,
queryImportersBatch, queryImportersBatch,
loadFTSExtension, loadFTSExtension,
wipeLbugDbFiles, wipeLbugDbFiles,
@ -136,7 +138,10 @@ import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js'; import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
import { STALE_HASH_SENTINEL } from './lbug/schema.js'; import { STALE_HASH_SENTINEL } from './lbug/schema.js';
import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.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 { SPRING_CONFIG_BINDINGS_FEATURE } from './ingestion/languages/java/analysis-features.js';
import { import {
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
@ -152,6 +157,7 @@ import {
const ANALYSIS_FEATURES = [ const ANALYSIS_FEATURES = [
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE, SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
SPRING_CONFIG_BINDINGS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE,
] as const; ] as const;
@ -2010,7 +2016,16 @@ async function runFullAnalysisInner(
// deleting on every non-pdg incremental run (N runs = N copies of // deleting on every non-pdg incremental run (N runs = N copies of
// every INJECTS row; CodeRelation has no PK and no read-side dedup). // every INJECTS row; CodeRelation has no PK and no read-side dedup).
await deleteAllInjects(); 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 // — their validity is a whole-program property (an A→C flow can be
// invalidated by a change to an intermediate function on a third // invalidated by a change to an intermediate function on a third
// file), so endpoint-writability extraction can't refresh them. // file), so endpoint-writability extraction can't refresh them.
@ -2018,7 +2033,7 @@ async function runFullAnalysisInner(
// graph (isGraphWideRelType), mirroring Community/Process. // graph (isGraphWideRelType), mirroring Community/Process.
if (options.pdg === true) { if (options.pdg === true) {
await deleteAllInterprocTaintPaths(); 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 // writeback. They are re-included from the FULL fresh graph
// (isGraphWideRelType) and the callSummaries phase recomputes every // (isGraphWideRelType) and the callSummaries phase recomputes every
// summary each run, so delete-all-then-rebuild keeps an unchanged // summary each run, so delete-all-then-rebuild keeps an unchanged

View file

@ -303,6 +303,12 @@ export const VALID_RELATION_TYPES = new Set([
// (WRAPS/FETCHES precedent): the 0.5 unknown-type floor applies there, // (WRAPS/FETCHES precedent): the 0.5 unknown-type floor applies there,
// and the edges carry their own confidence (0.8) in the graph. // and the edges carry their own confidence (0.8) in the graph.
'INJECTS', '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',
]); ]);
/** /**

View file

@ -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, // the main thread (the #1983 OOM). Because the two stores share this version,
// any future change to the `ParsedFile` serialization shape MUST bump // any future change to the `ParsedFile` serialization shape MUST bump
// SCHEMA_BUMP so both invalidate in lockstep. // 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 // 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 // construction sites (#2699 follow-up); a v25 cache replays worker results carrying the
// wrong Java anonymous-class ids. Cached results are replayed verbatim — including // 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). // JLS 13.1 immediate-host chains (#2555).
// v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity.
// v16: direct callee identity. // v16: direct callee identity.
const SCHEMA_BUMP = 27; const SCHEMA_BUMP = 28;
const GITNEXUS_PKG_VERSION = (() => { const GITNEXUS_PKG_VERSION = (() => {
try { try {
// package.json sits at gitnexus/package.json — two levels up from // package.json sits at gitnexus/package.json — two levels up from

View file

@ -85,6 +85,8 @@ const buildFixture = (
} }
relationships.push(edge('EXTENDS', `Class:${FILE_PATH}:Cls2`, `Class:${FILE_PATH}:Cls1`)); // retained 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('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. // 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}:fn0`));
relationships.push(edge('CALLS', `Function:${FILE_PATH}:fn0`, `Function:${FILE_PATH}:fn1`)); relationships.push(edge('CALLS', `Function:${FILE_PATH}:fn0`, `Function:${FILE_PATH}:fn1`));

View file

@ -14,6 +14,10 @@ import path from 'path';
import type { GraphRelationship } from 'gitnexus-shared'; import type { GraphRelationship } from 'gitnexus-shared';
import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { skipUnlessFtsAvailable } from '../helpers/fts-availability.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()` * 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); 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', () => { describe('unhappy path', () => {
it('throws on malformed Cypher query', async () => { it('throws on malformed Cypher query', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js'); const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');

View file

@ -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',
]);
});
});

View file

@ -5,12 +5,16 @@ import {
resolveAnalysisFeatureVersions, resolveAnalysisFeatureVersions,
type AnalysisFeatureDescriptor, type AnalysisFeatureDescriptor,
} from '../../src/core/analysis-features.js'; } 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'; import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js';
const FEATURES = [ const FEATURES = [
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE, SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
SPRING_CONFIG_BINDINGS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE,
] as const; ] as const;
@ -22,11 +26,13 @@ describe('analysis feature versions', () => {
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({
'graph.class-framework-annotations': 1, 'graph.class-framework-annotations': 1,
'spring.bean-inventory': 1, 'spring.bean-inventory': 1,
'spring.conditionals-auto-configuration': 1,
'spring.config-bindings': 1, 'spring.config-bindings': 1,
}); });
expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({ expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
'graph.class-framework-annotations': 1, 'graph.class-framework-annotations': 1,
'spring.bean-inventory': 1, 'spring.bean-inventory': 1,
'spring.conditionals-auto-configuration': 1,
}); });
expect( expect(
resolveAnalysisFeatureVersions(FEATURES, [ resolveAnalysisFeatureVersions(FEATURES, [
@ -37,6 +43,14 @@ describe('analysis feature versions', () => {
'graph.class-framework-annotations': 1, 'graph.class-framework-annotations': 1,
'spring.config-bindings': 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', () => { it('requires an exact, well-formed feature set', () => {

View file

@ -43,7 +43,11 @@ import {
stampEmbeddingCount, stampEmbeddingCount,
} from '../helpers/embedding-seed.js'; } from '../helpers/embedding-seed.js';
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.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'; import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js';
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-'); const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-');
@ -165,6 +169,38 @@ async function countInjects(repoPath: string): Promise<number> {
} }
} }
async function countSpringAutoConfigurationDeclarations(repoPath: string): Promise<number> {
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<number> {
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<IFoo>` + 2 implementers exactly /** Java DI fixture (#2200): `@Autowired List<IFoo>` + 2 implementers exactly
* 2 INJECTS edges (ConsumerFooA, ConsumerFooB). Same shapes as the * 2 INJECTS edges (ConsumerFooA, ConsumerFooB). Same shapes as the
* spring-di-pipeline integration fixture. */ * spring-di-pipeline integration fixture. */
@ -275,6 +311,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
expect(meta!.analysisFeatures).toEqual({ expect(meta!.analysisFeatures).toEqual({
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_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)); await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_BEAN_INVENTORY_FEATURE.id));
@ -291,6 +328,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
}); });
} finally { } finally {
await repo.cleanup(); await repo.cleanup();
@ -358,6 +396,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
}); });
} finally { } finally {
await repo.cleanup(); await repo.cleanup();
@ -916,6 +955,49 @@ describe('runFullAnalysis — incremental orchestration', () => {
await repo.cleanup(); await repo.cleanup();
} }
}, 600_000); }, 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);
}); });
/** /**

View file

@ -17,6 +17,10 @@ import {
extractChangedSubgraph, extractChangedSubgraph,
computeEffectiveWriteSet, computeEffectiveWriteSet,
} from '../../src/core/incremental/subgraph-extract.js'; } 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 => const makeFileNode = (id: string, filePath: string, label = 'Function'): GraphNode =>
({ ({
@ -37,12 +41,14 @@ const makeRel = (
sourceId: string, sourceId: string,
targetId: string, targetId: string,
type = 'CALLS', type = 'CALLS',
reason = 'test',
): GraphRelationship => ): GraphRelationship =>
({ ({
id, id,
sourceId, sourceId,
targetId, targetId,
type, type,
reason,
properties: {}, properties: {},
}) as unknown as GraphRelationship; }) as unknown as GraphRelationship;
@ -68,6 +74,25 @@ describe('extractChangedSubgraph', () => {
expect(sub.nodes.map((n) => n.id).sort()).toEqual(['comm-1', 'proc-1']); 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', () => { it('includes a relationship when at least one endpoint is writable', () => {
const g = createKnowledgeGraph(); const g = createKnowledgeGraph();
g.addNode(makeFileNode('a:fn', '/repo/a.ts')); g.addNode(makeFileNode('a:fn', '/repo/a.ts'));
@ -130,6 +155,39 @@ describe('extractChangedSubgraph', () => {
expect(sub.relationships.map((r) => r.id)).toEqual(['inj1']); 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)', () => { describe('computeEffectiveWriteSet (Finding 1)', () => {

View file

@ -75,6 +75,7 @@ const FULL_ORDER = [
'orm', 'orm',
'crossFile', 'crossFile',
'scopeResolution', 'scopeResolution',
'springAutoConfiguration',
'pruneLocalSymbols', 'pruneLocalSymbols',
'mro', 'mro',
'di', 'di',

View file

@ -111,6 +111,12 @@ describe('LadybugDB Schema', () => {
it('includes the DI collection-injection edge type (#2200)', () => { it('includes the DI collection-injection edge type (#2200)', () => {
expect(REL_TYPES).toContain('INJECTS'); 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', () => { describe('node schema DDL', () => {

View file

@ -39,6 +39,9 @@ describe('VALID_RELATION_TYPES', () => {
'WRAPS', 'WRAPS',
// Spring DI @Autowired collection injection (#2200) // Spring DI @Autowired collection injection (#2200)
'INJECTS', 'INJECTS',
// Conditional activation and metadata declaration/discovery (#2415)
'CONDITIONAL_ON',
'DECLARES',
] as const; ] as const;
it('contains all expected relation types', () => { it('contains all expected relation types', () => {

View file

@ -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<string, PhaseResult<unknown>>([
[
'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 });
}
});
});

View file

@ -68,7 +68,9 @@ describe('Java Spring injection syntax capture', () => {
`); `);
expect(facts).toHaveLength(1); 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([ expect(facts[0].injectionSites).toMatchObject([
{ {
kind: 'constructor', kind: 'constructor',
@ -167,8 +169,8 @@ describe('Kotlin Spring injection syntax capture', () => {
expect(facts).toHaveLength(1); expect(facts).toHaveLength(1);
expect(facts[0].classAnnotations).toEqual([ expect(facts[0].classAnnotations).toEqual([
{ name: 'Service', text: '@Service("checkout")' }, { name: 'Service', text: '@Service("checkout")', line: 2 },
{ name: 'Primary', text: '@Primary' }, { name: 'Primary', text: '@Primary', line: 2 },
]); ]);
expect(facts[0].injectionSites).toMatchObject([ expect(facts[0].injectionSites).toMatchObject([
{ {

View file

@ -4,7 +4,10 @@ import { getCopyQuery } from '../../src/core/lbug/lbug-adapter.js';
import { PARSE_CACHE_VERSION } from '../../src/storage/parse-cache.js'; import { PARSE_CACHE_VERSION } from '../../src/storage/parse-cache.js';
import { INCREMENTAL_SCHEMA_VERSION } from '../../src/storage/repo-manager.js'; import { INCREMENTAL_SCHEMA_VERSION } from '../../src/storage/repo-manager.js';
import { isSpringBeanCandidateSourceFile } from '../../src/core/ingestion/frameworks/spring/bean-catalog.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'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js';
describe('Spring Bean Class persistence schema', () => { 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', () => { it('meets the cache-version baselines required by the merged implementation', () => {
const parseSchemaVersion = Number.parseInt(PARSE_CACHE_VERSION, 10); const parseSchemaVersion = Number.parseInt(PARSE_CACHE_VERSION, 10);
expect(parseSchemaVersion).toBeGreaterThanOrEqual(20); expect(parseSchemaVersion).toBeGreaterThanOrEqual(22);
expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(8); expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(8);
expect(CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version).toBe(1); expect(CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version).toBe(1);
expect(SPRING_BEAN_INVENTORY_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', () => { it('limits incremental drift queries to Java and Kotlin Bean source files', () => {

View file

@ -137,6 +137,11 @@ describe('buildPhaseList under streamGraphEmit', () => {
}); });
describe('RETAINED_REL_TYPES tracks its readers', () => { 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 () => { it('retains every relationship type any phase reads back mid-pipeline', async () => {
// The round-trip test CANNOT catch drift here: addRelationship partitions // The round-trip test CANNOT catch drift here: addRelationship partitions
// edges between the graph and the CSVs, and a partition's union is // edges between the graph and the CSVs, and a partition's union is