diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinker.java b/src/main/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinker.java
new file mode 100644
index 00000000..4b9d4924
--- /dev/null
+++ b/src/main/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinker.java
@@ -0,0 +1,92 @@
+package io.github.randomcodespace.iq.analyzer.linker;
+
+import io.github.randomcodespace.iq.model.CodeEdge;
+import io.github.randomcodespace.iq.model.CodeNode;
+import io.github.randomcodespace.iq.model.EdgeKind;
+import io.github.randomcodespace.iq.model.NodeKind;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/**
+ * Links GUARD and MIDDLEWARE nodes to ENDPOINT nodes via PROTECTS edges.
+ *
+ * Uses file-path proximity as the matching heuristic: a guard or middleware
+ * in the same file as an endpoint is assumed to protect that endpoint.
+ * This correctly handles class-level Spring Security annotations
+ * (@PreAuthorize, @Secured on a class) which appear in the same file as
+ * the endpoint methods they protect.
+ */
+@Component
+public class GuardLinker implements Linker {
+
+ private static final Logger log = LoggerFactory.getLogger(GuardLinker.class);
+
+ @Override
+ public LinkResult link(List nodes, List edges) {
+ // Group guards/middlewares and endpoints by filePath
+ Map> guardsByFile = new TreeMap<>();
+ Map> endpointsByFile = new TreeMap<>();
+
+ for (CodeNode node : nodes) {
+ String fp = node.getFilePath();
+ if (fp == null || fp.isBlank()) continue;
+
+ if (node.getKind() == NodeKind.GUARD || node.getKind() == NodeKind.MIDDLEWARE) {
+ guardsByFile.computeIfAbsent(fp, k -> new ArrayList<>()).add(node);
+ } else if (node.getKind() == NodeKind.ENDPOINT) {
+ endpointsByFile.computeIfAbsent(fp, k -> new ArrayList<>()).add(node);
+ }
+ }
+
+ if (guardsByFile.isEmpty() || endpointsByFile.isEmpty()) {
+ return LinkResult.empty();
+ }
+
+ // Collect existing PROTECTS edges to avoid duplicates
+ Set existingProtects = new HashSet<>();
+ for (CodeEdge edge : edges) {
+ if (edge.getKind() == EdgeKind.PROTECTS && edge.getTarget() != null) {
+ existingProtects.add(edge.getSourceId() + "->" + edge.getTarget().getId());
+ }
+ }
+
+ List newEdges = new ArrayList<>();
+
+ // Same-file matching: each guard protects all endpoints in the same file
+ for (String filePath : new TreeSet<>(guardsByFile.keySet())) {
+ List fileEndpoints = endpointsByFile.get(filePath);
+ if (fileEndpoints == null || fileEndpoints.isEmpty()) continue;
+
+ List fileGuards = guardsByFile.get(filePath);
+ for (CodeNode guard : fileGuards) {
+ for (CodeNode endpoint : fileEndpoints) {
+ String key = guard.getId() + "->" + endpoint.getId();
+ if (!existingProtects.contains(key)) {
+ var edge = new CodeEdge();
+ edge.setId("guard-link:" + guard.getId() + "->" + endpoint.getId());
+ edge.setKind(EdgeKind.PROTECTS);
+ edge.setSourceId(guard.getId());
+ edge.setTarget(endpoint);
+ edge.setProperties(Map.of("inferred", true));
+ newEdges.add(edge);
+ existingProtects.add(key);
+ }
+ }
+ }
+ }
+
+ if (!newEdges.isEmpty()) {
+ log.debug("GuardLinker created {} PROTECTS edges", newEdges.size());
+ }
+ return LinkResult.ofEdges(newEdges);
+ }
+}
diff --git a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java
index 44b4a83a..d2923579 100644
--- a/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java
+++ b/src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java
@@ -307,6 +307,8 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins
tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.layer)");
tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.module)");
tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.filePath)");
+ tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.label_lower)");
+ tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.fqn_lower)");
tx.commit();
}
CliOutput.info(" Created Neo4j indexes");
diff --git a/src/main/java/io/github/randomcodespace/iq/detector/typescript/NestJSControllerDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/typescript/NestJSControllerDetector.java
index 50844cf8..593b0372 100644
--- a/src/main/java/io/github/randomcodespace/iq/detector/typescript/NestJSControllerDetector.java
+++ b/src/main/java/io/github/randomcodespace/iq/detector/typescript/NestJSControllerDetector.java
@@ -38,6 +38,8 @@ public class NestJSControllerDetector extends AbstractAntlrDetector {
private static final Pattern FETCH_RE = Pattern.compile(
"\\bfetch\\s*\\(\\s*['\"`]");
+ private static final Pattern NESTJS_IMPORT = Pattern.compile("from\\s+['\"]@nestjs/");
+
private static final Pattern CONTROLLER_PATTERN = Pattern.compile(
"@Controller\\(\\s*['\"`]?([^'\"`\\)\\s]*)['\"`]?\\s*\\)(?:\\s*@\\w+\\([^)]*\\))*\\s*\\n\\s*(?:export\\s+)?class\\s+(\\w+)"
);
@@ -78,9 +80,11 @@ public DetectorResult detect(DetectorContext ctx) {
@Override
protected DetectorResult detectWithRegex(DetectorContext ctx) {
+ String text = ctx.content();
+ if (!NESTJS_IMPORT.matcher(text).find()) return DetectorResult.empty();
+
List nodes = new ArrayList<>();
List edges = new ArrayList<>();
- String text = ctx.content();
String filePath = ctx.filePath();
String moduleName = ctx.moduleName();
@@ -161,6 +165,7 @@ protected DetectorResult detectWithRegex(DetectorContext ctx) {
edge.setId(classId + "->exposes->" + nodeId);
edge.setKind(EdgeKind.EXPOSES);
edge.setSourceId(classId);
+ edge.setTarget(node);
edges.add(edge);
}
}
diff --git a/src/main/java/io/github/randomcodespace/iq/detector/typescript/NestJSGuardsDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/typescript/NestJSGuardsDetector.java
index 420bd8f7..177509c4 100644
--- a/src/main/java/io/github/randomcodespace/iq/detector/typescript/NestJSGuardsDetector.java
+++ b/src/main/java/io/github/randomcodespace/iq/detector/typescript/NestJSGuardsDetector.java
@@ -28,6 +28,8 @@
@Component
public class NestJSGuardsDetector extends AbstractAntlrDetector {
+ private static final Pattern NESTJS_IMPORT = Pattern.compile("from\\s+['\"]@nestjs/");
+
private static final Pattern USE_GUARDS_PATTERN = Pattern.compile(
"@UseGuards\\(\\s*([^)]+)\\)"
);
@@ -67,8 +69,10 @@ public DetectorResult detect(DetectorContext ctx) {
@Override
protected DetectorResult detectWithRegex(DetectorContext ctx) {
- List nodes = new ArrayList<>();
String text = ctx.content();
+ if (!NESTJS_IMPORT.matcher(text).find()) return DetectorResult.empty();
+
+ List nodes = new ArrayList<>();
String filePath = ctx.filePath();
String moduleName = ctx.moduleName();
diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java
index de843aa0..0528bdcf 100644
--- a/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java
+++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java
@@ -23,7 +23,7 @@ public interface GraphRepository extends Neo4jRepository {
@Query("MATCH (n:CodeNode) WHERE n.filePath = $filePath RETURN n")
List findByFilePath(String filePath);
- @Query("MATCH (n:CodeNode) WHERE toLower(n.label) CONTAINS toLower($text) OR toLower(n.fqn) CONTAINS toLower($text) RETURN n LIMIT $limit")
+ @Query("MATCH (n:CodeNode) WHERE n.label_lower CONTAINS $text OR n.fqn_lower CONTAINS $text RETURN n LIMIT $limit")
List search(String text, int limit);
@Query("MATCH (n:CodeNode) WHERE n.label CONTAINS $text OR n.fqn CONTAINS $text RETURN n")
diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java
index 3e579d5c..564d4c8e 100644
--- a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java
+++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java
@@ -84,9 +84,11 @@ public void bulkSave(List nodes) {
}
} while (deleted > 0);
- // 2. Create index on id property for fast MATCH during edge creation
+ // 2. Create indexes: id for MATCH, label_lower/fqn_lower for fast case-insensitive search
try (Transaction tx = graphDb.beginTx()) {
tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.id)");
+ tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.label_lower)");
+ tx.execute("CREATE INDEX IF NOT EXISTS FOR (n:CodeNode) ON (n.fqn_lower)");
tx.commit();
}
@@ -182,6 +184,9 @@ private Map nodeToProps(CodeNode node) {
if (node.getAnnotations() != null && !node.getAnnotations().isEmpty()) {
props.put("annotations", String.join(",", node.getAnnotations()));
}
+ // Pre-lowered properties for index-backed case-insensitive search
+ props.put("label_lower", node.getLabel() != null ? node.getLabel().toLowerCase() : "");
+ if (node.getFqn() != null) props.put("fqn_lower", node.getFqn().toLowerCase());
if (node.getProperties() != null) {
for (var entry : node.getProperties().entrySet()) {
if (entry.getValue() != null) {
@@ -249,10 +254,11 @@ public List search(String text) {
}
public List search(String text, int limit) {
+ String lowerText = text.toLowerCase();
return queryNodes(
- "MATCH (n:CodeNode) WHERE toLower(n.label) CONTAINS toLower($text) "
- + "OR toLower(n.fqn) CONTAINS toLower($text) RETURN n LIMIT $limit",
- Map.of("text", text, "limit", limit));
+ "MATCH (n:CodeNode) WHERE n.label_lower CONTAINS $text "
+ + "OR n.fqn_lower CONTAINS $text RETURN n LIMIT $limit",
+ Map.of("text", lowerText, "limit", limit));
}
public List findNeighbors(String nodeId) {
@@ -273,6 +279,31 @@ public List findIncomingNeighbors(String nodeId) {
Map.of("nodeId", nodeId));
}
+ /**
+ * Batch-find all ENDPOINT/WEBSOCKET_ENDPOINT neighbors for a list of node IDs in one query.
+ * Returns a map of sourceNodeId -> list of endpoint neighbor nodes.
+ */
+ public Map> findEndpointNeighborsBatch(List nodeIds) {
+ Map> result = new java.util.LinkedHashMap<>();
+ if (nodeIds.isEmpty()) return result;
+ try (Transaction tx = graphDb.beginTx()) {
+ var queryResult = tx.execute(
+ "MATCH (n:CodeNode)-[]-(m:CodeNode) "
+ + "WHERE n.id IN $nodeIds AND m.kind IN ['ENDPOINT', 'WEBSOCKET_ENDPOINT'] "
+ + "RETURN n.id AS sourceId, m",
+ Map.of("nodeIds", nodeIds));
+ while (queryResult.hasNext()) {
+ var row = queryResult.next();
+ String sourceId = (String) row.get("sourceId");
+ Object val = row.get("m");
+ if (val instanceof org.neo4j.graphdb.Node neo4jNode) {
+ result.computeIfAbsent(sourceId, k -> new ArrayList<>()).add(nodeFromNeo4j(neo4jNode));
+ }
+ }
+ }
+ return result;
+ }
+
public long count() {
try (Transaction tx = graphDb.beginTx()) {
var result = tx.execute("MATCH (n:CodeNode) RETURN count(n) AS cnt");
diff --git a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java
index 26555192..954a21bd 100644
--- a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java
+++ b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java
@@ -337,19 +337,24 @@ public Map findRelatedEndpoints(String identifier) {
Set seenIds = new java.util.LinkedHashSet<>();
List