diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java
index d066126d..707f03b3 100644
--- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java
+++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java
@@ -17,6 +17,11 @@
import io.github.randomcodespace.iq.detector.DetectorUtils;
import io.github.randomcodespace.iq.grammar.AntlrParserFactory;
import io.github.randomcodespace.iq.intelligence.RepositoryIdentity;
+import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved;
+import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException;
+import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
+import io.github.randomcodespace.iq.intelligence.resolver.ResolverRegistry;
+import io.github.randomcodespace.iq.intelligence.resolver.SymbolResolver;
import io.github.randomcodespace.iq.model.CodeEdge;
import io.github.randomcodespace.iq.model.CodeNode;
import io.github.randomcodespace.iq.model.NodeKind;
@@ -89,6 +94,7 @@ public class Analyzer {
private final CodeIqUnifiedConfig unifiedConfig;
private final ConfigScanner configScanner;
private final ArchitectureKeywordFilter keywordFilter;
+ private final ResolverRegistry resolverRegistry;
/**
* Projection of the injected {@link CodeIqUnifiedConfig} tree into the flat
@@ -127,7 +133,8 @@ public Analyzer(
CodeIqConfig config,
CodeIqUnifiedConfig unifiedConfig,
ConfigScanner configScanner,
- ArchitectureKeywordFilter keywordFilter
+ ArchitectureKeywordFilter keywordFilter,
+ ResolverRegistry resolverRegistry
) {
this.registry = registry;
this.parser = parser;
@@ -138,6 +145,7 @@ public Analyzer(
this.unifiedConfig = unifiedConfig;
this.configScanner = configScanner;
this.keywordFilter = keywordFilter;
+ this.resolverRegistry = resolverRegistry;
}
/**
@@ -147,7 +155,11 @@ public Analyzer(
* equivalent to the "no {@code codeiq.yml} present" path
* (no detector filters, no language filter, auto parallelism). Tests that
* need to exercise filters should use the primary constructor with a
- * hand-rolled {@link CodeIqUnifiedConfig}.
+ * hand-rolled {@link CodeIqUnifiedConfig}. The {@link ResolverRegistry} is
+ * defaulted to an empty registry — every {@code resolverFor(...)} call
+ * returns the no-op resolver and every {@code resolved()} reads back as
+ * {@link EmptyResolved#INSTANCE}, which is the same observable behaviour as
+ * the pre-resolver pipeline.
*/
public Analyzer(
DetectorRegistry registry,
@@ -159,7 +171,65 @@ public Analyzer(
) {
this(registry, parser, fileDiscovery, layerClassifier, linkers, config,
CodeIqUnifiedConfig.empty(),
- new ConfigScanner(), new ArchitectureKeywordFilter());
+ new ConfigScanner(), new ArchitectureKeywordFilter(),
+ new ResolverRegistry(List.of()));
+ }
+
+ /**
+ * Bootstrap every registered {@link SymbolResolver} against the project
+ * root. Called exactly once per pipeline entry point (run / runBatchedIndex
+ * / runSmartIndex), before any file iteration. Per-resolver failures are
+ * logged inside {@link ResolverRegistry#bootstrap(Path)} and do not abort
+ * the pass — a misbehaving resolver simply returns {@link EmptyResolved}
+ * for its language for the rest of the run.
+ */
+ private void bootstrapResolvers(Path root) {
+ try {
+ resolverRegistry.bootstrap(root);
+ } catch (RuntimeException e) {
+ // ResolverRegistry already swallows per-resolver failures; this catch
+ // is purely defensive in case the registry itself blows up. The
+ // pipeline continues with NOOP resolvers (Optional.of(EmptyResolved)).
+ log.warn("Resolver bootstrap failed for {}: {}", root, e.getMessage());
+ }
+ }
+
+ /**
+ * Resolve symbols for a single file, swallowing {@link ResolutionException}
+ * so one resolver failure can't take down the whole file's detector pass.
+ * Returns {@link EmptyResolved#INSTANCE} on any failure (or when the
+ * resolver itself returns null, defensive).
+ *
+ * The orchestrator passes whatever it has: structured languages already
+ * have a {@code parsedAst} (YAML/JSON/etc. parse tree); for languages the
+ * top-level parser doesn't cover (Java, Python, …) we pass {@code content}
+ * as a fallback so language-specific resolvers can lazy-parse the source.
+ * Resolvers that don't understand the payload shape return EmptyResolved.
+ */
+ private Resolved resolveFor(DiscoveredFile file, Object parsedAst, String content) {
+ Object payload = parsedAst != null ? parsedAst : content;
+ SymbolResolver resolver = resolverRegistry.resolverFor(file.language());
+ try {
+ Resolved r = resolver.resolve(file, payload);
+ return r != null ? r : EmptyResolved.INSTANCE;
+ } catch (ResolutionException e) {
+ log.debug("resolver {} failed for {}: {}",
+ resolver.getClass().getSimpleName(), file.path(), e.getMessage());
+ return EmptyResolved.INSTANCE;
+ } catch (RuntimeException e) {
+ log.debug("resolver {} threw unexpectedly for {}: {}",
+ resolver.getClass().getSimpleName(), file.path(), e.toString());
+ return EmptyResolved.INSTANCE;
+ } catch (StackOverflowError e) {
+ // Pathological generic / type-cycle inputs can blow JavaSymbolSolver's
+ // recursion stack. Catching the Error keeps the virtual-thread
+ // worker alive and the file's resolution simply degrades to lexical.
+ // Other Errors (OOM, ThreadDeath) are not caught — they're fatal and
+ // should propagate.
+ log.warn("resolver {} stack-overflowed for {} — falling back to lexical",
+ resolver.getClass().getSimpleName(), file.path());
+ return EmptyResolved.INSTANCE;
+ }
}
/**
@@ -201,6 +271,8 @@ public AnalysisResult run(Path repoPath, Integer parallelism, boolean incrementa
final Path root = repoPath.toAbsolutePath().normalize();
+ bootstrapResolvers(root);
+
// Open incremental cache if enabled
AnalysisCache cache = null;
if (incremental) {
@@ -501,6 +573,8 @@ public AnalysisResult runBatchedIndex(Path repoPath, Integer parallelism, int ba
final Path root = repoPath.toAbsolutePath().normalize();
+ bootstrapResolvers(root);
+
// Always use H2 cache as the primary store during indexing
Path cachePath = root.resolve(config.getCacheDir()).resolve("analysis-cache.db");
AnalysisCache cache;
@@ -784,6 +858,8 @@ public AnalysisResult runSmartIndex(Path repoPath, Integer parallelism, int batc
Consumer report = onProgress != null ? onProgress : msg -> {};
final Path root = repoPath.toAbsolutePath().normalize();
+ bootstrapResolvers(root);
+
Path cachePath = root.resolve(config.getCacheDir()).resolve("analysis-cache.db");
AnalysisCache cache;
try {
@@ -1295,7 +1371,7 @@ DetectorResult analyzeFileWithRegistry(DiscoveredFile file, Path repoPath,
parsedData,
moduleName,
infraRegistry
- );
+ ).withResolved(resolveFor(file, parsedData, content));
List detectors = detectorRegistry.detectorsForLanguage(file.language());
if (detectors.isEmpty()) {
@@ -1503,7 +1579,7 @@ DetectorResult analyzeFile(DiscoveredFile file, Path repoPath, DetectorRegistry
content,
parsedData,
moduleName
- );
+ ).withResolved(resolveFor(file, parsedData, content));
// Run matching detectors and merge results
List detectors = detectorRegistry.detectorsForLanguage(file.language());
@@ -1593,7 +1669,8 @@ private DetectorResult analyzeFileRegexOnly(DiscoveredFile file, Path repoPath,
}
String moduleName = DetectorUtils.deriveModuleName(file.path().toString(), file.language());
- var ctx = new DetectorContext(file.path().toString(), file.language(), content, null, moduleName);
+ var ctx = new DetectorContext(file.path().toString(), file.language(), content, null, moduleName)
+ .withResolved(resolveFor(file, null, content));
List detectors = detectorRegistry.detectorsForLanguage(file.language());
var allNodes = new ArrayList();
diff --git a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java
index 8af2eed1..a12147b8 100644
--- a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java
+++ b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java
@@ -5,10 +5,14 @@
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.EnumDeclaration;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
+import com.github.javaparser.resolution.types.ResolvedType;
import io.github.randomcodespace.iq.detector.DetectorContext;
import io.github.randomcodespace.iq.detector.DetectorResult;
+import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
+import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved;
import io.github.randomcodespace.iq.model.CodeEdge;
import io.github.randomcodespace.iq.model.CodeNode;
+import io.github.randomcodespace.iq.model.Confidence;
import io.github.randomcodespace.iq.model.EdgeKind;
import io.github.randomcodespace.iq.model.NodeKind;
import org.springframework.stereotype.Component;
@@ -71,16 +75,28 @@ public DetectorResult detect(DetectorContext ctx) {
String text = ctx.content();
if (text == null || text.isEmpty()) return DetectorResult.empty();
- Optional cu = parse(ctx);
+ // Prefer the resolver-parsed CU when ctx.resolved() carries a
+ // JavaResolved — class hierarchy benefits a lot from FQN resolution
+ // because superclass / interface refs are routinely simple-named in
+ // source ("extends Service" not "extends com.example.Service") and
+ // EXTENDS/IMPLEMENTS edges are downstream-load-bearing.
+ Optional resolved = ctx.resolved()
+ .filter(Resolved::isAvailable)
+ .filter(JavaResolved.class::isInstance)
+ .map(JavaResolved.class::cast);
+
+ Optional cu = resolved.map(JavaResolved::cu).or(() -> parse(ctx));
if (cu.isPresent()) {
- return detectWithAst(cu.get(), ctx);
+ return detectWithAst(cu.get(), ctx, resolved);
}
return detectWithRegex(ctx);
}
// ==================== AST-based detection ====================
- private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
+ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx,
+ Optional resolved) {
+ boolean canResolve = resolved.isPresent();
List nodes = new ArrayList<>();
List edges = new ArrayList<>();
@@ -144,36 +160,17 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
node.setProperties(props);
nodes.add(node);
- // EXTENDS edges
- if (!isInterface) {
- for (String superclass : extendedTypes) {
- CodeEdge edge = new CodeEdge();
- edge.setId(nodeId + "->extends->*:" + superclass);
- edge.setKind(EdgeKind.EXTENDS);
- edge.setSourceId(nodeId);
- edge.setTarget(new CodeNode("*:" + superclass, NodeKind.CLASS, superclass));
- edges.add(edge);
- }
- } else {
- // Interfaces extend other interfaces
- for (String ext : extendedTypes) {
- CodeEdge edge = new CodeEdge();
- edge.setId(nodeId + "->extends->*:" + ext);
- edge.setKind(EdgeKind.EXTENDS);
- edge.setSourceId(nodeId);
- edge.setTarget(new CodeNode("*:" + ext, NodeKind.INTERFACE, ext));
- edges.add(edge);
- }
+ // EXTENDS edges — iterate the typed AST nodes (not the simple-name
+ // strings) so we can attempt FQN resolution per-type when ctx
+ // carries a JavaResolved.
+ NodeKind extendsTargetKind = isInterface ? NodeKind.INTERFACE : NodeKind.CLASS;
+ for (ClassOrInterfaceType ext : decl.getExtendedTypes()) {
+ addHierarchyEdge(nodeId, ext, EdgeKind.EXTENDS, extendsTargetKind, canResolve, edges);
}
// IMPLEMENTS edges
- for (String iface : implementedTypes) {
- CodeEdge edge = new CodeEdge();
- edge.setId(nodeId + "->implements->*:" + iface);
- edge.setKind(EdgeKind.IMPLEMENTS);
- edge.setSourceId(nodeId);
- edge.setTarget(new CodeNode("*:" + iface, NodeKind.INTERFACE, iface));
- edges.add(edge);
+ for (ClassOrInterfaceType impl : decl.getImplementedTypes()) {
+ addHierarchyEdge(nodeId, impl, EdgeKind.IMPLEMENTS, NodeKind.INTERFACE, canResolve, edges);
}
});
@@ -212,13 +209,8 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
node.setProperties(props);
nodes.add(node);
- for (String iface : interfaces) {
- CodeEdge edge = new CodeEdge();
- edge.setId(nodeId + "->implements->*:" + iface);
- edge.setKind(EdgeKind.IMPLEMENTS);
- edge.setSourceId(nodeId);
- edge.setTarget(new CodeNode("*:" + iface, NodeKind.INTERFACE, iface));
- edges.add(edge);
+ for (ClassOrInterfaceType impl : decl.getImplementedTypes()) {
+ addHierarchyEdge(nodeId, impl, EdgeKind.IMPLEMENTS, NodeKind.INTERFACE, canResolve, edges);
}
});
@@ -435,4 +427,46 @@ private List parseTypeList(String typeList) {
}
return result;
}
+
+ /**
+ * Emit an EXTENDS or IMPLEMENTS edge for a single type reference. When
+ * {@code canResolve} is true the helper attempts FQN resolution via the
+ * symbol solver and, on success, attaches {@code target_fqn} +
+ * {@link Confidence#RESOLVED} + source. The simple-name placeholder
+ * target is unchanged so EntityLinker / ClassHierarchyLinker post-passes
+ * are unaffected on the surface — they can opt to use {@code target_fqn}
+ * when present.
+ */
+ private void addHierarchyEdge(String sourceId, ClassOrInterfaceType target,
+ EdgeKind edgeKind, NodeKind targetKind,
+ boolean canResolve, List edges) {
+ String simpleName = target.getNameAsString();
+ Optional fqn = canResolve ? tryResolveFqn(target) : Optional.empty();
+
+ CodeEdge edge = new CodeEdge();
+ edge.setId(sourceId + "->" + edgeKind.getValue() + "->*:" + simpleName);
+ edge.setKind(edgeKind);
+ edge.setSourceId(sourceId);
+ edge.setTarget(new CodeNode("*:" + simpleName, targetKind, simpleName));
+ if (fqn.isPresent()) {
+ Map props = new LinkedHashMap<>();
+ props.put("target_fqn", fqn.get());
+ edge.setProperties(props);
+ edge.setConfidence(Confidence.RESOLVED);
+ edge.setSource(getName());
+ }
+ edges.add(edge);
+ }
+
+ private static Optional tryResolveFqn(ClassOrInterfaceType type) {
+ try {
+ ResolvedType rt = type.resolve();
+ if (rt.isReferenceType()) {
+ return Optional.of(rt.asReferenceType().getQualifiedName());
+ }
+ return Optional.of(rt.describe());
+ } catch (RuntimeException e) {
+ return Optional.empty();
+ }
+ }
}
diff --git a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetector.java
index f2c1dc7b..b96479f5 100644
--- a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetector.java
+++ b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetector.java
@@ -8,11 +8,15 @@
import com.github.javaparser.ast.expr.MemberValuePair;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.Type;
+import com.github.javaparser.resolution.types.ResolvedType;
import io.github.randomcodespace.iq.detector.DetectorContext;
import io.github.randomcodespace.iq.detector.DetectorDbHelper;
import io.github.randomcodespace.iq.detector.DetectorResult;
+import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
+import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved;
import io.github.randomcodespace.iq.model.CodeEdge;
import io.github.randomcodespace.iq.model.CodeNode;
+import io.github.randomcodespace.iq.model.Confidence;
import io.github.randomcodespace.iq.model.EdgeKind;
import io.github.randomcodespace.iq.model.NodeKind;
import org.springframework.stereotype.Component;
@@ -77,16 +81,28 @@ public DetectorResult detect(DetectorContext ctx) {
String text = ctx.content();
if (text == null || !text.contains("@Entity")) return DetectorResult.empty();
- Optional cu = parse(ctx);
+ // Prefer the resolver-parsed CU when ctx.resolved() carries a
+ // {@link JavaResolved}: that CU has the symbol solver attached, so
+ // {@code Type.resolve()} works inside detectWithAst and we can promote
+ // edges from SYNTACTIC → RESOLVED with a stable {@code target_fqn}.
+ // Fall back to the local ThreadLocal-pool parse otherwise — existing
+ // behaviour, no resolution attempts, defaults stamp SYNTACTIC.
+ Optional resolved = ctx.resolved()
+ .filter(Resolved::isAvailable)
+ .filter(JavaResolved.class::isInstance)
+ .map(JavaResolved.class::cast);
+
+ Optional cu = resolved.map(JavaResolved::cu).or(() -> parse(ctx));
if (cu.isPresent()) {
- return detectWithAst(cu.get(), ctx);
+ return detectWithAst(cu.get(), ctx, resolved);
}
return detectWithRegex(ctx);
}
// ==================== AST-based detection ====================
- private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
+ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx,
+ Optional resolved) {
List nodes = new ArrayList<>();
List edges = new ArrayList<>();
@@ -119,7 +135,7 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
nodes.add(node);
DetectorDbHelper.addDbEdge(entityId, ctx.registry(), nodes, edges);
- extractRelationshipEdges(classDecl, entityId, edges);
+ extractRelationshipEdges(classDecl, entityId, resolved, edges);
});
return DetectorResult.of(nodes, edges);
@@ -159,7 +175,9 @@ private void addColumnFromAnnotations(FieldDeclaration field, String fieldName,
}
private void extractRelationshipEdges(ClassOrInterfaceDeclaration classDecl,
- String entityId, List edges) {
+ String entityId,
+ Optional resolved,
+ List edges) {
for (FieldDeclaration field : classDecl.getFields()) {
for (AnnotationExpr ann : field.getAnnotations()) {
String annName = ann.getNameAsString();
@@ -169,10 +187,18 @@ private void extractRelationshipEdges(ClassOrInterfaceDeclaration classDecl,
String targetEntity = resolveTargetEntity(ann, field);
if (targetEntity == null) continue;
+ // Promote SYNTACTIC → RESOLVED when the symbol solver can give
+ // us a stable FQN for the relationship target. The simple-name
+ // edge ID + target placeholder are unchanged so EntityLinker's
+ // post-pass keeps working; target_fqn rides as a property and
+ // is the canonical pointer when present.
+ Optional targetFqn = resolved.flatMap(r -> resolveTargetFqn(field));
+
String mappedBy = extractAnnotationStringAttr(ann, "mappedBy");
Map edgeProps = new LinkedHashMap<>();
edgeProps.put("relationship_type", relType);
if (mappedBy != null) edgeProps.put("mapped_by", mappedBy);
+ targetFqn.ifPresent(fqn -> edgeProps.put("target_fqn", fqn));
CodeEdge edge = new CodeEdge();
edge.setId(entityId + "->maps_to->*:" + targetEntity);
@@ -180,11 +206,54 @@ private void extractRelationshipEdges(ClassOrInterfaceDeclaration classDecl,
edge.setSourceId(entityId);
edge.setTarget(new CodeNode("*:" + targetEntity, NodeKind.ENTITY, targetEntity));
edge.setProperties(edgeProps);
+ if (targetFqn.isPresent()) {
+ edge.setConfidence(Confidence.RESOLVED);
+ edge.setSource(getName());
+ }
edges.add(edge);
}
}
}
+ /**
+ * Resolve the target entity's fully-qualified name via the symbol solver.
+ * Mirrors {@link #resolveTargetEntity} but returns the FQN instead of a
+ * simple name. {@code @OneToMany List} → resolves the {@code Owner}
+ * type argument; {@code @ManyToOne Owner} → resolves the field type
+ * directly.
+ *
+ * Returns {@code Optional.empty()} on any resolver failure (unsolved
+ * symbol, missing type, classpath gap, etc.) — graceful fallback to
+ * SYNTACTIC tier.
+ */
+ private Optional resolveTargetFqn(FieldDeclaration field) {
+ for (VariableDeclarator var : field.getVariables()) {
+ Type type = var.getType();
+ if (!type.isClassOrInterfaceType()) continue;
+ ClassOrInterfaceType cit = type.asClassOrInterfaceType();
+ var typeArgsOpt = cit.getTypeArguments();
+ Type targetType;
+ if (typeArgsOpt.isPresent() && !typeArgsOpt.get().isEmpty()) {
+ targetType = typeArgsOpt.get().get(0);
+ } else {
+ targetType = cit;
+ }
+ try {
+ ResolvedType rt = targetType.resolve();
+ if (rt.isReferenceType()) {
+ return Optional.of(rt.asReferenceType().getQualifiedName());
+ }
+ return Optional.of(rt.describe());
+ } catch (RuntimeException e) {
+ // Resolver couldn't pin the type — typical when the classpath
+ // is incomplete or the type is genuinely unknown. Fall back to
+ // SYNTACTIC by returning empty.
+ return Optional.empty();
+ }
+ }
+ return Optional.empty();
+ }
+
private String resolveTargetEntity(AnnotationExpr ann, FieldDeclaration field) {
String targetEntity = extractAnnotationStringAttr(ann, "targetEntity");
if (targetEntity != null && targetEntity.endsWith(".class")) {
diff --git a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetector.java
index cdd579b5..063b41fc 100644
--- a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetector.java
+++ b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetector.java
@@ -1,11 +1,18 @@
package io.github.randomcodespace.iq.detector.jvm.java;
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
+import com.github.javaparser.ast.type.ClassOrInterfaceType;
+import com.github.javaparser.ast.type.Type;
+import com.github.javaparser.resolution.types.ResolvedType;
import io.github.randomcodespace.iq.detector.AbstractRegexDetector;
import io.github.randomcodespace.iq.detector.DetectorContext;
import io.github.randomcodespace.iq.detector.DetectorDbHelper;
import io.github.randomcodespace.iq.detector.DetectorResult;
+import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
+import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved;
import io.github.randomcodespace.iq.model.CodeEdge;
import io.github.randomcodespace.iq.model.CodeNode;
+import io.github.randomcodespace.iq.model.Confidence;
import io.github.randomcodespace.iq.model.EdgeKind;
import io.github.randomcodespace.iq.model.NodeKind;
import org.springframework.stereotype.Component;
@@ -116,6 +123,21 @@ public DetectorResult detect(DetectorContext ctx) {
properties.put("entity_type", entityType);
}
+ // RESOLVED tier: when ctx.resolved() carries a JavaResolved we look up
+ // the entity type's fully-qualified name via the symbol solver. The
+ // FQN rides on both the repo node (entity_fqn) and the QUERIES edge
+ // (target_fqn) so consumers (EntityLinker, query routing, the SPA)
+ // can pick the unambiguous reference when available.
+ Optional resolved = ctx.resolved()
+ .filter(Resolved::isAvailable)
+ .filter(JavaResolved.class::isInstance)
+ .map(JavaResolved.class::cast);
+ final String resolvedInterfaceName = interfaceName; // effectively-final capture for the lambda
+ Optional entityFqn = (entityType != null)
+ ? resolved.flatMap(jr -> resolveEntityFqn(jr, resolvedInterfaceName))
+ : Optional.empty();
+ entityFqn.ifPresent(fqn -> properties.put("entity_fqn", fqn));
+
// Extract @Query methods
List