mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 11:24:49 +08:00
Closes three gaps that broke `trace(controller, mapper-xml)` on real Spring +
MyBatis projects:
1. **Field-injected concrete-bean trace.** Java `this.<field>.method()` is
unwrapped at extraction (was surfaced as `this.<field>.method` and dropped
through every name-matcher strategy). The receiver name is then looked up
in the enclosing class's field declarations to get the declared type and
resolve the method on it. Closes the controller→bean hop when the field
name doesn't capitalize to the type (`userbo` → `UserBO`). General Java
fix, not Spring-specific.
2. **MyBatis XML mapper as a first-class language.** New extractor parses
`<mapper namespace="..."><select|insert|update|delete|sql id="X">` and
emits method-shaped nodes qualified as `<namespace>::<id>`, plus
`<include refid="X"/>` references to `<sql>` fragments. Non-mapper XML
(pom, log4j, web.xml) → file node only. A new synthesizer
(`mybatisJavaXmlEdges`) joins Java mapper methods to XML statements by
suffix-matching qualified names. Ambiguous simple-name collisions dropped
for precision.
3. **Spring `@Value`/`@ConfigurationProperties` → application config.**
`application.{yml,yaml,properties}` + profile variants parse on the
framework path; each leaf key becomes a `constant` node qualified by its
dotted path. `@Value("${k}")` / `@Value("${k:default}")` and
`@ConfigurationProperties(prefix="X")` emit binding nodes that resolve
with Spring's relaxed binding (kebab↔camel↔snake).
Validated on macrozheng/mall-tiny: full chain
`UmsRoleController.listResource → UmsRoleService.listResource → impl →
UmsResourceMapper.getResourceListByRoleId → XML <select>` connects across 5
hops via static + synthesized edges. 11/11 @Value annotations resolved
(incl. `@ConfigurationProperties(prefix="secure.ignored")`); 6/6 custom-SQL
mapper methods bridge to XML.
Tests: 4 new integration tests in frameworks-integration.test.ts. Full
suite: 1005 passed.
Docs: CHANGELOG `[Unreleased]` entry + dynamic-dispatch-coverage-playbook
narrative + matrix row.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
55839edd8f
commit
2543ae565a
@@ -10,7 +10,7 @@ import * as path from 'path';
|
||||
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
|
||||
import { Language } from '../types';
|
||||
|
||||
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'unknown'>;
|
||||
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
|
||||
|
||||
/**
|
||||
* WASM filename map — maps each language to its .wasm grammar file
|
||||
@@ -95,6 +95,13 @@ export const EXTENSION_MAP: Record<string, Language> = {
|
||||
'.luau': 'luau',
|
||||
'.m': 'objc',
|
||||
'.mm': 'objc',
|
||||
// XML: file-level tracking; the MyBatis extractor matches `<mapper namespace="...">`
|
||||
// shape and emits SQL-statement nodes (other XML returns empty).
|
||||
'.xml': 'xml',
|
||||
// Spring config: `application.properties` / `application-*.properties`. Same
|
||||
// shape as the `.yml` variants — the YAML/properties extractor emits one node
|
||||
// per leaf key, and the Spring resolver links `@Value("${k}")` references.
|
||||
'.properties': 'properties',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -267,6 +274,8 @@ export function isLanguageSupported(language: Language): boolean {
|
||||
if (language === 'liquid') return true; // custom regex extractor
|
||||
if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver
|
||||
if (language === 'twig') return true; // file-level tracking only
|
||||
if (language === 'xml') return true; // MyBatis mapper extractor
|
||||
if (language === 'properties') return true; // Spring config keys
|
||||
if (language === 'unknown') return false;
|
||||
return language in WASM_GRAMMAR_FILES;
|
||||
}
|
||||
@@ -277,6 +286,7 @@ export function isLanguageSupported(language: Language): boolean {
|
||||
export function isGrammarLoaded(language: Language): boolean {
|
||||
if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
|
||||
if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed
|
||||
if (language === 'xml' || language === 'properties') return true; // no WASM grammar needed
|
||||
return languageCache.has(language);
|
||||
}
|
||||
|
||||
@@ -357,6 +367,8 @@ export function getLanguageDisplayName(language: Language): string {
|
||||
objc: 'Objective-C',
|
||||
yaml: 'YAML',
|
||||
twig: 'Twig',
|
||||
xml: 'XML',
|
||||
properties: 'Java properties',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
return names[language] || language;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Edge, ExtractionError, ExtractionResult, Node, UnresolvedReference } from '../types';
|
||||
import { generateNodeId } from './tree-sitter-helpers';
|
||||
|
||||
/**
|
||||
* MyBatisExtractor — parses MyBatis mapper XML files.
|
||||
*
|
||||
* MyBatis splits a DAO interface across two files: a Java interface (parsed by
|
||||
* tree-sitter) declares the method, and an XML mapper file holds the SQL keyed
|
||||
* by `<namespace>` (the fully-qualified Java type name) and `id` (the method
|
||||
* name). Without the XML side in the graph, `trace(Controller, ...DAO.method)`
|
||||
* dead-ends at the interface method — the SQL it actually runs is invisible,
|
||||
* and "what does this query touch" / "where is this column written" can't be
|
||||
* answered.
|
||||
*
|
||||
* This extractor emits one method-shaped node per `<select|insert|update|
|
||||
* delete>` and per `<sql>` fragment, qualified as `<namespace>::<id>` so the
|
||||
* MyBatis framework synthesizer (`src/resolution/frameworks/mybatis.ts`) can
|
||||
* link the matching Java method → XML statement by suffix-matching qualified
|
||||
* names. `<include refid="...">` inside a statement yields an unresolved
|
||||
* reference to the SQL fragment, also keyed by `<namespace>::<refid>`.
|
||||
*
|
||||
* Non-mapper XML (Maven `pom.xml`, Spring beans XML, `web.xml`, log4j config,
|
||||
* etc.) is detected by the absence of a `<mapper namespace="...">` root and
|
||||
* returns just a file node — we still need the file row so the watcher can
|
||||
* track it, but we emit no symbols.
|
||||
*/
|
||||
export class MyBatisExtractor {
|
||||
private filePath: string;
|
||||
private source: string;
|
||||
private nodes: Node[] = [];
|
||||
private edges: Edge[] = [];
|
||||
private unresolvedReferences: UnresolvedReference[] = [];
|
||||
private errors: ExtractionError[] = [];
|
||||
private lineStarts: number[] = [];
|
||||
|
||||
constructor(filePath: string, source: string) {
|
||||
this.filePath = filePath;
|
||||
this.source = source;
|
||||
this.computeLineStarts();
|
||||
}
|
||||
|
||||
extract(): ExtractionResult {
|
||||
const startTime = Date.now();
|
||||
|
||||
const fileNode = this.createFileNode();
|
||||
|
||||
try {
|
||||
const mapperMatch = this.findMapperRoot();
|
||||
if (mapperMatch) {
|
||||
this.extractMapper(fileNode.id, mapperMatch.namespace, mapperMatch.bodyStart, mapperMatch.bodyEnd);
|
||||
}
|
||||
} catch (error) {
|
||||
this.errors.push({
|
||||
message: `MyBatis extraction error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
severity: 'error',
|
||||
code: 'parse_error',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: this.nodes,
|
||||
edges: this.edges,
|
||||
unresolvedReferences: this.unresolvedReferences,
|
||||
errors: this.errors,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
private createFileNode(): Node {
|
||||
const lines = this.source.split('\n');
|
||||
const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
|
||||
const node: Node = {
|
||||
id,
|
||||
kind: 'file',
|
||||
name: this.filePath.split('/').pop() || this.filePath,
|
||||
qualifiedName: this.filePath,
|
||||
filePath: this.filePath,
|
||||
language: 'xml',
|
||||
startLine: 1,
|
||||
endLine: lines.length || 1,
|
||||
startColumn: 0,
|
||||
endColumn: lines[lines.length - 1]?.length ?? 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
this.nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the `<mapper namespace="X">` opening tag. Returns the namespace and
|
||||
* the byte offsets of the body (between the opening and closing tag) so
|
||||
* statement extraction can be scoped to mapper contents.
|
||||
*/
|
||||
private findMapperRoot(): { namespace: string; bodyStart: number; bodyEnd: number } | null {
|
||||
const open = /<mapper\b([^>]*)>/.exec(this.source);
|
||||
if (!open) return null;
|
||||
const attrs = open[1] ?? '';
|
||||
const nsMatch = /\bnamespace\s*=\s*"([^"]+)"/.exec(attrs);
|
||||
if (!nsMatch) return null;
|
||||
const bodyStart = open.index + open[0].length;
|
||||
const closeIdx = this.source.indexOf('</mapper>', bodyStart);
|
||||
const bodyEnd = closeIdx >= 0 ? closeIdx : this.source.length;
|
||||
return { namespace: nsMatch[1]!, bodyStart, bodyEnd };
|
||||
}
|
||||
|
||||
private extractMapper(fileNodeId: string, namespace: string, bodyStart: number, bodyEnd: number): void {
|
||||
const body = this.source.slice(bodyStart, bodyEnd);
|
||||
// Match each top-level statement-shaped element. The body may have nested
|
||||
// tags (`<if>`, `<foreach>`, `<include>`), so we scan with a regex that
|
||||
// pairs an opening tag to its matching close — the simple form below works
|
||||
// because MyBatis statement elements are not themselves nested.
|
||||
const stmtRegex = /<(select|insert|update|delete|sql)\b([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = stmtRegex.exec(body)) !== null) {
|
||||
const elemType = m[1]!;
|
||||
const attrs = m[2] ?? '';
|
||||
const elemBody = m[3] ?? '';
|
||||
const idMatch = /\bid\s*=\s*"([^"]+)"/.exec(attrs);
|
||||
if (!idMatch) continue;
|
||||
const id = idMatch[1]!;
|
||||
const absoluteIndex = bodyStart + m.index;
|
||||
const startLine = this.getLineNumber(absoluteIndex);
|
||||
const endLine = this.getLineNumber(absoluteIndex + m[0].length);
|
||||
const qualified = `${namespace}::${id}`;
|
||||
const isSqlFragment = elemType === 'sql';
|
||||
const nodeId = generateNodeId(this.filePath, 'method', qualified, startLine);
|
||||
const node: Node = {
|
||||
id: nodeId,
|
||||
kind: 'method',
|
||||
name: id,
|
||||
qualifiedName: qualified,
|
||||
filePath: this.filePath,
|
||||
language: 'xml',
|
||||
signature: this.buildSignature(elemType, attrs, isSqlFragment),
|
||||
startLine,
|
||||
endLine,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
docstring: this.previewSql(elemBody),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
this.nodes.push(node);
|
||||
this.edges.push({ source: fileNodeId, target: nodeId, kind: 'contains' });
|
||||
|
||||
// <include refid="X"/> → reference to the SQL fragment in this mapper
|
||||
// (or in another mapper, when the refid is qualified — `ns.X`).
|
||||
const includeRegex = /<include\b[^>]*\brefid\s*=\s*"([^"]+)"/g;
|
||||
let inc: RegExpExecArray | null;
|
||||
while ((inc = includeRegex.exec(elemBody)) !== null) {
|
||||
const refid = inc[1]!;
|
||||
const refQualified = refid.includes('.') ? refid.replace(/\./g, '::') : `${namespace}::${refid}`;
|
||||
const includeOffset = absoluteIndex + (m[0].length - m[3]!.length - `</${elemType}>`.length) + inc.index;
|
||||
const line = this.getLineNumber(includeOffset);
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: nodeId,
|
||||
referenceName: refQualified,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildSignature(elemType: string, attrs: string, isSqlFragment: boolean): string {
|
||||
if (isSqlFragment) return '<sql>';
|
||||
const verb = elemType.toUpperCase();
|
||||
const result = /\bresultType\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
||||
const param = /\bparameterType\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
||||
const parts = [verb];
|
||||
if (param) parts.push(`param=${param}`);
|
||||
if (result) parts.push(`result=${result}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
private previewSql(body: string): string {
|
||||
return body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
}
|
||||
|
||||
private computeLineStarts(): void {
|
||||
this.lineStarts = [0];
|
||||
for (let i = 0; i < this.source.length; i++) {
|
||||
if (this.source.charCodeAt(i) === 10) this.lineStarts.push(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private getLineNumber(offset: number): number {
|
||||
// Binary search
|
||||
let lo = 0;
|
||||
let hi = this.lineStarts.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >>> 1;
|
||||
if (this.lineStarts[mid]! <= offset) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo + 1;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { LiquidExtractor } from './liquid-extractor';
|
||||
import { SvelteExtractor } from './svelte-extractor';
|
||||
import { DfmExtractor } from './dfm-extractor';
|
||||
import { VueExtractor } from './vue-extractor';
|
||||
import { MyBatisExtractor } from './mybatis-extractor';
|
||||
import {
|
||||
getAllFrameworkResolvers,
|
||||
getApplicableFrameworks,
|
||||
@@ -1453,7 +1454,23 @@ export class TreeSitterExtractor {
|
||||
if (nameField && objectField && (node.type === 'method_invocation' || node.type === 'member_call_expression' || node.type === 'scoped_call_expression')) {
|
||||
// Method call with explicit receiver: receiver.method() / $receiver->method() / ClassName::method()
|
||||
const methodName = getNodeText(nameField, this.source);
|
||||
let receiverName = getNodeText(objectField, this.source);
|
||||
// Java `this.userbo.toLogin2()` parses as method_invocation(object=field_access(this, userbo)).
|
||||
// Without unwrapping, receiverName is `this.userbo` and the name-matcher's
|
||||
// single-dot receiver regex fails. Pull out the immediate field after `this.`
|
||||
// so the receiver is the field name (`userbo`), which the resolver can then
|
||||
// look up in the enclosing class's field declarations.
|
||||
let receiverName: string;
|
||||
if (objectField.type === 'field_access') {
|
||||
const inner = getChildByField(objectField, 'object');
|
||||
const fld = getChildByField(objectField, 'field');
|
||||
if (inner && fld && (inner.type === 'this' || inner.type === 'this_expression')) {
|
||||
receiverName = getNodeText(fld, this.source);
|
||||
} else {
|
||||
receiverName = getNodeText(objectField, this.source);
|
||||
}
|
||||
} else {
|
||||
receiverName = getNodeText(objectField, this.source);
|
||||
}
|
||||
// Strip PHP $ prefix from variable names
|
||||
receiverName = receiverName.replace(/^\$/, '');
|
||||
|
||||
@@ -2687,10 +2704,16 @@ export function extractFromSource(
|
||||
// Use custom extractor for Liquid
|
||||
const extractor = new LiquidExtractor(filePath, source);
|
||||
result = extractor.extract();
|
||||
} else if (detectedLanguage === 'yaml' || detectedLanguage === 'twig') {
|
||||
// No symbol extraction — file is tracked at the file-record level only.
|
||||
// Framework extractors (e.g. Drupal routing resolver) run below and may
|
||||
// add route nodes / references for yaml files such as *.routing.yml.
|
||||
} else if (detectedLanguage === 'xml') {
|
||||
// Custom extractor for MyBatis mapper XML. Non-mapper XML returns just a
|
||||
// file node so the watcher tracks it without emitting symbols.
|
||||
const extractor = new MyBatisExtractor(filePath, source);
|
||||
result = extractor.extract();
|
||||
} else if (detectedLanguage === 'yaml' || detectedLanguage === 'twig' || detectedLanguage === 'properties') {
|
||||
// No symbol extraction at this stage — files are tracked at the file-record
|
||||
// level only. Framework extractors (Drupal routing yml, Spring `@Value`
|
||||
// resolution against application.yml/application.properties) run later and
|
||||
// add per-file nodes/references when they apply.
|
||||
result = { nodes: [], edges: [], unresolvedReferences: [], errors: [], durationMs: 0 };
|
||||
} else if (
|
||||
detectedLanguage === 'pascal' &&
|
||||
|
||||
@@ -775,11 +775,77 @@ function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* MyBatis: link a Java mapper interface method to the XML statement that holds
|
||||
* its SQL. The XML extractor (`src/extraction/mybatis-extractor.ts`) qualifies
|
||||
* each `<select|insert|update|delete|sql id="X">` as `<namespace>::<id>` where
|
||||
* `<namespace>` is the Java FQN of the mapper interface. A Java method's
|
||||
* qualifiedName ends with `<ClassName>::<methodName>`, so we suffix-match the
|
||||
* last two segments of the XML qualified name to find a unique Java method by
|
||||
* `<ClassName>::<methodName>` (`ClassName` = last dotted segment of the XML
|
||||
* namespace). Cross-mapper `<include refid="other.X">` references go through
|
||||
* the normal qualified-name resolver — only the Java↔XML bridge is synthetic.
|
||||
*
|
||||
* Precision over recall: ambiguous mappers (multiple Java classes with the
|
||||
* same simple name) are dropped. We need-not bridge by package because Java
|
||||
* mapper interfaces are typically uniquely named within a project.
|
||||
*/
|
||||
function mybatisJavaXmlEdges(queries: QueryBuilder): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Index Java methods by `<ClassName>::<methodName>` for O(1) lookup.
|
||||
const javaIndex = new Map<string, Node[]>();
|
||||
for (const m of queries.getNodesByKind('method')) {
|
||||
if (m.language !== 'java' && m.language !== 'kotlin') continue;
|
||||
const parts = m.qualifiedName.split('::');
|
||||
const last = parts[parts.length - 1];
|
||||
const cls = parts[parts.length - 2];
|
||||
if (!last || !cls) continue;
|
||||
const key = `${cls}::${last}`;
|
||||
const arr = javaIndex.get(key);
|
||||
if (arr) arr.push(m); else javaIndex.set(key, [m]);
|
||||
}
|
||||
|
||||
for (const xml of queries.getNodesByKind('method')) {
|
||||
if (xml.language !== 'xml') continue;
|
||||
// Qualified name: `<namespace>::<id>`. Extract the simple class name.
|
||||
const colonIdx = xml.qualifiedName.lastIndexOf('::');
|
||||
if (colonIdx < 0) continue;
|
||||
const namespace = xml.qualifiedName.slice(0, colonIdx);
|
||||
const id = xml.qualifiedName.slice(colonIdx + 2);
|
||||
if (!namespace || !id) continue;
|
||||
const dotIdx = namespace.lastIndexOf('.');
|
||||
const className = dotIdx >= 0 ? namespace.slice(dotIdx + 1) : namespace;
|
||||
const candidates = javaIndex.get(`${className}::${id}`);
|
||||
if (!candidates || candidates.length === 0) continue;
|
||||
// Drop ambiguous matches (multiple same-name classes); the user can
|
||||
// disambiguate by adding the package-suffix match in a future enhancement.
|
||||
if (candidates.length > 1) continue;
|
||||
const java = candidates[0]!;
|
||||
const key = `${java.id}>${xml.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: java.id,
|
||||
target: xml.id,
|
||||
kind: 'calls',
|
||||
line: java.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'mybatis-java-xml',
|
||||
via: `${className}.${id}`,
|
||||
registeredAt: `${xml.filePath}:${xml.startLine}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
|
||||
* React re-render + JSX children + Vue templates + RN event channel +
|
||||
* Fabric native-impl). Returns the count added. Never throws into
|
||||
* indexing — callers wrap in try/catch.
|
||||
* Fabric native-impl + MyBatis Java↔XML). Returns the count added. Never
|
||||
* throws into indexing — callers wrap in try/catch.
|
||||
*/
|
||||
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
|
||||
const fieldEdges = fieldChannelEdges(queries, ctx);
|
||||
@@ -792,6 +858,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
||||
const ifaceEdges = interfaceOverrideEdges(queries);
|
||||
const rnEventEdgesList = rnEventEdges(ctx);
|
||||
const fabricNativeEdges = fabricNativeImplEdges(ctx);
|
||||
const mybatisEdges = mybatisJavaXmlEdges(queries);
|
||||
|
||||
const merged: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -806,6 +873,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
||||
...ifaceEdges,
|
||||
...rnEventEdgesList,
|
||||
...fabricNativeEdges,
|
||||
...mybatisEdges,
|
||||
]) {
|
||||
const key = `${e.source}>${e.target}`;
|
||||
if (seen.has(key)) continue;
|
||||
|
||||
@@ -10,7 +10,15 @@ import { stripCommentsForRegex } from '../strip-comments';
|
||||
|
||||
export const springResolver: FrameworkResolver = {
|
||||
name: 'spring',
|
||||
languages: ['java', 'kotlin'],
|
||||
languages: ['java', 'kotlin', 'yaml', 'properties'],
|
||||
|
||||
claimsReference(name: string): boolean {
|
||||
// `@ConfigurationProperties(prefix="app.cache")` emits a reference whose
|
||||
// name carries the `:prefix` sentinel — there's no declared symbol with
|
||||
// that exact spelling, so the resolver's name-existence pre-filter would
|
||||
// drop it. Opt those through.
|
||||
return name.endsWith(':prefix');
|
||||
},
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
// Check for pom.xml with Spring
|
||||
@@ -50,6 +58,66 @@ export const springResolver: FrameworkResolver = {
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
// Spring config-key references — `@Value("${key}")` (single leaf) and
|
||||
// `@ConfigurationProperties(prefix="X")` (entire subtree, marked with the
|
||||
// `:prefix` suffix in extractSpringValueBindings). Lookup goes through
|
||||
// Spring's relaxed binding (kebab/camel/snake → canonical lowercase).
|
||||
if (ref.referenceName.endsWith(':prefix')) {
|
||||
const prefix = ref.referenceName.slice(0, -':prefix'.length);
|
||||
const canonPrefix = canonicalConfigKey(prefix);
|
||||
// Prefer an exact prefix match (one node = the prefix subtree). Without
|
||||
// node-level subtree representation we map to the closest matching key.
|
||||
const candidates = context.getNodesByKind('constant').filter(
|
||||
(n) => (n.language === 'yaml' || n.language === 'properties')
|
||||
&& canonicalConfigKey(n.qualifiedName).startsWith(canonPrefix),
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
// Pick the SHORTEST canonical name — it's the closest binding point
|
||||
// (`app.cache` over `app.cache.name.user-token` for prefix=`app.cache`).
|
||||
const best = candidates.reduce((a, b) =>
|
||||
canonicalConfigKey(a.qualifiedName).length <= canonicalConfigKey(b.qualifiedName).length ? a : b,
|
||||
);
|
||||
return { original: ref, targetNodeId: best.id, confidence: 0.85, resolvedBy: 'framework' };
|
||||
}
|
||||
if (ref.referenceName.includes('.') && ref.language !== 'java' && ref.language !== 'kotlin') {
|
||||
// Spring config dotted key — only when the source language is Java/Kotlin
|
||||
// (the bindings come from `@Value`). Skip non-Spring refs that happen to
|
||||
// have dots in them.
|
||||
}
|
||||
if (
|
||||
(ref.language === 'java' || ref.language === 'kotlin') &&
|
||||
ref.referenceName.includes('.') &&
|
||||
!ref.referenceName.includes('::') &&
|
||||
// Exclude method-call style (single-dot, both sides lower-camel). Spring
|
||||
// config keys are typically 3+ segments and contain kebabs/dashes; we
|
||||
// can't filter perfectly but skipping single-dot keeps the lookup tight.
|
||||
ref.referenceName.split('.').length >= 2
|
||||
) {
|
||||
const canonRef = canonicalConfigKey(ref.referenceName);
|
||||
const candidates = context.getNodesByKind('constant').filter(
|
||||
(n) => n.kind === 'constant'
|
||||
&& (n.language === 'yaml' || n.language === 'properties')
|
||||
&& canonicalConfigKey(n.qualifiedName) === canonRef,
|
||||
);
|
||||
if (candidates.length === 1) {
|
||||
return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.9, resolvedBy: 'framework' };
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
// Multiple profile-specific files (application-dev.yml +
|
||||
// application-prod.yml) can define the same key. Prefer the one with
|
||||
// the shortest profile suffix (the base `application.yml` wins over
|
||||
// profile variants when both exist), then by alphabetical path so the
|
||||
// pick is deterministic across reindexes.
|
||||
const score = (n: Node) => {
|
||||
const base = n.filePath.split('/').pop() ?? '';
|
||||
const isBase = /^(application|bootstrap)\.(yml|yaml|properties)$/i.test(base);
|
||||
return (isBase ? 0 : 1) * 1000 + base.length;
|
||||
};
|
||||
const best = candidates.reduce((a, b) => (score(a) <= score(b) ? a : b));
|
||||
return { original: ref, targetNodeId: best.id, confidence: 0.75, resolvedBy: 'framework' };
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 1: Service references (dependency injection)
|
||||
if (ref.referenceName.endsWith('Service')) {
|
||||
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
|
||||
@@ -119,6 +187,13 @@ export const springResolver: FrameworkResolver = {
|
||||
},
|
||||
|
||||
extract(filePath, content) {
|
||||
// Spring config files (application.yml / application.properties /
|
||||
// bootstrap.yml + per-profile variants) are extracted on the framework
|
||||
// path, not in the language extractor, so the keys become first-class
|
||||
// nodes a `@Value("${k}")` reference can resolve to.
|
||||
if (isSpringConfigFile(filePath)) {
|
||||
return extractSpringConfig(filePath, content);
|
||||
}
|
||||
// Spring Boot is used from both Java and Kotlin (identical @GetMapping etc.
|
||||
// annotations); the difference is method syntax — Kotlin `fun name(...)` vs
|
||||
// Java `public X name(...)` — handled in the method regex below.
|
||||
@@ -210,10 +285,210 @@ export const springResolver: FrameworkResolver = {
|
||||
});
|
||||
}
|
||||
|
||||
// @Value("${key}") and @ConfigurationProperties(prefix="...") — bind
|
||||
// Spring config-key references in Java/Kotlin source. The reference target
|
||||
// is the corresponding YAML/properties leaf-key node emitted by
|
||||
// extractSpringConfig; springResolver.resolve looks it up with relaxed
|
||||
// binding (kebab/camel/snake collapse).
|
||||
extractSpringValueBindings(filePath, safe, lang, now, nodes, references);
|
||||
|
||||
return { nodes, references };
|
||||
},
|
||||
};
|
||||
|
||||
/** Spring config file patterns: application(-profile)?.{yml,yaml,properties} +
|
||||
* bootstrap variants. Matches the basename, not the path, so a project that
|
||||
* vendors `application.yml` under `src/main/resources` and one under `src/test/
|
||||
* resources` are both picked up. */
|
||||
function isSpringConfigFile(filePath: string): boolean {
|
||||
const base = filePath.split('/').pop() ?? '';
|
||||
return /^(application|bootstrap)(-[\w.-]+)?\.(yml|yaml|properties)$/i.test(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Spring config file (YAML or .properties) and emit one `constant`
|
||||
* node per LEAF key, with `qualifiedName` = the dotted path. Leaf keys are
|
||||
* what `@Value("${k}")` references hit; intermediate keys aren't bound by
|
||||
* Spring's `@Value` (a `@ConfigurationProperties` class binds a SUBTREE, and
|
||||
* those references are resolved at lookup time by prefix-suffix matching).
|
||||
*/
|
||||
function extractSpringConfig(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): { nodes: Node[]; references: UnresolvedRef[] } {
|
||||
const nodes: Node[] = [];
|
||||
const isProperties = /\.properties$/i.test(filePath);
|
||||
const lang = isProperties ? 'properties' : 'yaml';
|
||||
const now = Date.now();
|
||||
|
||||
const emitLeaf = (dottedKey: string, line: number, valueText: string) => {
|
||||
if (!dottedKey) return;
|
||||
nodes.push({
|
||||
id: `spring-config:${filePath}:${line}:${dottedKey}`,
|
||||
kind: 'constant',
|
||||
name: dottedKey.split('.').pop() ?? dottedKey,
|
||||
qualifiedName: dottedKey,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: valueText.length,
|
||||
language: lang,
|
||||
signature: dottedKey,
|
||||
docstring: valueText.slice(0, 200),
|
||||
updatedAt: now,
|
||||
});
|
||||
};
|
||||
|
||||
if (isProperties) {
|
||||
// Properties format: `k1.k2.k3 = value` (or `:` separator, or no value).
|
||||
// Lines starting with `#`/`!` are comments. Backslash continuations are
|
||||
// valid but rare; we don't try to join them (a continued value is still
|
||||
// a value of the same key).
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i] ?? '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
|
||||
const sep = (() => {
|
||||
for (let j = 0; j < raw.length; j++) {
|
||||
const ch = raw[j];
|
||||
if (ch === '=' || ch === ':') return j;
|
||||
if (ch === '\\' && raw[j + 1]) { j++; continue; }
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
if (sep < 0) continue;
|
||||
const key = raw.slice(0, sep).trim();
|
||||
const val = raw.slice(sep + 1).trim();
|
||||
emitLeaf(key, i + 1, val);
|
||||
}
|
||||
return { nodes, references: [] };
|
||||
}
|
||||
|
||||
// YAML: indent-based. We track a stack of (indent, key) so the dotted path
|
||||
// is built by joining ancestor keys with `.`. A leaf is a line with a value
|
||||
// on the same line (after `:`). List items, flow-style scalars, and `---`
|
||||
// separators are ignored — they don't bind to `@Value` anyway.
|
||||
const stack: Array<{ indent: number; key: string }> = [];
|
||||
const yamlLines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < yamlLines.length; i++) {
|
||||
const raw = yamlLines[i] ?? '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed === '---' || trimmed.startsWith('- ')) continue;
|
||||
const indent = raw.length - raw.replace(/^[\t ]+/, '').length;
|
||||
const colonIdx = (() => {
|
||||
let inStr: string | null = null;
|
||||
for (let j = 0; j < raw.length; j++) {
|
||||
const ch = raw[j];
|
||||
if (inStr) { if (ch === inStr && raw[j - 1] !== '\\') inStr = null; continue; }
|
||||
if (ch === '"' || ch === "'") { inStr = ch; continue; }
|
||||
if (ch === ':') return j;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
if (colonIdx < 0) continue;
|
||||
const key = raw.slice(indent, colonIdx).trim();
|
||||
if (!key) continue;
|
||||
const after = raw.slice(colonIdx + 1).trim();
|
||||
while (stack.length > 0 && stack[stack.length - 1]!.indent >= indent) stack.pop();
|
||||
const dotted = [...stack.map((s) => s.key), key].join('.');
|
||||
if (after === '' || after.startsWith('#')) {
|
||||
stack.push({ indent, key });
|
||||
} else {
|
||||
// A leaf with an inline value (or a flow-mapping like `{ a: 1 }` — we
|
||||
// emit it as a leaf, not as a subtree; precision is fine for `@Value`).
|
||||
const valStripped = after.replace(/^["']|["']$/g, '');
|
||||
emitLeaf(dotted, i + 1, valStripped);
|
||||
}
|
||||
}
|
||||
return { nodes, references: [] };
|
||||
}
|
||||
|
||||
/** Append `@Value("${k}")` and `@ConfigurationProperties(prefix=...)`
|
||||
* references discovered in `safe` (comments stripped) into the caller's
|
||||
* `nodes`/`references` arrays. */
|
||||
function extractSpringValueBindings(
|
||||
filePath: string,
|
||||
safe: string,
|
||||
lang: 'java' | 'kotlin',
|
||||
now: number,
|
||||
nodes: Node[],
|
||||
references: UnresolvedRef[],
|
||||
): void {
|
||||
const valueRe = /@Value\s*\(\s*["']\$\{([^}:]+)(?::[^}]*)?\}["']\s*\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = valueRe.exec(safe)) !== null) {
|
||||
const key = m[1]!.trim();
|
||||
if (!key) continue;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const bindNode: Node = {
|
||||
id: `spring-value:${filePath}:${line}:${key}`,
|
||||
kind: 'constant',
|
||||
name: key,
|
||||
qualifiedName: `${filePath}::@Value:${key}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: m[0].length,
|
||||
language: lang,
|
||||
signature: `@Value("${key}")`,
|
||||
updatedAt: now,
|
||||
};
|
||||
nodes.push(bindNode);
|
||||
references.push({
|
||||
fromNodeId: bindNode.id,
|
||||
referenceName: key,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
filePath,
|
||||
language: lang,
|
||||
});
|
||||
}
|
||||
|
||||
const cpRe = /@ConfigurationProperties\s*\(\s*(?:prefix\s*=\s*)?["']([^"']+)["']/g;
|
||||
while ((m = cpRe.exec(safe)) !== null) {
|
||||
const prefix = m[1]!.trim();
|
||||
if (!prefix) continue;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const bindNode: Node = {
|
||||
id: `spring-cp:${filePath}:${line}:${prefix}`,
|
||||
kind: 'constant',
|
||||
name: prefix,
|
||||
qualifiedName: `${filePath}::@ConfigurationProperties:${prefix}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: m[0].length,
|
||||
language: lang,
|
||||
signature: `@ConfigurationProperties("${prefix}")`,
|
||||
updatedAt: now,
|
||||
};
|
||||
nodes.push(bindNode);
|
||||
references.push({
|
||||
fromNodeId: bindNode.id,
|
||||
// Mark the reference with a `:prefix` suffix so springResolver.resolve
|
||||
// knows to expand it into the SUBTREE rather than a single key.
|
||||
referenceName: `${prefix}:prefix`,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
filePath,
|
||||
language: lang,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Spring's relaxed binding (`cache-list` ↔ `cacheList` ↔ `cache_list` ↔
|
||||
* `CACHE_LIST`) collapses on lowercase + dash/underscore removal. We compare
|
||||
* candidate keys to a reference in this canonical form. */
|
||||
function canonicalConfigKey(key: string): string {
|
||||
return key.toLowerCase().replace(/[-_]/g, '');
|
||||
}
|
||||
|
||||
// Directory patterns
|
||||
const SERVICE_DIRS = ['/service/', '/services/'];
|
||||
const REPO_DIRS = ['/repository/', '/repositories/'];
|
||||
|
||||
@@ -262,6 +262,66 @@ function inferCppReceiverType(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Java/Kotlin: infer a receiver's declared type by walking field declarations
|
||||
* in the class enclosing the call site. The field's `signature` is already in
|
||||
* the form "<TypeName> <fieldName>" (set by tree-sitter.ts extractField), so we
|
||||
* pull the type from there. Handles Spring `@Resource UserBO userbo;` /
|
||||
* `@Autowired private UserService userService;` where the receiver field name
|
||||
* doesn't match the class name by Java naming convention.
|
||||
*
|
||||
* Returns the bare type name (generics stripped, dotted package stripped) or
|
||||
* null when no matching field is in the enclosing class.
|
||||
*/
|
||||
function inferJavaFieldReceiverType(
|
||||
receiverName: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): string | null {
|
||||
const inFile = context.getNodesInFile(ref.filePath);
|
||||
if (inFile.length === 0) return null;
|
||||
|
||||
// Find the class enclosing the call line (tightest match by latest start).
|
||||
let enclosing: Node | null = null;
|
||||
for (const n of inFile) {
|
||||
if (n.kind !== 'class' && n.kind !== 'interface') continue;
|
||||
if (n.language !== ref.language) continue;
|
||||
const end = n.endLine ?? n.startLine;
|
||||
if (n.startLine <= ref.line && end >= ref.line) {
|
||||
if (!enclosing || n.startLine >= enclosing.startLine) enclosing = n;
|
||||
}
|
||||
}
|
||||
if (!enclosing) return null;
|
||||
|
||||
const enclosingEnd = enclosing.endLine ?? enclosing.startLine;
|
||||
const field = inFile.find(
|
||||
(n) =>
|
||||
n.kind === 'field' &&
|
||||
n.name === receiverName &&
|
||||
n.language === ref.language &&
|
||||
n.startLine >= enclosing.startLine &&
|
||||
(n.endLine ?? n.startLine) <= enclosingEnd,
|
||||
);
|
||||
if (!field || !field.signature) return null;
|
||||
|
||||
// Signature shape: "<TypeName> <fieldName>" (extractField). Pull the type,
|
||||
// strip generics + dotted package, drop array/varargs markers.
|
||||
const beforeName = field.signature.slice(
|
||||
0,
|
||||
field.signature.lastIndexOf(field.name),
|
||||
);
|
||||
const typeRaw = beforeName.trim();
|
||||
if (!typeRaw) return null;
|
||||
|
||||
const typeNoGenerics = typeRaw.replace(/<[^>]*>/g, '').trim();
|
||||
const typeNoArray = typeNoGenerics.replace(/\[\s*\]/g, '').replace(/\.\.\.$/, '').trim();
|
||||
const parts = typeNoArray.split(/[.\s]+/).filter(Boolean);
|
||||
const lastPart = parts[parts.length - 1];
|
||||
if (!lastPart) return null;
|
||||
if (!/^[A-Z]/.test(lastPart)) return null; // primitives / lowercase → skip
|
||||
return lastPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve by method name on a class/object
|
||||
*/
|
||||
@@ -297,6 +357,28 @@ export function matchMethodCall(
|
||||
}
|
||||
}
|
||||
|
||||
// Java/Kotlin: receiver may be a field whose name doesn't match the type by
|
||||
// Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
|
||||
// the field in the enclosing class to get its declared type, then resolve
|
||||
// the method on that type. Covers Spring `@Resource`/`@Autowired` field
|
||||
// injection where the field type is the concrete bean class.
|
||||
if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) {
|
||||
const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context);
|
||||
if (inferredType) {
|
||||
const typedMatch = resolveMethodOnType(
|
||||
inferredType,
|
||||
methodName!,
|
||||
ref,
|
||||
context,
|
||||
0.9,
|
||||
'instance-method',
|
||||
);
|
||||
if (typedMatch) {
|
||||
return typedMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 1: Direct class name match (existing logic)
|
||||
const classCandidates = context.getNodesByName(objectOrClass!);
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ export const LANGUAGES = [
|
||||
'objc',
|
||||
'yaml',
|
||||
'twig',
|
||||
'xml',
|
||||
'properties',
|
||||
'unknown',
|
||||
] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user