mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
e307286d52
commit
ff86ccf1e7
40 changed files with 2135 additions and 47 deletions
|
|
@ -140,6 +140,19 @@ export type RelationshipType =
|
|||
* Lets Cypher queries trace which beans the container injects into a given
|
||||
* consumer, complementing the structural `IMPLEMENTS` heritage edges. */
|
||||
| 'INJECTS'
|
||||
/** Spring activation constraint. Source = a conditional Bean/configuration
|
||||
* Class or factory Method; target = the referenced configuration Property
|
||||
* when statically identifiable, otherwise an Annotation evidence node.
|
||||
* The reason records the annotation and explicitly marks activation as
|
||||
* unknown because runtime environment/classpath state may override source
|
||||
* configuration. */
|
||||
| 'CONDITIONAL_ON'
|
||||
/** Metadata declaration/discovery relationship. Source = a metadata File;
|
||||
* target = the declared candidate node. This deliberately does not claim
|
||||
* that the target is active or registered at runtime. Framework-specific
|
||||
* semantics belong in `reason` so the relationship can be reused by other
|
||||
* metadata-driven systems. */
|
||||
| 'DECLARES'
|
||||
/** Vue component event system: a handler function in a parent component is
|
||||
* bound to an event emitted by a child component (`@event="handlerFn"`).
|
||||
* Source = handler Function/Method node in the parent.
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export const REL_TYPES = [
|
|||
'WRAPS',
|
||||
'QUERIES',
|
||||
'INJECTS',
|
||||
'CONDITIONAL_ON',
|
||||
'DECLARES',
|
||||
// Taint/PDG substrate (issue #2080) — reserved edge types, emitted by no
|
||||
// phase yet (CFG → M1, REACHING_DEF → M2, TAINTED/SANITIZES/TAINT_PATH →
|
||||
// M3/M4). REACHING_DEF's variable name rides the relation's `reason` column.
|
||||
|
|
|
|||
263
gitnexus/bench/spring-conditionals/measure.mjs
Normal file
263
gitnexus/bench/spring-conditionals/measure.mjs
Normal 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();
|
||||
|
|
@ -6,8 +6,8 @@
|
|||
* replaced, produce a smaller KnowledgeGraph that contains:
|
||||
*
|
||||
* - Every node whose `properties.filePath` is in `toWriteSet`.
|
||||
* - Every graph-wide node (Community, Process) — these are regenerated
|
||||
* each run by the communities/processes phases and must be fully
|
||||
* - Every graph-wide node (Community, Process, and Spring metadata
|
||||
* placeholders) — these are regenerated each run and must be fully
|
||||
* rewritten.
|
||||
* - Every relationship where AT LEAST ONE endpoint is in the writable
|
||||
* set above. Relationships entirely between unchanged-file nodes
|
||||
|
|
@ -51,8 +51,15 @@
|
|||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { createKnowledgeGraph } from '../graph/graph.js';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import {
|
||||
isSpringAutoConfigurationDeclaration,
|
||||
isSpringAutoConfigurationSyntheticClass,
|
||||
} from '../ingestion/frameworks/spring/auto-configuration.js';
|
||||
|
||||
const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process';
|
||||
const isGraphWideNode = (node: GraphNode): boolean =>
|
||||
node.label === 'Community' ||
|
||||
node.label === 'Process' ||
|
||||
isSpringAutoConfigurationSyntheticClass(node);
|
||||
|
||||
/**
|
||||
* Relationship types whose VALIDITY is a whole-program property, not a
|
||||
|
|
@ -81,8 +88,17 @@ const isGraphWide = (label: string): boolean => label === 'Community' || label =
|
|||
// analyze, and the `incrementalInProgress` dirty flag (saved before any
|
||||
// delete) forces a full rebuild on the next run. Temporary absence is
|
||||
// possible; duplicates are not.
|
||||
const isGraphWideRelType = (type: string): boolean =>
|
||||
type === 'TAINT_PATH' || type === 'CALL_SUMMARY' || type === 'INJECTS';
|
||||
//
|
||||
// Spring auto-configuration DECLARES edges (#2415) are also recomputed from
|
||||
// repository-wide metadata. A third-file class addition/removal can retarget
|
||||
// an unchanged declaration, so they need the same global re-extract contract.
|
||||
// DECLARES itself is generic, however: only the two Spring-owned reasons are
|
||||
// graph-wide, leaving future metadata systems under their own lifecycle.
|
||||
const isGraphWideRelationship = (relationship: GraphRelationship): boolean =>
|
||||
relationship.type === 'TAINT_PATH' ||
|
||||
relationship.type === 'CALL_SUMMARY' ||
|
||||
relationship.type === 'INJECTS' ||
|
||||
isSpringAutoConfigurationDeclaration(relationship);
|
||||
|
||||
/**
|
||||
* Build a Map<nodeId, filePath> for every File-bound node in the graph.
|
||||
|
|
@ -106,7 +122,7 @@ export const extractChangedSubgraph = (
|
|||
|
||||
fullGraph.forEachNode((n: GraphNode) => {
|
||||
const filePath = n.properties?.filePath as string | undefined;
|
||||
const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label);
|
||||
const include = (filePath && toWriteSet.has(filePath)) || isGraphWideNode(n);
|
||||
if (include) {
|
||||
sub.addNode(n);
|
||||
writableNodeIds.add(n.id);
|
||||
|
|
@ -117,7 +133,7 @@ export const extractChangedSubgraph = (
|
|||
if (
|
||||
writableNodeIds.has(r.sourceId) ||
|
||||
writableNodeIds.has(r.targetId) ||
|
||||
isGraphWideRelType(r.type)
|
||||
isGraphWideRelationship(r)
|
||||
) {
|
||||
sub.addRelationship(r);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,23 @@ export const SPRING_BEAN_INVENTORY_FEATURE: AnalysisFeatureDescriptor = {
|
|||
version: 1,
|
||||
appliesTo: (filePaths) => filePaths.some(isSpringBeanCandidateSourceFile),
|
||||
};
|
||||
|
||||
function isSpringConditionOrAutoConfigurationFile(filePath: string): boolean {
|
||||
const normalized = `/${filePath.replaceAll('\\', '/')}`.toLowerCase();
|
||||
return (
|
||||
normalized.endsWith('.java') ||
|
||||
normalized.endsWith('.kt') ||
|
||||
normalized.endsWith('.kts') ||
|
||||
normalized.endsWith('/meta-inf/spring.factories') ||
|
||||
normalized.endsWith(
|
||||
'/meta-inf/spring/org.springframework.boot.autoconfigure.autoconfiguration.imports',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Durable completeness contract for conditional and auto-configuration evidence. */
|
||||
export const SPRING_CONDITIONALS_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'spring.conditionals-auto-configuration',
|
||||
version: 1,
|
||||
appliesTo: (filePaths) => filePaths.some(isSpringConditionOrAutoConfigurationFile),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export const SPRING_BEAN_STEREOTYPES = new Map<string, SpringBeanStereotype>([
|
|||
['org.springframework.stereotype.Controller', { role: 'controller' }],
|
||||
['org.springframework.web.bind.annotation.RestController', { role: 'rest-controller' }],
|
||||
['org.springframework.context.annotation.Configuration', { role: 'configuration' }],
|
||||
['org.springframework.boot.autoconfigure.AutoConfiguration', { role: 'auto-configuration' }],
|
||||
]);
|
||||
|
||||
export function deriveSpringBeanMetadata(
|
||||
|
|
|
|||
409
gitnexus/src/core/ingestion/frameworks/spring/conditionals.ts
Normal file
409
gitnexus/src/core/ingestion/frameworks/spring/conditionals.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ const CAPTURE_RELEVANT_ANNOTATIONS = new Set([
|
|||
'Controller',
|
||||
'RestController',
|
||||
'Configuration',
|
||||
'AutoConfiguration',
|
||||
]);
|
||||
|
||||
const STEREOTYPE_SIMPLE_NAMES = new Set(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '../jvm/package-facts.js';
|
||||
import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js';
|
||||
import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js';
|
||||
import type { JavaSpringConditionalFact } from './spring-conditionals.js';
|
||||
import type { JavaSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
export type JavaClassAnnotationFact = ClassAnnotationFact;
|
||||
|
|
@ -19,17 +20,20 @@ export interface JavaCaptureSideChannel {
|
|||
readonly packageFact: JvmPackageFact;
|
||||
readonly classAnnotations: readonly JavaClassAnnotationFact[];
|
||||
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
|
||||
readonly springConditionalFacts?: readonly JavaSpringConditionalFact[];
|
||||
readonly springDiFacts?: readonly JavaSpringDiClassFact[];
|
||||
}
|
||||
|
||||
const classAnnotations = createClassAnnotationFactStore();
|
||||
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
|
||||
const springConditionalFacts = new Map<string, readonly JavaSpringConditionalFact[]>();
|
||||
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
|
||||
|
||||
/** Clear facts retained by a prior workspace pass in a long-lived process. */
|
||||
export function clearJavaClassAnnotationFacts(): void {
|
||||
classAnnotations.clear();
|
||||
springConfigConsumers.clear();
|
||||
springConditionalFacts.clear();
|
||||
springDiFacts.clear();
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +59,20 @@ export function getJavaSpringConfigConsumerFacts(
|
|||
return springConfigConsumers.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setJavaSpringConditionalFacts(
|
||||
filePath: string,
|
||||
facts: readonly JavaSpringConditionalFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springConditionalFacts.delete(filePath);
|
||||
else springConditionalFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getJavaSpringConditionalFacts(
|
||||
filePath: string,
|
||||
): readonly JavaSpringConditionalFact[] {
|
||||
return springConditionalFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setJavaSpringDiFacts(
|
||||
filePath: string,
|
||||
facts: readonly JavaSpringDiClassFact[],
|
||||
|
|
@ -73,11 +91,13 @@ export function collectJavaCaptureSideChannel(
|
|||
): JavaCaptureSideChannel | undefined {
|
||||
const facts = classAnnotations.get(filePath);
|
||||
const configConsumers = springConfigConsumers.get(filePath) ?? [];
|
||||
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
|
||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||
const packageFact = getJavaPackageFact(filePath);
|
||||
if (
|
||||
facts.length === 0 &&
|
||||
configConsumers.length === 0 &&
|
||||
conditionFacts.length === 0 &&
|
||||
diFacts.length === 0 &&
|
||||
packageFact === undefined
|
||||
) {
|
||||
|
|
@ -88,6 +108,7 @@ export function collectJavaCaptureSideChannel(
|
|||
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
|
||||
classAnnotations: facts,
|
||||
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
|
||||
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
|
||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -108,6 +129,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
|||
) {
|
||||
setJavaClassAnnotationFacts(parsed.filePath, []);
|
||||
setJavaSpringConfigConsumerFacts(parsed.filePath, []);
|
||||
setJavaSpringConditionalFacts(parsed.filePath, []);
|
||||
setJavaSpringDiFacts(parsed.filePath, []);
|
||||
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||
return;
|
||||
|
|
@ -117,6 +139,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
|||
parsed.filePath,
|
||||
Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [],
|
||||
);
|
||||
setJavaSpringConditionalFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : [],
|
||||
);
|
||||
setJavaSpringDiFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||
|
|
|
|||
|
|
@ -36,12 +36,17 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
|||
import {
|
||||
setJavaClassAnnotationFacts,
|
||||
setJavaSpringConfigConsumerFacts,
|
||||
setJavaSpringConditionalFacts,
|
||||
setJavaSpringDiFacts,
|
||||
} from './capture-side-channel.js';
|
||||
import { captureJavaPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
|
||||
import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js';
|
||||
import {
|
||||
captureJavaSpringConditionalFacts,
|
||||
type JavaSpringConditionalFact,
|
||||
} from './spring-conditionals.js';
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
|
||||
|
|
@ -126,6 +131,7 @@ export function emitJavaScopeCaptures(
|
|||
const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
const classAnnotations = new Map<ScopeId, Set<string>>();
|
||||
const springConditionalFacts: JavaSpringConditionalFact[] = [];
|
||||
const springDiFacts: JavaSpringDiClassFact[] = [];
|
||||
const springDiClassNodeIds = new Set<number>();
|
||||
|
||||
|
|
@ -150,6 +156,9 @@ export function emitJavaScopeCaptures(
|
|||
const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration');
|
||||
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
|
||||
springDiClassNodeIds.add(springDiClassNode.id);
|
||||
springConditionalFacts.push(
|
||||
...captureJavaSpringConditionalFacts(springDiClassNode, filePath),
|
||||
);
|
||||
const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath);
|
||||
if (fact !== null) springDiFacts.push(fact);
|
||||
}
|
||||
|
|
@ -347,6 +356,7 @@ export function emitJavaScopeCaptures(
|
|||
filePath,
|
||||
captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath),
|
||||
);
|
||||
setJavaSpringConditionalFacts(filePath, springConditionalFacts);
|
||||
setJavaSpringDiFacts(filePath, springDiFacts);
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import { populateJavaPackageSiblings } from './package-siblings.js';
|
||||
import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
|
||||
import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
|
||||
import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js';
|
||||
import { attachJavaSpringDiMetadata } from './spring-di.js';
|
||||
import {
|
||||
applyJavaCaptureSideChannel,
|
||||
|
|
@ -87,6 +88,7 @@ const javaScopeResolver: ScopeResolver = {
|
|||
populateRangeBindings: populateJavaCrossFileReturnTypes,
|
||||
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => {
|
||||
attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -13,7 +13,9 @@ import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
|||
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
import { getJavaSpringDiFacts } from './capture-side-channel.js';
|
||||
|
||||
export type JavaAnnotationSyntaxFact = SpringDiAnnotationFact;
|
||||
export interface JavaAnnotationSyntaxFact extends SpringDiAnnotationFact {
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
export type JavaSpringDependencyFact = SpringDiDependencyFact<JavaAnnotationSyntaxFact>;
|
||||
|
||||
|
|
@ -29,7 +31,7 @@ export type JavaSpringDiClassFact = SpringDiClassFact<
|
|||
JavaSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
|
||||
export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
|
||||
const facts: JavaAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers') continue;
|
||||
|
|
@ -37,7 +39,11 @@ function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
|
|||
if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue;
|
||||
const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild;
|
||||
if (nameNode === null) continue;
|
||||
facts.push({ name: nameNode.text.trim(), text: modifier.text.trim() });
|
||||
facts.push({
|
||||
name: nameNode.text.trim(),
|
||||
text: modifier.text.trim(),
|
||||
line: modifier.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
|
|
@ -55,7 +61,7 @@ function dependenciesOf(callable: SyntaxNode): JavaSpringDependencyFact[] {
|
|||
dependencies.push({
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations: annotationFacts(parameter),
|
||||
annotations: javaSpringAnnotationFacts(parameter),
|
||||
});
|
||||
}
|
||||
return dependencies;
|
||||
|
|
@ -73,14 +79,14 @@ export function captureJavaSpringDiClassFact(
|
|||
): JavaSpringDiClassFact | null {
|
||||
const body = classNode.childForFieldName('body');
|
||||
if (body === null) return null;
|
||||
const classAnnotations = annotationFacts(classNode);
|
||||
const classAnnotations = javaSpringAnnotationFacts(classNode);
|
||||
const injectionSites: JavaSpringInjectionSiteFact[] = [];
|
||||
|
||||
const constructors = body.namedChildren.filter(
|
||||
(child) => child.type === 'constructor_declaration',
|
||||
);
|
||||
for (const constructor of constructors) {
|
||||
const annotations = annotationFacts(constructor);
|
||||
const annotations = javaSpringAnnotationFacts(constructor);
|
||||
const implicitConstructor =
|
||||
constructors.length === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
|
|
@ -97,7 +103,7 @@ export function captureJavaSpringDiClassFact(
|
|||
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'field_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
const annotations = javaSpringAnnotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const typeNode = member.childForFieldName('type');
|
||||
if (typeNode === null) continue;
|
||||
|
|
@ -120,7 +126,7 @@ export function captureJavaSpringDiClassFact(
|
|||
});
|
||||
}
|
||||
} else if (member.type === 'method_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
const annotations = javaSpringAnnotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'method',
|
||||
|
|
|
|||
|
|
@ -49,9 +49,11 @@ import {
|
|||
} from '../jvm/package-facts.js';
|
||||
import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js';
|
||||
import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js';
|
||||
import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
|
||||
import type { KotlinSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
const classAnnotations = createClassAnnotationFactStore();
|
||||
const springConditionalFacts = new Map<string, readonly KotlinSpringConditionalFact[]>();
|
||||
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
|
||||
|
||||
/**
|
||||
|
|
@ -68,12 +70,15 @@ export interface KotlinCaptureSideChannel {
|
|||
readonly packageFact: JvmPackageFact;
|
||||
/** Class annotation syntax collected by the existing scope traversal. */
|
||||
readonly classAnnotations: readonly ClassAnnotationFact[];
|
||||
/** Profile, conditional, and auto-configuration syntax captured per owner. */
|
||||
readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[];
|
||||
/** Constructor, property, and method injection syntax captured per class. */
|
||||
readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
|
||||
}
|
||||
|
||||
export function clearKotlinClassAnnotationFacts(): void {
|
||||
classAnnotations.clear();
|
||||
springConditionalFacts.clear();
|
||||
springDiFacts.clear();
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +93,20 @@ export function getKotlinClassAnnotationFacts(filePath: string): readonly ClassA
|
|||
return classAnnotations.get(filePath);
|
||||
}
|
||||
|
||||
export function setKotlinSpringConditionalFacts(
|
||||
filePath: string,
|
||||
facts: readonly KotlinSpringConditionalFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springConditionalFacts.delete(filePath);
|
||||
else springConditionalFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getKotlinSpringConditionalFacts(
|
||||
filePath: string,
|
||||
): readonly KotlinSpringConditionalFact[] {
|
||||
return springConditionalFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setKotlinSpringDiFacts(
|
||||
filePath: string,
|
||||
facts: readonly KotlinSpringDiClassFact[],
|
||||
|
|
@ -110,11 +129,13 @@ export function collectKotlinCaptureSideChannel(
|
|||
): KotlinCaptureSideChannel | undefined {
|
||||
const companionScopes = getCompanionScopesForFile(filePath);
|
||||
const annotationFacts = classAnnotations.get(filePath);
|
||||
const conditionFacts = springConditionalFacts.get(filePath) ?? [];
|
||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||
const packageFact = getKotlinPackageFact(filePath);
|
||||
if (
|
||||
companionScopes.length === 0 &&
|
||||
annotationFacts.length === 0 &&
|
||||
conditionFacts.length === 0 &&
|
||||
diFacts.length === 0 &&
|
||||
packageFact === undefined
|
||||
) {
|
||||
|
|
@ -125,6 +146,7 @@ export function collectKotlinCaptureSideChannel(
|
|||
companionScopes,
|
||||
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
|
||||
classAnnotations: annotationFacts,
|
||||
...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
|
||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -148,6 +170,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
|||
!Array.isArray(data.classAnnotations)
|
||||
) {
|
||||
classAnnotations.set(parsed.filePath, []);
|
||||
setKotlinSpringConditionalFacts(parsed.filePath, []);
|
||||
setKotlinSpringDiFacts(parsed.filePath, []);
|
||||
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||
return;
|
||||
|
|
@ -156,6 +179,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
|||
markCompanionScope(parsed.filePath, scopeId);
|
||||
}
|
||||
classAnnotations.set(parsed.filePath, data.classAnnotations);
|
||||
setKotlinSpringConditionalFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : [],
|
||||
);
|
||||
setKotlinSpringDiFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||
|
|
|
|||
|
|
@ -18,10 +18,18 @@ import { normalizeKotlinType } from './interpret.js';
|
|||
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
|
||||
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
|
||||
import { markCompanionScope } from './companion-scopes.js';
|
||||
import { setKotlinClassAnnotationFacts, setKotlinSpringDiFacts } from './capture-side-channel.js';
|
||||
import {
|
||||
setKotlinClassAnnotationFacts,
|
||||
setKotlinSpringConditionalFacts,
|
||||
setKotlinSpringDiFacts,
|
||||
} from './capture-side-channel.js';
|
||||
import { captureKotlinPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
|
||||
import {
|
||||
captureKotlinSpringConditionalFacts,
|
||||
type KotlinSpringConditionalFact,
|
||||
} from './spring-conditionals.js';
|
||||
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
|
||||
|
||||
|
|
@ -84,6 +92,7 @@ export function emitKotlinScopeCaptures(
|
|||
|
||||
const out: CaptureMatch[] = [];
|
||||
const classAnnotations = new Map<ScopeId, Set<string>>();
|
||||
const springConditionalFacts: KotlinSpringConditionalFact[] = [];
|
||||
const springDiFacts: KotlinSpringDiClassFact[] = [];
|
||||
const springDiClassNodeIds = new Set<number>();
|
||||
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
||||
|
|
@ -112,6 +121,9 @@ export function emitKotlinScopeCaptures(
|
|||
const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration');
|
||||
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
|
||||
springDiClassNodeIds.add(springDiClassNode.id);
|
||||
springConditionalFacts.push(
|
||||
...captureKotlinSpringConditionalFacts(springDiClassNode, filePath),
|
||||
);
|
||||
const fact = captureKotlinSpringDiClassFact(springDiClassNode, filePath);
|
||||
if (fact !== null) springDiFacts.push(fact);
|
||||
}
|
||||
|
|
@ -298,6 +310,7 @@ export function emitKotlinScopeCaptures(
|
|||
}
|
||||
|
||||
setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations));
|
||||
setKotlinSpringConditionalFacts(filePath, springConditionalFacts);
|
||||
setKotlinSpringDiFacts(filePath, springDiFacts);
|
||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
|
||||
return out;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { populateKotlinPackageSiblings } from './package-siblings.js';
|
|||
import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
|
||||
import { clearKotlinPackageFacts } from './package-facts.js';
|
||||
import { attachKotlinSpringDiMetadata } from './spring-di.js';
|
||||
import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
|
||||
|
||||
/**
|
||||
* Kotlin scope resolver for RFC #909 Ring 3.
|
||||
|
|
@ -126,6 +127,7 @@ export const kotlinScopeResolver: ScopeResolver = {
|
|||
populateNamespaceSiblings: populateKotlinPackageSiblings,
|
||||
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => {
|
||||
attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.j
|
|||
|
||||
export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact {
|
||||
readonly useSiteTarget?: string;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
export type KotlinSpringDependencyFact = SpringDiDependencyFact<KotlinAnnotationSyntaxFact>;
|
||||
|
|
@ -57,6 +58,7 @@ function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | nu
|
|||
return {
|
||||
name: nameNode.text.trim(),
|
||||
text: annotation.text.trim(),
|
||||
line: annotation.startPosition.row + 1,
|
||||
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
|
||||
};
|
||||
}
|
||||
|
|
@ -71,7 +73,7 @@ function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyn
|
|||
return facts;
|
||||
}
|
||||
|
||||
function annotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
|
||||
export function kotlinSpringAnnotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
|
||||
const facts: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
|
||||
|
|
@ -94,7 +96,7 @@ function parameterDependency(
|
|||
return {
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations: [...precedingAnnotations, ...annotationFacts(parameter)],
|
||||
annotations: [...precedingAnnotations, ...kotlinSpringAnnotationFacts(parameter)],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +136,7 @@ function propertyDependency(property: SyntaxNode): KotlinSpringDependencyFact |
|
|||
const nameNode = variable.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const typeNode = directTypeNode(variable);
|
||||
if (nameNode === undefined || typeNode === undefined) return null;
|
||||
const annotations = annotationFacts(property);
|
||||
const annotations = kotlinSpringAnnotationFacts(property);
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
|
|
@ -162,7 +164,7 @@ export function captureKotlinSpringDiClassFact(
|
|||
filePath: string,
|
||||
): KotlinSpringDiClassFact | null {
|
||||
if (!isKotlinBeanCandidateClass(classNode)) return null;
|
||||
const classAnnotations = annotationFacts(classNode);
|
||||
const classAnnotations = kotlinSpringAnnotationFacts(classNode);
|
||||
const injectionSites: KotlinSpringInjectionSiteFact[] = [];
|
||||
const body = classNode.namedChildren.find((child) => child.type === 'class_body');
|
||||
const primaryConstructor = classNode.namedChildren.find(
|
||||
|
|
@ -174,7 +176,7 @@ export function captureKotlinSpringDiClassFact(
|
|||
(primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length;
|
||||
|
||||
if (primaryConstructor !== undefined) {
|
||||
const annotations = annotationFacts(primaryConstructor);
|
||||
const annotations = kotlinSpringAnnotationFacts(primaryConstructor);
|
||||
const implicitConstructor =
|
||||
constructorCount === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
|
|
@ -191,7 +193,7 @@ export function captureKotlinSpringDiClassFact(
|
|||
}
|
||||
|
||||
for (const constructor of secondaryConstructors) {
|
||||
const annotations = annotationFacts(constructor);
|
||||
const annotations = kotlinSpringAnnotationFacts(constructor);
|
||||
const implicitConstructor =
|
||||
constructorCount === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
|
|
@ -209,7 +211,7 @@ export function captureKotlinSpringDiClassFact(
|
|||
if (body !== undefined) {
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'property_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
const annotations = kotlinSpringAnnotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const dependency = propertyDependency(member);
|
||||
if (dependency === null) continue;
|
||||
|
|
@ -221,7 +223,7 @@ export function captureKotlinSpringDiClassFact(
|
|||
dependencies: [dependency],
|
||||
});
|
||||
} else if (member.type === 'function_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
const annotations = kotlinSpringAnnotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const name =
|
||||
member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ??
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export {
|
|||
type ScopeResolutionOutput,
|
||||
} from '../scope-resolution/pipeline/phase.js';
|
||||
export { springConfigPhase, type SpringConfigOutput } from './spring-config.js';
|
||||
export {
|
||||
springAutoConfigurationPhase,
|
||||
type SpringAutoConfigurationOutput,
|
||||
} from './spring-auto-configuration.js';
|
||||
export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js';
|
||||
export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
|
||||
export { callSummariesPhase, type CallSummariesOutput } from './call-summaries.js';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -33,6 +33,7 @@ import {
|
|||
crossFilePhase,
|
||||
scopeResolutionPhase,
|
||||
springConfigPhase,
|
||||
springAutoConfigurationPhase,
|
||||
pruneLocalSymbolsPhase,
|
||||
taintSummariesPhase,
|
||||
callSummariesPhase,
|
||||
|
|
@ -261,7 +262,7 @@ export interface PipelineOptions {
|
|||
* Phase dependency graph:
|
||||
*
|
||||
* scan → structure → [springConfig, markdown, cobol] → parse → [routes, tools, orm]
|
||||
* → crossFile → scopeResolution → pruneLocalSymbols
|
||||
* → crossFile → scopeResolution → springAutoConfiguration → pruneLocalSymbols
|
||||
* → mro → di → communities → processes
|
||||
*
|
||||
* To add a new phase: create a file in pipeline-phases/, export the phase
|
||||
|
|
@ -288,6 +289,7 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
|
|||
.register(ormPhase)
|
||||
.register(crossFilePhase)
|
||||
.register(scopeResolutionPhase)
|
||||
.register(springAutoConfigurationPhase)
|
||||
.register(pruneLocalSymbolsPhase)
|
||||
// M4 (#2084): interprocedural taint fixpoint — the first real opt-in
|
||||
// pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on
|
||||
|
|
|
|||
|
|
@ -1255,12 +1255,11 @@ export function findChild(node: SyntaxNode, type: string): SyntaxNode | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/** Remove bidi-override and zero-width control characters. Doc text is
|
||||
* attacker-influenced (any indexed repo) and is returned verbatim to MCP
|
||||
* clients, so strip Trojan-Source-style hidden controls from the description
|
||||
* before it leaves the extractor (#2286 review). Scoped to the doc-comment path
|
||||
* only — global `sanitizeUTF8` is intentionally untouched. */
|
||||
const stripBidiAndZeroWidth = (text: string): string =>
|
||||
/** Remove bidi-override and zero-width control characters from attacker-
|
||||
* influenced repository text before it is exposed through graph descriptions
|
||||
* or MCP output (#2286). Global `sanitizeUTF8` intentionally remains focused
|
||||
* on encoding/control-character validity. */
|
||||
export const stripBidiAndZeroWidth = (text: string): string =>
|
||||
Array.from(text)
|
||||
.filter((ch) => {
|
||||
const c = ch.codePointAt(0) ?? 0;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ import {
|
|||
} from './sidecar-recovery.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
SPRING_AUTO_CONFIGURATION_REASONS,
|
||||
SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX,
|
||||
} from '../ingestion/frameworks/spring/auto-configuration.js';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relationship CSV splitting — extracted for testability (PR #818)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2614,9 +2618,9 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{
|
|||
/**
|
||||
* Shared mechanics for the delete-all-relationships-of-one-type family
|
||||
* ({@link deleteAllInterprocTaintPaths}, {@link deleteAllCallSummaries},
|
||||
* {@link deleteAllInjects}): count the typed CodeRelation rows, then DELETE
|
||||
* them (relationship-level — these are edge types, not node labels, so
|
||||
* endpoints are untouched).
|
||||
* {@link deleteAllInjects}, {@link deleteSpringAutoConfigurationDeclarations}):
|
||||
* count the matching CodeRelation rows, then DELETE them (relationship-level —
|
||||
* these are edge types, not node labels, so endpoints are untouched).
|
||||
*
|
||||
* count + DELETE run as one critical section on the singleton connection so a
|
||||
* concurrent WAL-checkpoint cannot corrupt native state mid-delete (#pdg).
|
||||
|
|
@ -2624,11 +2628,13 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{
|
|||
* @param relType the CodeRelation `type` value to delete (e.g. 'INJECTS')
|
||||
* @param logTag the `[tag]` prefix on the abort error message
|
||||
* @param duplicateNoun what the abort message says would be duplicated
|
||||
* @param exactReasons optional reason allowlist for a shared relationship type
|
||||
*/
|
||||
const deleteAllRelationshipsOfType = async (
|
||||
relType: string,
|
||||
logTag: string,
|
||||
duplicateNoun: string,
|
||||
exactReasons?: readonly string[],
|
||||
): Promise<{ edgesDeleted: number }> => {
|
||||
const c = conn;
|
||||
if (!c) {
|
||||
|
|
@ -2637,16 +2643,23 @@ const deleteAllRelationshipsOfType = async (
|
|||
return withConnLock(async () => {
|
||||
let edgesDeleted = 0;
|
||||
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
const reasonFilter =
|
||||
exactReasons === undefined || exactReasons.length === 0
|
||||
? ''
|
||||
: ` AND (${exactReasons
|
||||
.map((reason) => `r.reason = '${escapeCypherString(reason)}'`)
|
||||
.join(' OR ')})`;
|
||||
const predicate = `r.type = '${escapeCypherString(relType)}'${reasonFilter}`;
|
||||
try {
|
||||
countResult = await c.query(
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE r.type = '${relType}' RETURN count(r) AS cnt`,
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE ${predicate} RETURN count(r) AS cnt`,
|
||||
);
|
||||
const result = Array.isArray(countResult) ? countResult[0] : countResult;
|
||||
const rows = await result.getAll();
|
||||
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
|
||||
if (count > 0) {
|
||||
await closeQueryResults(
|
||||
await c.query(`MATCH ()-[r:CodeRelation]->() WHERE r.type = '${relType}' DELETE r`),
|
||||
await c.query(`MATCH ()-[r:CodeRelation]->() WHERE ${predicate} DELETE r`),
|
||||
);
|
||||
edgesDeleted = count;
|
||||
}
|
||||
|
|
@ -2732,6 +2745,65 @@ export const deleteAllCallSummaries = async (): Promise<{ edgesDeleted: number }
|
|||
export const deleteAllInjects = async (): Promise<{ edgesDeleted: number }> =>
|
||||
deleteAllRelationshipsOfType('INJECTS', 'di', 'duplicate INJECTS edges');
|
||||
|
||||
/**
|
||||
* Drop Spring-owned auto-configuration `DECLARES` relationships before
|
||||
* incremental writeback. `DECLARES` is generic, so exact reason filtering is
|
||||
* required: other metadata systems must retain their own declarations.
|
||||
*/
|
||||
export const deleteSpringAutoConfigurationDeclarations = async (): Promise<{
|
||||
edgesDeleted: number;
|
||||
}> =>
|
||||
deleteAllRelationshipsOfType(
|
||||
'DECLARES',
|
||||
'spring-auto-configuration',
|
||||
'duplicate auto-configuration declarations',
|
||||
SPRING_AUTO_CONFIGURATION_REASONS,
|
||||
);
|
||||
|
||||
/**
|
||||
* Drop synthetic source-unavailable auto-configuration Class nodes before
|
||||
* incremental writeback. The fresh full graph re-emits every still-needed
|
||||
* synthetic node; deleting first also removes placeholders that became stale
|
||||
* when a real source class appeared.
|
||||
*/
|
||||
export const deleteSpringAutoConfigurationSyntheticClasses = async (): Promise<{
|
||||
nodesDeleted: number;
|
||||
}> => {
|
||||
const c = conn;
|
||||
if (!c) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
return withConnLock(async () => {
|
||||
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
|
||||
const idPrefix = escapeCypherString(SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX);
|
||||
const predicate = `n.id STARTS WITH '${idPrefix}'`;
|
||||
try {
|
||||
countResult = await c.query(`MATCH (n:Class) WHERE ${predicate} RETURN count(n) AS cnt`);
|
||||
const result = Array.isArray(countResult) ? countResult[0] : countResult;
|
||||
const rows = await result.getAll();
|
||||
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
|
||||
if (count > 0) {
|
||||
await closeQueryResults(
|
||||
await c.query(`MATCH (n:Class) WHERE ${predicate} DETACH DELETE n`),
|
||||
);
|
||||
}
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
return { nodesDeleted: count };
|
||||
} catch (err) {
|
||||
if (countResult) await closeQueryResults(countResult);
|
||||
if (classifyDeleteAllError(err) === 'benign-missing-table') {
|
||||
return { nodesDeleted: 0 };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
'[spring-auto-configuration] failed to clear synthetic Class nodes before ' +
|
||||
`incremental re-write (${message}) — aborting to avoid stale placeholders; ` +
|
||||
'the next run will full-rebuild',
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Full-Text Search (FTS) Functions
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import {
|
|||
deleteAllInterprocTaintPaths,
|
||||
deleteAllCallSummaries,
|
||||
deleteAllInjects,
|
||||
deleteSpringAutoConfigurationDeclarations,
|
||||
deleteSpringAutoConfigurationSyntheticClasses,
|
||||
queryImportersBatch,
|
||||
loadFTSExtension,
|
||||
wipeLbugDbFiles,
|
||||
|
|
@ -136,7 +138,10 @@ import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
|
|||
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
|
||||
import { STALE_HASH_SENTINEL } from './lbug/schema.js';
|
||||
import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js';
|
||||
import { SPRING_BEAN_INVENTORY_FEATURE } from './ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
} from './ingestion/frameworks/spring/analysis-features.js';
|
||||
import { SPRING_CONFIG_BINDINGS_FEATURE } from './ingestion/languages/java/analysis-features.js';
|
||||
import {
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
|
|
@ -152,6 +157,7 @@ import {
|
|||
const ANALYSIS_FEATURES = [
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
] as const;
|
||||
|
||||
|
|
@ -2010,7 +2016,16 @@ async function runFullAnalysisInner(
|
|||
// deleting on every non-pdg incremental run (N runs = N copies of
|
||||
// every INJECTS row; CodeRelation has no PK and no read-side dedup).
|
||||
await deleteAllInjects();
|
||||
// 2b. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
|
||||
// 2b. Drop Spring-owned DECLARES edges (#2415). The
|
||||
// auto-configuration phase scans every metadata file and recomputes
|
||||
// the full set each run; exact reason filtering leaves declarations
|
||||
// owned by other metadata systems untouched.
|
||||
await deleteSpringAutoConfigurationDeclarations();
|
||||
// 2c. Drop source-unavailable auto-configuration placeholders. Fresh
|
||||
// synthetic nodes are graph-wide in extractChangedSubgraph, so this
|
||||
// also removes an orphan when a newly-added real class takes over.
|
||||
await deleteSpringAutoConfigurationSyntheticClasses();
|
||||
// 2d. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
|
||||
// — their validity is a whole-program property (an A→C flow can be
|
||||
// invalidated by a change to an intermediate function on a third
|
||||
// file), so endpoint-writability extraction can't refresh them.
|
||||
|
|
@ -2018,7 +2033,7 @@ async function runFullAnalysisInner(
|
|||
// graph (isGraphWideRelType), mirroring Community/Process.
|
||||
if (options.pdg === true) {
|
||||
await deleteAllInterprocTaintPaths();
|
||||
// 2c. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg`
|
||||
// 2e. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg`
|
||||
// writeback. They are re-included from the FULL fresh graph
|
||||
// (isGraphWideRelType) and the callSummaries phase recomputes every
|
||||
// summary each run, so delete-all-then-rebuild keeps an unchanged
|
||||
|
|
|
|||
|
|
@ -303,6 +303,12 @@ export const VALID_RELATION_TYPES = new Set([
|
|||
// (WRAPS/FETCHES precedent): the 0.5 unknown-type floor applies there,
|
||||
// and the edges carry their own confidence (0.8) in the graph.
|
||||
'INJECTS',
|
||||
// Conditional and metadata-declaration evidence is opt-in for impact
|
||||
// traversal, like INJECTS: explicit filters can follow activation
|
||||
// constraints and declarations without changing the default callgraph
|
||||
// surface.
|
||||
'CONDITIONAL_ON',
|
||||
'DECLARES',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// the main thread (the #1983 OOM). Because the two stores share this version,
|
||||
// any future change to the `ParsedFile` serialization shape MUST bump
|
||||
// SCHEMA_BUMP so both invalidate in lockstep.
|
||||
// v28: Java/Kotlin capture side-channels persist Spring condition facts and
|
||||
// annotation-source line numbers (#2415).
|
||||
// v26: the enclosing-callable walk stops at class bodies and anonymous-class
|
||||
// construction sites (#2699 follow-up); a v25 cache replays worker results carrying the
|
||||
// wrong Java anonymous-class ids. Cached results are replayed verbatim — including
|
||||
|
|
@ -101,7 +103,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// JLS 13.1 immediate-host chains (#2555).
|
||||
// v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity.
|
||||
// v16: direct callee identity.
|
||||
const SCHEMA_BUMP = 27;
|
||||
const SCHEMA_BUMP = 28;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ const buildFixture = (
|
|||
}
|
||||
relationships.push(edge('EXTENDS', `Class:${FILE_PATH}:Cls2`, `Class:${FILE_PATH}:Cls1`)); // retained
|
||||
relationships.push(edge('IMPORTS', `File:${FILE_PATH}`, `Class:${FILE_PATH}:Cls1`)); // streamed
|
||||
relationships.push(edge('DECLARES', `File:${FILE_PATH}`, `Class:${FILE_PATH}:Cls1`)); // streamed
|
||||
relationships.push(edge('CONDITIONAL_ON', `Class:${FILE_PATH}:Cls2`, `Class:${FILE_PATH}:Cls1`)); // streamed
|
||||
// Self-edge and an exact duplicate id — both must appear exactly once.
|
||||
relationships.push(edge('CALLS', `Function:${FILE_PATH}:fn0`, `Function:${FILE_PATH}:fn0`));
|
||||
relationships.push(edge('CALLS', `Function:${FILE_PATH}:fn0`, `Function:${FILE_PATH}:fn1`));
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import path from 'path';
|
|||
import type { GraphRelationship } from 'gitnexus-shared';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
import { skipUnlessFtsAvailable } from '../helpers/fts-availability.js';
|
||||
import {
|
||||
SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION,
|
||||
SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX,
|
||||
} from '../../src/core/ingestion/frameworks/spring/auto-configuration.js';
|
||||
|
||||
/**
|
||||
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
|
||||
|
|
@ -170,6 +174,66 @@ withTestLbugDB(
|
|||
expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1);
|
||||
});
|
||||
|
||||
it('deleteSpringAutoConfigurationDeclarations: removes only Spring DECLARES edges (#2415)', async () => {
|
||||
const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationDeclarations } =
|
||||
await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
await expect(deleteSpringAutoConfigurationDeclarations()).resolves.toEqual({
|
||||
edgesDeleted: 0,
|
||||
});
|
||||
const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as Array<{
|
||||
id: string;
|
||||
}>;
|
||||
expect(fns.length).toBe(2);
|
||||
await coreExecuteQuery(
|
||||
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
|
||||
`CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'spring-auto-configuration-import', step: 0}]->(b)`,
|
||||
);
|
||||
await coreExecuteQuery(
|
||||
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
|
||||
`CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'spring-auto-configuration-factory', step: 0}]->(b)`,
|
||||
);
|
||||
await coreExecuteQuery(
|
||||
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
|
||||
`CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'other-metadata-system', step: 0}]->(b)`,
|
||||
);
|
||||
|
||||
await expect(deleteSpringAutoConfigurationDeclarations()).resolves.toEqual({
|
||||
edgesDeleted: 2,
|
||||
});
|
||||
const left = await coreExecuteQuery(
|
||||
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' RETURN count(r) AS cnt`,
|
||||
);
|
||||
expect(Number((left[0] as { cnt: number }).cnt)).toBe(1);
|
||||
});
|
||||
|
||||
it('deleteSpringAutoConfigurationSyntheticClasses: removes only metadata placeholders (#2415)', async () => {
|
||||
const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationSyntheticClasses } =
|
||||
await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
await coreExecuteQuery(
|
||||
`CREATE (:Class {id: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}com.example.ExternalAutoConfiguration', ` +
|
||||
`name: 'ExternalAutoConfiguration', ` +
|
||||
`filePath: 'META-INF/spring.factories', ` +
|
||||
`description: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION}'})`,
|
||||
);
|
||||
await coreExecuteQuery(
|
||||
`CREATE (:Class {id: 'Class:src/Real.java:Real', name: 'Real', ` +
|
||||
`filePath: 'src/Real.java', ` +
|
||||
`description: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION}'})`,
|
||||
);
|
||||
await expect(deleteSpringAutoConfigurationSyntheticClasses()).resolves.toEqual({
|
||||
nodesDeleted: 1,
|
||||
});
|
||||
const syntheticLeft = await coreExecuteQuery(
|
||||
`MATCH (n:Class) WHERE n.id STARTS WITH '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}' ` +
|
||||
'RETURN count(n) AS cnt',
|
||||
);
|
||||
expect(Number((syntheticLeft[0] as { cnt: number }).cnt)).toBe(0);
|
||||
const realClasses = await coreExecuteQuery('MATCH (n:Class) RETURN count(n) AS cnt');
|
||||
expect(Number((realClasses[0] as { cnt: number }).cnt)).toBe(2);
|
||||
});
|
||||
|
||||
describe('unhappy path', () => {
|
||||
it('throws on malformed Cypher query', async () => {
|
||||
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
|
|
|||
322
gitnexus/test/integration/spring-conditionals-pipeline.test.ts
Normal file
322
gitnexus/test/integration/spring-conditionals-pipeline.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,12 +5,16 @@ import {
|
|||
resolveAnalysisFeatureVersions,
|
||||
type AnalysisFeatureDescriptor,
|
||||
} from '../../src/core/analysis-features.js';
|
||||
import { SPRING_BEAN_INVENTORY_FEATURE } from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js';
|
||||
|
||||
const FEATURES = [
|
||||
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
] as const;
|
||||
|
||||
|
|
@ -22,11 +26,13 @@ describe('analysis feature versions', () => {
|
|||
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.bean-inventory': 1,
|
||||
'spring.conditionals-auto-configuration': 1,
|
||||
'spring.config-bindings': 1,
|
||||
});
|
||||
expect(resolveAnalysisFeatureVersions(FEATURES, ['BUILD.GRADLE.KTS'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.bean-inventory': 1,
|
||||
'spring.conditionals-auto-configuration': 1,
|
||||
});
|
||||
expect(
|
||||
resolveAnalysisFeatureVersions(FEATURES, [
|
||||
|
|
@ -37,6 +43,14 @@ describe('analysis feature versions', () => {
|
|||
'graph.class-framework-annotations': 1,
|
||||
'spring.config-bindings': 1,
|
||||
});
|
||||
expect(
|
||||
resolveAnalysisFeatureVersions(FEATURES, [
|
||||
'src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports',
|
||||
]),
|
||||
).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'spring.conditionals-auto-configuration': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires an exact, well-formed feature set', () => {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ import {
|
|||
stampEmbeddingCount,
|
||||
} from '../helpers/embedding-seed.js';
|
||||
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js';
|
||||
import { SPRING_BEAN_INVENTORY_FEATURE } from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js';
|
||||
import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js';
|
||||
|
||||
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-');
|
||||
|
|
@ -165,6 +169,38 @@ async function countInjects(repoPath: string): Promise<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
|
||||
* 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the
|
||||
* spring-di-pipeline integration fixture. */
|
||||
|
|
@ -275,6 +311,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
expect(meta!.analysisFeatures).toEqual({
|
||||
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
||||
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
||||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
||||
});
|
||||
|
||||
await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_BEAN_INVENTORY_FEATURE.id));
|
||||
|
|
@ -291,6 +328,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
|
||||
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
||||
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
||||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
|
|
@ -358,6 +396,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
|
||||
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
||||
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
||||
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
|
|
@ -916,6 +955,49 @@ describe('runFullAnalysis — incremental orchestration', () => {
|
|||
await repo.cleanup();
|
||||
}
|
||||
}, 600_000);
|
||||
|
||||
it('incremental runs do not duplicate repository-wide Spring DECLARES edges (#2415)', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
try {
|
||||
const sourceDir = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example');
|
||||
const metadataDir = path.join(repo.dbPath, 'src', 'main', 'resources', 'META-INF', 'spring');
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await mkdir(metadataDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(metadataDir, 'org.springframework.boot.autoconfigure.AutoConfiguration.imports'),
|
||||
'com.example.ExampleAutoConfiguration\n',
|
||||
'utf-8',
|
||||
);
|
||||
gitCommitAll(repo.dbPath, 'add auto configuration metadata');
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
||||
expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1);
|
||||
expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(1);
|
||||
|
||||
const target = path.join(repo.dbPath, 'src', 'logger.ts');
|
||||
for (const run of [1, 2]) {
|
||||
const before = await readFile(target, 'utf-8');
|
||||
await writeFile(target, `${before}\n// auto-register idempotency touch ${run}\n`, 'utf-8');
|
||||
gitCommitAll(repo.dbPath, `unrelated auto-register touch ${run}`);
|
||||
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
||||
expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1);
|
||||
expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(1);
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(sourceDir, 'ExampleAutoConfiguration.java'),
|
||||
'package com.example;\npublic class ExampleAutoConfiguration {}\n',
|
||||
'utf-8',
|
||||
);
|
||||
gitCommitAll(repo.dbPath, 'add source for metadata-only auto configuration');
|
||||
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
||||
expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1);
|
||||
expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
}, 600_000);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import {
|
|||
extractChangedSubgraph,
|
||||
computeEffectiveWriteSet,
|
||||
} from '../../src/core/incremental/subgraph-extract.js';
|
||||
import {
|
||||
SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION,
|
||||
SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX,
|
||||
} from '../../src/core/ingestion/frameworks/spring/auto-configuration.js';
|
||||
|
||||
const makeFileNode = (id: string, filePath: string, label = 'Function'): GraphNode =>
|
||||
({
|
||||
|
|
@ -37,12 +41,14 @@ const makeRel = (
|
|||
sourceId: string,
|
||||
targetId: string,
|
||||
type = 'CALLS',
|
||||
reason = 'test',
|
||||
): GraphRelationship =>
|
||||
({
|
||||
id,
|
||||
sourceId,
|
||||
targetId,
|
||||
type,
|
||||
reason,
|
||||
properties: {},
|
||||
}) as unknown as GraphRelationship;
|
||||
|
||||
|
|
@ -68,6 +74,25 @@ describe('extractChangedSubgraph', () => {
|
|||
expect(sub.nodes.map((n) => n.id).sort()).toEqual(['comm-1', 'proc-1']);
|
||||
});
|
||||
|
||||
it('always includes Spring auto-configuration synthetic Class nodes', () => {
|
||||
const g = createKnowledgeGraph();
|
||||
g.addNode({
|
||||
id: `${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}com.example.ExternalAutoConfiguration`,
|
||||
label: 'Class',
|
||||
properties: {
|
||||
name: 'ExternalAutoConfiguration',
|
||||
filePath: '/repo/META-INF/spring.factories',
|
||||
description: SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION,
|
||||
},
|
||||
});
|
||||
|
||||
const sub = extractChangedSubgraph(g, new Set(['/repo/unrelated.ts']));
|
||||
|
||||
expect(sub.nodes.map((node) => node.id)).toEqual([
|
||||
`${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}com.example.ExternalAutoConfiguration`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes a relationship when at least one endpoint is writable', () => {
|
||||
const g = createKnowledgeGraph();
|
||||
g.addNode(makeFileNode('a:fn', '/repo/a.ts'));
|
||||
|
|
@ -130,6 +155,39 @@ describe('extractChangedSubgraph', () => {
|
|||
|
||||
expect(sub.relationships.map((r) => r.id)).toEqual(['inj1']);
|
||||
});
|
||||
|
||||
it('always includes Spring DECLARES edges between unchanged metadata and classes (#2415)', () => {
|
||||
const g = createKnowledgeGraph();
|
||||
g.addNode(makeFileNode('metadata:File', '/repo/META-INF/spring.factories', 'File'));
|
||||
g.addNode(makeFileNode('config:Class', '/repo/AutoConfig.java', 'Class'));
|
||||
g.addRelationship(
|
||||
makeRel(
|
||||
'declares1',
|
||||
'metadata:File',
|
||||
'config:Class',
|
||||
'DECLARES',
|
||||
'spring-auto-configuration-factory',
|
||||
),
|
||||
);
|
||||
g.addRelationship(makeRel('call1', 'metadata:File', 'config:Class', 'CALLS'));
|
||||
|
||||
const sub = extractChangedSubgraph(g, new Set(['/repo/unrelated.ts']));
|
||||
|
||||
expect(sub.relationships.map((relationship) => relationship.id)).toEqual(['declares1']);
|
||||
});
|
||||
|
||||
it('does not make another metadata system graph-wide just because it uses DECLARES', () => {
|
||||
const g = createKnowledgeGraph();
|
||||
g.addNode(makeFileNode('metadata:File', '/repo/META-INF/example.metadata', 'File'));
|
||||
g.addNode(makeFileNode('target:Class', '/repo/Target.java', 'Class'));
|
||||
g.addRelationship(
|
||||
makeRel('declares1', 'metadata:File', 'target:Class', 'DECLARES', 'example-discovery'),
|
||||
);
|
||||
|
||||
const sub = extractChangedSubgraph(g, new Set(['/repo/unrelated.ts']));
|
||||
|
||||
expect(sub.relationships).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeEffectiveWriteSet (Finding 1)', () => {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const FULL_ORDER = [
|
|||
'orm',
|
||||
'crossFile',
|
||||
'scopeResolution',
|
||||
'springAutoConfiguration',
|
||||
'pruneLocalSymbols',
|
||||
'mro',
|
||||
'di',
|
||||
|
|
|
|||
|
|
@ -111,6 +111,12 @@ describe('LadybugDB Schema', () => {
|
|||
it('includes the DI collection-injection edge type (#2200)', () => {
|
||||
expect(REL_TYPES).toContain('INJECTS');
|
||||
});
|
||||
|
||||
it('includes Spring condition and auto-configuration edge types (#2415)', () => {
|
||||
expect(REL_TYPES).toContain('CONDITIONAL_ON');
|
||||
expect(REL_TYPES).toContain('DECLARES');
|
||||
expect(REL_TYPES).not.toContain('AUTO_REGISTERS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node schema DDL', () => {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ describe('VALID_RELATION_TYPES', () => {
|
|||
'WRAPS',
|
||||
// Spring DI @Autowired collection injection (#2200)
|
||||
'INJECTS',
|
||||
// Conditional activation and metadata declaration/discovery (#2415)
|
||||
'CONDITIONAL_ON',
|
||||
'DECLARES',
|
||||
] as const;
|
||||
|
||||
it('contains all expected relation types', () => {
|
||||
|
|
|
|||
144
gitnexus/test/unit/spring-auto-configuration.test.ts
Normal file
144
gitnexus/test/unit/spring-auto-configuration.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -68,7 +68,9 @@ describe('Java Spring injection syntax capture', () => {
|
|||
`);
|
||||
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(facts[0].classAnnotations).toEqual([{ name: 'Service', text: '@Service("checkout")' }]);
|
||||
expect(facts[0].classAnnotations).toEqual([
|
||||
{ name: 'Service', text: '@Service("checkout")', line: 2 },
|
||||
]);
|
||||
expect(facts[0].injectionSites).toMatchObject([
|
||||
{
|
||||
kind: 'constructor',
|
||||
|
|
@ -167,8 +169,8 @@ describe('Kotlin Spring injection syntax capture', () => {
|
|||
|
||||
expect(facts).toHaveLength(1);
|
||||
expect(facts[0].classAnnotations).toEqual([
|
||||
{ name: 'Service', text: '@Service("checkout")' },
|
||||
{ name: 'Primary', text: '@Primary' },
|
||||
{ name: 'Service', text: '@Service("checkout")', line: 2 },
|
||||
{ name: 'Primary', text: '@Primary', line: 2 },
|
||||
]);
|
||||
expect(facts[0].injectionSites).toMatchObject([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { getCopyQuery } from '../../src/core/lbug/lbug-adapter.js';
|
|||
import { PARSE_CACHE_VERSION } from '../../src/storage/parse-cache.js';
|
||||
import { INCREMENTAL_SCHEMA_VERSION } from '../../src/storage/repo-manager.js';
|
||||
import { isSpringBeanCandidateSourceFile } from '../../src/core/ingestion/frameworks/spring/bean-catalog.js';
|
||||
import { SPRING_BEAN_INVENTORY_FEATURE } from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
SPRING_BEAN_INVENTORY_FEATURE,
|
||||
SPRING_CONDITIONALS_FEATURE,
|
||||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js';
|
||||
|
||||
describe('Spring Bean Class persistence schema', () => {
|
||||
|
|
@ -18,10 +21,11 @@ describe('Spring Bean Class persistence schema', () => {
|
|||
|
||||
it('meets the cache-version baselines required by the merged implementation', () => {
|
||||
const parseSchemaVersion = Number.parseInt(PARSE_CACHE_VERSION, 10);
|
||||
expect(parseSchemaVersion).toBeGreaterThanOrEqual(20);
|
||||
expect(parseSchemaVersion).toBeGreaterThanOrEqual(22);
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBeGreaterThanOrEqual(8);
|
||||
expect(CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version).toBe(1);
|
||||
expect(SPRING_BEAN_INVENTORY_FEATURE.version).toBe(1);
|
||||
expect(SPRING_CONDITIONALS_FEATURE.version).toBe(1);
|
||||
});
|
||||
|
||||
it('limits incremental drift queries to Java and Kotlin Bean source files', () => {
|
||||
|
|
|
|||
|
|
@ -137,6 +137,11 @@ describe('buildPhaseList under streamGraphEmit', () => {
|
|||
});
|
||||
|
||||
describe('RETAINED_REL_TYPES tracks its readers', () => {
|
||||
it('streams write-only conditional and declaration evidence', () => {
|
||||
expect(RETAINED_REL_TYPES.has('CONDITIONAL_ON')).toBe(false);
|
||||
expect(RETAINED_REL_TYPES.has('DECLARES')).toBe(false);
|
||||
});
|
||||
|
||||
it('retains every relationship type any phase reads back mid-pipeline', async () => {
|
||||
// The round-trip test CANNOT catch drift here: addRelationship partitions
|
||||
// edges between the graph and the CSVs, and a partition's union is
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue