gitnexus 1.6.11-rc.16 → 1.6.11-rc.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,20 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { XMLParser } from 'fast-xml-parser';
3
4
  import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
4
5
  import { logger } from '../../logger.js';
6
+ // POMs are static metadata. Parse hierarchy with a real XML parser, but do not
7
+ // invoke Maven or resolve the effective model. Properties, profiles, and remote
8
+ // parent resolution remain outside this extractor's deterministic boundary.
9
+ const pomParser = new XMLParser({
10
+ ignoreAttributes: true,
11
+ removeNSPrefix: true,
12
+ trimValues: true,
13
+ parseTagValue: false,
14
+ processEntities: false,
15
+ ignoreDeclaration: true,
16
+ ignorePiTags: true,
17
+ });
5
18
  async function parseJavaManifest(repoPath) {
6
19
  const pomPath = path.join(repoPath, 'pom.xml');
7
20
  try {
@@ -9,13 +22,14 @@ async function parseJavaManifest(repoPath) {
9
22
  return parsePom(content);
10
23
  }
11
24
  catch {
12
- // fall through to Gradle
25
+ // Missing pom.xml — fall through to Gradle.
13
26
  }
27
+ const gradleSidecars = await readGradleSidecars(repoPath);
14
28
  for (const name of ['build.gradle.kts', 'build.gradle']) {
15
29
  const gradlePath = path.join(repoPath, name);
16
30
  try {
17
31
  const content = await fs.readFile(gradlePath, 'utf-8');
18
- return parseGradle(content, repoPath);
32
+ return parseGradle(content, repoPath, gradleSidecars);
19
33
  }
20
34
  catch {
21
35
  continue;
@@ -23,45 +37,244 @@ async function parseJavaManifest(repoPath) {
23
37
  }
24
38
  return null;
25
39
  }
40
+ async function readIfPresent(filePath) {
41
+ try {
42
+ return await fs.readFile(filePath, 'utf-8');
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ async function readGradleSidecars(repoPath) {
49
+ const [properties, settingsKts, settingsGroovy, catalog] = await Promise.all([
50
+ readIfPresent(path.join(repoPath, 'gradle.properties')),
51
+ readIfPresent(path.join(repoPath, 'settings.gradle.kts')),
52
+ readIfPresent(path.join(repoPath, 'settings.gradle')),
53
+ readIfPresent(path.join(repoPath, 'gradle', 'libs.versions.toml')),
54
+ ]);
55
+ const sidecars = {
56
+ catalogLibraries: new Map(),
57
+ catalogBundles: new Map(),
58
+ };
59
+ const groupMatch = properties?.match(/(?:^|\n)\s*group\s*=\s*([^\s#]+)/);
60
+ if (groupMatch)
61
+ sidecars.propertiesGroup = groupMatch[1];
62
+ const settings = settingsKts ?? settingsGroovy;
63
+ const nameMatch = settings?.match(/rootProject\.name\s*=\s*['"]([^'"]+)['"]/);
64
+ if (nameMatch)
65
+ sidecars.rootProjectName = nameMatch[1];
66
+ if (catalog) {
67
+ const parsed = parseGradleVersionCatalog(catalog);
68
+ sidecars.catalogLibraries = parsed.libraries;
69
+ sidecars.catalogBundles = parsed.bundles;
70
+ }
71
+ return sidecars;
72
+ }
73
+ function catalogAccessors(alias) {
74
+ const dotted = alias.replace(/[-_]/g, '.');
75
+ const camel = alias.replace(/[-_]+([A-Za-z0-9])/g, (_, char) => char.toUpperCase());
76
+ return [...new Set([alias, dotted, camel])];
77
+ }
78
+ function projectAccessorToArtifactId(accessor) {
79
+ const last = accessor.split('.').pop();
80
+ return last.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`).replace(/^-/, '');
81
+ }
82
+ function moduleToGa(module) {
83
+ const parts = module.split(':');
84
+ return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : undefined;
85
+ }
86
+ function parseInlineTomlTable(rhs) {
87
+ const fields = {};
88
+ for (const match of rhs.matchAll(/([A-Za-z0-9_-]+)\s*=\s*['"]([^'"]+)['"]/g)) {
89
+ fields[match[1]] = match[2];
90
+ }
91
+ return fields;
92
+ }
93
+ /** Default Gradle catalog (`gradle/libs.versions.toml`) — aliases only, no version resolution. */
94
+ function parseGradleVersionCatalog(toml) {
95
+ const libraries = new Map();
96
+ const bundles = new Map();
97
+ let section = 'other';
98
+ const addLibrary = (alias, ga) => {
99
+ for (const accessor of catalogAccessors(alias))
100
+ libraries.set(accessor, ga);
101
+ };
102
+ for (const raw of toml.split(/\r?\n/)) {
103
+ const line = raw.replace(/#.*$/, '').trim();
104
+ if (!line)
105
+ continue;
106
+ const header = line.match(/^\[([^\]]+)\]$/);
107
+ if (header) {
108
+ const name = header[1];
109
+ section =
110
+ name === 'libraries' || name.endsWith('.libraries')
111
+ ? 'libraries'
112
+ : name === 'bundles' || name.endsWith('.bundles')
113
+ ? 'bundles'
114
+ : 'other';
115
+ continue;
116
+ }
117
+ if (section === 'libraries') {
118
+ const dottedModule = line.match(/^([A-Za-z0-9._-]+)\.module\s*=\s*['"]([^'"]+)['"]$/);
119
+ if (dottedModule) {
120
+ const ga = moduleToGa(dottedModule[2]);
121
+ if (ga)
122
+ addLibrary(dottedModule[1], ga);
123
+ continue;
124
+ }
125
+ const assignment = line.match(/^([A-Za-z0-9._-]+)\s*=\s*(.+)$/);
126
+ if (!assignment)
127
+ continue;
128
+ const alias = assignment[1];
129
+ const rhs = assignment[2].trim();
130
+ const quoted = rhs.match(/^['"]([^'"]+)['"]$/);
131
+ if (quoted) {
132
+ const ga = moduleToGa(quoted[1]);
133
+ if (ga)
134
+ addLibrary(alias, ga);
135
+ continue;
136
+ }
137
+ const table = parseInlineTomlTable(rhs);
138
+ const ga = table.module
139
+ ? moduleToGa(table.module)
140
+ : table.group && table.name
141
+ ? `${table.group}:${table.name}`
142
+ : undefined;
143
+ if (ga)
144
+ addLibrary(alias, ga);
145
+ continue;
146
+ }
147
+ if (section === 'bundles') {
148
+ const assignment = line.match(/^([A-Za-z0-9._-]+)\s*=\s*\[([^\]]*)\]$/);
149
+ if (!assignment)
150
+ continue;
151
+ const members = [...assignment[2].matchAll(/['"]([^'"]+)['"]/g)].map((match) => match[1]);
152
+ for (const accessor of catalogAccessors(assignment[1]))
153
+ bundles.set(accessor, members);
154
+ }
155
+ }
156
+ return { libraries, bundles };
157
+ }
158
+ const GRADLE_GROUP_PATTERNS = [
159
+ /(?:^|[\n{;])\s*(?:rootProject\.)?group\s*=\s*['"]([^'"]+)['"]/,
160
+ /(?:^|[\n{;])\s*group\s+['"]([^'"]+)['"]/,
161
+ ];
162
+ const GRADLE_COORD_CONFIGS = 'implementation|api|compileOnly|runtimeOnly|testImplementation|testApi|testCompileOnly|compile|kapt|ksp|commonMainImplementation|commonMainApi';
163
+ const CATALOG_ALIAS = '([A-Za-z0-9_]+(?:\\.[A-Za-z0-9_]+)*)(?:\\.get\\(\\)|\\.asProvider\\(\\))?';
164
+ function gradleDepRe(suffix) {
165
+ return new RegExp(`(?:${GRADLE_COORD_CONFIGS})\\s*${suffix}`, 'g');
166
+ }
167
+ function parseGradleGroup(content) {
168
+ for (const pattern of GRADLE_GROUP_PATTERNS) {
169
+ const match = content.match(pattern);
170
+ if (match?.[1])
171
+ return match[1];
172
+ }
173
+ return undefined;
174
+ }
175
+ function asXmlNode(value) {
176
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
177
+ ? value
178
+ : undefined;
179
+ }
180
+ function xmlText(value) {
181
+ if (typeof value === 'string' || typeof value === 'number') {
182
+ const text = String(value).trim();
183
+ return text || undefined;
184
+ }
185
+ const nested = asXmlNode(value)?.['#text'];
186
+ if (nested === undefined)
187
+ return undefined;
188
+ return xmlText(nested);
189
+ }
190
+ function xmlChildText(node, name) {
191
+ return node ? xmlText(node[name]) : undefined;
192
+ }
193
+ function asList(value) {
194
+ if (value === undefined || value === null)
195
+ return [];
196
+ return Array.isArray(value) ? value : [value];
197
+ }
198
+ /** Direct project dependencies only — not BOM, profiles, or plugin classpath. */
199
+ function collectProjectDependencies(project, deps) {
200
+ const dependencies = asXmlNode(project.dependencies);
201
+ if (!dependencies)
202
+ return;
203
+ for (const dep of asList(dependencies.dependency)) {
204
+ const depNode = asXmlNode(dep);
205
+ const groupId = xmlChildText(depNode, 'groupId');
206
+ const artifactId = xmlChildText(depNode, 'artifactId');
207
+ if (groupId && artifactId)
208
+ deps.push(`${groupId}:${artifactId}`);
209
+ }
210
+ }
26
211
  function parsePom(content) {
27
- const projectGroupMatch = content.match(/<project[^>]*>[\s\S]*?<groupId>([^<]+)<\/groupId>/);
28
- const projectArtifactMatch = content.match(/<project[^>]*>[\s\S]*?<artifactId>([^<]+)<\/artifactId>/);
29
- if (!projectGroupMatch || !projectArtifactMatch)
212
+ let parsed;
213
+ try {
214
+ // parseSourceSafe guards tree-sitter's Windows SIGSEGV by switching to a
215
+ // chunked input callback above 16 KB; XMLParser only accepts XML text, so
216
+ // routing POMs through it silently yields an empty document.
217
+ // eslint-disable-next-line gitnexus/require-safe-parse
218
+ parsed = pomParser.parse(content);
219
+ }
220
+ catch {
30
221
  return null;
31
- const groupId = projectGroupMatch[1].trim();
32
- const artifactId = projectArtifactMatch[1].trim();
33
- const deps = [];
34
- const depBlocks = content.matchAll(/<dependency>\s*([\s\S]*?)<\/dependency>/g);
35
- for (const block of depBlocks) {
36
- const gMatch = block[1].match(/<groupId>([^<]+)<\/groupId>/);
37
- const aMatch = block[1].match(/<artifactId>([^<]+)<\/artifactId>/);
38
- if (gMatch && aMatch) {
39
- deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`);
40
- }
41
222
  }
223
+ const project = asXmlNode(asXmlNode(parsed)?.project);
224
+ if (!project)
225
+ return null;
226
+ // Maven inherits groupId from <parent>, but artifactId is always the
227
+ // project's own direct child and must never fall back to parent.artifactId.
228
+ const groupId = xmlChildText(project, 'groupId') ?? xmlChildText(asXmlNode(project.parent), 'groupId');
229
+ const artifactId = xmlChildText(project, 'artifactId');
230
+ if (!groupId || !artifactId)
231
+ return null;
232
+ const deps = [];
233
+ collectProjectDependencies(project, deps);
42
234
  return { groupId, artifactId, deps: [...new Set(deps)] };
43
235
  }
44
- function parseGradle(content, repoPath) {
45
- const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/);
46
- const dirName = path.basename(repoPath);
47
- const groupId = groupMatch ? groupMatch[1] : '';
236
+ function parseGradle(content, repoPath, sidecars = { catalogLibraries: new Map(), catalogBundles: new Map() }) {
237
+ // Static text + default catalog file. Do not execute Gradle.
238
+ const groupId = parseGradleGroup(content) ?? sidecars.propertiesGroup ?? '';
48
239
  if (!groupId)
49
240
  return null;
50
- const artifactId = dirName;
241
+ const artifactId = sidecars.rootProjectName ?? path.basename(repoPath);
242
+ const { catalogLibraries, catalogBundles } = sidecars;
51
243
  const deps = [];
52
- // implementation("group:artifact:version") or api("group:artifact:version")
53
- const depMatches = content.matchAll(/(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
54
- for (const m of depMatches) {
55
- const parts = m[1].split(':');
56
- if (parts.length >= 2) {
57
- deps.push(`${parts[0]}:${parts[1]}`);
244
+ const pushCatalogAlias = (alias) => {
245
+ const ga = catalogLibraries.get(alias);
246
+ if (ga)
247
+ deps.push(ga);
248
+ };
249
+ const namedPattern = gradleDepRe(`(?:\\(\\s*)?(?:group\\s*=\\s*['"](?<group1>[^'"]+)['"]\\s*,\\s*name\\s*=\\s*['"](?<name1>[^'"]+)['"]|name\\s*=\\s*['"](?<name2>[^'"]+)['"]\\s*,\\s*group\\s*=\\s*['"](?<group2>[^'"]+)['"]|group:\\s*['"](?<group3>[^'"]+)['"]\\s*,\\s*name:\\s*['"](?<name3>[^'"]+)['"]|name:\\s*['"](?<name4>[^'"]+)['"]\\s*,\\s*group:\\s*['"](?<group4>[^'"]+)['"])`);
250
+ for (const match of content.matchAll(namedPattern)) {
251
+ const group = match.groups?.group1 ?? match.groups?.group2 ?? match.groups?.group3 ?? match.groups?.group4;
252
+ const name = match.groups?.name1 ?? match.groups?.name2 ?? match.groups?.name3 ?? match.groups?.name4;
253
+ if (group && name)
254
+ deps.push(`${group}:${name}`);
255
+ }
256
+ for (const match of content.matchAll(gradleDepRe(`(?:\\(\\s*)?libs(?:\\.libraries)?\\.(?!bundles\\.|plugins\\.)${CATALOG_ALIAS}`))) {
257
+ pushCatalogAlias(match[1]);
258
+ }
259
+ for (const match of content.matchAll(gradleDepRe(`(?:\\(\\s*)?libs\\.bundles\\.${CATALOG_ALIAS}`))) {
260
+ for (const member of catalogBundles.get(match[1]) ?? []) {
261
+ for (const accessor of catalogAccessors(member))
262
+ pushCatalogAlias(accessor);
58
263
  }
59
264
  }
60
- // implementation(project(":subproject"))
61
- const projDeps = content.matchAll(/(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g);
62
- for (const m of projDeps) {
63
- const subName = m[1].replace(/^:/, '');
64
- deps.push(`${groupId}:${subName}`);
265
+ for (const match of content.matchAll(gradleDepRe(`\\(\\s*projects\\.([A-Za-z][A-Za-z0-9.]*)`))) {
266
+ deps.push(`${groupId}:${projectAccessorToArtifactId(match[1])}`);
267
+ }
268
+ for (const match of content.matchAll(gradleDepRe(`(?:\\(\\s*['"]([^'"]+)['"]\\s*\\)|['"]([^'"]+)['"])`))) {
269
+ const coord = match[1] ?? match[2];
270
+ if (!coord)
271
+ continue;
272
+ const parts = coord.split(':');
273
+ if (parts.length >= 2)
274
+ deps.push(`${parts[0]}:${parts[1]}`);
275
+ }
276
+ for (const match of content.matchAll(gradleDepRe(`(?:\\(\\s*)?project\\s*\\(\\s*['"]([^'"]+)['"]\\s*\\)`))) {
277
+ deps.push(`${groupId}:${match[1].replace(/^:/, '')}`);
65
278
  }
66
279
  return { groupId, artifactId, deps: [...new Set(deps)] };
67
280
  }
@@ -0,0 +1,22 @@
1
+ import type { ParsedFile, Range, ScopeId } from '../../../../_shared/index.js';
2
+ import type { KnowledgeGraph } from '../../../graph/types.js';
3
+ import type { DiInjectionMatch } from '../../di-extractors/index.js';
4
+ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
5
+ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
6
+ export interface SpringDynamicLookupFact {
7
+ readonly ownerScopeId: ScopeId;
8
+ readonly ownerRange: Range;
9
+ readonly receiverName: string;
10
+ readonly methodName: string;
11
+ readonly targetTypeName: string;
12
+ }
13
+ export declare function springDynamicLookupCardinality(receiverName: string, methodName: string): DiInjectionMatch['cardinality'] | null;
14
+ export interface SpringDynamicLookupMetadataAdapter {
15
+ getFacts(filePath: string): readonly SpringDynamicLookupFact[];
16
+ }
17
+ /**
18
+ * Attach AST-captured programmatic Spring lookups to the framework-neutral DI
19
+ * resolver. Java/Kotlin own syntax capture; this shared JVM/Spring seam owns
20
+ * import-aware type binding and metadata attachment.
21
+ */
22
+ export declare function createSpringDynamicLookupMetadataAttacher(adapter: SpringDynamicLookupMetadataAdapter): (graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, indexes: ScopeResolutionIndexes) => void;
@@ -0,0 +1,124 @@
1
+ import { SPRING_DI_INJECTION_SITES_PROPERTY } from '../../di-extractors/spring.js';
2
+ import { resolveCallerGraphId, resolveDefGraphId, } from '../../scope-resolution/graph-bridge/ids.js';
3
+ import { isClassLike, lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
4
+ const COLLECTION_LOOKUP_METHODS = new Set(['getBeans', 'getBeansOfType']);
5
+ const SINGLE_LOOKUP_METHODS = new Set(['getBean']);
6
+ /**
7
+ * Distinctive utility names plus conventional Spring context variable names.
8
+ * Generic locals remain recall-oriented because repositories often omit the
9
+ * third-party context type from the index; AST call/class-literal gates and
10
+ * import-aware target resolution prevent the raw-text false-positive class.
11
+ */
12
+ const KNOWN_RECEIVERS = new Set([
13
+ 'SpringContextUtil',
14
+ 'SpringContextHolder',
15
+ 'SpringBeanUtil',
16
+ 'ApplicationContextProvider',
17
+ 'BeanFactoryProvider',
18
+ 'ApplicationContext',
19
+ 'BeanFactory',
20
+ 'ListableBeanFactory',
21
+ 'applicationContext',
22
+ 'context',
23
+ 'ctx',
24
+ 'appContext',
25
+ 'beanFactory',
26
+ ]);
27
+ export function springDynamicLookupCardinality(receiverName, methodName) {
28
+ const receiverSimpleName = receiverName.slice(receiverName.lastIndexOf('.') + 1);
29
+ if (!KNOWN_RECEIVERS.has(receiverSimpleName))
30
+ return null;
31
+ if (COLLECTION_LOOKUP_METHODS.has(methodName))
32
+ return 'collection';
33
+ if (SINGLE_LOOKUP_METHODS.has(methodName))
34
+ return 'single';
35
+ return null;
36
+ }
37
+ function visibleTypeDefinitions(fact, indexes) {
38
+ const simpleName = fact.targetTypeName.slice(fact.targetTypeName.lastIndexOf('.') + 1);
39
+ let scopeId = fact.ownerScopeId;
40
+ while (scopeId !== null) {
41
+ const visible = lookupBindingsAt(scopeId, simpleName, indexes)
42
+ .map(({ def }) => def)
43
+ .filter((def) => isClassLike(def.type))
44
+ .filter((def) => !fact.targetTypeName.includes('.') || def.qualifiedName === fact.targetTypeName);
45
+ if (visible.length > 0) {
46
+ const unique = new Map(visible.map((def) => [def.nodeId, def]));
47
+ return [...unique.values()];
48
+ }
49
+ scopeId = indexes.scopeTree.getScope(scopeId)?.parent ?? null;
50
+ }
51
+ return [];
52
+ }
53
+ function resolveTargetTypeName(graph, fact, callerLanguage, nodeLookup, indexes) {
54
+ const graphIds = new Set();
55
+ for (const definition of visibleTypeDefinitions(fact, indexes)) {
56
+ const graphId = resolveDefGraphId(definition.filePath, definition, nodeLookup);
57
+ if (graphId === undefined)
58
+ continue;
59
+ const node = graph.getNode(graphId);
60
+ if ((node?.label === 'Class' ||
61
+ node?.label === 'Interface' ||
62
+ node?.label === 'Record' ||
63
+ node?.label === 'Enum') &&
64
+ node.properties.language === callerLanguage) {
65
+ graphIds.add(graphId);
66
+ }
67
+ }
68
+ if (graphIds.size !== 1)
69
+ return undefined;
70
+ const targetId = graphIds.values().next().value;
71
+ if (targetId === undefined)
72
+ return undefined;
73
+ const target = graph.getNode(targetId);
74
+ if (target === undefined)
75
+ return undefined;
76
+ const qualifiedName = target.properties.qualifiedName;
77
+ return typeof qualifiedName === 'string' ? qualifiedName : target.properties.name;
78
+ }
79
+ /**
80
+ * Attach AST-captured programmatic Spring lookups to the framework-neutral DI
81
+ * resolver. Java/Kotlin own syntax capture; this shared JVM/Spring seam owns
82
+ * import-aware type binding and metadata attachment.
83
+ */
84
+ export function createSpringDynamicLookupMetadataAttacher(adapter) {
85
+ return (graph, parsedFiles, nodeLookup, indexes) => {
86
+ for (const parsed of parsedFiles) {
87
+ for (const fact of adapter.getFacts(parsed.filePath)) {
88
+ const cardinality = springDynamicLookupCardinality(fact.receiverName, fact.methodName);
89
+ if (cardinality === null)
90
+ continue;
91
+ const callerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup, {
92
+ startLine: fact.ownerRange.startLine,
93
+ startCol: fact.ownerRange.startCol,
94
+ });
95
+ if (callerId === undefined)
96
+ continue;
97
+ const caller = graph.getNode(callerId);
98
+ if (caller === undefined ||
99
+ (caller.label !== 'Function' &&
100
+ caller.label !== 'Method' &&
101
+ caller.label !== 'Constructor')) {
102
+ continue;
103
+ }
104
+ const targetTypeName = resolveTargetTypeName(graph, fact, caller.properties.language, nodeLookup, indexes);
105
+ if (targetTypeName === undefined)
106
+ continue;
107
+ const match = {
108
+ targetTypeName,
109
+ cardinality,
110
+ edgeSource: 'site',
111
+ reason: `Spring dynamic lookup: ${fact.receiverName}.${fact.methodName}(${fact.targetTypeName})`,
112
+ };
113
+ // Singular lookups intentionally use the shared DI selection policy:
114
+ // a unique/@Primary candidate wins; unresolved multiplicity is an
115
+ // explicit 0.5-confidence fan-out rather than a guessed runtime winner.
116
+ const existing = caller.properties[SPRING_DI_INJECTION_SITES_PROPERTY];
117
+ caller.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = [
118
+ ...(Array.isArray(existing) ? existing : []),
119
+ match,
120
+ ];
121
+ }
122
+ }
123
+ };
124
+ }
@@ -5,6 +5,7 @@ import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js';
5
5
  import type { JavaSpringAopFact } from './spring-aop.js';
6
6
  import type { JavaSpringConditionalFact } from './spring-conditionals.js';
7
7
  import type { JavaSpringDiClassFact } from './spring-di.js';
8
+ import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
8
9
  import type { JavaSpringNonHttpHandlerFact } from './spring-non-http-handlers.js';
9
10
  export type JavaClassAnnotationFact = ClassAnnotationFact;
10
11
  export interface JavaCaptureSideChannel {
@@ -15,6 +16,7 @@ export interface JavaCaptureSideChannel {
15
16
  readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
16
17
  readonly springConditionalFacts?: readonly JavaSpringConditionalFact[];
17
18
  readonly springDiFacts?: readonly JavaSpringDiClassFact[];
19
+ readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[];
18
20
  readonly springNonHttpHandlerFacts?: readonly JavaSpringNonHttpHandlerFact[];
19
21
  }
20
22
  /** Clear facts retained by a prior workspace pass in a long-lived process. */
@@ -29,6 +31,8 @@ export declare function setJavaSpringConditionalFacts(filePath: string, facts: r
29
31
  export declare function getJavaSpringConditionalFacts(filePath: string): readonly JavaSpringConditionalFact[];
30
32
  export declare function setJavaSpringDiFacts(filePath: string, facts: readonly JavaSpringDiClassFact[]): void;
31
33
  export declare function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiClassFact[];
34
+ export declare function setJavaSpringDynamicLookupFacts(filePath: string, facts: readonly SpringDynamicLookupFact[]): void;
35
+ export declare function getJavaSpringDynamicLookupFacts(filePath: string): readonly SpringDynamicLookupFact[];
32
36
  export declare function setJavaSpringNonHttpHandlerFacts(filePath: string, facts: readonly JavaSpringNonHttpHandlerFact[]): void;
33
37
  export declare function getJavaSpringNonHttpHandlerFacts(filePath: string): readonly JavaSpringNonHttpHandlerFact[];
34
38
  /** Snapshot worker-local Java annotation facts for ParsedFile serialization. */
@@ -6,6 +6,7 @@ const springAopFacts = new Map();
6
6
  const springConfigConsumers = new Map();
7
7
  const springConditionalFacts = new Map();
8
8
  const springDiFacts = new Map();
9
+ const springDynamicLookupFacts = new Map();
9
10
  const springNonHttpHandlerFacts = new Map();
10
11
  /** Clear facts retained by a prior workspace pass in a long-lived process. */
11
12
  export function clearJavaClassAnnotationFacts() {
@@ -14,6 +15,7 @@ export function clearJavaClassAnnotationFacts() {
14
15
  springConfigConsumers.clear();
15
16
  springConditionalFacts.clear();
16
17
  springDiFacts.clear();
18
+ springDynamicLookupFacts.clear();
17
19
  springNonHttpHandlerFacts.clear();
18
20
  }
19
21
  export function setJavaSpringAopFacts(filePath, facts) {
@@ -56,6 +58,15 @@ export function setJavaSpringDiFacts(filePath, facts) {
56
58
  export function getJavaSpringDiFacts(filePath) {
57
59
  return springDiFacts.get(filePath) ?? [];
58
60
  }
61
+ export function setJavaSpringDynamicLookupFacts(filePath, facts) {
62
+ if (facts.length === 0)
63
+ springDynamicLookupFacts.delete(filePath);
64
+ else
65
+ springDynamicLookupFacts.set(filePath, facts);
66
+ }
67
+ export function getJavaSpringDynamicLookupFacts(filePath) {
68
+ return springDynamicLookupFacts.get(filePath) ?? [];
69
+ }
59
70
  export function setJavaSpringNonHttpHandlerFacts(filePath, facts) {
60
71
  if (facts.length === 0)
61
72
  springNonHttpHandlerFacts.delete(filePath);
@@ -72,6 +83,7 @@ export function collectJavaCaptureSideChannel(filePath) {
72
83
  const configConsumers = springConfigConsumers.get(filePath) ?? [];
73
84
  const conditionFacts = springConditionalFacts.get(filePath) ?? [];
74
85
  const diFacts = springDiFacts.get(filePath) ?? [];
86
+ const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? [];
75
87
  const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
76
88
  const packageFact = getJavaPackageFact(filePath);
77
89
  if (facts.length === 0 &&
@@ -79,6 +91,7 @@ export function collectJavaCaptureSideChannel(filePath) {
79
91
  configConsumers.length === 0 &&
80
92
  conditionFacts.length === 0 &&
81
93
  diFacts.length === 0 &&
94
+ dynamicLookupFacts.length === 0 &&
82
95
  nonHttpHandlerFacts.length === 0 &&
83
96
  packageFact === undefined) {
84
97
  return undefined;
@@ -91,6 +104,7 @@ export function collectJavaCaptureSideChannel(filePath) {
91
104
  ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
92
105
  ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
93
106
  ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
107
+ ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}),
94
108
  ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
95
109
  };
96
110
  }
@@ -110,6 +124,7 @@ export function applyJavaCaptureSideChannel(parsed) {
110
124
  setJavaSpringConfigConsumerFacts(parsed.filePath, []);
111
125
  setJavaSpringConditionalFacts(parsed.filePath, []);
112
126
  setJavaSpringDiFacts(parsed.filePath, []);
127
+ setJavaSpringDynamicLookupFacts(parsed.filePath, []);
113
128
  setJavaSpringNonHttpHandlerFacts(parsed.filePath, []);
114
129
  setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
115
130
  return;
@@ -119,6 +134,7 @@ export function applyJavaCaptureSideChannel(parsed) {
119
134
  setJavaSpringConfigConsumerFacts(parsed.filePath, Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : []);
120
135
  setJavaSpringConditionalFacts(parsed.filePath, Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : []);
121
136
  setJavaSpringDiFacts(parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : []);
137
+ setJavaSpringDynamicLookupFacts(parsed.filePath, Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : []);
122
138
  setJavaSpringNonHttpHandlerFacts(parsed.filePath, Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : []);
123
139
  setJavaPackageFact(parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT);
124
140
  }
@@ -7,11 +7,12 @@ import { getJavaParser, getJavaScopeQuery } from './query.js';
7
7
  import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
8
8
  import { getTreeSitterBufferSize } from '../../constants.js';
9
9
  import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
10
- import { setJavaClassAnnotationFacts, setJavaSpringAopFacts, setJavaSpringConfigConsumerFacts, setJavaSpringConditionalFacts, setJavaSpringDiFacts, setJavaSpringNonHttpHandlerFacts, } from './capture-side-channel.js';
10
+ import { setJavaClassAnnotationFacts, setJavaSpringAopFacts, setJavaSpringConfigConsumerFacts, setJavaSpringConditionalFacts, setJavaSpringDiFacts, setJavaSpringDynamicLookupFacts, setJavaSpringNonHttpHandlerFacts, } from './capture-side-channel.js';
11
11
  import { captureJavaPackageFact } from './package-facts.js';
12
12
  import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
13
13
  import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
14
14
  import { captureJavaSpringDiClassFact } from './spring-di.js';
15
+ import { captureJavaSpringDynamicLookupFact } from './spring-dynamic-lookup.js';
15
16
  import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
16
17
  import { captureJavaSpringAopFacts } from './spring-aop.js';
17
18
  import { captureJavaSpringConditionalFacts, } from './spring-conditionals.js';
@@ -98,6 +99,8 @@ export function emitJavaScopeCaptures(sourceText, filePath, cachedTree) {
98
99
  const springDiFacts = [];
99
100
  const springNonHttpHandlerFacts = [];
100
101
  const springDiClassNodeIds = new Set();
102
+ const springDynamicLookupFacts = [];
103
+ const springDynamicLookupNodeIds = new Set();
101
104
  for (const m of rawMatches) {
102
105
  const grouped = {};
103
106
  // Parallel tag -> captured SyntaxNode map. The tree-sitter query already
@@ -116,6 +119,13 @@ export function emitJavaScopeCaptures(sourceText, filePath, cachedTree) {
116
119
  }
117
120
  if (Object.keys(grouped).length === 0)
118
121
  continue;
122
+ const dynamicLookupNode = nodeIfType(nodeMap['@reference.call.member'], 'method_invocation');
123
+ if (dynamicLookupNode !== null && !springDynamicLookupNodeIds.has(dynamicLookupNode.id)) {
124
+ springDynamicLookupNodeIds.add(dynamicLookupNode.id);
125
+ const fact = captureJavaSpringDynamicLookupFact(dynamicLookupNode, filePath);
126
+ if (fact !== null)
127
+ springDynamicLookupFacts.push(fact);
128
+ }
119
129
  const springAopTypeNode = [
120
130
  nodeIfType(nodeMap['@scope.class'], 'class_declaration'),
121
131
  nodeIfType(nodeMap['@scope.class'], 'interface_declaration'),
@@ -287,6 +297,7 @@ export function emitJavaScopeCaptures(sourceText, filePath, cachedTree) {
287
297
  setJavaSpringAopFacts(filePath, springAopFacts);
288
298
  setJavaSpringConditionalFacts(filePath, springConditionalFacts);
289
299
  setJavaSpringDiFacts(filePath, springDiFacts);
300
+ setJavaSpringDynamicLookupFacts(filePath, springDynamicLookupFacts);
290
301
  setJavaSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
291
302
  return [
292
303
  ...resolveVarTypeBindings(out),
@@ -19,6 +19,7 @@ import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
19
19
  import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js';
20
20
  import { attachJavaSpringDiMetadata } from './spring-di.js';
21
21
  import { attachJavaSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
22
+ import { attachJavaSpringDynamicLookup } from './spring-dynamic-lookup.js';
22
23
  import { applyJavaCaptureSideChannel, clearJavaClassAnnotationFacts, } from './capture-side-channel.js';
23
24
  import { clearJavaPackageFacts } from './package-facts.js';
24
25
  const javaScopeResolver = {
@@ -65,6 +66,7 @@ const javaScopeResolver = {
65
66
  attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
66
67
  attachJavaSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes);
67
68
  attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
69
+ attachJavaSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes);
68
70
  },
69
71
  };
70
72
  export { javaScopeResolver };
@@ -0,0 +1,8 @@
1
+ import { type SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
2
+ import { type SyntaxNode } from '../../utils/ast-helpers.js';
3
+ /** Capture real Java method invocations; comments and literals are never visited as calls. */
4
+ export declare function captureJavaSpringDynamicLookupFact(node: SyntaxNode, filePath: string): SpringDynamicLookupFact | null;
5
+ /** Standalone extractor for focused tests; production reuses scope-query call nodes. */
6
+ export declare function captureJavaSpringDynamicLookupFacts(rootNode: SyntaxNode, filePath: string): SpringDynamicLookupFact[];
7
+ /** Attach Java lookup facts for later resolution by the shared DI phase. */
8
+ export declare const attachJavaSpringDynamicLookup: (graph: import("../../../graph/types.js").KnowledgeGraph, parsedFiles: readonly import('../../../../_shared/index.js').ParsedFile[], nodeLookup: import("../../scope-resolution/graph-bridge/node-lookup.js").GraphNodeLookup, indexes: import("../../model/scope-resolution-indexes.js").ScopeResolutionIndexes) => void;
@@ -0,0 +1,62 @@
1
+ import { makeScopeId } from '../../../../_shared/index.js';
2
+ import { createSpringDynamicLookupMetadataAttacher, springDynamicLookupCardinality, } from '../../frameworks/spring/dynamic-lookups.js';
3
+ import { findAncestorBeforeBoundary, nodeToCapture, } from '../../utils/ast-helpers.js';
4
+ import { getJavaSpringDynamicLookupFacts } from './capture-side-channel.js';
5
+ const CALLABLE_NODE_TYPES = new Set([
6
+ 'method_declaration',
7
+ 'constructor_declaration',
8
+ 'compact_constructor_declaration',
9
+ ]);
10
+ const NO_CALLABLE_BOUNDARIES = new Set();
11
+ function classLiteralTypeName(argument) {
12
+ if (argument.type !== 'class_literal' || argument.namedChildCount !== 1)
13
+ return null;
14
+ return argument.namedChild(0)?.text.trim() ?? null;
15
+ }
16
+ /** Capture real Java method invocations; comments and literals are never visited as calls. */
17
+ export function captureJavaSpringDynamicLookupFact(node, filePath) {
18
+ if (node.type !== 'method_invocation')
19
+ return null;
20
+ const receiverName = node.childForFieldName('object')?.text.trim();
21
+ const methodName = node.childForFieldName('name')?.text.trim();
22
+ const argumentsNode = node.childForFieldName('arguments');
23
+ if (receiverName === undefined || methodName === undefined || argumentsNode === null)
24
+ return null;
25
+ if (springDynamicLookupCardinality(receiverName, methodName) === null)
26
+ return null;
27
+ const argumentsWithoutComments = argumentsNode.namedChildren.filter((child) => child.type !== 'line_comment' && child.type !== 'block_comment');
28
+ if (argumentsWithoutComments.length !== 1)
29
+ return null;
30
+ const argument = argumentsWithoutComments[0];
31
+ if (argument === undefined)
32
+ return null;
33
+ const targetTypeName = classLiteralTypeName(argument);
34
+ if (targetTypeName === null)
35
+ return null;
36
+ const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, NO_CALLABLE_BOUNDARIES);
37
+ if (owner === null)
38
+ return null;
39
+ const ownerCapture = nodeToCapture('@spring-dynamic-lookup.owner', owner);
40
+ return {
41
+ ownerScopeId: makeScopeId({
42
+ filePath,
43
+ range: ownerCapture.range,
44
+ kind: 'Function',
45
+ }),
46
+ ownerRange: ownerCapture.range,
47
+ receiverName,
48
+ methodName,
49
+ targetTypeName,
50
+ };
51
+ }
52
+ /** Standalone extractor for focused tests; production reuses scope-query call nodes. */
53
+ export function captureJavaSpringDynamicLookupFacts(rootNode, filePath) {
54
+ return rootNode
55
+ .descendantsOfType('method_invocation')
56
+ .map((node) => captureJavaSpringDynamicLookupFact(node, filePath))
57
+ .filter((fact) => fact !== null);
58
+ }
59
+ /** Attach Java lookup facts for later resolution by the shared DI phase. */
60
+ export const attachJavaSpringDynamicLookup = createSpringDynamicLookupMetadataAttacher({
61
+ getFacts: getJavaSpringDynamicLookupFacts,
62
+ });
@@ -39,6 +39,7 @@
39
39
  import type { ParsedFile, ScopeId } from '../../../../_shared/index.js';
40
40
  import { type ClassAnnotationFact } from '../../frameworks/spring/bean-candidates.js';
41
41
  import { type JvmPackageFact } from '../jvm/package-facts.js';
42
+ import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
42
43
  import type { KotlinSpringAopFact } from './spring-aop.js';
43
44
  import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
44
45
  import type { KotlinSpringDiClassFact } from './spring-di.js';
@@ -63,6 +64,8 @@ export interface KotlinCaptureSideChannel {
63
64
  readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[];
64
65
  /** Constructor, property, and method injection syntax captured per class. */
65
66
  readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
67
+ /** Programmatic Spring bean lookups captured per callable. */
68
+ readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[];
66
69
  /** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */
67
70
  readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[];
68
71
  }
@@ -75,6 +78,8 @@ export declare function setKotlinSpringConditionalFacts(filePath: string, facts:
75
78
  export declare function getKotlinSpringConditionalFacts(filePath: string): readonly KotlinSpringConditionalFact[];
76
79
  export declare function setKotlinSpringDiFacts(filePath: string, facts: readonly KotlinSpringDiClassFact[]): void;
77
80
  export declare function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringDiClassFact[];
81
+ export declare function setKotlinSpringDynamicLookupFacts(filePath: string, facts: readonly SpringDynamicLookupFact[]): void;
82
+ export declare function getKotlinSpringDynamicLookupFacts(filePath: string): readonly SpringDynamicLookupFact[];
78
83
  export declare function setKotlinSpringNonHttpHandlerFacts(filePath: string, facts: readonly KotlinSpringNonHttpHandlerFact[]): void;
79
84
  export declare function getKotlinSpringNonHttpHandlerFacts(filePath: string): readonly KotlinSpringNonHttpHandlerFact[];
80
85
  /**
@@ -44,12 +44,14 @@ const classAnnotations = createClassAnnotationFactStore();
44
44
  const springAopFacts = new Map();
45
45
  const springConditionalFacts = new Map();
46
46
  const springDiFacts = new Map();
47
+ const springDynamicLookupFacts = new Map();
47
48
  const springNonHttpHandlerFacts = new Map();
48
49
  export function clearKotlinClassAnnotationFacts() {
49
50
  classAnnotations.clear();
50
51
  springAopFacts.clear();
51
52
  springConditionalFacts.clear();
52
53
  springDiFacts.clear();
54
+ springDynamicLookupFacts.clear();
53
55
  springNonHttpHandlerFacts.clear();
54
56
  }
55
57
  export function setKotlinSpringAopFacts(filePath, facts) {
@@ -85,6 +87,15 @@ export function setKotlinSpringDiFacts(filePath, facts) {
85
87
  export function getKotlinSpringDiFacts(filePath) {
86
88
  return springDiFacts.get(filePath) ?? [];
87
89
  }
90
+ export function setKotlinSpringDynamicLookupFacts(filePath, facts) {
91
+ if (facts.length === 0)
92
+ springDynamicLookupFacts.delete(filePath);
93
+ else
94
+ springDynamicLookupFacts.set(filePath, facts);
95
+ }
96
+ export function getKotlinSpringDynamicLookupFacts(filePath) {
97
+ return springDynamicLookupFacts.get(filePath) ?? [];
98
+ }
88
99
  export function setKotlinSpringNonHttpHandlerFacts(filePath, facts) {
89
100
  if (facts.length === 0)
90
101
  springNonHttpHandlerFacts.delete(filePath);
@@ -105,6 +116,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
105
116
  const aopFacts = springAopFacts.get(filePath) ?? [];
106
117
  const conditionFacts = springConditionalFacts.get(filePath) ?? [];
107
118
  const diFacts = springDiFacts.get(filePath) ?? [];
119
+ const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? [];
108
120
  const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
109
121
  const packageFact = getKotlinPackageFact(filePath);
110
122
  if (companionScopes.length === 0 &&
@@ -112,6 +124,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
112
124
  aopFacts.length === 0 &&
113
125
  conditionFacts.length === 0 &&
114
126
  diFacts.length === 0 &&
127
+ dynamicLookupFacts.length === 0 &&
115
128
  nonHttpHandlerFacts.length === 0 &&
116
129
  packageFact === undefined) {
117
130
  return undefined;
@@ -124,6 +137,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
124
137
  ...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}),
125
138
  ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}),
126
139
  ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
140
+ ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}),
127
141
  ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
128
142
  };
129
143
  }
@@ -147,6 +161,7 @@ export function applyKotlinCaptureSideChannel(parsed) {
147
161
  setKotlinSpringAopFacts(parsed.filePath, []);
148
162
  setKotlinSpringConditionalFacts(parsed.filePath, []);
149
163
  setKotlinSpringDiFacts(parsed.filePath, []);
164
+ setKotlinSpringDynamicLookupFacts(parsed.filePath, []);
150
165
  setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []);
151
166
  setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
152
167
  return;
@@ -158,6 +173,7 @@ export function applyKotlinCaptureSideChannel(parsed) {
158
173
  setKotlinSpringAopFacts(parsed.filePath, Array.isArray(data.springAopFacts) ? data.springAopFacts : []);
159
174
  setKotlinSpringConditionalFacts(parsed.filePath, Array.isArray(data.springConditionalFacts) ? data.springConditionalFacts : []);
160
175
  setKotlinSpringDiFacts(parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : []);
176
+ setKotlinSpringDynamicLookupFacts(parsed.filePath, Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : []);
161
177
  setKotlinSpringNonHttpHandlerFacts(parsed.filePath, Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : []);
162
178
  setKotlinPackageFact(parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT);
163
179
  }
@@ -10,10 +10,11 @@ import { normalizeKotlinType } from './interpret.js';
10
10
  import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
11
11
  import { getKotlinParser, getKotlinScopeQuery } from './query.js';
12
12
  import { markCompanionScope } from './companion-scopes.js';
13
- import { setKotlinClassAnnotationFacts, setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, setKotlinSpringNonHttpHandlerFacts, } from './capture-side-channel.js';
13
+ import { setKotlinClassAnnotationFacts, setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, setKotlinSpringDynamicLookupFacts, setKotlinSpringNonHttpHandlerFacts, } from './capture-side-channel.js';
14
14
  import { captureKotlinPackageFact } from './package-facts.js';
15
15
  import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
16
16
  import { captureKotlinSpringDiClassFact } from './spring-di.js';
17
+ import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js';
17
18
  import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
18
19
  import { captureKotlinSpringAopFacts } from './spring-aop.js';
19
20
  import { captureKotlinSpringConditionalFacts, } from './spring-conditionals.js';
@@ -75,6 +76,8 @@ export function emitKotlinScopeCaptures(sourceText, filePath, cachedTree) {
75
76
  const springNonHttpHandlerFacts = [];
76
77
  const springNonHttpHandlerTypeNodeIds = new Set();
77
78
  const springDiClassNodeIds = new Set();
79
+ const springDynamicLookupFacts = [];
80
+ const springDynamicLookupNodeIds = new Set();
78
81
  const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
79
82
  out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
80
83
  out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
@@ -97,6 +100,13 @@ export function emitKotlinScopeCaptures(sourceText, filePath, cachedTree) {
97
100
  }
98
101
  if (Object.keys(grouped).length === 0)
99
102
  continue;
103
+ const dynamicLookupNode = nodeIfType(groupedNodes['@reference.call.member'], 'call_expression');
104
+ if (dynamicLookupNode !== null && !springDynamicLookupNodeIds.has(dynamicLookupNode.id)) {
105
+ springDynamicLookupNodeIds.add(dynamicLookupNode.id);
106
+ const fact = captureKotlinSpringDynamicLookupFact(dynamicLookupNode, filePath);
107
+ if (fact !== null)
108
+ springDynamicLookupFacts.push(fact);
109
+ }
100
110
  // tree-sitter-kotlin represents both classes and interfaces with
101
111
  // `class_declaration`; `object_declaration` is the separate object form.
102
112
  const springAopTypeNode = [
@@ -279,6 +289,7 @@ export function emitKotlinScopeCaptures(sourceText, filePath, cachedTree) {
279
289
  setKotlinSpringAopFacts(filePath, springAopFacts);
280
290
  setKotlinSpringConditionalFacts(filePath, springConditionalFacts);
281
291
  setKotlinSpringDiFacts(filePath, springDiFacts);
292
+ setKotlinSpringDynamicLookupFacts(filePath, springDynamicLookupFacts);
282
293
  setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
283
294
  out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
284
295
  return out;
@@ -15,6 +15,7 @@ import { clearKotlinPackageFacts } from './package-facts.js';
15
15
  import { attachKotlinSpringDiMetadata } from './spring-di.js';
16
16
  import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
17
17
  import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
18
+ import { attachKotlinSpringDynamicLookup } from './spring-dynamic-lookup.js';
18
19
  /**
19
20
  * Kotlin scope resolver for RFC #909 Ring 3.
20
21
  *
@@ -121,6 +122,7 @@ export const kotlinScopeResolver = {
121
122
  attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes);
122
123
  attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
123
124
  attachKotlinSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes);
125
+ attachKotlinSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes);
124
126
  },
125
127
  };
126
128
  /**
@@ -0,0 +1,8 @@
1
+ import { type SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
2
+ import { type SyntaxNode } from '../../utils/ast-helpers.js';
3
+ /** Capture real Kotlin calls using `Type::class` or `Type::class.java`. */
4
+ export declare function captureKotlinSpringDynamicLookupFact(node: SyntaxNode, filePath: string): SpringDynamicLookupFact | null;
5
+ /** Standalone extractor for focused tests; production reuses scope-query call nodes. */
6
+ export declare function captureKotlinSpringDynamicLookupFacts(rootNode: SyntaxNode, filePath: string): SpringDynamicLookupFact[];
7
+ /** Attach Kotlin lookup facts for later resolution by the shared DI phase. */
8
+ export declare const attachKotlinSpringDynamicLookup: (graph: import("../../../graph/types.js").KnowledgeGraph, parsedFiles: readonly import('../../../../_shared/index.js').ParsedFile[], nodeLookup: import("../../scope-resolution/graph-bridge/node-lookup.js").GraphNodeLookup, indexes: import("../../model/scope-resolution-indexes.js").ScopeResolutionIndexes) => void;
@@ -0,0 +1,77 @@
1
+ import { makeScopeId } from '../../../../_shared/index.js';
2
+ import { createSpringDynamicLookupMetadataAttacher, springDynamicLookupCardinality, } from '../../frameworks/spring/dynamic-lookups.js';
3
+ import { findAncestorBeforeBoundary, nodeToCapture, } from '../../utils/ast-helpers.js';
4
+ import { getKotlinSpringDynamicLookupFacts } from './capture-side-channel.js';
5
+ // Kotlin emits graph callables for functions and secondary constructors.
6
+ // `init {}` / primary-constructor bodies have no independent callable node, so
7
+ // attributing their lookups to the enclosing Class would violate graph semantics.
8
+ const CALLABLE_NODE_TYPES = new Set(['function_declaration', 'secondary_constructor']);
9
+ const NO_CALLABLE_BOUNDARIES = new Set();
10
+ const KOTLIN_CLASS_LITERAL = /^([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)::class(?:\.java)?$/;
11
+ function navigationParts(node) {
12
+ if (node.type !== 'navigation_expression')
13
+ return null;
14
+ const text = node.text.trim();
15
+ const separator = text.lastIndexOf('.');
16
+ if (separator <= 0 || separator === text.length - 1)
17
+ return null;
18
+ return {
19
+ receiverName: text.slice(0, separator),
20
+ methodName: text.slice(separator + 1),
21
+ };
22
+ }
23
+ function singleClassLiteralArgument(node) {
24
+ const suffix = node.namedChildren.find((child) => child.type === 'call_suffix');
25
+ const argumentsNode = suffix?.namedChildren.find((child) => child.type === 'value_arguments');
26
+ if (argumentsNode === undefined)
27
+ return null;
28
+ const argumentsWithoutComments = argumentsNode.namedChildren.filter((child) => child.type !== 'line_comment' && child.type !== 'multiline_comment');
29
+ if (argumentsWithoutComments.length !== 1)
30
+ return null;
31
+ const value = argumentsWithoutComments[0];
32
+ if (value?.type !== 'value_argument' || value.namedChildCount !== 1)
33
+ return null;
34
+ return value.namedChild(0)?.text.trim().match(KOTLIN_CLASS_LITERAL)?.[1] ?? null;
35
+ }
36
+ /** Capture real Kotlin calls using `Type::class` or `Type::class.java`. */
37
+ export function captureKotlinSpringDynamicLookupFact(node, filePath) {
38
+ if (node.type !== 'call_expression')
39
+ return null;
40
+ const callee = node.namedChildren.find((child) => child.type === 'navigation_expression');
41
+ if (callee === undefined)
42
+ return null;
43
+ const parts = navigationParts(callee);
44
+ if (parts === null)
45
+ return null;
46
+ if (springDynamicLookupCardinality(parts.receiverName, parts.methodName) === null)
47
+ return null;
48
+ const targetTypeName = singleClassLiteralArgument(node);
49
+ if (targetTypeName === null)
50
+ return null;
51
+ const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, NO_CALLABLE_BOUNDARIES);
52
+ if (owner === null)
53
+ return null;
54
+ const ownerCapture = nodeToCapture('@spring-dynamic-lookup.owner', owner);
55
+ return {
56
+ ownerScopeId: makeScopeId({
57
+ filePath,
58
+ range: ownerCapture.range,
59
+ kind: 'Function',
60
+ }),
61
+ ownerRange: ownerCapture.range,
62
+ receiverName: parts.receiverName,
63
+ methodName: parts.methodName,
64
+ targetTypeName,
65
+ };
66
+ }
67
+ /** Standalone extractor for focused tests; production reuses scope-query call nodes. */
68
+ export function captureKotlinSpringDynamicLookupFacts(rootNode, filePath) {
69
+ return rootNode
70
+ .descendantsOfType('call_expression')
71
+ .map((node) => captureKotlinSpringDynamicLookupFact(node, filePath))
72
+ .filter((fact) => fact !== null);
73
+ }
74
+ /** Attach Kotlin lookup facts for later resolution by the shared DI phase. */
75
+ export const attachKotlinSpringDynamicLookup = createSpringDynamicLookupMetadataAttacher({
76
+ getFacts: getKotlinSpringDynamicLookupFacts,
77
+ });
@@ -48,6 +48,9 @@ function providerCandidates(ids, providers) {
48
48
  // candidates when none are known instead of dropping the injection entirely.
49
49
  return recognized.length > 0 ? recognized : all;
50
50
  }
51
+ function isConcreteTypeNode(node) {
52
+ return node?.label === 'Class' || node?.label === 'Record' || node?.label === 'Enum';
53
+ }
51
54
  export const diPhase = {
52
55
  name: 'di',
53
56
  deps: ['mro'],
@@ -103,21 +106,25 @@ export const diPhase = {
103
106
  ambiguousInjections: 0,
104
107
  };
105
108
  }
106
- const interfaceToImplementers = new Map();
109
+ const directSubtypes = new Map();
107
110
  const directSupertypes = new Map();
108
111
  for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
109
- const set = interfaceToImplementers.get(rel.targetId) ?? new Set();
110
- set.add(rel.sourceId);
111
- interfaceToImplementers.set(rel.targetId, set);
112
+ const subtypes = directSubtypes.get(rel.targetId) ?? new Set();
113
+ subtypes.add(rel.sourceId);
114
+ directSubtypes.set(rel.targetId, subtypes);
112
115
  const supertypes = directSupertypes.get(rel.sourceId) ?? new Set();
113
116
  supertypes.add(rel.targetId);
114
117
  directSupertypes.set(rel.sourceId, supertypes);
115
118
  }
116
119
  for (const rel of ctx.graph.iterRelationshipsByType('EXTENDS')) {
120
+ const subtypes = directSubtypes.get(rel.targetId) ?? new Set();
121
+ subtypes.add(rel.sourceId);
122
+ directSubtypes.set(rel.targetId, subtypes);
117
123
  const supertypes = directSupertypes.get(rel.sourceId) ?? new Set();
118
124
  supertypes.add(rel.targetId);
119
125
  directSupertypes.set(rel.sourceId, supertypes);
120
126
  }
127
+ const orderedDirectSubtypes = new Map([...directSubtypes].map(([typeId, subtypes]) => [typeId, [...subtypes].sort().reverse()]));
121
128
  const memberToClass = new Map();
122
129
  for (const relationType of ['HAS_PROPERTY', 'HAS_METHOD']) {
123
130
  for (const rel of ctx.graph.iterRelationshipsByType(relationType)) {
@@ -128,18 +135,45 @@ export const diPhase = {
128
135
  const interfacesByLanguage = new Map();
129
136
  const classesByLanguage = new Map();
130
137
  ctx.graph.forEachNode((node) => {
131
- if (node.label !== 'Class' && node.label !== 'Interface')
138
+ const concreteType = isConcreteTypeNode(node);
139
+ if (!concreteType && node.label !== 'Interface')
132
140
  return;
133
141
  const language = node.properties.language;
134
142
  if (typeof language !== 'string' || !candidateLanguages.has(language))
135
143
  return;
136
- const indexes = node.label === 'Class' ? classesByLanguage : interfacesByLanguage;
144
+ const indexes = concreteType ? classesByLanguage : interfacesByLanguage;
137
145
  const index = indexes.get(language) ?? emptyNameIndex();
138
146
  addIndexedName(index, node);
139
147
  indexes.set(language, index);
140
- if (node.label === 'Class')
148
+ if (concreteType)
141
149
  providerNodes.set(node.id, node);
142
150
  });
151
+ const concreteSubtypesByRoot = new Map();
152
+ const concreteSubtypes = (rootTypeId, language) => {
153
+ const cacheKey = `${language}\0${rootTypeId}`;
154
+ const cached = concreteSubtypesByRoot.get(cacheKey);
155
+ if (cached !== undefined)
156
+ return cached;
157
+ const concrete = new Set();
158
+ const queue = [rootTypeId];
159
+ const visited = new Set();
160
+ while (queue.length > 0) {
161
+ const typeId = queue.pop();
162
+ if (typeId === undefined || visited.has(typeId))
163
+ continue;
164
+ visited.add(typeId);
165
+ const typeNode = ctx.graph.getNode(typeId);
166
+ if (typeNode !== undefined &&
167
+ isConcreteTypeNode(typeNode) &&
168
+ typeNode.properties.language === language) {
169
+ concrete.add(typeId);
170
+ }
171
+ const children = orderedDirectSubtypes.get(typeId) ?? [];
172
+ queue.push(...children);
173
+ }
174
+ concreteSubtypesByRoot.set(cacheKey, concrete);
175
+ return concrete;
176
+ };
143
177
  // A declaration returning a concrete class is assignable to every class or
144
178
  // interface that type extends/implements. Expand once per language+type and
145
179
  // register the declaration under those ancestor names. This keeps named
@@ -227,13 +261,12 @@ export const diPhase = {
227
261
  ambiguousTypeNames.add(candidate.targetTypeName);
228
262
  continue;
229
263
  }
230
- const structural = new Set();
231
- if (typeof classEntry === 'string')
232
- structural.add(classEntry);
233
- if (typeof interfaceEntry === 'string') {
234
- for (const id of interfaceToImplementers.get(interfaceEntry) ?? [])
235
- structural.add(id);
236
- }
264
+ const rootTypeId = typeof classEntry === 'string'
265
+ ? classEntry
266
+ : typeof interfaceEntry === 'string'
267
+ ? interfaceEntry
268
+ : undefined;
269
+ const structural = new Set(rootTypeId === undefined ? [] : concreteSubtypes(rootTypeId, candidate.language));
237
270
  for (const id of providedTypes.get(candidate.language)?.get(candidate.targetTypeName) ?? []) {
238
271
  structural.add(id);
239
272
  }
@@ -663,7 +663,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
663
663
  // each (no JSON/path/generation siblings). A v80 index still names `.json`
664
664
  // keys and would skip workers while scope-resolution found nothing — the
665
665
  // #1983 main-thread reparse. origin/main at allocation is 80.
666
- const SCHEMA_BUMP = 81;
666
+ // 81 -> 82: Java and Kotlin ParsedFile capture side channels now carry
667
+ // programmatic Spring lookup facts. A warm v81 cache has no such facts, so it
668
+ // would skip workers and silently omit the new INJECTS edges. origin/main at
669
+ // allocation is 81.
670
+ const SCHEMA_BUMP = 82;
667
671
  const GITNEXUS_PKG_VERSION = (() => {
668
672
  try {
669
673
  // package.json sits at gitnexus/package.json — two levels up from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.11-rc.16",
3
+ "version": "1.6.11-rc.18",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -66,6 +66,7 @@
66
66
  "cors": "^2.8.5",
67
67
  "express": "^5.2.1",
68
68
  "express-rate-limit": "^8.4.1",
69
+ "fast-xml-parser": "^5.11.1",
69
70
  "glob": "^13.0.6",
70
71
  "graphology": "^0.26.0",
71
72
  "graphology-indices": "^0.17.0",