Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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=AZ1KD9GieCrKBrOzbmFZ&open=AZ1KD9GieCrKBrOzbmFZ&pullRequest=15
// 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 @@ -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");
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
39 changes: 35 additions & 4 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 84 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=AZ1KD9HKeCrKBrOzbmFc&open=AZ1KD9HKeCrKBrOzbmFc&pullRequest=15
if (nodes.isEmpty()) return;
long start = System.currentTimeMillis();

Expand All @@ -84,9 +84,11 @@
}
} 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();
}

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

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

Check failure on line 173 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=AZ1KD9HKeCrKBrOzbmFb&open=AZ1KD9HKeCrKBrOzbmFb&pullRequest=15
Map<String, Object> props = new HashMap<>();
props.put("id", node.getId());
props.put("kind", node.getKind().getValue());
Expand All @@ -182,6 +184,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 @@ -249,10 +254,11 @@
}

public List<CodeNode> 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));

Check failure on line 261 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=AZ1KD9HKeCrKBrOzbmFa&open=AZ1KD9HKeCrKBrOzbmFa&pullRequest=15
}

public List<CodeNode> findNeighbors(String nodeId) {
Expand All @@ -273,6 +279,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