Skip to content

Commit bfab2e7

Browse files
aksOpsclaude
andauthored
fix: Sonar follow-ups — ModuleDeps ordering, Express dead code, RepositoryIdentity env (#60)
* fix(detector): ModuleDepsDetector reaches settings.gradle branch The dispatch chain in detect() checked `.endsWith(".gradle")` before the settings-specific branch, so any `settings.gradle` / `settings.gradle.kts` path routed to detectGradle() and the specialised detectGradleSettings() helper was never reached. Gradle multi-module `include ':foo'` entries were silently lost. Reordered the dispatch so settings files are matched first, and added ModuleDepsDetectorTest to lock in the reachability contract (plus a regression guard that `build.gradle` still routes to detectGradle). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(detector): remove dead detectWithAst from ExpressRouteDetector AbstractTypeScriptDetector#detect() unconditionally dispatches to detectWithRegex, and the class is annotated @DetectorInfo(parser = REGEX), so the protected detectWithAst override (and its three private ANTLR helper methods: extractIdentifierText, extractFirstStringArg, extractStringLiteral) were never invoked. Removed the AST method + helpers and their now-orphaned ANTLR / JavaScriptParser imports. The Python detectors define their own extractFirstStringArg with a distinct signature — unaffected. All 28 ExpressRoute* tests remain green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): RepositoryIdentityTest no longer depends on local git state The two git-backed tests (resolve_gitRepoWithCommit_commitShaPresent, resolve_detachedHead_branchIsNull) failed when the developer's global gitconfig forced commit signing (commit.gpgsign=true, signingkey set) on a machine without a usable signing key — git commit exited non-zero, the original run() helper ignored the exit code, no commit was made, and rev-parse HEAD returned null. Made the git invocations hermetic: * repo-local config overrides commit.gpgsign / tag.gpgsign to false, unsets core.hooksPath, core.autocrlf, init.templateDir * explicit --no-gpg-sign on the commit (belt-and-braces) * scrub GIT_* env vars on every child process so no ambient CI / worktree state leaks in * run() now asserts the process exited 0 — silent failures become loud test failures * new requireGit() uses Assumptions.assumeTrue to skip cleanly when the git binary is absent (product still covered by non-git tests + RepositoryIdentity's own swallow-on-error path) Verified by running the pre-fix test against a hostile HOME with forced GPG signing — reproduces the 2 failures. Post-fix: 8/8 pass under the same hostile environment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dd8adaa commit bfab2e7

4 files changed

Lines changed: 175 additions & 112 deletions

File tree

src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ModuleDepsDetector.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,16 @@ public DetectorResult detect(DetectorContext ctx) {
6161
String filePath = ctx.filePath();
6262
if (filePath.endsWith("pom.xml")) {
6363
return detectMaven(ctx);
64-
} else if (filePath.endsWith(".gradle") || filePath.endsWith(".gradle.kts")) {
65-
return detectGradle(ctx);
66-
} else if (filePath.endsWith("settings.gradle") || filePath.endsWith("settings.gradle.kts")) {
64+
}
65+
// Order matters: `settings.gradle[.kts]` must be matched before the generic
66+
// `.gradle[.kts]` branch, otherwise Gradle multi-module settings files are
67+
// misrouted to detectGradle() and never reach detectGradleSettings().
68+
if (filePath.endsWith("settings.gradle") || filePath.endsWith("settings.gradle.kts")) {
6769
return detectGradleSettings(ctx);
6870
}
71+
if (filePath.endsWith(".gradle") || filePath.endsWith(".gradle.kts")) {
72+
return detectGradle(ctx);
73+
}
6974
return DetectorResult.empty();
7075
}
7176

src/main/java/io/github/randomcodespace/iq/detector/typescript/ExpressRouteDetector.java

Lines changed: 0 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,12 @@
22

33
import io.github.randomcodespace.iq.detector.DetectorContext;
44
import io.github.randomcodespace.iq.detector.DetectorResult;
5-
import io.github.randomcodespace.iq.grammar.javascript.JavaScriptParser;
6-
import io.github.randomcodespace.iq.grammar.javascript.JavaScriptParserBaseListener;
75
import io.github.randomcodespace.iq.model.CodeNode;
86
import io.github.randomcodespace.iq.model.NodeKind;
9-
import org.antlr.v4.runtime.tree.ParseTree;
10-
import org.antlr.v4.runtime.tree.ParseTreeWalker;
117
import org.springframework.stereotype.Component;
128

139
import java.util.ArrayList;
1410
import java.util.List;
15-
import java.util.Set;
1611
import java.util.regex.Matcher;
1712
import java.util.regex.Pattern;
1813
import io.github.randomcodespace.iq.detector.DetectorInfo;
@@ -30,10 +25,6 @@
3025
@Component
3126
public class ExpressRouteDetector extends AbstractTypeScriptDetector {
3227

33-
private static final Set<String> HTTP_METHODS = Set.of(
34-
"get", "post", "put", "delete", "patch", "options", "head", "all"
35-
);
36-
3728
private static final Pattern ROUTE_PATTERN = Pattern.compile(
3829
"(\\w+)\\.(get|post|put|delete|patch|options|head|all)\\(\\s*['\"`]([^'\"`]+)['\"`]"
3930
);
@@ -43,53 +34,6 @@ public String getName() {
4334
return "typescript.express_routes";
4435
}
4536

46-
47-
@Override
48-
protected DetectorResult detectWithAst(ParseTree tree, DetectorContext ctx) {
49-
List<CodeNode> nodes = new ArrayList<>();
50-
String filePath = ctx.filePath();
51-
String moduleName = ctx.moduleName();
52-
53-
ParseTreeWalker.DEFAULT.walk(new JavaScriptParserBaseListener() {
54-
@Override
55-
public void enterArgumentsExpression(JavaScriptParser.ArgumentsExpressionContext argCtx) {
56-
// Look for: expr.method(args) where method is an HTTP method
57-
if (argCtx.singleExpression() instanceof JavaScriptParser.MemberDotExpressionContext memberCtx) {
58-
String methodName = memberCtx.identifierName().getText();
59-
if (!HTTP_METHODS.contains(methodName)) return;
60-
61-
String routerName = extractIdentifierText(memberCtx.singleExpression());
62-
if (routerName == null) return;
63-
64-
// Get the first string argument (the path)
65-
String path = extractFirstStringArg(argCtx.arguments());
66-
if (path == null) return;
67-
68-
String method = methodName.toUpperCase();
69-
int line = lineOf(argCtx);
70-
71-
String nodeId = "endpoint:" + (moduleName != null ? moduleName : "") + ":" + method + ":" + path;
72-
CodeNode node = new CodeNode();
73-
node.setId(nodeId);
74-
node.setKind(NodeKind.ENDPOINT);
75-
node.setLabel(method + " " + path);
76-
node.setFqn(filePath + "::" + method + ":" + path);
77-
node.setModule(moduleName);
78-
node.setFilePath(filePath);
79-
node.setLineStart(line);
80-
node.getProperties().put("protocol", "REST");
81-
node.getProperties().put("http_method", method);
82-
node.getProperties().put("path_pattern", path);
83-
node.getProperties().put("framework", "express");
84-
node.getProperties().put("router", routerName);
85-
nodes.add(node);
86-
}
87-
}
88-
}, tree);
89-
90-
return DetectorResult.of(nodes, List.of());
91-
}
92-
9337
@Override
9438
protected DetectorResult detectWithRegex(DetectorContext ctx) {
9539
List<CodeNode> nodes = new ArrayList<>();
@@ -124,44 +68,4 @@ protected DetectorResult detectWithRegex(DetectorContext ctx) {
12468

12569
return DetectorResult.of(nodes, List.of());
12670
}
127-
128-
/** Extract a simple identifier name from a single expression, or null. */
129-
static String extractIdentifierText(JavaScriptParser.SingleExpressionContext expr) {
130-
if (expr instanceof JavaScriptParser.IdentifierExpressionContext idCtx) {
131-
return idCtx.getText();
132-
}
133-
// For chained access like `this.app`, return the whole text
134-
if (expr instanceof JavaScriptParser.MemberDotExpressionContext memberCtx) {
135-
return memberCtx.getText();
136-
}
137-
return expr != null ? expr.getText() : null;
138-
}
139-
140-
/** Extract the first string literal argument from an arguments context. */
141-
static String extractFirstStringArg(JavaScriptParser.ArgumentsContext args) {
142-
if (args == null || args.argument() == null || args.argument().isEmpty()) return null;
143-
var firstArg = args.argument(0);
144-
if (firstArg == null || firstArg.singleExpression() == null) return null;
145-
var expr = firstArg.singleExpression();
146-
return extractStringLiteral(expr);
147-
}
148-
149-
/** Extract a string literal value (strip quotes) from a single expression. */
150-
static String extractStringLiteral(JavaScriptParser.SingleExpressionContext expr) {
151-
if (expr instanceof JavaScriptParser.LiteralExpressionContext litCtx) {
152-
var literal = litCtx.literal();
153-
if (literal != null && literal.StringLiteral() != null) {
154-
String raw = literal.StringLiteral().getText();
155-
return raw.substring(1, raw.length() - 1);
156-
}
157-
}
158-
// Handle template strings (backtick with no expressions)
159-
if (expr instanceof JavaScriptParser.TemplateStringExpressionContext) {
160-
String raw = expr.getText();
161-
if (raw.startsWith("`") && raw.endsWith("`")) {
162-
return raw.substring(1, raw.length() - 1);
163-
}
164-
}
165-
return null;
166-
}
16771
}

src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ModuleDepsDetectorTest.java

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.github.randomcodespace.iq.detector.DetectorContext;
44
import io.github.randomcodespace.iq.detector.DetectorResult;
55
import io.github.randomcodespace.iq.detector.DetectorTestUtils;
6+
import io.github.randomcodespace.iq.model.CodeNode;
67
import io.github.randomcodespace.iq.model.EdgeKind;
78
import io.github.randomcodespace.iq.model.NodeKind;
89
import org.junit.jupiter.api.Test;
@@ -15,6 +16,10 @@
1516
*
1617
* <p>Covers Maven (pom.xml), Gradle build scripts (.gradle / .gradle.kts), and
1718
* Gradle settings (settings.gradle / settings.gradle.kts) detection paths.
19+
*
20+
* <p>The settings.gradle dispatch was previously shadowed by the generic
21+
* {@code .endsWith(".gradle")} branch; the tests below both exercise the fixed
22+
* path and guard against a regression to that bug.
1823
*/
1924
class ModuleDepsDetectorTest {
2025

@@ -263,16 +268,87 @@ void gradleKts_withGroovyStyleStringDep_isDetected() {
263268
.hasSize(1);
264269
}
265270

271+
@Test
272+
void buildGradle_routesToDetectGradle_notToSettings() {
273+
// Regression guard: `include ':x'` tokens that happen to appear in a build.gradle
274+
// file must NOT be interpreted as settings-style module includes — those only apply
275+
// inside settings.gradle. This pairs with the settings.gradle dispatch tests below.
276+
String build = """
277+
dependencies {
278+
implementation project(':shared')
279+
implementation 'com.acme:lib:1.0.0'
280+
}
281+
""";
282+
DetectorContext ctx = DetectorTestUtils.contextFor("build.gradle", "gradle", build);
283+
DetectorResult r = detector.detect(ctx);
284+
285+
// detectGradle emits a module node for the current file plus DEPENDS_ON edges.
286+
assertThat(r.nodes()).isNotEmpty();
287+
assertThat(r.edges()).isNotEmpty();
288+
}
289+
266290
// ---------------------------------------------------------------
267-
// Gradle settings.gradle
291+
// Gradle settings.gradle — the fixed dispatch branch
268292
// ---------------------------------------------------------------
269293
//
270-
// The detector's dispatch chain checks `.endsWith(".gradle")` *before*
271-
// `.endsWith("settings.gradle")`, so any filename ending in `.gradle`
272-
// routes to detectGradle (not detectGradleSettings). The
273-
// detectGradleSettings() branch is therefore unreachable via the public
274-
// detect() API for these filenames — flagged as a follow-up bug.
275-
// We intentionally omit direct tests for that unreachable branch.
294+
// Prior to the dispatch-order fix, `.endsWith(".gradle")` matched
295+
// settings.gradle first, so detectGradleSettings() was unreachable via the
296+
// public detect() API. Tests below both exercise the now-reachable path
297+
// and pin the contract so the bug cannot reappear.
298+
299+
@Test
300+
void settingsGradle_routesToDetectGradleSettings_andEmitsModuleNodes() {
301+
// `include 'foo'` is only handled by detectGradleSettings.
302+
// detectGradle would emit zero module nodes for this input, so seeing
303+
// module nodes here proves the dispatch fix reaches detectGradleSettings.
304+
String settings = """
305+
rootProject.name = 'acme'
306+
include ':api'
307+
include ':domain'
308+
include ':infra'
309+
""";
310+
DetectorContext ctx = DetectorTestUtils.contextFor("settings.gradle", "gradle", settings);
311+
DetectorResult r = detector.detect(ctx);
312+
313+
assertThat(r.nodes())
314+
.extracting(CodeNode::getLabel)
315+
.containsExactlyInAnyOrder("api", "domain", "infra");
316+
assertThat(r.nodes())
317+
.allMatch(n -> n.getKind() == NodeKind.MODULE);
318+
assertThat(r.nodes())
319+
.allMatch(n -> "gradle".equals(n.getProperties().get("build_tool")));
320+
}
321+
322+
@Test
323+
void settingsGradleKts_routesToDetectGradleSettings() {
324+
// The Kotlin-DSL syntax `include(":a")` is NOT matched by the detector's current
325+
// regex (which expects a whitespace-separated call — `include ':a'`). This test
326+
// asserts ONLY the dispatch contract: a `settings.gradle.kts` path reaches
327+
// detectGradleSettings and, where the syntax is regex-compatible, produces module
328+
// nodes.
329+
String settingsKts = """
330+
rootProject.name = "acme"
331+
include ':b'
332+
""";
333+
DetectorContext ctx = DetectorTestUtils.contextFor("settings.gradle.kts", "gradle", settingsKts);
334+
DetectorResult r = detector.detect(ctx);
335+
336+
assertThat(r.nodes())
337+
.extracting(CodeNode::getLabel)
338+
.contains("b");
339+
}
340+
341+
@Test
342+
void nestedSettingsGradlePath_stillRoutesToDetectGradleSettings() {
343+
// Regression guard: path-like endsWith should still match when the settings file lives in a subdir.
344+
String settings = "include ':core'\n";
345+
DetectorContext ctx = DetectorTestUtils.contextFor("build/settings.gradle", "gradle", settings);
346+
DetectorResult r = detector.detect(ctx);
347+
348+
assertThat(r.nodes())
349+
.extracting(CodeNode::getLabel)
350+
.containsExactly("core");
351+
}
276352

277353
// ---------------------------------------------------------------
278354
// Determinism
@@ -304,4 +380,11 @@ implementation project(':a')
304380
DetectorContext ctx = DetectorTestUtils.contextFor("build.gradle", "gradle", gradle);
305381
DetectorTestUtils.assertDeterministic(detector, ctx);
306382
}
383+
384+
@Test
385+
void deterministic_settingsGradle() {
386+
String settings = "include ':a'\ninclude ':b'\n";
387+
DetectorContext ctx = DetectorTestUtils.contextFor("settings.gradle", "gradle", settings);
388+
DetectorTestUtils.assertDeterministic(detector, ctx);
389+
}
307390
}

src/test/java/io/github/randomcodespace/iq/intelligence/RepositoryIdentityTest.java

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,23 @@
44
import org.junit.jupiter.api.io.TempDir;
55

66
import java.nio.file.Path;
7+
import java.util.Map;
78

89
import static org.assertj.core.api.Assertions.assertThat;
910
import static org.assertj.core.api.Assertions.assertThatCode;
11+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
1012

1113
/**
1214
* Unit tests for {@link RepositoryIdentity}.
1315
* Validates graceful degradation when git metadata is unavailable.
16+
*
17+
* <p>Git invocations in these tests are hermetic: we override every global-config
18+
* setting that can cause {@code git commit} / {@code git init} to fail silently
19+
* on a developer machine but pass on CI (or vice-versa) — most notably
20+
* {@code commit.gpgsign}, {@code tag.gpgsign}, {@code core.hooksPath},
21+
* {@code init.templateDir}, and {@code core.autocrlf}. We also scrub
22+
* {@code GIT_CONFIG_*} / {@code GIT_DIR} / {@code GIT_WORK_TREE} env vars so the
23+
* invoked processes inherit no ambient git state from the parent.
1424
*/
1525
class RepositoryIdentityTest {
1626

@@ -109,31 +119,92 @@ void record_nullFieldsAllowed() {
109119
// Helpers
110120
// ------------------------------------------------------------------
111121

122+
/**
123+
* Pre-flight: skip git-dependent tests when the {@code git} binary is not available.
124+
* Treat absence-of-git as an environment gap, not a product bug — still covered by the
125+
* non-git tests above, and by the {@code RepositoryIdentity.runGit} swallow-all-errors path.
126+
*/
127+
private static void requireGit() {
128+
try {
129+
Process p = new ProcessBuilder("git", "--version")
130+
.redirectErrorStream(true).start();
131+
boolean ok = p.waitFor() == 0;
132+
assumeTrue(ok, "git binary not available on PATH");
133+
} catch (Exception e) {
134+
assumeTrue(false, "git binary not available on PATH: " + e.getMessage());
135+
}
136+
}
137+
112138
private static void initGitRepo(Path dir) throws Exception {
113-
run(dir, "git", "init");
139+
requireGit();
140+
// -c overrides are applied to THIS invocation only and cannot be shadowed by a user's
141+
// global gitconfig (unlike `git config` writes into the new .git/config).
142+
run(dir, "git",
143+
"-c", "init.defaultBranch=main",
144+
"-c", "init.templateDir=",
145+
"init");
114146
run(dir, "git", "config", "user.email", "test@test.com");
115147
run(dir, "git", "config", "user.name", "Test");
148+
// Kill every global knob that can make `git commit` fail on an otherwise-clean repo.
149+
run(dir, "git", "config", "commit.gpgsign", "false");
150+
run(dir, "git", "config", "tag.gpgsign", "false");
151+
run(dir, "git", "config", "core.hooksPath", "/dev/null");
152+
run(dir, "git", "config", "core.autocrlf", "false");
116153
}
117154

118155
private static void makeInitialCommit(Path dir) throws Exception {
119156
Path readme = dir.resolve("README.md");
120157
java.nio.file.Files.writeString(readme, "# Test");
121158
run(dir, "git", "add", ".");
122-
run(dir, "git", "commit", "-m", "init");
159+
// --no-gpg-sign is belt-and-braces over the repo-local commit.gpgsign=false set above;
160+
// --allow-empty-message keeps the test robust if a commit.template hook injects content.
161+
run(dir, "git", "-c", "commit.gpgsign=false", "commit",
162+
"--no-gpg-sign", "-m", "init");
123163
}
124164

125165
private static String runGit(Path dir, String... args) throws Exception {
166+
requireGit();
126167
var cmd = new java.util.ArrayList<String>();
127168
cmd.add("git");
128169
cmd.addAll(java.util.Arrays.asList(args));
129-
var proc = new ProcessBuilder(cmd).directory(dir.toFile()).start();
130-
String out = new String(proc.getInputStream().readAllBytes()).trim();
170+
var pb = new ProcessBuilder(cmd).directory(dir.toFile());
171+
scrubGitEnv(pb.environment());
172+
var proc = pb.start();
173+
String out;
174+
try (var is = proc.getInputStream()) {
175+
out = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).trim();
176+
}
131177
proc.waitFor();
132178
return out;
133179
}
134180

181+
/**
182+
* Execute a git sub-command and assert it exited 0. Fails the test loudly (not silently)
183+
* if setup cannot complete — preferred over ignoring the exit code, which is what let the
184+
* GPG-signing failure slip through before.
185+
*/
135186
private static void run(Path dir, String... cmd) throws Exception {
136-
new ProcessBuilder(cmd).directory(dir.toFile())
137-
.redirectErrorStream(true).start().waitFor();
187+
var pb = new ProcessBuilder(cmd).directory(dir.toFile()).redirectErrorStream(true);
188+
scrubGitEnv(pb.environment());
189+
Process proc = pb.start();
190+
String stderr;
191+
try (var is = proc.getInputStream()) {
192+
stderr = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
193+
}
194+
int exit = proc.waitFor();
195+
if (exit != 0) {
196+
throw new IllegalStateException(
197+
"Command failed (exit " + exit + "): " + String.join(" ", cmd)
198+
+ "\n" + stderr);
199+
}
200+
}
201+
202+
/**
203+
* Remove every ambient git env var that could leak the parent shell's context into the
204+
* child process (most commonly {@code GIT_DIR}/{@code GIT_WORK_TREE} in a worktree-based
205+
* setup, or {@code GIT_CONFIG_*} injected by CI runners).
206+
*/
207+
private static void scrubGitEnv(Map<String, String> env) {
208+
env.keySet().removeIf(k -> k.startsWith("GIT_"));
138209
}
139210
}

0 commit comments

Comments
 (0)