Skip to content
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<CodeNode> nodes, List<CodeEdge> edges) {

Check failure on line 34 in src/main/java/io/github/randomcodespace/iq/analyzer/linker/GuardLinker.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1KFXhl_cRLGSbPkaJc&open=AZ1KFXhl_cRLGSbPkaJc&pullRequest=16
// Group guards/middlewares and endpoints by filePath
Map<String, List<CodeNode>> guardsByFile = new TreeMap<>();
Map<String, List<CodeNode>> 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<String> existingProtects = new HashSet<>();
for (CodeEdge edge : edges) {
if (edge.getKind() == EdgeKind.PROTECTS && edge.getTarget() != null) {
existingProtects.add(edge.getSourceId() + "->" + edge.getTarget().getId());
}
}

List<CodeEdge> newEdges = new ArrayList<>();

// Same-file matching: each guard protects all endpoints in the same file
for (String filePath : new TreeSet<>(guardsByFile.keySet())) {
List<CodeNode> fileEndpoints = endpointsByFile.get(filePath);
if (fileEndpoints == null || fileEndpoints.isEmpty()) continue;

List<CodeNode> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@

import java.util.ArrayList;
import java.util.HashMap;
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 Kafka/RabbitMQ producers to consumers via shared topic names.
* Links messaging producers to consumers via shared topic/queue/event names.
* <p>
* Scans for TOPIC/QUEUE nodes and matches PRODUCES edges with CONSUMES
* edges on the same topic label to create direct producer-to-consumer
* Scans for TOPIC/QUEUE/EVENT/MESSAGE_QUEUE nodes and matches producer edges
* (PRODUCES, SENDS_TO, PUBLISHES) with consumer edges (CONSUMES, RECEIVES_FROM,
* LISTENS) on the same topic label to create direct producer-to-consumer
* CALLS edges.
*/
@Component
Expand All @@ -30,11 +30,12 @@
private static final Logger log = LoggerFactory.getLogger(TopicLinker.class);

@Override
public LinkResult link(List<CodeNode> nodes, List<CodeEdge> edges) {

Check warning on line 33 in src/main/java/io/github/randomcodespace/iq/analyzer/linker/TopicLinker.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A "Brain Method" was detected. Refactor it to reduce at least one of the following metrics: LOC from 69 to 64, Complexity from 25 to 14, Nesting Level from 5 to 2, Number of Variables from 26 to 6.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1KFXeB_cRLGSbPkaJb&open=AZ1KFXeB_cRLGSbPkaJb&pullRequest=16
// Collect topic/queue nodes by label
// Collect topic/queue/event/message_queue nodes by label
Map<String, List<String>> topicIdsByLabel = new TreeMap<>();
for (CodeNode node : nodes) {
if (node.getKind() == NodeKind.TOPIC || node.getKind() == NodeKind.QUEUE) {
if (node.getKind() == NodeKind.TOPIC || node.getKind() == NodeKind.QUEUE
|| node.getKind() == NodeKind.EVENT || node.getKind() == NodeKind.MESSAGE_QUEUE) {
topicIdsByLabel
.computeIfAbsent(node.getLabel(), k -> new ArrayList<>())
.add(node.getId());
Expand All @@ -51,11 +52,13 @@
Map<String, List<String>> consumersByTopic = new TreeMap<>();

for (CodeEdge edge : edges) {
if (edge.getKind() == EdgeKind.PRODUCES && edge.getTarget() != null) {
if (edge.getTarget() == null) continue;
EdgeKind kind = edge.getKind();
if (kind == EdgeKind.PRODUCES || kind == EdgeKind.SENDS_TO || kind == EdgeKind.PUBLISHES) {
producersByTopic
.computeIfAbsent(edge.getTarget().getId(), k -> new ArrayList<>())
.add(edge.getSourceId());
} else if (edge.getKind() == EdgeKind.CONSUMES && edge.getTarget() != null) {
} else if (kind == EdgeKind.CONSUMES || kind == EdgeKind.RECEIVES_FROM || kind == EdgeKind.LISTENS) {
consumersByTopic
.computeIfAbsent(edge.getTarget().getId(), k -> new ArrayList<>())
.add(edge.getSourceId());
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ 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.execute("CREATE FULLTEXT INDEX search_index IF NOT EXISTS "
+ "FOR (n:CodeNode) ON EACH [n.label_lower, n.fqn_lower] "
+ "OPTIONS {indexConfig: {`fulltext.analyzer`: 'keyword'}}");
tx.commit();
}
// Wait for all indexes (including fulltext) to finish building
try (Transaction tx = db.beginTx()) {
tx.execute("CALL db.awaitIndexes(300)");
tx.commit();
}
CliOutput.info(" Created Neo4j indexes");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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+)"
);
Expand Down Expand Up @@ -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<CodeNode> nodes = new ArrayList<>();
List<CodeEdge> edges = new ArrayList<>();
String text = ctx.content();
String filePath = ctx.filePath();
String moduleName = ctx.moduleName();

Expand Down Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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*([^)]+)\\)"
);
Expand Down Expand Up @@ -67,8 +69,10 @@ public DetectorResult detect(DetectorContext ctx) {

@Override
protected DetectorResult detectWithRegex(DetectorContext ctx) {
List<CodeNode> nodes = new ArrayList<>();
String text = ctx.content();
if (!NESTJS_IMPORT.matcher(text).find()) return DetectorResult.empty();

List<CodeNode> nodes = new ArrayList<>();
String filePath = ctx.filePath();
String moduleName = ctx.moduleName();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public interface GraphRepository extends Neo4jRepository<CodeNode, String> {
@Query("MATCH (n:CodeNode) WHERE n.filePath = $filePath RETURN n")
List<CodeNode> 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<CodeNode> search(String text, int limit);

@Query("MATCH (n:CodeNode) WHERE n.label CONTAINS $text OR n.fqn CONTAINS $text RETURN n")
Expand Down
72 changes: 66 additions & 6 deletions src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
* Creates an index on CodeNode.id for fast MATCH during edge creation.
* Logs progress every 10K items for visibility on large graphs.
*/
public void bulkSave(List<CodeNode> nodes) {

Check warning on line 71 in src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A "Brain Method" was detected. Refactor it to reduce at least one of the following metrics: LOC from 87 to 64, Complexity from 19 to 14, Nesting Level from 3 to 2, Number of Variables from 29 to 6.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1KFXia_cRLGSbPkaJf&open=AZ1KFXia_cRLGSbPkaJf&pullRequest=16
if (nodes.isEmpty()) return;
long start = System.currentTimeMillis();

Expand All @@ -84,9 +84,14 @@
}
} 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.execute("CREATE FULLTEXT INDEX search_index IF NOT EXISTS "
+ "FOR (n:CodeNode) ON EACH [n.label_lower, n.fqn_lower] "
+ "OPTIONS {indexConfig: {`fulltext.analyzer`: 'keyword'}}");
tx.commit();
}

Expand Down Expand Up @@ -168,7 +173,7 @@
}

/** Convert a CodeNode to a flat property map for Cypher SET. */
private Map<String, Object> nodeToProps(CodeNode node) {

Check failure on line 176 in src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1KFXia_cRLGSbPkaJe&open=AZ1KFXia_cRLGSbPkaJe&pullRequest=16
Map<String, Object> props = new HashMap<>();
props.put("id", node.getId());
props.put("kind", node.getKind().getValue());
Expand All @@ -182,6 +187,9 @@
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) {
Expand Down Expand Up @@ -244,15 +252,42 @@

public List<CodeNode> search(String text) {
return queryNodes(
"MATCH (n:CodeNode) WHERE n.label CONTAINS $text OR n.fqn CONTAINS $text RETURN n",
Map.of("text", text));
"CALL db.index.fulltext.queryNodes('search_index', $text) "
+ "YIELD node RETURN node AS n",
Map.of("text", toLuceneQuery(text)));
}

public List<CodeNode> search(String text, int limit) {
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));
"CALL db.index.fulltext.queryNodes('search_index', $text) "
+ "YIELD node RETURN node AS n LIMIT $limit",
Map.of("text", toLuceneQuery(text), "limit", limit));

Check failure on line 264 in src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "limit" 8 times.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1KFXia_cRLGSbPkaJd&open=AZ1KFXia_cRLGSbPkaJd&pullRequest=16
}

/**
* Wraps a search term in Lucene wildcard syntax for substring matching against
* the fulltext index (which stores lowercased property values via keyword analyzer).
* Escapes Lucene special characters before wrapping.
*/
private static String toLuceneQuery(String text) {
String lower = text.toLowerCase();
String escaped = lower
.replace("\\", "\\\\")
.replace("+", "\\+")
.replace("-", "\\-")
.replace("!", "\\!")
.replace("(", "\\(")
.replace(")", "\\)")
.replace("{", "\\{")
.replace("}", "\\}")
.replace("[", "\\[")
.replace("]", "\\]")
.replace("^", "\\^")
.replace("\"", "\\\"")
.replace("~", "\\~")
.replace(":", "\\:")
.replace("/", "\\/");
return "*" + escaped + "*";
}

public List<CodeNode> findNeighbors(String nodeId) {
Expand All @@ -273,6 +308,31 @@
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<String, List<CodeNode>> findEndpointNeighborsBatch(List<String> nodeIds) {
Map<String, List<CodeNode>> 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");
Expand Down
30 changes: 21 additions & 9 deletions src/main/java/io/github/randomcodespace/iq/query/QueryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -337,19 +337,24 @@ public Map<String, Object> findRelatedEndpoints(String identifier) {
Set<String> seenIds = new java.util.LinkedHashSet<>();
List<Map<String, Object>> endpoints = new ArrayList<>();

// First pass: collect matches that are themselves endpoints
for (CodeNode match : matches) {
if (match.getKind() == NodeKind.ENDPOINT || match.getKind() == NodeKind.WEBSOCKET_ENDPOINT) {
if (seenIds.add(match.getId())) {
endpoints.add(nodeToMap(match));
}
}
// Check neighbors for connected endpoints
List<CodeNode> neighbors = graphStore.findNeighbors(match.getId());
for (CodeNode neighbor : neighbors) {
if ((neighbor.getKind() == NodeKind.ENDPOINT || neighbor.getKind() == NodeKind.WEBSOCKET_ENDPOINT)
&& seenIds.add(neighbor.getId())) {
}

// Single batched query for all endpoint neighbors (replaces N+1 loop)
List<String> matchIds = matches.stream().map(CodeNode::getId).toList();
Map<String, List<CodeNode>> endpointNeighbors = graphStore.findEndpointNeighborsBatch(matchIds);
for (Map.Entry<String, List<CodeNode>> entry : endpointNeighbors.entrySet()) {
String sourceId = entry.getKey();
for (CodeNode neighbor : entry.getValue()) {
if (seenIds.add(neighbor.getId())) {
Map<String, Object> epMap = nodeToMap(neighbor);
epMap.put("connected_via", match.getId());
epMap.put("connected_via", sourceId);
endpoints.add(epMap);
}
}
Expand Down Expand Up @@ -379,9 +384,10 @@ public Map<String, Object> getTopology() {
* they are always present from parent modules/config files.
*/
private static final List<String> SEMANTIC_EDGE_KINDS = List.of(
"calls", "imports", "depends_on", "uses", "extends", "implements",
"calls", "imports", "depends_on", "extends", "implements",
"injects", "queries", "maps_to", "consumes", "listens",
"invokes_rmi", "overrides", "connects_to", "triggers", "renders");
"invokes_rmi", "overrides", "connects_to", "triggers", "renders",
"protects");

/**
* Node kinds that are entry points — they are intended to have no callers
Expand All @@ -393,7 +399,13 @@ public Map<String, Object> getTopology() {
NodeKind.MIGRATION.getValue(),
NodeKind.CONFIG_FILE.getValue(),
NodeKind.CONFIG_KEY.getValue(),
NodeKind.CONFIG_DEFINITION.getValue());
NodeKind.CONFIG_DEFINITION.getValue(),
NodeKind.GUARD.getValue(),
NodeKind.MIDDLEWARE.getValue(),
NodeKind.TOPIC.getValue(),
NodeKind.QUEUE.getValue(),
NodeKind.EVENT.getValue(),
NodeKind.MESSAGE_QUEUE.getValue());

@Cacheable(value = "dead-code", key = "#kind + ':' + #limit")
public Map<String, Object> findDeadCode(String kind, int limit) {
Expand Down
Loading
Loading