From 7fd1ea2288faa8525e5e57e1b456b0b9ba663a6a Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 08:06:33 +0000 Subject: [PATCH 01/15] build: enable JaCoCo coverage report generation across all modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds jacoco-maven-plugin to the parent pom — prepare-agent on test phase, report on verify phase. Inherited by every child module, so 'mvn verify' now produces /target/site/jacoco/jacoco.xml in protocol, daemon and cli. Feeds the scanner's coverage import flag so coverage can be imported alongside rule findings in the same pass: sonar agent-scan analyze . \ --coverage protocol/target/site/jacoco/jacoco.xml \ --coverage daemon/target/site/jacoco/jacoco.xml \ --coverage cli/target/site/jacoco/jacoco.xml Self-scan with coverage reports 84.22% overall line coverage (protocol 88.8%, daemon 85.5%, cli 83.0%) — measurable baseline for the project's own quality gate. Co-Authored-By: Claude Opus 4.7 --- pom.xml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pom.xml b/pom.xml index 7783fba..4e5eca4 100644 --- a/pom.xml +++ b/pom.xml @@ -116,8 +116,37 @@ maven-surefire-plugin 3.2.5 + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + prepare-agent + + + report + verify + report + + + + From f8cde7c9718812a3b6461d1e37e668ac004885f8 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 08:38:39 +0000 Subject: [PATCH 02/15] fix(sonar): drop all CRITICAL findings + bugs flagged by self-scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-scan of this branch reported 77 issues, 27 CRITICAL, 8 BUG, 9 SECURITY_HOTSPOT. After this commit: 38 issues, 0 CRITICAL, 0 BUG, 0 SECURITY_HOTSPOT — every priority finding addressed. The remaining 38 are MAJOR/MINOR/INFO style nits. ## Thread-safety: replace 'volatile + mutation' with AtomicReference java:S3077 fired on 8 fields holding mutable references that 'volatile' alone cannot make thread-safe. Converted to AtomicReference with matching read/write call sites: daemon/AnalysisService.java analysisEnteredHook daemon/DaemonServer.java dispatcher, onStop, lastOpenedChannel, idleTask, idleTaskToken, onRequestClaimed, onIdleTaskFired ## Security hotspots daemon/AnalysisService.java Files.createTempDirectory now passes PosixFilePermissions rwx------ as a FileAttribute; non-POSIX fallback (Windows) is acceptable because the per-user temp is ACL-restricted there. @SuppressWarnings("java:S5443") with docstring justifying the rule's false positive on the FileAttribute variant. daemon/PluginRuntime.java input length bounded to 64 chars before feeding to the NODE_VERSION regex (S5852 ReDoS defense), and @SuppressWarnings on the field — the pattern has no nested quantifiers and no overlapping alternations, surface is nil. Same method gets @SuppressWarnings("java:S4036"): PATH-resolving 'node' is the intended behaviour for a developer tool. daemon/RuleCatalog.java @SuppressWarnings("java:S5042") with docstring on indexJar — entry names are matched against a strict regex and only used to read entry content via getInputStream; never resolved to a filesystem path, no zip-slip surface. daemon/SonarWayProfiles.java same suppression / same justification. cli/FileResolver.java @SuppressWarnings("java:S4036") on gitDiffNames — PATH-resolving 'git' intentional; hard-coding /usr/bin/git would break Homebrew / asdf / mise / nix. cli/setup/Tar.java @SuppressWarnings("java:S2612") on applyMode — OTHERS_EXECUTE bit is mirrored from the trusted Temurin tar entry's recorded mode, not a hardcoded grant beyond what the verified archive declares. setExecutable return value now checked (S899). cli/SonarCommand.java narrowed makeExecutable's pre-push hook permission to owner-only (dropped GROUP_EXECUTE/OTHERS_EXECUTE). spike/EngineSpike.java @SuppressWarnings("java:S5443") — exploratory code, not in production reactor. ## Cognitive complexity (java:S3776) Extracted private helpers to drop these methods below the 15-complexity threshold: cli/TextReporter#render (was 20) cli/coverage/CloverCoverageParser (was 17) cli/coverage/CoverageFormat#detect (was 22) cli/setup/Tar#extract (was 22) daemon/TestPathDetector#isTest (was 18) Behaviour preserved. Each helper has a single responsibility (extract one entry, build one issue line, parse one file element, etc.); the public methods become thin orchestrators. ## Mechanical java:S1192 - extracted 'error' (DaemonClient) and 'warning' (SarifReporter) as private static final constants; RuleCatalog switch case labels now use DEFAULT_SEVERITY/DEFAULT_TYPE constants. java:S2093 - SetupCommandTest's four 'StringWriter/PrintWriter + finally { close }' blocks converted to try-with-resources. java:S1186 - 9 empty test-stub methods (StubRpc.shutdown, StubControl.start across five test files) got intentionally-empty comments explaining the no-op behaviour each test relies on. ## Build (parent pom) jacoco-maven-plugin 0.8.12 -> 0.8.13 to handle Java 25 class files (v69). The earlier version threw 'Unsupported class file major version 69' when surefire ran instrumented test classes on Temurin 25. ## Verified Full reactor (protocol + daemon + cli) builds and tests green with jacoco active; line coverage holds at 84.62% overall; the self-scan re-run reports 0 CRITICAL / 0 BUG / 0 SECURITY_HOTSPOT. Co-Authored-By: Claude Opus 4.7 --- .../java/dev/sonarcli/cli/DaemonClient.java | 11 ++- .../java/dev/sonarcli/cli/FileResolver.java | 7 ++ .../java/dev/sonarcli/cli/SarifReporter.java | 7 +- .../java/dev/sonarcli/cli/SonarCommand.java | 10 +- .../java/dev/sonarcli/cli/TextReporter.java | 73 ++++++++------ .../cli/coverage/CloverCoverageParser.java | 69 +++++++++---- .../sonarcli/cli/coverage/CoverageFormat.java | 96 ++++++++++++------- .../main/java/dev/sonarcli/cli/setup/Tar.java | 81 ++++++++++------ .../java/dev/sonarcli/cli/CommandTest.java | 3 + .../dev/sonarcli/cli/CoverageCliTest.java | 4 + .../test/java/dev/sonarcli/cli/HelpTest.java | 4 + .../sonarcli/cli/InstallHookCommandTest.java | 4 + .../dev/sonarcli/cli/RulesCommandTest.java | 4 + .../sonarcli/cli/setup/SetupCommandTest.java | 33 ++++--- .../dev/sonarcli/daemon/AnalysisService.java | 38 +++++++- .../dev/sonarcli/daemon/DaemonServer.java | 70 +++++++------- .../dev/sonarcli/daemon/PluginRuntime.java | 30 +++++- .../java/dev/sonarcli/daemon/RuleCatalog.java | 13 ++- .../dev/sonarcli/daemon/SonarWayProfiles.java | 9 ++ .../dev/sonarcli/daemon/TestPathDetector.java | 34 ++++--- pom.xml | 5 +- .../java/dev/sonarcli/spike/EngineSpike.java | 6 ++ 22 files changed, 416 insertions(+), 195 deletions(-) diff --git a/cli/src/main/java/dev/sonarcli/cli/DaemonClient.java b/cli/src/main/java/dev/sonarcli/cli/DaemonClient.java index f9d29f1..bf58bde 100644 --- a/cli/src/main/java/dev/sonarcli/cli/DaemonClient.java +++ b/cli/src/main/java/dev/sonarcli/cli/DaemonClient.java @@ -47,6 +47,9 @@ */ public final class DaemonClient implements DaemonRpc { + /** JSON field the daemon uses to signal a failure payload. */ + private static final String ERROR_FIELD = "error"; + private final SocketPaths paths; private final DaemonLauncher launcher; @@ -89,9 +92,9 @@ public java.util.List ruleCatalog() { WireMessage response = exchange( new WireMessage(newId(), Method.RULE_METADATA, null)); JsonNode body = response.payload(); - if (body != null && body.isObject() && body.has("error")) { + if (body != null && body.isObject() && body.has(ERROR_FIELD)) { throw new DaemonException( - "daemon error (RULE_METADATA): " + body.get("error").asText()); + "daemon error (RULE_METADATA): " + body.get(ERROR_FIELD).asText()); } try { return Json.mapper().convertValue( @@ -119,9 +122,9 @@ public void shutdown() { private T call(Method method, JsonNode payload, Class responseType) { WireMessage response = exchange(new WireMessage(newId(), method, payload)); JsonNode body = response.payload(); - if (body != null && body.isObject() && body.has("error")) { + if (body != null && body.isObject() && body.has(ERROR_FIELD)) { throw new DaemonException( - "daemon error (" + method + "): " + body.get("error").asText()); + "daemon error (" + method + "): " + body.get(ERROR_FIELD).asText()); } try { return Json.mapper().treeToValue(body, responseType); diff --git a/cli/src/main/java/dev/sonarcli/cli/FileResolver.java b/cli/src/main/java/dev/sonarcli/cli/FileResolver.java index 22c3f53..3f3291e 100644 --- a/cli/src/main/java/dev/sonarcli/cli/FileResolver.java +++ b/cli/src/main/java/dev/sonarcli/cli/FileResolver.java @@ -153,6 +153,13 @@ public ResolvedFiles resolveDiff(Path projectDir, String ref) { return rebased(base, existing); } + /** + * Suppresses {@code java:S4036}: {@code sonar} is a developer tool invoked + * from a developer shell; PATH-resolving {@code git} is the intended + * behaviour, not a tampering vector. Hard-coding {@code /usr/bin/git} + * would break Homebrew, asdf, mise, nix, and Windows installs. + */ + @SuppressWarnings("java:S4036") private static List gitDiffNames(Path workingTree, String ref) { ProcessBuilder builder = new ProcessBuilder( "git", "diff", "--name-only", ref) diff --git a/cli/src/main/java/dev/sonarcli/cli/SarifReporter.java b/cli/src/main/java/dev/sonarcli/cli/SarifReporter.java index 103a8c2..1d06847 100644 --- a/cli/src/main/java/dev/sonarcli/cli/SarifReporter.java +++ b/cli/src/main/java/dev/sonarcli/cli/SarifReporter.java @@ -32,6 +32,7 @@ public final class SarifReporter implements Reporter { private static final String SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"; private static final String SARIF_VERSION = "2.1.0"; + private static final String SARIF_LEVEL_WARNING = "warning"; @Override public String render(AnalyzeResponse response, RuleMetadataIndex index, @@ -143,13 +144,13 @@ private static ObjectNode resultNode(Issue issue) { /** Maps an engine severity to a SARIF result level. */ private static String level(String severity) { if (severity == null) { - return "warning"; + return SARIF_LEVEL_WARNING; } return switch (severity.toUpperCase(Locale.ROOT)) { case "BLOCKER", "CRITICAL" -> "error"; - case "MAJOR" -> "warning"; + case "MAJOR" -> SARIF_LEVEL_WARNING; case "MINOR", "INFO" -> "note"; - default -> "warning"; + default -> SARIF_LEVEL_WARNING; }; } diff --git a/cli/src/main/java/dev/sonarcli/cli/SonarCommand.java b/cli/src/main/java/dev/sonarcli/cli/SonarCommand.java index a5a7139..b2c20d3 100644 --- a/cli/src/main/java/dev/sonarcli/cli/SonarCommand.java +++ b/cli/src/main/java/dev/sonarcli/cli/SonarCommand.java @@ -488,18 +488,18 @@ public Integer call() throws java.io.IOException { return EXIT_CLEAN; } - /** Adds owner/group/other execute permission, where the FS supports it. */ + /** Adds owner-only execute permission, where the FS supports it. */ private static void makeExecutable(java.nio.file.Path file) throws java.io.IOException { try { var perms = new java.util.HashSet<>( java.nio.file.Files.getPosixFilePermissions(file)); + // Owner-only execute: a per-user git hook never needs to be + // runnable by group/other. perms.add(java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE); - perms.add(java.nio.file.attribute.PosixFilePermission.GROUP_EXECUTE); - perms.add(java.nio.file.attribute.PosixFilePermission.OTHERS_EXECUTE); java.nio.file.Files.setPosixFilePermissions(file, perms); } catch (UnsupportedOperationException notPosix) { - // Non-POSIX filesystem — fall back to the File API. - if (!file.toFile().setExecutable(true)) { + // Non-POSIX filesystem — fall back to the File API (owner-only). + if (!file.toFile().setExecutable(true, true)) { throw new java.io.IOException( "could not make the hook executable: " + file); } diff --git a/cli/src/main/java/dev/sonarcli/cli/TextReporter.java b/cli/src/main/java/dev/sonarcli/cli/TextReporter.java index ea8dcb6..5bfe4c4 100644 --- a/cli/src/main/java/dev/sonarcli/cli/TextReporter.java +++ b/cli/src/main/java/dev/sonarcli/cli/TextReporter.java @@ -29,36 +29,10 @@ public String render(AnalyzeResponse response, RuleMetadataIndex index, if (byFile.isEmpty()) { out.append("No issues found.\n"); } else { - for (Map.Entry> entry : byFile.entrySet()) { - out.append(entry.getKey()).append('\n'); - for (Issue issue : entry.getValue()) { - out.append(" ") - .append(issue.startLine()).append(':').append(issue.startColumn()) - .append(" ").append(issue.severity()) - .append(" ").append(issue.ruleKey()) - .append(" ").append(issue.message()) - .append('\n'); - appendRuleGuidance(out, index.lookup(issue.ruleKey())); - } - } - int count = response.issues().size(); - out.append('\n') - .append(count).append(count == 1 ? " issue" : " issues") - .append(" in ").append(byFile.size()) - .append(byFile.size() == 1 ? " file.\n" : " files.\n"); + appendIssues(out, byFile, index, response.issues().size()); } - List warnings = response.warnings(); - if (warnings != null && !warnings.isEmpty()) { - out.append('\n').append("Warnings:\n"); - for (AnalysisWarning warning : warnings) { - out.append(" "); - if (warning.filePath() != null && !warning.filePath().isBlank()) { - out.append(warning.filePath()).append(": "); - } - out.append(warning.message()).append('\n'); - } - } + appendWarnings(out, response.warnings()); if (coverage != null) { appendCoverage(out, coverage); @@ -66,6 +40,49 @@ public String render(AnalyzeResponse response, RuleMetadataIndex index, return out.toString(); } + /** Renders every per-file issue block and the trailing summary line. */ + private static void appendIssues(StringBuilder out, + Map> byFile, + RuleMetadataIndex index, int totalCount) { + for (Map.Entry> entry : byFile.entrySet()) { + out.append(entry.getKey()).append('\n'); + for (Issue issue : entry.getValue()) { + appendIssue(out, issue, index); + } + } + out.append('\n') + .append(totalCount).append(totalCount == 1 ? " issue" : " issues") + .append(" in ").append(byFile.size()) + .append(byFile.size() == 1 ? " file.\n" : " files.\n"); + } + + /** Renders one issue line plus any indented rule guidance beneath it. */ + private static void appendIssue(StringBuilder out, Issue issue, + RuleMetadataIndex index) { + out.append(" ") + .append(issue.startLine()).append(':').append(issue.startColumn()) + .append(" ").append(issue.severity()) + .append(" ").append(issue.ruleKey()) + .append(" ").append(issue.message()) + .append('\n'); + appendRuleGuidance(out, index.lookup(issue.ruleKey())); + } + + /** Renders the analysis-warnings section, if there are any. */ + private static void appendWarnings(StringBuilder out, List warnings) { + if (warnings == null || warnings.isEmpty()) { + return; + } + out.append('\n').append("Warnings:\n"); + for (AnalysisWarning warning : warnings) { + out.append(" "); + if (warning.filePath() != null && !warning.filePath().isBlank()) { + out.append(warning.filePath()).append(": "); + } + out.append(warning.message()).append('\n'); + } + } + /** * Appends a coverage summary: the overall percentage followed by a * per-file breakdown, each line {@code }. diff --git a/cli/src/main/java/dev/sonarcli/cli/coverage/CloverCoverageParser.java b/cli/src/main/java/dev/sonarcli/cli/coverage/CloverCoverageParser.java index b71a08a..c14a6d2 100644 --- a/cli/src/main/java/dev/sonarcli/cli/coverage/CloverCoverageParser.java +++ b/cli/src/main/java/dev/sonarcli/cli/coverage/CloverCoverageParser.java @@ -32,27 +32,7 @@ public CoverageReport parse(Path path) { var coveredByFile = new java.util.LinkedHashMap>(); for (Element file : CoverageXml.elementsByTag(root, "file")) { - String filePath = file.getAttribute("path"); - if (filePath == null || filePath.isBlank()) { - filePath = file.getAttribute("name"); - } - if (filePath == null || filePath.isBlank()) { - continue; - } - var coverable = coverableByFile.computeIfAbsent( - filePath, k -> new java.util.TreeSet<>()); - var covered = coveredByFile.computeIfAbsent( - filePath, k -> new java.util.TreeSet<>()); - for (Element line : CoverageXml.children(file, "line")) { - if (!"stmt".equals(line.getAttribute("type"))) { - continue; - } - int number = CoverageXml.intAttr(line, "num"); - coverable.add(number); - if (CoverageXml.intAttr(line, "count") > 0) { - covered.add(number); - } - } + collectFile(file, coverableByFile, coveredByFile); } List files = new ArrayList<>(); @@ -60,4 +40,51 @@ public CoverageReport parse(Path path) { files.add(new FileCoverage(file, coveredByFile.get(file), coverable))); return new CoverageReport(files); } + + /** + * Records the statement-coverage line numbers for one {@code } + * element into the accumulating maps, keyed by the file's path attribute. + */ + private static void collectFile( + Element file, + java.util.Map> coverableByFile, + java.util.Map> coveredByFile) { + String filePath = filePathOf(file); + if (filePath == null) { + return; + } + var coverable = coverableByFile.computeIfAbsent( + filePath, k -> new java.util.TreeSet<>()); + var covered = coveredByFile.computeIfAbsent( + filePath, k -> new java.util.TreeSet<>()); + for (Element line : CoverageXml.children(file, "line")) { + recordStatementLine(line, coverable, covered); + } + } + + /** Returns the file's path attribute, or {@code name} as fallback, or {@code null}. */ + private static String filePathOf(Element file) { + String filePath = file.getAttribute("path"); + if (filePath == null || filePath.isBlank()) { + filePath = file.getAttribute("name"); + } + if (filePath == null || filePath.isBlank()) { + return null; + } + return filePath; + } + + /** Adds a {@code } entry to coverable/covered as appropriate. */ + private static void recordStatementLine(Element line, + java.util.NavigableSet coverable, + java.util.NavigableSet covered) { + if (!"stmt".equals(line.getAttribute("type"))) { + return; + } + int number = CoverageXml.intAttr(line, "num"); + coverable.add(number); + if (CoverageXml.intAttr(line, "count") > 0) { + covered.add(number); + } + } } diff --git a/cli/src/main/java/dev/sonarcli/cli/coverage/CoverageFormat.java b/cli/src/main/java/dev/sonarcli/cli/coverage/CoverageFormat.java index 145f204..c87fcc3 100644 --- a/cli/src/main/java/dev/sonarcli/cli/coverage/CoverageFormat.java +++ b/cli/src/main/java/dev/sonarcli/cli/coverage/CoverageFormat.java @@ -55,6 +55,21 @@ public static CoverageFormat detect(Path path) { String head = readHead(path); String name = path.getFileName().toString(); + CoverageFormat detected = detectXml(head); + if (detected == null) { + detected = detectTextual(head, name); + } + if (detected != null) { + return detected; + } + + throw new CoverageException( + "unrecognized coverage report format: " + path + + " (expected JaCoCo, Cobertura, LCOV, Go profile, Clover, or SimpleCov)"); + } + + /** Identifies the XML-based formats (JaCoCo, Clover, Cobertura) from a head snippet. */ + private static CoverageFormat detectXml(String head) { if (head.contains("", lt); - if (i < 0) { - return false; - } - i += 2; - continue; + int skipped = skipNonElement(head, lt); + if (skipped == lt) { + // Not a prolog/comment/doctype — this is the first real element. + return matchesTag(head, lt, tag); } - if (head.startsWith("", lt); - if (i < 0) { - return false; - } - i += 3; - continue; - } - if (head.startsWith("', lt); - if (i < 0) { - return false; - } - i += 1; - continue; - } - // First real element: must be '. - int after = lt + 1 + tag.length(); - if (head.regionMatches(lt + 1, tag, 0, tag.length()) - && after <= n - && (after == n || Character.isWhitespace(head.charAt(after)) - || head.charAt(after) == '>')) { - return true; + if (skipped < 0) { + return false; } - return false; + i = skipped; } return false; } + /** + * If {@code head} at {@code lt} starts a prolog ({@code }), comment + * ({@code }), or DOCTYPE ({@code }), returns the index just + * past the construct; returns {@code -1} when the construct is unterminated; + * returns {@code lt} unchanged when the position is a real element tag. + */ + private static int skipNonElement(String head, int lt) { + if (head.startsWith("", lt); + return end < 0 ? -1 : end + 2; + } + if (head.startsWith("", lt); + return end < 0 ? -1 : end + 3; + } + if (head.startsWith("', lt); + return end < 0 ? -1 : end + 1; + } + return lt; + } + + /** Whether the element at {@code lt} is {@code }. */ + private static boolean matchesTag(String head, int lt, String tag) { + int n = head.length(); + int after = lt + 1 + tag.length(); + return head.regionMatches(lt + 1, tag, 0, tag.length()) + && after <= n + && (after == n + || Character.isWhitespace(head.charAt(after)) + || head.charAt(after) == '>'); + } + private static String readHead(Path path) { try (var in = Files.newInputStream(path)) { byte[] buffer = in.readNBytes(SNIFF_BYTES); diff --git a/cli/src/main/java/dev/sonarcli/cli/setup/Tar.java b/cli/src/main/java/dev/sonarcli/cli/setup/Tar.java index c6a8f89..8ba7f7a 100644 --- a/cli/src/main/java/dev/sonarcli/cli/setup/Tar.java +++ b/cli/src/main/java/dev/sonarcli/cli/setup/Tar.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -53,13 +54,7 @@ static void extractTarGz(InputStream in, Path destDir, boolean stripTopDir) try (GZIPInputStream gz = new GZIPInputStream(in)) { byte[] header = new byte[BLOCK]; String longName = null; - while (true) { - if (!readFully(gz, header)) { - return; // truncated/end of stream - } - if (isAllZero(header)) { - return; // end-of-archive marker - } + while (readFully(gz, header) && !isAllZero(header)) { String name = longName != null ? longName : cString(header, 0, NAME_LEN); longName = null; long size = parseOctal(header, SIZE_OFFSET, 12); @@ -71,29 +66,39 @@ static void extractTarGz(InputStream in, Path destDir, boolean stripTopDir) longName = readEntryString(gz, size); continue; } + extractEntry(gz, dest, stripTopDir, name, size, type, mode); + } + } + } - String relative = stripTopDir ? stripFirstComponent(name) : name; - if (relative == null || relative.isEmpty()) { - skip(gz, padded(size)); - continue; - } - Path target = dest.resolve(relative).normalize(); - if (!target.startsWith(dest)) { - throw new IOException("tar entry escapes destination: " + name); - } + /** + * Dispatches one tar entry: skips it when stripping leaves an empty path, + * creates a directory entry, writes a regular file, or skips any other + * entry type (symlinks, etc.). + */ + private static void extractEntry(InputStream gz, Path dest, boolean stripTopDir, + String name, long size, char type, int mode) + throws IOException { + String relative = stripTopDir ? stripFirstComponent(name) : name; + if (relative == null || relative.isEmpty()) { + skip(gz, padded(size)); + return; + } + Path target = dest.resolve(relative).normalize(); + if (!target.startsWith(dest)) { + throw new IOException("tar entry escapes destination: " + name); + } - if (type == '5' || name.endsWith("/")) { - Files.createDirectories(target); - skip(gz, padded(size)); - } else if (type == '0' || type == '\0') { - Files.createDirectories(target.getParent()); - writeEntry(gz, target, size); - applyMode(target, mode); - } else { - // Symlinks and other types: skip the payload, ignore. - skip(gz, padded(size)); - } - } + if (type == '5' || name.endsWith("/")) { + Files.createDirectories(target); + skip(gz, padded(size)); + } else if (type == '0' || type == '\0') { + Files.createDirectories(target.getParent()); + writeEntry(gz, target, size); + applyMode(target, mode); + } else { + // Symlinks and other types: skip the payload, ignore. + skip(gz, padded(size)); } } @@ -118,6 +123,13 @@ private static String readEntryString(InputStream in, long size) throws IOExcept return new String(buf, 0, end, StandardCharsets.UTF_8); } + /** + * Suppresses {@code java:S2612}: the {@code OTHERS_EXECUTE} bit is mirrored + * from the trusted Temurin JRE tar entry's recorded mode (Temurin ships + * shared binaries as world-executable so any user can run the JRE); we + * are not granting permissions beyond what the verified archive declares. + */ + @SuppressWarnings("java:S2612") private static void applyMode(Path file, int mode) { if ((mode & 0_111) == 0) { return; // no execute bits set @@ -132,11 +144,22 @@ private static void applyMode(Path file, int mode) { perms.add(PosixFilePermission.GROUP_EXECUTE); } if ((mode & 0_001) != 0) { + // NOSONAR(java:S2612) the execute bit is mirrored from the + // tar entry's recorded mode (Temurin JRE binaries ship as + // world-executable); we are not granting permissions beyond + // what the trusted archive declares. perms.add(PosixFilePermission.OTHERS_EXECUTE); } Files.setPosixFilePermissions(file, perms); } catch (UnsupportedOperationException | IOException notPosix) { - file.toFile().setExecutable(true); + // Owner-only execute is sufficient for our extracted JRE binaries; + // group/other execute are not required for the daemon launcher path. + if (!file.toFile().setExecutable(true, true)) { + throw new UncheckedIOException(new IOException( + "could not mark " + file + " executable " + + "(POSIX permissions unsupported and " + + "File.setExecutable failed)")); + } } } diff --git a/cli/src/test/java/dev/sonarcli/cli/CommandTest.java b/cli/src/test/java/dev/sonarcli/cli/CommandTest.java index 6549808..ee78723 100644 --- a/cli/src/test/java/dev/sonarcli/cli/CommandTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/CommandTest.java @@ -62,6 +62,9 @@ public List ruleCatalog() { @Override public void shutdown() { + // Intentionally empty: these tests exercise ping/analyze/rule lookups; + // the CLI never invokes shutdown on the RPC stub, so a no-op is the + // intended behaviour and there is nothing to assert here. } } diff --git a/cli/src/test/java/dev/sonarcli/cli/CoverageCliTest.java b/cli/src/test/java/dev/sonarcli/cli/CoverageCliTest.java index fa06594..939b22a 100644 --- a/cli/src/test/java/dev/sonarcli/cli/CoverageCliTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/CoverageCliTest.java @@ -61,6 +61,8 @@ public List ruleCatalog() { @Override public void shutdown() { + // Intentionally empty: the coverage tests do not exercise daemon + // shutdown; the no-op satisfies the interface contract for this stub. } } @@ -73,6 +75,8 @@ public boolean isRunning() { @Override public void start() { + // Intentionally empty: this stub reports the daemon as already + // running, so start() is never expected to do anything meaningful. } @Override diff --git a/cli/src/test/java/dev/sonarcli/cli/HelpTest.java b/cli/src/test/java/dev/sonarcli/cli/HelpTest.java index 904f6a0..e4d37fa 100644 --- a/cli/src/test/java/dev/sonarcli/cli/HelpTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/HelpTest.java @@ -50,6 +50,8 @@ public List ruleCatalog() { @Override public void shutdown() { + // Intentionally empty: help rendering never contacts the daemon, + // so the stub has nothing to clean up. No-op is the contract. } } @@ -62,6 +64,8 @@ public boolean isRunning() { @Override public void start() { + // Intentionally empty: help rendering never starts the daemon; + // this stub exists only to satisfy the interface for SonarCommand. } @Override diff --git a/cli/src/test/java/dev/sonarcli/cli/InstallHookCommandTest.java b/cli/src/test/java/dev/sonarcli/cli/InstallHookCommandTest.java index 8d970ae..1a39702 100644 --- a/cli/src/test/java/dev/sonarcli/cli/InstallHookCommandTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/InstallHookCommandTest.java @@ -53,6 +53,8 @@ public List ruleCatalog() { @Override public void shutdown() { + // Intentionally empty: install-hook never invokes daemon shutdown, + // so this stub method is reached only via interface contract. } } @@ -65,6 +67,8 @@ public boolean isRunning() { @Override public void start() { + // Intentionally empty: install-hook only writes git hook files; it + // never starts the daemon, so this stub method must do nothing. } @Override diff --git a/cli/src/test/java/dev/sonarcli/cli/RulesCommandTest.java b/cli/src/test/java/dev/sonarcli/cli/RulesCommandTest.java index 12f2c69..47e1388 100644 --- a/cli/src/test/java/dev/sonarcli/cli/RulesCommandTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/RulesCommandTest.java @@ -65,6 +65,8 @@ public List ruleCatalog() { @Override public void shutdown() { + // Intentionally empty: the rules subcommand tests never trigger + // daemon shutdown; this method exists only to satisfy the interface. } } @@ -77,6 +79,8 @@ public boolean isRunning() { @Override public void start() { + // Intentionally empty: the rules subcommand never starts the + // daemon; the stub reports it as already running. } @Override diff --git a/cli/src/test/java/dev/sonarcli/cli/setup/SetupCommandTest.java b/cli/src/test/java/dev/sonarcli/cli/setup/SetupCommandTest.java index 4baeefc..ad359b0 100644 --- a/cli/src/test/java/dev/sonarcli/cli/setup/SetupCommandTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/setup/SetupCommandTest.java @@ -98,11 +98,11 @@ private CommandLine setupCommandInto(Path base) { @Test @DisplayName("sonar setup provisions plugins and engine then reports success") - void provisionsAndReportsSuccess(@TempDir Path base) { - StringWriter out = new StringWriter(); - try { + void provisionsAndReportsSuccess(@TempDir Path base) throws Exception { + try (StringWriter out = new StringWriter(); + PrintWriter writer = new PrintWriter(out)) { CommandLine cmd = setupCommandInto(base); - cmd.setOut(new PrintWriter(out)); + cmd.setOut(writer); int code = cmd.execute("--repo", baseUrl()); assertEquals(0, code, "a successful setup must exit 0"); @@ -119,10 +119,11 @@ void provisionsAndReportsSuccess(@TempDir Path base) { @Test @DisplayName("--repo is threaded through to the PluginProvisioner") - void repoOptionIsThreaded(@TempDir Path base) { - try { + void repoOptionIsThreaded(@TempDir Path base) throws Exception { + try (StringWriter sink = new StringWriter(); + PrintWriter writer = new PrintWriter(sink)) { CommandLine cmd = setupCommandInto(base); - cmd.setOut(new PrintWriter(new StringWriter())); + cmd.setOut(writer); cmd.execute("--repo", baseUrl()); assertEquals(baseUrl(), seenRepoBase.get(), @@ -153,10 +154,10 @@ void offlineProvisioning(@TempDir Path base, @TempDir Path src) throws Exception // Stop the server: --offline must not touch the network. server.stop(0); - StringWriter out = new StringWriter(); - try { + try (StringWriter out = new StringWriter(); + PrintWriter writer = new PrintWriter(out)) { CommandLine cmd = setupCommandInto(base); - cmd.setOut(new PrintWriter(out)); + cmd.setOut(writer); int code = cmd.execute("--offline", bundle.toString()); assertEquals(0, code, "offline setup must exit 0"); @@ -172,7 +173,7 @@ void offlineProvisioning(@TempDir Path base, @TempDir Path src) throws Exception @Test @DisplayName("a checksum failure exits 2 with a message naming the artifact") - void checksumFailureExitsTwo(@TempDir Path base) { + void checksumFailureExitsTwo(@TempDir Path base) throws Exception { // Tamper the java plugin's expected checksum. Manifest.Artifact java = manifest.plugins().get(0); Manifest tampered = new Manifest("10.24.0.81415", manifest.engine(), @@ -181,11 +182,13 @@ void checksumFailureExitsTwo(@TempDir Path base) { Manifest original = manifest; manifest = tampered; - StringWriter err = new StringWriter(); - try { + try (StringWriter err = new StringWriter(); + StringWriter outSink = new StringWriter(); + PrintWriter outWriter = new PrintWriter(outSink); + PrintWriter errWriter = new PrintWriter(err)) { CommandLine cmd = setupCommandInto(base); - cmd.setOut(new PrintWriter(new StringWriter())); - cmd.setErr(new PrintWriter(err)); + cmd.setOut(outWriter); + cmd.setErr(errWriter); int code = cmd.execute("--repo", baseUrl()); assertEquals(2, code, "a checksum failure must exit 2"); diff --git a/daemon/src/main/java/dev/sonarcli/daemon/AnalysisService.java b/daemon/src/main/java/dev/sonarcli/daemon/AnalysisService.java index 927c7e7..dc8b384 100644 --- a/daemon/src/main/java/dev/sonarcli/daemon/AnalysisService.java +++ b/daemon/src/main/java/dev/sonarcli/daemon/AnalysisService.java @@ -4,6 +4,8 @@ import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -12,6 +14,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -106,7 +109,7 @@ public final class AnalysisService implements AutoCloseable { * exercise the {@link #close}-waits-for-analysis path deterministically. * {@code null} (a no-op) in production. */ - private volatile Runnable analysisEnteredHook; + private final AtomicReference analysisEnteredHook = new AtomicReference<>(); /** * Builds a warm service over the resolved analyzer-plugin directory: the @@ -168,7 +171,7 @@ public AnalyzeResponse analyze(AnalyzeRequest request) { // time, and close() must be able to wait behind an in-flight call. analysisLock.lock(); try { - Runnable hook = analysisEnteredHook; + Runnable hook = analysisEnteredHook.get(); if (hook != null) { hook.run(); } @@ -427,7 +430,7 @@ public void close() { * @param hook the hook to run after the analysis lock is acquired */ void setAnalysisEnteredHookForTest(Runnable hook) { - this.analysisEnteredHook = hook; + this.analysisEnteredHook.set(hook); } /** @@ -651,7 +654,7 @@ private String languageKeyFor(String ruleKey) { private static Path createWorkDir() { try { - Path dir = Files.createTempDirectory("sonar-daemon-work"); + Path dir = createPrivateTempDir("sonar-daemon-work"); // Safety net: if the process dies before close() runs (a SIGKILL, // an OOM, an unhandled error), a JVM shutdown hook still removes // the directory. File#deleteOnExit is not used: it is non-recursive @@ -665,6 +668,33 @@ private static Path createWorkDir() { } } + /** + * Creates a temp directory readable, writable, and executable only by the + * owner (POSIX {@code rwx------}). On a non-POSIX filesystem (e.g. Windows) + * the POSIX attribute is unsupported, so falls back to the no-attribute + * form — Windows ACLs make the per-user temp directory owner-restricted by + * default, so the fallback is not world-readable in practice. + */ + /** + * Creates a temp directory with owner-only POSIX permissions + * ({@code rwx------}) where the OS supports it. Suppresses + * {@code java:S5443}: the rule fires on any + * {@link Files#createTempDirectory(String, FileAttribute[])} call, but the + * explicit {@code rwx------} attribute is exactly the safe variant the + * rule wants. The non-POSIX fallback (Windows) is acceptable because + * Windows' per-user temp dir is owner-restricted via ACL by default. + */ + @SuppressWarnings("java:S5443") + private static Path createPrivateTempDir(String prefix) throws IOException { + try { + FileAttribute ownerOnly = PosixFilePermissions.asFileAttribute( + PosixFilePermissions.fromString("rwx------")); + return Files.createTempDirectory(prefix, ownerOnly); + } catch (UnsupportedOperationException nonPosix) { + return Files.createTempDirectory(prefix); + } + } + private static AnalysisResults await(CompletableFuture future) { try { return future.get(); diff --git a/daemon/src/main/java/dev/sonarcli/daemon/DaemonServer.java b/daemon/src/main/java/dev/sonarcli/daemon/DaemonServer.java index 9819d27..61d3eb9 100644 --- a/daemon/src/main/java/dev/sonarcli/daemon/DaemonServer.java +++ b/daemon/src/main/java/dev/sonarcli/daemon/DaemonServer.java @@ -28,6 +28,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import dev.sonarcli.protocol.MessageCodec; import dev.sonarcli.protocol.WireMessage; @@ -92,8 +93,9 @@ public final class DaemonServer { private final Duration idleTimeout; private final Duration connectionTimeout; - private volatile java.util.function.Function dispatcher; - private volatile Runnable onStop = () -> { }; + private final AtomicReference> dispatcher = + new AtomicReference<>(); + private final AtomicReference onStop = new AtomicReference<>(() -> { }); private final CountDownLatch listeningLatch = new CountDownLatch(1); private final CountDownLatch stoppedLatch = new CountDownLatch(1); @@ -125,11 +127,11 @@ public final class DaemonServer { * in {@link #start}, retained only so a test can assert it is closed when * {@code bind()} fails. Not used by production logic. */ - private volatile ServerSocketChannel lastOpenedChannel; + private final AtomicReference lastOpenedChannel = new AtomicReference<>(); private Thread acceptThread; private ScheduledExecutorService idleScheduler; - private volatile ScheduledFuture idleTask; + private final AtomicReference> idleTask = new AtomicReference<>(); /** * Identity token of the currently-armed idle task. A scheduled idle task @@ -140,7 +142,7 @@ public final class DaemonServer { * {@link #claimRequest}; volatile so a test helper can read the live token * without contending for that monitor. */ - private volatile Object idleTaskToken; + private final AtomicReference idleTaskToken = new AtomicReference<>(); /** * Test-only seam: run inside the synchronized {@link #claimRequest} block, @@ -148,14 +150,14 @@ public final class DaemonServer { * released. A test sets this to force the idle timer to fire at exactly the * frame-read→claim instant and prove the claimed request still completes. */ - private volatile Runnable onRequestClaimed = () -> { }; + private final AtomicReference onRequestClaimed = new AtomicReference<>(() -> { }); /** * Test-only seam: run at the very start of a scheduled idle task, before * it contends for the server monitor. A test uses it to observe that the * idle timer fired while a request claim is in progress. */ - private volatile Runnable onIdleTaskFired = () -> { }; + private final AtomicReference onIdleTaskFired = new AtomicReference<>(() -> { }); /** Bounded pool that serves accepted connections; created in {@link #start}. */ private ExecutorService connectionPool; @@ -190,7 +192,7 @@ public DaemonServer( Duration idleTimeout, Duration connectionTimeout) { this.socketPath = Objects.requireNonNull(socketPath, "socketPath"); - this.dispatcher = dispatcher; + this.dispatcher.set(dispatcher); this.idleTimeout = Objects.requireNonNull(idleTimeout, "idleTimeout"); this.connectionTimeout = Objects.requireNonNull(connectionTimeout, "connectionTimeout"); @@ -213,7 +215,7 @@ int connectionQueueCapacity() { * been closed rather than leaked. */ boolean lastOpenedChannelIsOpen() { - ServerSocketChannel channel = lastOpenedChannel; + ServerSocketChannel channel = lastOpenedChannel.get(); return channel != null && channel.isOpen(); } @@ -233,7 +235,7 @@ boolean stopping() { * frame-read vs idle-shutdown race deterministically from another thread. */ void forceIdleTimeoutCheck() { - idleTimeoutFired(idleTaskToken); + idleTimeoutFired(idleTaskToken.get()); } /** @@ -246,7 +248,7 @@ void forceIdleTimeoutCheck() { * @param onRequestClaimed the callback; must not block on the server monitor */ void setOnRequestClaimed(Runnable onRequestClaimed) { - this.onRequestClaimed = Objects.requireNonNull(onRequestClaimed, "onRequestClaimed"); + this.onRequestClaimed.set(Objects.requireNonNull(onRequestClaimed, "onRequestClaimed")); } /** @@ -256,12 +258,12 @@ void setOnRequestClaimed(Runnable onRequestClaimed) { * @param onIdleTaskFired the callback; must not block on the server monitor */ void setOnIdleTaskFired(Runnable onIdleTaskFired) { - this.onIdleTaskFired = Objects.requireNonNull(onIdleTaskFired, "onIdleTaskFired"); + this.onIdleTaskFired.set(Objects.requireNonNull(onIdleTaskFired, "onIdleTaskFired")); } /** Sets the request dispatcher; must be set before {@link #start}. */ public void setDispatcher(java.util.function.Function dispatcher) { - this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher"); + this.dispatcher.set(Objects.requireNonNull(dispatcher, "dispatcher")); } /** @@ -278,7 +280,7 @@ public void setDispatcher(java.util.function.Function * @param onStop the teardown callback; must be set before {@link #start} */ public void setOnStop(Runnable onStop) { - this.onStop = Objects.requireNonNull(onStop, "onStop"); + this.onStop.set(Objects.requireNonNull(onStop, "onStop")); } /** @@ -288,7 +290,7 @@ public void setOnStop(Runnable onStop) { * @throws IllegalStateException if no dispatcher has been set */ public void start() { - if (dispatcher == null) { + if (dispatcher.get() == null) { throw new IllegalStateException("dispatcher must be set before start()"); } // Open into a local so a bind() failure does not leak the channel: @@ -299,7 +301,7 @@ public void start() { try { Files.deleteIfExists(socketPath); ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX); - lastOpenedChannel = channel; + lastOpenedChannel.set(channel); try { channel.bind(UnixDomainSocketAddress.of(socketPath)); } catch (IOException | RuntimeException bindFailure) { @@ -414,7 +416,7 @@ public void stop() { idleScheduler.shutdownNow(); } try { - onStop.run(); + onStop.get().run(); } catch (RuntimeException ignored) { // A teardown failure must not leave the socket file behind or // strand a caller waiting on awaitStopped(). @@ -485,7 +487,7 @@ private void serveConnection(SocketChannel connection) { // client between frames is still reaped. watchdog = rearmConnectionTimeout(watchdog, connection); try { - WireMessage response = dispatcher.apply(request); + WireMessage response = dispatcher.get().apply(request); MessageCodec.writeMessage(out, response); } finally { if (inFlight.decrementAndGet() == 0) { @@ -565,12 +567,12 @@ private synchronized void claimRequest() { // Test-only seam — see onRequestClaimed. Runs while the monitor is // held and inFlight is already incremented, so a concurrently-firing // idle task is forced to block here and then abort on inFlight > 0. - onRequestClaimed.run(); - if (idleTask != null) { - idleTask.cancel(false); - idleTask = null; + onRequestClaimed.get().run(); + ScheduledFuture current = idleTask.getAndSet(null); + if (current != null) { + current.cancel(false); } - idleTaskToken = null; + idleTaskToken.set(null); } /** @@ -588,23 +590,23 @@ private synchronized void resetIdleTimer() { if (stopping.get() || idleScheduler == null || idleScheduler.isShutdown()) { return; } - if (idleTask != null) { - idleTask.cancel(false); - idleTask = null; + ScheduledFuture current = idleTask.getAndSet(null); + if (current != null) { + current.cancel(false); } - idleTaskToken = null; + idleTaskToken.set(null); if (inFlight.get() > 0) { // A request is executing — defer arming until it completes. return; } Object token = new Object(); - idleTaskToken = token; + idleTaskToken.set(token); try { - idleTask = idleScheduler.schedule( + idleTask.set(idleScheduler.schedule( () -> idleTimeoutFired(token), - idleTimeout.toMillis(), TimeUnit.MILLISECONDS); + idleTimeout.toMillis(), TimeUnit.MILLISECONDS)); } catch (RejectedExecutionException stopped) { - idleTaskToken = null; + idleTaskToken.set(null); } } @@ -619,12 +621,12 @@ private synchronized void resetIdleTimer() { * @param token the identity token captured when this task was armed */ private void idleTimeoutFired(Object token) { - onIdleTaskFired.run(); + onIdleTaskFired.get().run(); synchronized (this) { - if (stopping.get() || idleTaskToken != token || inFlight.get() > 0) { + if (stopping.get() || idleTaskToken.get() != token || inFlight.get() > 0) { return; // superseded, or a request is in flight — do not stop. } - idleTaskToken = null; + idleTaskToken.set(null); } stop(); } diff --git a/daemon/src/main/java/dev/sonarcli/daemon/PluginRuntime.java b/daemon/src/main/java/dev/sonarcli/daemon/PluginRuntime.java index 56e4059..4afea2b 100644 --- a/daemon/src/main/java/dev/sonarcli/daemon/PluginRuntime.java +++ b/daemon/src/main/java/dev/sonarcli/daemon/PluginRuntime.java @@ -133,17 +133,38 @@ public static Set loadedLanguagesFor(Set loadedPluginKeys return loaded; } - /** Matches the {@code vX.Y.Z} output of {@code node --version}. */ + /** + * Matches the {@code vX.Y.Z} output of {@code node --version}. + * + *

Suppresses {@code java:S5852}: this regex has no nested quantifiers + * and no overlapping alternations, and {@link #detectNodeVersion()} bounds + * the input length to 64 chars before invoking the matcher. ReDoS surface + * is nil; Sonar's rule is being conservative on any quantifier-bearing + * pattern. + */ + @SuppressWarnings("java:S5852") private static final Pattern NODE_VERSION = Pattern.compile("v?(\\d+\\.\\d+\\.\\d+)"); /** * Detects the host Node.js version by running {@code node --version}. * + *

Suppresses: + *

    + *
  • {@code java:S4036} — PATH-resolving {@code node} is intended; this + * is a developer tool and Node is canonically managed by nvm / asdf + * / mise / fnm, none of which use a stable absolute path.
  • + *
  • {@code java:S5852} — {@link #NODE_VERSION} is + * {@code v?(\d+\.\d+\.\d+)}: no nested quantifiers, no overlapping + * alternations; the input is additionally bounded to 64 chars + * before being matched. ReDoS surface is nil.
  • + *
+ * * @return the parsed version, or {@link Optional#empty()} if Node is absent * or its version could not be determined — in which case the * JS/TS/CSS analyzer is simply skipped, not a fatal error */ + @SuppressWarnings({"java:S4036", "java:S5852"}) static Optional detectNodeVersion() { try { Process process = new ProcessBuilder("node", "--version") @@ -158,7 +179,12 @@ static Optional detectNodeVersion() { process.destroyForcibly(); return Optional.empty(); } - if (output == null) { + if (output == null || output.length() > 64) { + // Bound input length before regex match — `node --version` + // emits one short `vX.Y.Z` line; anything pathologically long + // is a misconfigured shim or corrupt output, not a valid + // version. Bounding also defangs the (already low) S5852 + // ReDoS concern on NODE_VERSION. return Optional.empty(); } Matcher m = NODE_VERSION.matcher(output.trim()); diff --git a/daemon/src/main/java/dev/sonarcli/daemon/RuleCatalog.java b/daemon/src/main/java/dev/sonarcli/daemon/RuleCatalog.java index 68a247f..e6fa9ce 100644 --- a/daemon/src/main/java/dev/sonarcli/daemon/RuleCatalog.java +++ b/daemon/src/main/java/dev/sonarcli/daemon/RuleCatalog.java @@ -104,6 +104,15 @@ public static RuleCatalog fromPluginsDir(Path pluginsDir) { return new RuleCatalog(rules); } + /** + * Suppresses {@code java:S5042} ("expanding an archive file") on this + * method: we never resolve a {@link JarEntry#getName()} to a filesystem + * path. Entry names are matched against a strict {@link #RULE_JSON} regex + * and used only to fetch the entry's bytes via + * {@link JarFile#getInputStream(java.util.zip.ZipEntry)} for in-memory + * parsing — no zip-slip surface exists here. + */ + @SuppressWarnings("java:S5042") private static void indexJar(Path jarPath, Map rules) { try (JarFile jar = new JarFile(jarPath.toFile())) { var it = jar.entries(); @@ -196,7 +205,7 @@ private static String normalizeSeverity(String raw) { } String upper = raw.toUpperCase(Locale.ROOT); return switch (upper) { - case "BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO" -> upper; + case "BLOCKER", "CRITICAL", DEFAULT_SEVERITY, "MINOR", "INFO" -> upper; default -> DEFAULT_SEVERITY; }; } @@ -207,7 +216,7 @@ private static String normalizeType(String raw) { } String upper = raw.toUpperCase(Locale.ROOT); return switch (upper) { - case "BUG", "CODE_SMELL", "VULNERABILITY", "SECURITY_HOTSPOT" -> upper; + case "BUG", DEFAULT_TYPE, "VULNERABILITY", "SECURITY_HOTSPOT" -> upper; default -> DEFAULT_TYPE; }; } diff --git a/daemon/src/main/java/dev/sonarcli/daemon/SonarWayProfiles.java b/daemon/src/main/java/dev/sonarcli/daemon/SonarWayProfiles.java index 319fcf1..2cc7d34 100644 --- a/daemon/src/main/java/dev/sonarcli/daemon/SonarWayProfiles.java +++ b/daemon/src/main/java/dev/sonarcli/daemon/SonarWayProfiles.java @@ -124,6 +124,15 @@ public static SonarWayProfiles load(Path pluginsDir) { return new SonarWayProfiles(byLanguage); } + /** + * Suppresses {@code java:S5042} ("expanding an archive file") on this + * method: we never resolve a {@link JarEntry#getName()} to a filesystem + * path. Entry names are matched against a strict {@link #PROFILE_JSON} + * regex and used only to fetch the entry's bytes via + * {@link JarFile#getInputStream(java.util.zip.ZipEntry)} for in-memory + * parsing — no zip-slip surface exists here. + */ + @SuppressWarnings("java:S5042") private static void indexJar(Path jarPath, Map> byLanguage) { try (JarFile jar = new JarFile(jarPath.toFile())) { var it = jar.entries(); diff --git a/daemon/src/main/java/dev/sonarcli/daemon/TestPathDetector.java b/daemon/src/main/java/dev/sonarcli/daemon/TestPathDetector.java index 88ccd17..e2a0a9f 100644 --- a/daemon/src/main/java/dev/sonarcli/daemon/TestPathDetector.java +++ b/daemon/src/main/java/dev/sonarcli/daemon/TestPathDetector.java @@ -82,26 +82,36 @@ public static boolean isTest(String relativePath, SonarLanguage language) { return false; } String normalized = relativePath.replace('\\', '/'); + return matchesCommonTestPath(normalized) + || matchesLanguageTestFilename(normalized, language); + } + /** Path-segment check — cross-language, always runs first. */ + private static boolean matchesCommonTestPath(String normalizedPath) { for (Pattern p : COMMON_PATH_PATTERNS) { - if (p.matcher(normalized).find()) { + if (p.matcher(normalizedPath).find()) { return true; } } + return false; + } - if (language != null) { - List patterns = FILENAME_PATTERNS.get(language); - if (patterns != null) { - int slash = normalized.lastIndexOf('/'); - String filename = slash < 0 ? normalized : normalized.substring(slash + 1); - for (Pattern p : patterns) { - if (p.matcher(filename).matches()) { - return true; - } - } + /** Filename check — per-language. No-op when {@code language} is null. */ + private static boolean matchesLanguageTestFilename(String normalizedPath, SonarLanguage language) { + if (language == null) { + return false; + } + List patterns = FILENAME_PATTERNS.get(language); + if (patterns == null) { + return false; + } + int slash = normalizedPath.lastIndexOf('/'); + String filename = slash < 0 ? normalizedPath : normalizedPath.substring(slash + 1); + for (Pattern p : patterns) { + if (p.matcher(filename).matches()) { + return true; } } - return false; } } diff --git a/pom.xml b/pom.xml index 4e5eca4..53d69f5 100644 --- a/pom.xml +++ b/pom.xml @@ -119,7 +119,10 @@ org.jacoco jacoco-maven-plugin - 0.8.12 + + 0.8.13 diff --git a/spike/src/main/java/dev/sonarcli/spike/EngineSpike.java b/spike/src/main/java/dev/sonarcli/spike/EngineSpike.java index 257cb69..3be8343 100644 --- a/spike/src/main/java/dev/sonarcli/spike/EngineSpike.java +++ b/spike/src/main/java/dev/sonarcli/spike/EngineSpike.java @@ -46,6 +46,12 @@ public final class EngineSpike { /** java:S1118 -- utility classes should not have public constructors. */ private static final String RULE_KEY = "java:S1118"; + /** + * Suppresses {@code java:S5443}: this is exploratory / spike code, not in + * the production reactor. Standard {@link Files#createTempDirectory} is + * acceptable for a developer-run experiment. + */ + @SuppressWarnings("java:S5443") public static void main(String[] args) throws Exception { if (args.length != 3) { System.err.println("Usage: EngineSpike "); From 6d202bae42388e090c903a8faa596a0171aaef06 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 08:38:39 +0000 Subject: [PATCH 03/15] ci: add self-scan workflow for parity with local quality gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `.github/workflows/sonar.yml` runs the project's own scanner against the branch on every PR + push to main. Triggers on pull_request(main), push(main), workflow_dispatch. Steps: build + test the reactor (which now produces jacoco.xml per module via the JaCoCo plugin added earlier in this PR), build the dist skill bundle, point SONAR_PREDICTOR_HOME at it so the scan runs against THIS branch's daemon (not the previously released bundle on Maven Central), run `agent-scan analyze .` with all three jacoco XMLs, write a summary table into \$GITHUB_STEP_SUMMARY, upload scan.json as a 14-day artifact. Informational only for now — does NOT exit non-zero on findings; the gate emits ::warning:: on any CRITICAL bug or security hotspot. A follow-up will tighten the gate to fail on new findings once the existing baseline is reviewed (TODO comment in the workflow). No secrets referenced; no Nexus, no GPG. Pure code-quality job. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/sonar.yml | 142 ++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 .github/workflows/sonar.yml diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000..31dc64b --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,142 @@ +name: Self-scan (sonar-predictor against itself) + +# Runs the project's OWN scanner — the in-repo daemon, freshly built from this +# branch — against the repository on every PR and on pushes to main. The point +# is CI parity with the local self-scan we run during development: every change +# passes through the same gate, so the bar we apply to others applies to us. +# +# We deliberately do NOT use the previously released bundle from Maven Central. +# SONAR_PREDICTOR_HOME is repointed at dist/target/skill/sonar-predictor so the +# scan exercises the branch's analyzer code, not yesterday's release. + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +jobs: + self-scan: + name: Self-scan + runs-on: ubuntu-latest + steps: + # fetch-depth: 0 keeps the full history available so we can switch to + # `--diff`-style semantics later without re-checking out the repo. + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # JDK 17 is the project's build/runtime target. Temurin is the safe default. + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # The JS/TS analyzer plugin spawns Node at runtime to lint JS/TS sources, + # so Node must be on PATH when the scan runs (not just at build time). + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + # Cache the local Maven repo across runs. Keyed on every pom.xml in the + # tree so a dependency change invalidates cleanly; restore-keys lets a + # partial cache hit still seed most of ~/.m2. + - name: Cache Maven repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + # Build + test the whole reactor except `dist` (the bundle module is + # packaged separately below). `verify` runs the integration tests AND + # produces the JaCoCo XML reports we feed back into the scan as coverage + # evidence. failIfNoSpecifiedTests=false keeps modules with no tests + # from breaking the reactor. + - name: Build and test (generates JaCoCo XML reports) + run: mvn -B -ntp -pl '!dist' verify -Dsurefire.failIfNoSpecifiedTests=false + + # Now build the skill bundle. -am pulls in upstream modules if they + # weren't already installed by the previous step. -DskipTests because + # the tests already ran — re-running them here just wastes minutes. + - name: Build dist (skill bundle this scan will use) + run: mvn -B -ntp -pl dist -am package -DskipTests + + # The actual self-scan. We override SONAR_PREDICTOR_HOME so the wrapper + # script picks up THIS branch's freshly-built daemon jar and analyzer + # plugins, not whatever happens to be installed globally. Three JaCoCo + # XMLs are passed in as coverage evidence — one per Java module that + # produces coverage. agent-scan writes JSON to .sonar-predictor/scan.json + # and prints a human summary on stdout; we want both. + - name: Run self-scan + run: | + export SONAR_PREDICTOR_HOME="$(pwd)/dist/target/skill/sonar-predictor" + ./plugin/skills/sonar-predictor/bin/sonar agent-scan analyze . \ + --coverage protocol/target/site/jacoco/jacoco.xml \ + --coverage daemon/target/site/jacoco/jacoco.xml \ + --coverage cli/target/site/jacoco/jacoco.xml + + # Render headline counts into the GitHub job summary so reviewers see + # the scan result inline on the run page without downloading artifacts. + # `// ([.files[]?.issues[]?]|length)` is the fallback path when an older + # JSON shape doesn't carry a top-level issueCount. + - name: Render scan summary + if: always() + run: | + J=.sonar-predictor/scan.json + if [ ! -f "$J" ]; then + echo "## Sonar self-scan" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Scan JSON not produced — see the **Run self-scan** step log." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + { + echo "## Sonar self-scan" + echo + echo "| Metric | Value |" + echo "| --- | --- |" + echo "| Total issues | $(jq -r '.issueCount // ([.files[]?.issues[]?]|length)' "$J") |" + echo "| Coverage (line, overall) | $(jq -r '.coverage.overallPercent // "n/a"' "$J")% |" + echo "| Severity | $(jq -rc '[.files[]?.issues[]?.severity]|group_by(.)|map("\(.[0])=\(length)")|join(" ")' "$J") |" + echo "| Type | $(jq -rc '[.files[]?.issues[]?.type]|group_by(.)|map("\(.[0])=\(length)")|join(" ")' "$J") |" + echo "| Warnings | $(jq -r '.warnings // [] | length' "$J") |" + } >> "$GITHUB_STEP_SUMMARY" + + # Always upload the scan JSON, even on failure, so a broken scan is + # still debuggable from the run page. 14-day retention is enough to + # cover a typical PR review cycle without pinning storage forever. + - name: Upload scan JSON + if: always() + uses: actions/upload-artifact@v4 + with: + name: sonar-scan-${{ github.run_id }} + path: .sonar-predictor/scan.json + retention-days: 14 + + # Informational gate. We compute the count of CRITICAL bugs and security + # hotspots and emit a `::warning::` so they show on the PR's Checks tab, + # but we deliberately do NOT `exit 1` yet — first runs are baseline data + # while we work the existing findings down to zero. + # + # TODO: once the backlog is clear, flip this to `exit 1` on any + # CRITICAL bug or security hotspot and rename this step to "Quality gate". + - name: Quality gate (informational only) + if: always() + run: | + J=.sonar-predictor/scan.json + if [ ! -f "$J" ]; then + echo "No scan JSON found — skipping gate." + exit 0 + fi + CRIT=$(jq -r '[.files[]?.issues[]? | select(.severity=="CRITICAL" and .type=="BUG")] | length' "$J") + HOT=$(jq -r '[.files[]?.issues[]? | select(.type=="SECURITY_HOTSPOT")] | length' "$J") + echo "Critical bugs: $CRIT" + echo "Security hotspots: $HOT" + if [ "$CRIT" -gt 0 ] || [ "$HOT" -gt 0 ]; then + echo "::warning::Self-scan found $CRIT critical bug(s) and $HOT security hotspot(s). Gate is informational for now; will enforce later." + fi From 2658b9f836bc9de16378d7aa99dddb18fe9c330f Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 08:47:54 +0000 Subject: [PATCH 04/15] ci(sonar): treat exit-1 (issues found) as scan success, not job failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI's three-state exit codes are part of its contract: 0=clean, 1=issues found (normal), 2+=tool error. GitHub Actions' default 'bash -e' shell propagated exit 1 from a healthy scan as a step failure — masking the 'job ran but found things' signal we actually want, and breaking the workflow on the very first run that found any issue. Fix: capture rc with set+e/set-e, fail the step only on rc>=2 (real tool error). The Quality gate step (still informational) keeps the 'should we block on the findings themselves?' decision, decoupled from 'did the scanner run?'. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/sonar.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 31dc64b..64306cb 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -73,13 +73,30 @@ jobs: # XMLs are passed in as coverage evidence — one per Java module that # produces coverage. agent-scan writes JSON to .sonar-predictor/scan.json # and prints a human summary on stdout; we want both. + # + # IMPORTANT: the CLI uses three-state exit codes + # 0 = clean (no findings at the floor) + # 1 = issues found (a normal scan outcome, not a failure) + # 2 = tool error (broken input, daemon unreachable, no Java) + # Step success means "the scanner ran". Whether the *result* should + # fail the build is decided by the Quality gate step below. We must + # not let `bash -e` propagate exit-1 from a healthy scan as a job + # failure; we propagate exit code only when it's >= 2. - name: Run self-scan run: | + set +e export SONAR_PREDICTOR_HOME="$(pwd)/dist/target/skill/sonar-predictor" ./plugin/skills/sonar-predictor/bin/sonar agent-scan analyze . \ --coverage protocol/target/site/jacoco/jacoco.xml \ --coverage daemon/target/site/jacoco/jacoco.xml \ --coverage cli/target/site/jacoco/jacoco.xml + rc=$? + set -e + echo "Self-scan exit code: $rc (0=clean, 1=issues found, 2+=tool error)" + if [ "$rc" -ge 2 ]; then + echo "::error::Self-scan tool error (exit $rc) — see step log." + exit "$rc" + fi # Render headline counts into the GitHub job summary so reviewers see # the scan result inline on the run page without downloading artifacts. From b0e8fb5974d502a4425c0ba152b0b99d35c40231 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 08:56:29 +0000 Subject: [PATCH 05/15] feat(cli): native --save flag (writes report to PATH + compact summary on stdout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin must not depend on external tools. The bootstrap's agent-scan was shelling jq to render a summary from scan.json after the CLI dumped the JSON via stdout-redirect; on machines without jq the wrapper degraded to a one-line pointer. That's a leak — every external tool in a wrapper weakens the 'plugin self-contained' guarantee. Move the responsibility into the CLI: sonar --format json --save .sonar-predictor/scan.json analyze . When --save is set, the CLI: 1. writes the formatted report (any --format) to PATH instead of stdout 2. prints a compact, structured summary on stdout itself: sonar-predictor: N issues written to severity: BLOCKER=x CRITICAL=y MAJOR=z MINOR=a INFO=b type: BUG=p CODE_SMELL=q VULNERABILITY=r SECURITY_HOTSPOT=s coverage: NN.NN%% line Severity/type rollup uses a preferred ordering; zero-count buckets omitted; unknown keys appended in sorted order. Coverage line only when a coverage report was imported. 3. preserves the existing exit code (0 clean / 1 issues / 2 tool error) The bootstrap's agent_scan() shrinks to glue: default args, .gitignore housekeeping, single CLI invocation with --save. No jq, no awk-on-JSON, no shell-side parsing. The 'install jq for an inline summary' fallback goes away — there's no longer a fallback to take, because the summary is always produced by the CLI itself. CI workflows are still free to jq scan.json for further extraction (the user's stated rule: pipeline may use jq, plugin may not). The Render scan summary + Quality gate steps in .github/workflows/sonar.yml are unchanged. Verified end to end: build green, full reactor tests green, agent-scan emits the four-line summary with the expected counts and the coverage line; scan.json on disk matches the in-context counts. Co-Authored-By: Claude Opus 4.7 --- .../java/dev/sonarcli/cli/SonarCommand.java | 83 ++++++++++++++++++- plugin/skills/sonar-predictor/bin/sonar | 33 +++----- 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/cli/src/main/java/dev/sonarcli/cli/SonarCommand.java b/cli/src/main/java/dev/sonarcli/cli/SonarCommand.java index b2c20d3..22de929 100644 --- a/cli/src/main/java/dev/sonarcli/cli/SonarCommand.java +++ b/cli/src/main/java/dev/sonarcli/cli/SonarCommand.java @@ -109,6 +109,16 @@ public final class SonarCommand implements Runnable { "e.g. --test-path 'src/integration/**'."}) private java.util.List additionalTestPaths = new java.util.ArrayList<>(); + @Option(names = "--save", paramLabel = "PATH", + description = { + "Write the formatted report (per --format) to PATH", + "instead of stdout. Stdout then carries a compact summary —", + "issue count, severity and type breakdown, target file —", + "so an agent or CI step gets a usable signal without", + "ever needing to parse the report itself.", + "Removes the need for jq in agent wrappers."}) + private String savePath; + @Spec private CommandSpec spec; @@ -174,7 +184,24 @@ private int analyzeAndReport(FileResolver.ResolvedFiles resolved, PrintWriter ou case TEXT -> new TextReporter(); case SARIF -> new SarifReporter(); }; - out.print(reporter.render(reported, index, coverage)); + String rendered = reporter.render(reported, index, coverage); + + if (savePath != null && !savePath.isEmpty()) { + // --save: report goes to the file, summary to stdout. Lets an + // agent / CI step skip raw-report parsing and avoid pulling in jq. + java.nio.file.Path target = java.nio.file.Path.of(savePath).toAbsolutePath(); + try { + java.nio.file.Files.createDirectories(target.getParent()); + java.nio.file.Files.writeString(target, rendered, + java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new IllegalStateException( + "could not write report to " + target + ": " + e.getMessage(), e); + } + writeSummary(out, filtered, target.toString(), coverage); + } else { + out.print(rendered); + } out.flush(); boolean issuesFail = !filtered.isEmpty(); @@ -183,6 +210,60 @@ private int analyzeAndReport(FileResolver.ResolvedFiles resolved, PrintWriter ou return issuesFail || coverageFail ? EXIT_ISSUES : EXIT_CLEAN; } + /** + * Writes the compact "{N} issues written to {path}" summary used by the + * agent / CI invocation path. Three lines max: total, severity rollup, + * type rollup. Kept tiny so it never crowds an agent's context. + */ + private static void writeSummary(PrintWriter out, List issues, + String target, + dev.sonarcli.cli.coverage.CoverageReport coverage) { + out.printf("sonar-predictor: %d issues written to %s%n", issues.size(), target); + if (!issues.isEmpty()) { + out.print(" severity: "); + out.println(rollup(issues, Issue::severity, + java.util.List.of("BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"))); + out.print(" type: "); + out.println(rollup(issues, Issue::type, + java.util.List.of("BUG", "CODE_SMELL", "VULNERABILITY", "SECURITY_HOTSPOT"))); + } + if (coverage != null) { + out.printf(" coverage: %.2f%% line%n", coverage.overallPercent()); + } + } + + /** + * "{KEY}={count} {KEY}={count} ..." rendering, with the keys in the given + * preferred order, omitting zero-count keys. Unknown keys (out-of-band + * severities/types from a custom analyzer) are appended at the end in + * sorted order so they're still visible. + */ + private static String rollup(List issues, + java.util.function.Function bucket, + List preferredOrder) { + java.util.Map counts = issues.stream() + .map(bucket) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.groupingBy( + java.util.function.Function.identity(), + java.util.stream.Collectors.counting())); + StringBuilder sb = new StringBuilder(); + for (String key : preferredOrder) { + Long c = counts.remove(key); + if (c != null && c > 0) { + if (sb.length() > 0) sb.append(' '); + sb.append(key).append('=').append(c); + } + } + counts.entrySet().stream() + .sorted(java.util.Map.Entry.comparingByKey()) + .forEach(e -> { + if (sb.length() > 0) sb.append(' '); + sb.append(e.getKey()).append('=').append(e.getValue()); + }); + return sb.toString(); + } + /** * The coverage-import knobs a subcommand collects from {@code --coverage} * and {@code --coverage-min}. A picocli {@code @ArgGroup}-style mixin would diff --git a/plugin/skills/sonar-predictor/bin/sonar b/plugin/skills/sonar-predictor/bin/sonar index 150a49e..a959ee3 100755 --- a/plugin/skills/sonar-predictor/bin/sonar +++ b/plugin/skills/sonar-predictor/bin/sonar @@ -233,13 +233,15 @@ if [ -n "${JAVA_HOME:-}" ]; then fi # The `agent-scan` subcommand bakes the out-of-context discipline into the -# tool: run the scan with JSON output redirected to .sonar-predictor/scan.json -# at the project root, add that path to .gitignore on first use, and print a -# compact summary on stdout. The calling agent reports the summary and the -# file path; deeper drill-down happens with jq on the file, on demand. +# tool: run the scan with JSON output written to .sonar-predictor/scan.json at +# the project root, add that path to .gitignore on first use, and print a +# compact summary on stdout. +# +# No external dependencies (no jq, no awk JSON-parsing): the CLI's --save flag +# writes the report to the target file and emits the summary itself. The +# wrapper here is just glue — argument defaults, .gitignore housekeeping. agent_scan() { local scan_file=".sonar-predictor/scan.json" - mkdir -p "$(dirname "$scan_file")" # Add the scan dir to .gitignore on first use, only inside a git repo. if git rev-parse --git-dir >/dev/null 2>&1; then @@ -253,23 +255,10 @@ agent_scan() { set -- check --diff fi - local rc=0 - set +e - "$REAL_SONAR" --format json "$@" > "$scan_file" 2>&1 - rc=$? - set -e - - if command -v jq >/dev/null 2>&1; then - local total sev - total="$(jq -r '(.issueCount // ([.files[]?.issues[]?] | length))' "$scan_file" 2>/dev/null)" - sev="$(jq -rc '[.files[]?.issues[]?.severity] | group_by(.) | map("\(.[0])=\(length)") | join(" ")' "$scan_file" 2>/dev/null)" - echo "sonar-predictor: ${total:-?} issues written to $scan_file" - [ -n "$sev" ] && echo " severity: $sev" - echo " query: jq '...' $scan_file" - else - echo "sonar-predictor: scan complete -> $scan_file (install jq for an inline summary)" - fi - return "$rc" + # CLI does the work: --save writes the JSON report to the target file and + # prints the compact "N issues written to / severity ... / type ..." + # summary on stdout. Stderr from the analyzer flows through unfiltered. + "$REAL_SONAR" --format json --save "$scan_file" "$@" } if [ "${1:-}" = "agent-scan" ]; then From 9294595ead890bea66aa0cf6d1f42844ed873281 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 16:57:40 +0800 Subject: [PATCH 06/15] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/sonar.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 64306cb..94e343b 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,5 +1,8 @@ name: Self-scan (sonar-predictor against itself) +permissions: + contents: read + # Runs the project's OWN scanner — the in-repo daemon, freshly built from this # branch — against the repository on every PR and on pushes to main. The point # is CI parity with the local self-scan we run during development: every change From 18877ad5d619881a76aeb84f9e433f3331bada62 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 09:00:08 +0000 Subject: [PATCH 07/15] ci: add SonarQube Cloud scan for reference-truth parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/sonarqube-cloud.yml — runs the official SonarQube Cloud scan via mvn sonar:sonar on every PR and push to main, alongside the in-repo self-scan. Lets us compare counts: drift between our daemon and SonarSource's own pipeline becomes visible on every PR. Why Maven-driven (sonar:sonar) instead of the generic scan-action: this is a Maven multi-module project; the Sonar Maven plugin auto-discovers src/main/java, src/test/java, the JaCoCo XMLs, and the compiled-classes path per module. Less configuration to drift out of sync with the build. One-time setup required (documented in the workflow header): 1. Import the repo as a SonarQube Cloud project at sonarcloud.io. 2. Add a SONAR_TOKEN repo secret from the generated token. Without it, the Verify secrets step fails fast with a clear message, so the missing-token state is obvious from the job summary. The if: guard on the job skips fork PRs (which can't read secrets) so external contributors don't get a hard ❌ on every PR. They still get the in-repo self-scan. Permissions: contents:read + pull-requests:read (CodeQL-flagged least-privilege; matches the same pattern just applied to sonar.yml on this branch). Co-Authored-By: Claude Opus 4.7 --- .github/workflows/sonarqube-cloud.yml | 143 ++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 .github/workflows/sonarqube-cloud.yml diff --git a/.github/workflows/sonarqube-cloud.yml b/.github/workflows/sonarqube-cloud.yml new file mode 100644 index 0000000..6dfe1e2 --- /dev/null +++ b/.github/workflows/sonarqube-cloud.yml @@ -0,0 +1,143 @@ +name: SonarQube Cloud scan + +# Least-privilege per CodeQL's "Workflow does not contain permissions" rule. +# - contents: read — checkout the branch +# - pull-requests: read — let SonarQube Cloud decorate the PR via its own token +permissions: + contents: read + pull-requests: read + +# Runs the official SonarQube Cloud scan against the branch on every PR and on +# pushes to main. This complements .github/workflows/sonar.yml (our own self- +# scan): SonarQube Cloud is the canonical reference, so a delta between the +# two surfaces drift in our daemon's behaviour vs. SonarSource's own pipeline. +# +# Setup required (one-time, by the repo admin): +# 1. Sign in to https://sonarcloud.io with the repo's GitHub org/account. +# 2. Import this repo as a SonarQube Cloud project. +# (Project key is conventionally `_`; organisation slug +# is `` in lowercase.) +# 3. Configure "Analysis Method" → "With GitHub Actions"; copy the generated +# `SONAR_TOKEN`. +# 4. In this repo: Settings → Secrets and variables → Actions → New +# repository secret named SONAR_TOKEN, paste the token. +# +# Without SONAR_TOKEN the workflow fails fast in the "Verify secrets" step — +# the rest of the pipeline never runs, so a missing token is obvious from the +# job summary. +# +# Why a Maven-driven scan (sonar:sonar) rather than the generic scan-action? +# This is a Maven multi-module project; the Maven plugin auto-discovers +# `src/main/java`, `src/test/java`, JaCoCo XMLs, and the compiled-classes path +# per module. Less configuration to drift out of sync. + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +jobs: + sonarqube-cloud: + name: SonarQube Cloud + runs-on: ubuntu-latest + # Skip when the token isn't configured (fork PRs, first-run before setup) + # — emits a soft-fail rather than every PR carrying a hard ❌. + if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + steps: + # Full history lets SonarQube Cloud compute new-code coverage and + # author-blame correctly. Without fetch-depth: 0 the scan sees a + # shallow clone and falls back to "all code is new code" per PR. + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # JDK 17 matches maven.compiler.release in the parent pom. + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # The JS/TS analyzer plugin spawns Node at scan time. Without this the + # JS/TS sensors silently skip — and our counts then diverge from the + # self-scan, which has the same prerequisite. + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + # Two caches: Maven repo + SonarQube Cloud analysis cache. The Sonar + # cache stores per-language scan state (e.g. JS/TS server-side + # analysis blobs) so reruns of unchanged files are much faster. + - name: Cache Maven repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + - name: Cache SonarQube Cloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: | + ${{ runner.os }}-sonar + + # Verify SONAR_TOKEN exists before we waste minutes on a full build that + # the scan step would then fail to authenticate. Clear setup hint in + # the failure message — no need to read the workflow header. + - name: Verify secrets + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + if [ -z "${SONAR_TOKEN:-}" ]; then + echo "::error::SONAR_TOKEN secret is not set. See the header of this workflow for one-time SonarQube Cloud setup." + exit 1 + fi + echo "SONAR_TOKEN configured — proceeding with scan." + + # `mvn verify` runs tests AND generates the per-module JaCoCo XMLs that + # the Sonar Maven plugin picks up automatically from + # /target/site/jacoco/jacoco.xml. failIfNoSpecifiedTests=false + # keeps the dist module (no tests) from breaking the reactor. + # We skip the dist module's assembly here — sonar:sonar doesn't need + # the skill bundle, and skipping shaves ~30s. + - name: Build, test, generate JaCoCo XML + run: mvn -B -ntp -pl '!dist' verify -Dsurefire.failIfNoSpecifiedTests=false + + # The actual SonarQube Cloud scan via the Maven plugin. Reads + # SONAR_TOKEN from env. Project key + organisation conventions: + # `_` and `` lowercased — adjust if + # the SonarCloud project is set up under different identifiers. + - name: SonarQube Cloud scan + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mvn -B -ntp -pl '!dist' \ + org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ + -Dsonar.projectKey=RandomCodeSpace_sonar-predict \ + -Dsonar.organization=randomcodespace \ + -Dsonar.host.url=https://sonarcloud.io \ + -Dsonar.qualitygate.wait=false \ + -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml,../protocol/target/site/jacoco/jacoco.xml,../daemon/target/site/jacoco/jacoco.xml,../cli/target/site/jacoco/jacoco.xml + + # Project link printed into the job summary so a reviewer can jump + # straight to the SonarCloud dashboard from the PR Checks tab. + - name: Link to SonarQube Cloud dashboard + if: always() + run: | + { + echo "## SonarQube Cloud" + echo + echo "Reference scan — compare against the in-repo self-scan to confirm parity." + echo + echo "Dashboard: https://sonarcloud.io/project/overview?id=RandomCodeSpace_sonar-predict" + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "PR view: https://sonarcloud.io/project/issues?id=RandomCodeSpace_sonar-predict&pullRequest=${{ github.event.pull_request.number }}" + fi + } >> "$GITHUB_STEP_SUMMARY" From 9c40d6effca6359ad4fe832dc8ac9a63e9cf53b4 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 09:06:42 +0000 Subject: [PATCH 08/15] =?UTF-8?q?ci:=20parity=20workflow=20=E2=80=94=20sel?= =?UTF-8?q?f-scan=20vs=20SonarQube=20Cloud,=20diffed=20per=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/parity.yml and scripts/scan_parity.py. Replaces the standalone .github/workflows/sonarqube-cloud.yml — the parity job runs the SonarQube Cloud scan AND our self-scan in the same job, then diffs the issue lists, so drift between our daemon and SonarSource's own pipeline shows up directly on every PR. What it computes (stdlib Python, no third-party deps): - total issues per source (self vs cloud) - per-rule cardinality with deltas (self count, cloud count, delta) - issues present in only one source, keyed on (ruleKey, file, line) - parity score = common / union (Jaccard on issue identity) Output: - Markdown report rendered into \$GITHUB_STEP_SUMMARY on every run - Machine-readable .sonar-predictor/parity.json uploaded as an artifact (14-day retention) alongside scan.json Workflow shape: 1. Single build + test (generates the JaCoCo XMLs both scans consume) 2. (A) self-scan via the in-repo CLI's --save flag 3. (B) SonarQube Cloud scan via mvn sonar:sonar 4. Poll SonarQube Cloud's analysis API until the just-published run shows up (~30-60s typical, 3-min cap) 5. (C) Pull Cloud issues via /api/issues/search, diff against self-scan, write report Skips on fork PRs (no SONAR_TOKEN reachable); the standalone sonar.yml self-scan still gates them on our daemon's findings. The standalone sonarqube-cloud.yml is removed — its work is a strict subset of what parity.yml does. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/parity.yml | 199 +++++++++++++++++++ .github/workflows/sonarqube-cloud.yml | 143 -------------- scripts/scan_parity.py | 272 ++++++++++++++++++++++++++ 3 files changed, 471 insertions(+), 143 deletions(-) create mode 100644 .github/workflows/parity.yml delete mode 100644 .github/workflows/sonarqube-cloud.yml create mode 100644 scripts/scan_parity.py diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 0000000..0df8990 --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,199 @@ +name: Scan parity (self-scan ↔ SonarQube Cloud) + +# Runs BOTH scans on the same commit and diffs their issue lists. Every PR +# answers: "does our daemon find what SonarSource's own pipeline finds?". +# +# This workflow supersedes .github/workflows/sonarqube-cloud.yml — that file +# was removed in the same commit; the Cloud scan now happens here, alongside +# the self-scan and the parity comparison. The standalone self-scan +# (sonar.yml) is kept because it works on fork PRs (no SONAR_TOKEN needed), +# whereas this one requires the token and so skips for forks. +# +# Setup required (one-time, by the repo admin): +# 1. Sign in to https://sonarcloud.io with the repo's GitHub org. +# 2. Import this repo as a SonarQube Cloud project. +# Project key: RandomCodeSpace_sonar-predict, organisation: randomcodespace +# (adjust the env values below if SonarCloud assigns different ones). +# 3. Configure "Analysis Method" → "With GitHub Actions"; copy SONAR_TOKEN. +# 4. Add SONAR_TOKEN as a repo secret. + +permissions: + contents: read + pull-requests: read + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +env: + SONAR_PROJECT_KEY: RandomCodeSpace_sonar-predict + SONAR_ORGANIZATION: randomcodespace + SONAR_HOST_URL: https://sonarcloud.io + +jobs: + parity: + name: Scan parity + runs-on: ubuntu-latest + # Skip when the token isn't reachable (fork PRs) — the standalone + # self-scan workflow still gates fork PRs on our daemon's findings. + if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache Maven repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Cache SonarQube Cloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: | + ${{ runner.os }}-sonar + + # Fail fast if the SONAR_TOKEN secret isn't configured. The Cloud step + # below would just error obscurely; this is a clearer signal. + - name: Verify SONAR_TOKEN + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + if [ -z "${SONAR_TOKEN:-}" ]; then + echo "::error::SONAR_TOKEN not set — parity workflow needs SonarQube Cloud access. See the header of this workflow for setup." + exit 1 + fi + echo "SONAR_TOKEN configured." + + # Single build that both scans then consume. `verify` also produces + # the per-module JaCoCo XMLs that both scans use as coverage evidence. + - name: Build and test (generates JaCoCo XML) + run: mvn -B -ntp -pl '!dist' verify -Dsurefire.failIfNoSpecifiedTests=false + + - name: Build dist (skill bundle for self-scan) + run: mvn -B -ntp -pl dist -am package -DskipTests + + # --- (A) Self-scan ------------------------------------------------------ + + - name: Run self-scan + run: | + set +e + export SONAR_PREDICTOR_HOME="$(pwd)/dist/target/skill/sonar-predictor" + ./plugin/skills/sonar-predictor/bin/sonar agent-scan analyze . \ + --coverage protocol/target/site/jacoco/jacoco.xml \ + --coverage daemon/target/site/jacoco/jacoco.xml \ + --coverage cli/target/site/jacoco/jacoco.xml + rc=$? + set -e + echo "Self-scan exit code: $rc (0=clean, 1=issues found, 2+=tool error)" + if [ "$rc" -ge 2 ]; then + echo "::error::Self-scan tool error (exit $rc)" + exit "$rc" + fi + + # --- (B) SonarQube Cloud scan ------------------------------------------ + + - name: Run SonarQube Cloud scan + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mvn -B -ntp -pl '!dist' \ + org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ + -Dsonar.projectKey="${SONAR_PROJECT_KEY}" \ + -Dsonar.organization="${SONAR_ORGANIZATION}" \ + -Dsonar.host.url="${SONAR_HOST_URL}" \ + -Dsonar.qualitygate.wait=false \ + -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml,../protocol/target/site/jacoco/jacoco.xml,../daemon/target/site/jacoco/jacoco.xml,../cli/target/site/jacoco/jacoco.xml + + # SonarQube Cloud processes the scan asynchronously after upload. Poll + # the last completed analysis until the timestamp moves past the start + # of this run, or give up after ~3 minutes (most analyses complete in + # 30-60s; longer polls aren't worth holding the runner for). + - name: Wait for SonarQube Cloud processing + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + START_TS=$(date +%s) + for attempt in $(seq 1 18); do + RESP=$(curl -fsSL -u "$SONAR_TOKEN:" \ + "${SONAR_HOST_URL}/api/project_analyses/search?project=${SONAR_PROJECT_KEY}&ps=1" || echo '{}') + ANALYSIS_TS=$(echo "$RESP" | jq -r '.analyses[0].date // empty' || true) + if [ -n "$ANALYSIS_TS" ]; then + ANALYSIS_EPOCH=$(date -d "$ANALYSIS_TS" +%s 2>/dev/null || echo 0) + if [ "$ANALYSIS_EPOCH" -ge "$START_TS" ]; then + echo "Latest analysis ($ANALYSIS_TS) is from this run." + exit 0 + fi + echo "Attempt $attempt: latest analysis is $ANALYSIS_TS (before run start), waiting…" + else + echo "Attempt $attempt: no analyses yet on SonarQube Cloud, waiting…" + fi + sleep 10 + done + echo "::warning::Timed out waiting for SonarQube Cloud to publish this run's analysis. Parity diff will use the most-recent published state." + + # --- (C) Parity diff ---------------------------------------------------- + + - name: Diff self-scan vs SonarQube Cloud + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + PR_ARG="" + BRANCH_ARG="" + if [ "${{ github.event_name }}" = "pull_request" ]; then + PR_ARG="--pull-request ${{ github.event.pull_request.number }}" + else + BRANCH_ARG="--branch ${{ github.ref_name }}" + fi + python3 scripts/scan_parity.py \ + --self-scan .sonar-predictor/scan.json \ + --project-key "${SONAR_PROJECT_KEY}" \ + --organization "${SONAR_ORGANIZATION}" \ + --host "${SONAR_HOST_URL}" \ + $PR_ARG $BRANCH_ARG \ + --out .sonar-predictor/parity.json + + - name: Upload scan + parity artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: scan-parity-${{ github.run_id }} + path: | + .sonar-predictor/scan.json + .sonar-predictor/parity.json + retention-days: 14 + + - name: Link to SonarQube Cloud dashboard + if: always() + run: | + { + echo "" + echo "---" + echo "" + echo "**SonarQube Cloud dashboard:** ${SONAR_HOST_URL}/project/overview?id=${SONAR_PROJECT_KEY}" + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "" + echo "**PR-scoped view:** ${SONAR_HOST_URL}/project/issues?id=${SONAR_PROJECT_KEY}&pullRequest=${{ github.event.pull_request.number }}" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sonarqube-cloud.yml b/.github/workflows/sonarqube-cloud.yml deleted file mode 100644 index 6dfe1e2..0000000 --- a/.github/workflows/sonarqube-cloud.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: SonarQube Cloud scan - -# Least-privilege per CodeQL's "Workflow does not contain permissions" rule. -# - contents: read — checkout the branch -# - pull-requests: read — let SonarQube Cloud decorate the PR via its own token -permissions: - contents: read - pull-requests: read - -# Runs the official SonarQube Cloud scan against the branch on every PR and on -# pushes to main. This complements .github/workflows/sonar.yml (our own self- -# scan): SonarQube Cloud is the canonical reference, so a delta between the -# two surfaces drift in our daemon's behaviour vs. SonarSource's own pipeline. -# -# Setup required (one-time, by the repo admin): -# 1. Sign in to https://sonarcloud.io with the repo's GitHub org/account. -# 2. Import this repo as a SonarQube Cloud project. -# (Project key is conventionally `_`; organisation slug -# is `` in lowercase.) -# 3. Configure "Analysis Method" → "With GitHub Actions"; copy the generated -# `SONAR_TOKEN`. -# 4. In this repo: Settings → Secrets and variables → Actions → New -# repository secret named SONAR_TOKEN, paste the token. -# -# Without SONAR_TOKEN the workflow fails fast in the "Verify secrets" step — -# the rest of the pipeline never runs, so a missing token is obvious from the -# job summary. -# -# Why a Maven-driven scan (sonar:sonar) rather than the generic scan-action? -# This is a Maven multi-module project; the Maven plugin auto-discovers -# `src/main/java`, `src/test/java`, JaCoCo XMLs, and the compiled-classes path -# per module. Less configuration to drift out of sync. - -on: - pull_request: - branches: [main] - push: - branches: [main] - workflow_dispatch: - -jobs: - sonarqube-cloud: - name: SonarQube Cloud - runs-on: ubuntu-latest - # Skip when the token isn't configured (fork PRs, first-run before setup) - # — emits a soft-fail rather than every PR carrying a hard ❌. - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} - steps: - # Full history lets SonarQube Cloud compute new-code coverage and - # author-blame correctly. Without fetch-depth: 0 the scan sees a - # shallow clone and falls back to "all code is new code" per PR. - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # JDK 17 matches maven.compiler.release in the parent pom. - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - # The JS/TS analyzer plugin spawns Node at scan time. Without this the - # JS/TS sensors silently skip — and our counts then diverge from the - # self-scan, which has the same prerequisite. - - name: Set up Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: '20' - - # Two caches: Maven repo + SonarQube Cloud analysis cache. The Sonar - # cache stores per-language scan state (e.g. JS/TS server-side - # analysis blobs) so reruns of unchanged files are much faster. - - name: Cache Maven repository - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-maven- - - name: Cache SonarQube Cloud packages - uses: actions/cache@v4 - with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: | - ${{ runner.os }}-sonar - - # Verify SONAR_TOKEN exists before we waste minutes on a full build that - # the scan step would then fail to authenticate. Clear setup hint in - # the failure message — no need to read the workflow header. - - name: Verify secrets - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: | - if [ -z "${SONAR_TOKEN:-}" ]; then - echo "::error::SONAR_TOKEN secret is not set. See the header of this workflow for one-time SonarQube Cloud setup." - exit 1 - fi - echo "SONAR_TOKEN configured — proceeding with scan." - - # `mvn verify` runs tests AND generates the per-module JaCoCo XMLs that - # the Sonar Maven plugin picks up automatically from - # /target/site/jacoco/jacoco.xml. failIfNoSpecifiedTests=false - # keeps the dist module (no tests) from breaking the reactor. - # We skip the dist module's assembly here — sonar:sonar doesn't need - # the skill bundle, and skipping shaves ~30s. - - name: Build, test, generate JaCoCo XML - run: mvn -B -ntp -pl '!dist' verify -Dsurefire.failIfNoSpecifiedTests=false - - # The actual SonarQube Cloud scan via the Maven plugin. Reads - # SONAR_TOKEN from env. Project key + organisation conventions: - # `_` and `` lowercased — adjust if - # the SonarCloud project is set up under different identifiers. - - name: SonarQube Cloud scan - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - mvn -B -ntp -pl '!dist' \ - org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ - -Dsonar.projectKey=RandomCodeSpace_sonar-predict \ - -Dsonar.organization=randomcodespace \ - -Dsonar.host.url=https://sonarcloud.io \ - -Dsonar.qualitygate.wait=false \ - -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml,../protocol/target/site/jacoco/jacoco.xml,../daemon/target/site/jacoco/jacoco.xml,../cli/target/site/jacoco/jacoco.xml - - # Project link printed into the job summary so a reviewer can jump - # straight to the SonarCloud dashboard from the PR Checks tab. - - name: Link to SonarQube Cloud dashboard - if: always() - run: | - { - echo "## SonarQube Cloud" - echo - echo "Reference scan — compare against the in-repo self-scan to confirm parity." - echo - echo "Dashboard: https://sonarcloud.io/project/overview?id=RandomCodeSpace_sonar-predict" - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "PR view: https://sonarcloud.io/project/issues?id=RandomCodeSpace_sonar-predict&pullRequest=${{ github.event.pull_request.number }}" - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/scan_parity.py b/scripts/scan_parity.py new file mode 100644 index 0000000..a27db9e --- /dev/null +++ b/scripts/scan_parity.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +Compare our in-repo self-scan output against SonarQube Cloud's issue list +for the same commit, and report the delta. + +What gets compared: + * total counts, per-source + * rule-level cardinality — for each rule key, "self" count vs "cloud" count + * issue-level intersection / symmetric difference, keyed on + (ruleKey, file, line); per-issue messages are not compared because + SonarQube Cloud may post-process them + +Inputs: + --self-scan PATH Path to our scan.json (from `bin/sonar --save`) + --project-key KEY SonarQube Cloud projectKey + --organization SLUG SonarQube Cloud organisation slug + --host URL SonarQube Cloud host (default: https://sonarcloud.io) + --branch NAME Optional: branch name (analyzed via `branch` filter) + --pull-request NUMBER Optional: PR number (analyzed via `pullRequest` filter) + +The SONAR_TOKEN env var is read for the SonarQube Cloud HTTP Basic auth. + +Output: a Markdown report written to stdout (and to $GITHUB_STEP_SUMMARY when +that env var is set). Exit 0 always — this is an observational tool, not a +gate. CI workflows can layer their own gate on top by parsing the JSON +artifact this script writes to --out (defaulting to .sonar-predictor/parity.json). + +No third-party Python dependencies: stdlib only (urllib, json, base64). +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import urllib.parse +import urllib.request +from collections import Counter +from typing import Any + + +SOURCE_SELF = "self" +SOURCE_CLOUD = "cloud" + + +def load_self_scan(path: str) -> list[dict[str, Any]]: + with open(path, encoding="utf-8") as f: + doc = json.load(f) + issues: list[dict[str, Any]] = [] + for file_block in doc.get("files", []) or []: + file_path = file_block.get("file") or "" + for issue in file_block.get("issues", []) or []: + issues.append( + { + "ruleKey": issue.get("ruleKey"), + "file": _normalize_path(file_path), + "line": issue.get("startLine") or 0, + "severity": issue.get("severity"), + "type": issue.get("type"), + "source": SOURCE_SELF, + } + ) + return issues + + +def _normalize_path(p: str) -> str: + """Strip leading './' and any absolute-prefix so self and cloud paths match.""" + p = p.replace("\\", "/").lstrip("./") + # Cloud component looks like `:src/main/...`; strip the colon-prefix. + if ":" in p: + p = p.split(":", 1)[-1] + return p + + +def fetch_cloud_issues( + host: str, + project_key: str, + organization: str, + token: str, + branch: str | None, + pull_request: str | None, +) -> list[dict[str, Any]]: + """Pull every OPEN issue for the project (or PR) from the SonarQube Cloud HTTP API.""" + auth = base64.b64encode(f"{token}:".encode()).decode() + headers = {"Authorization": f"Basic {auth}", "Accept": "application/json"} + + out: list[dict[str, Any]] = [] + page = 1 + page_size = 500 + while True: + params: dict[str, Any] = { + "componentKeys": project_key, + "organization": organization, + "ps": page_size, + "p": page, + "resolved": "false", + } + if pull_request: + params["pullRequest"] = pull_request + elif branch: + params["branch"] = branch + url = f"{host}/api/issues/search?{urllib.parse.urlencode(params)}" + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=60) as resp: + payload = json.load(resp) + + for issue in payload.get("issues", []) or []: + component = issue.get("component", "") + out.append( + { + "ruleKey": issue.get("rule"), + "file": _normalize_path(component), + "line": issue.get("line") or 0, + "severity": issue.get("severity"), + "type": issue.get("type"), + "source": SOURCE_CLOUD, + } + ) + + total = payload.get("total", 0) + if page * page_size >= total or not payload.get("issues"): + break + page += 1 + if page > 20: # 10_000-issue safety cap + break + return out + + +def _key(issue: dict[str, Any]) -> tuple[str, str, int]: + return (issue.get("ruleKey") or "", issue.get("file") or "", int(issue.get("line") or 0)) + + +def compare(self_issues: list[dict[str, Any]], cloud_issues: list[dict[str, Any]]) -> dict[str, Any]: + self_by_key = {_key(i): i for i in self_issues} + cloud_by_key = {_key(i): i for i in cloud_issues} + + self_keys = set(self_by_key) + cloud_keys = set(cloud_by_key) + both = self_keys & cloud_keys + only_self = self_keys - cloud_keys + only_cloud = cloud_keys - self_keys + + return { + "counts": { + "self": len(self_issues), + "cloud": len(cloud_issues), + "common": len(both), + "only_self": len(only_self), + "only_cloud": len(only_cloud), + }, + "per_rule": _rule_breakdown(self_issues, cloud_issues), + "only_self_samples": [self_by_key[k] for k in sorted(only_self)][:10], + "only_cloud_samples": [cloud_by_key[k] for k in sorted(only_cloud)][:10], + } + + +def _rule_breakdown(self_issues: list[dict[str, Any]], cloud_issues: list[dict[str, Any]]) -> list[dict[str, Any]]: + self_counts = Counter(i["ruleKey"] for i in self_issues) + cloud_counts = Counter(i["ruleKey"] for i in cloud_issues) + all_rules = sorted(set(self_counts) | set(cloud_counts)) + rows = [] + for rule in all_rules: + s = self_counts.get(rule, 0) + c = cloud_counts.get(rule, 0) + rows.append({"rule": rule, "self": s, "cloud": c, "delta": s - c}) + rows.sort(key=lambda r: (-abs(r["delta"]), -max(r["self"], r["cloud"]))) + return rows + + +def render_markdown(report: dict[str, Any]) -> str: + c = report["counts"] + lines = ["## Scan parity — self-scan ↔ SonarQube Cloud", ""] + lines += [ + "| Metric | Value |", + "| --- | --- |", + f"| Self-scan total | {c['self']} |", + f"| SonarQube Cloud total | {c['cloud']} |", + f"| In both (same rule, same file, same line) | {c['common']} |", + f"| Only in self-scan | {c['only_self']} |", + f"| Only in SonarQube Cloud | {c['only_cloud']} |", + "", + "Parity score: " + f"**{_parity_pct(c):.1f}%** " + f"(common ÷ union)", + "", + ] + + rule_rows = [r for r in report["per_rule"] if r["delta"] != 0] + if rule_rows: + lines += [ + "### Rules with count drift", + "", + "Positive delta → our self-scan reports more than SonarQube Cloud. " + "Negative → SonarQube Cloud reports more.", + "", + "| Rule | Self | Cloud | Δ |", + "| --- | ---: | ---: | ---: |", + ] + for r in rule_rows[:30]: + lines.append(f"| `{r['rule']}` | {r['self']} | {r['cloud']} | {r['delta']:+d} |") + if len(rule_rows) > 30: + lines.append(f"| _… {len(rule_rows) - 30} more rules with drift, see parity.json artifact_ | | | |") + lines.append("") + else: + lines += ["### Rules with count drift", "", "_None — every rule has matching counts on both sides._", ""] + + if report["only_self_samples"]: + lines += ["### Sample issues only in self-scan (up to 10)", ""] + for i in report["only_self_samples"]: + lines.append(f"- `{i['ruleKey']}` {i['file']}:{i['line']}") + lines.append("") + if report["only_cloud_samples"]: + lines += ["### Sample issues only in SonarQube Cloud (up to 10)", ""] + for i in report["only_cloud_samples"]: + lines.append(f"- `{i['ruleKey']}` {i['file']}:{i['line']}") + lines.append("") + + return "\n".join(lines) + + +def _parity_pct(c: dict[str, int]) -> float: + union = c["self"] + c["cloud"] - c["common"] + if union == 0: + return 100.0 + return 100.0 * c["common"] / union + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-scan", required=True) + parser.add_argument("--project-key", required=True) + parser.add_argument("--organization", required=True) + parser.add_argument("--host", default="https://sonarcloud.io") + parser.add_argument("--branch") + parser.add_argument("--pull-request") + parser.add_argument("--out", default=".sonar-predictor/parity.json") + args = parser.parse_args(argv) + + token = os.environ.get("SONAR_TOKEN") + if not token: + print("error: SONAR_TOKEN env var is required", file=sys.stderr) + return 2 + + self_issues = load_self_scan(args.self_scan) + cloud_issues = fetch_cloud_issues( + args.host, + args.project_key, + args.organization, + token, + args.branch, + args.pull_request, + ) + report = compare(self_issues, cloud_issues) + + os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True) + with open(args.out, "w", encoding="utf-8") as f: + json.dump(report, f, indent=2) + + md = render_markdown(report) + print(md) + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + with open(summary_path, "a", encoding="utf-8") as f: + f.write(md) + f.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From 84b44d3c58950a7b13c0d31328ca8c103e1672f3 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 09:44:19 +0000 Subject: [PATCH 09/15] chore: remove the standalone engine-spike module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike served its purpose — its FileClientInputFile draft was ported into daemon/.../FileInputFile.java, and the analyzer-bootstrap logic it prototyped is the production daemon. No production code imports anything from dev.sonarcli.spike (the spike has its own groupId, is not in the parent's , and has only been touched twice in git history — original add + my @SuppressWarnings pass earlier in this PR). Carrying it forward had three costs and zero current benefit: - 30+ noisy Sonar findings against prototype code nobody runs (the largest single source of drift in our self-vs-cloud parity report). - Every refactor in daemon/ raised 'do I also update spike?' (answer was always 'no', but the question kept coming up). - Confused new contributors about which module is the canonical one. FileInputFile's javadoc loses its 'Ported from the engine spike' credit line in the same commit — the dangling reference would outlive the artifact it points at. The class's behavioural description is unchanged. Co-Authored-By: Claude Opus 4.7 --- .../dev/sonarcli/daemon/FileInputFile.java | 8 +- .../__pycache__/scan_parity.cpython-314.pyc | Bin 0 -> 15776 bytes spike/.gitignore | 11 - spike/README.md | 39 ---- spike/fixture/UtilityClass.java | 22 -- spike/pom.xml | 70 ------ .../java/dev/sonarcli/spike/EngineSpike.java | 215 ------------------ .../sonarcli/spike/FileClientInputFile.java | 71 ------ 8 files changed, 3 insertions(+), 433 deletions(-) create mode 100644 scripts/__pycache__/scan_parity.cpython-314.pyc delete mode 100644 spike/.gitignore delete mode 100644 spike/README.md delete mode 100644 spike/fixture/UtilityClass.java delete mode 100644 spike/pom.xml delete mode 100644 spike/src/main/java/dev/sonarcli/spike/EngineSpike.java delete mode 100644 spike/src/main/java/dev/sonarcli/spike/FileClientInputFile.java diff --git a/daemon/src/main/java/dev/sonarcli/daemon/FileInputFile.java b/daemon/src/main/java/dev/sonarcli/daemon/FileInputFile.java index d34d3fc..e7b0362 100644 --- a/daemon/src/main/java/dev/sonarcli/daemon/FileInputFile.java +++ b/daemon/src/main/java/dev/sonarcli/daemon/FileInputFile.java @@ -13,11 +13,9 @@ import org.sonarsource.sonarlint.core.commons.api.SonarLanguage; /** - * {@link ClientInputFile} backed by a real file on disk. - * - *

Ported from the engine spike's {@code FileClientInputFile}, extended to - * carry an explicit {@link SonarLanguage} and to derive a {@code /}-separated - * relative path from a base directory. + * {@link ClientInputFile} backed by a real file on disk. Carries an explicit + * {@link SonarLanguage} and derives a {@code /}-separated relative path from + * a base directory (the engine expects forward slashes regardless of OS). */ public final class FileInputFile implements ClientInputFile { diff --git a/scripts/__pycache__/scan_parity.cpython-314.pyc b/scripts/__pycache__/scan_parity.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1864537fbbe968a8e360926f4354f68e9bd66c1a GIT binary patch literal 15776 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 literal 0 HcmV?d00001 diff --git a/spike/.gitignore b/spike/.gitignore deleted file mode 100644 index 193bbae..0000000 --- a/spike/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Maven build output -target/ -dependency-reduced-pom.xml - -# Vendored analyzer plugin jar — not committed (large binary; fetch separately). -plugins/*.jar - -# Build artifacts and IDE metadata are not committed. -.settings/ -.project -.classpath diff --git a/spike/README.md b/spike/README.md deleted file mode 100644 index 90222e1..0000000 --- a/spike/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# engine-spike - -Throwaway SPIKE proving the SonarSource standalone analysis engine can be -embedded in plain Java 17, load `sonar-java-plugin` from a file path, analyze -a Java source file, and emit a real issue **fully offline**. - -This is a STANDALONE Maven project. It is deliberately NOT a module of the -parent `sonar-predictor` reactor. - -## Run - -```sh -mvn clean package -java -jar target/engine-spike.jar \ - plugins/sonar-java-plugin-8.15.0.39343.jar \ - fixture \ - fixture/UtilityClass.java -``` - -Expected tail of output: - -``` -[spike] issues raised : 1 - - ruleKey=java:S1118 message="Add a private constructor to hide the implicit public one." line=11 -[spike] SUCCESS: engine embedded offline, 1 issue(s) raised, including java:S1118 -``` - -## Layout - -- `pom.xml` — standalone build; shade plugin builds a runnable fat jar. -- `src/main/java` — `EngineSpike` (driver) + `FileClientInputFile`. -- `plugins/` — vendored `sonar-java-plugin` jar, loaded at runtime by path. -- `fixture/` — `UtilityClass.java`, violates rule `java:S1118`. - -## Key facts - -- Engine: `org.sonarsource.sonarlint.core:sonarlint-analysis-engine:10.24.0.81415`. -- The plugin jar is loaded from a FILE PATH, not as a Maven dependency. -- Analysis makes ZERO outbound network calls (verified with `strace`). diff --git a/spike/fixture/UtilityClass.java b/spike/fixture/UtilityClass.java deleted file mode 100644 index b0b5645..0000000 --- a/spike/fixture/UtilityClass.java +++ /dev/null @@ -1,22 +0,0 @@ -package fixture; - -/** - * Fixture for the embedding spike. - * - * This class has only static members and an implicit PUBLIC default - * constructor. SonarSource rule java:S1118 ("Utility classes should not - * have public constructors") flags exactly this. Adding a private - * constructor would silence the rule -- so do NOT add one. - */ -public class UtilityClass { - - public static final String GREETING = "hello"; - - public static int add(int a, int b) { - return a + b; - } - - public static int square(int n) { - return n * n; - } -} diff --git a/spike/pom.xml b/spike/pom.xml deleted file mode 100644 index 271fb1e..0000000 --- a/spike/pom.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - 4.0.0 - - - dev.sonarcli - engine-spike - 0.1.0-SNAPSHOT - jar - engine-spike - - - 17 - UTF-8 - 10.24.0.81415 - - - - - - org.sonarsource.sonarlint.core - sonarlint-analysis-engine - ${sonarlint.version} - - - - - engine-spike - - - org.apache.maven.plugins - maven-compiler-plugin - 3.13.0 - - - - org.apache.maven.plugins - maven-shade-plugin - 3.5.3 - - - package - shade - - - - dev.sonarcli.spike.EngineSpike - - - - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - - - - - - - diff --git a/spike/src/main/java/dev/sonarcli/spike/EngineSpike.java b/spike/src/main/java/dev/sonarcli/spike/EngineSpike.java deleted file mode 100644 index 3be8343..0000000 --- a/spike/src/main/java/dev/sonarcli/spike/EngineSpike.java +++ /dev/null @@ -1,215 +0,0 @@ -package dev.sonarcli.spike; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Stream; - -import org.sonarsource.sonarlint.core.analysis.AnalysisScheduler; -import org.sonarsource.sonarlint.core.analysis.api.ActiveRule; -import org.sonarsource.sonarlint.core.analysis.api.AnalysisConfiguration; -import org.sonarsource.sonarlint.core.analysis.api.AnalysisResults; -import org.sonarsource.sonarlint.core.analysis.api.AnalysisSchedulerConfiguration; -import org.sonarsource.sonarlint.core.analysis.api.Issue; -import org.sonarsource.sonarlint.core.analysis.api.TriggerType; -import org.sonarsource.sonarlint.core.analysis.command.AnalyzeCommand; -import org.sonarsource.sonarlint.core.commons.api.SonarLanguage; -import org.sonarsource.sonarlint.core.commons.log.LogOutput; -import org.sonarsource.sonarlint.core.commons.log.SonarLintLogger; -import org.sonarsource.sonarlint.core.commons.progress.SonarLintCancelMonitor; -import org.sonarsource.sonarlint.core.commons.progress.TaskManager; -import org.sonarsource.sonarlint.core.plugin.commons.LoadedPlugins; -import org.sonarsource.sonarlint.core.plugin.commons.PluginsLoadResult; -import org.sonarsource.sonarlint.core.plugin.commons.PluginsLoader; - -/** - * SPIKE: prove the SonarSource standalone analysis engine can be embedded in - * plain Java 17, load the sonar-java-plugin from a file path, analyze one - * Java source file, and emit a real issue -- fully offline. - * - * API verified via javap against the published jars (engine 10.24.0.81415). - * Notable deltas from the hinted API are documented inline. - * - * Usage: EngineSpike <plugin-jar> <fixture-dir> <fixture-file> - */ -public final class EngineSpike { - - /** java:S1118 -- utility classes should not have public constructors. */ - private static final String RULE_KEY = "java:S1118"; - - /** - * Suppresses {@code java:S5443}: this is exploratory / spike code, not in - * the production reactor. Standard {@link Files#createTempDirectory} is - * acceptable for a developer-run experiment. - */ - @SuppressWarnings("java:S5443") - public static void main(String[] args) throws Exception { - if (args.length != 3) { - System.err.println("Usage: EngineSpike "); - System.exit(2); - } - Path pluginJar = Paths.get(args[0]).toAbsolutePath(); - Path fixtureDir = Paths.get(args[1]).toAbsolutePath(); - Path fixtureFile = Paths.get(args[2]).toAbsolutePath(); - - require(Files.isRegularFile(pluginJar), "plugin jar not found: " + pluginJar); - require(Files.isRegularFile(fixtureFile), "fixture file not found: " + fixtureFile); - - Path workDir = Files.createTempDirectory("engine-spike-work"); - - System.out.println("[spike] plugin jar : " + pluginJar); - System.out.println("[spike] fixture : " + fixtureFile); - System.out.println("[spike] work dir : " + workDir); - - // ---- 0. Register a global log target ------------------------------- - // GOTCHA: PluginsLoader (and the analyzer) log through the thread-local - // SonarLintLogger. Without a target registered BEFORE PluginsLoader - // .load(), it throws "No log output configured". The LogOutput handed - // to AnalysisScheduler is NOT enough -- it must be set globally first. - // LogOutput's two log() methods are both default (not a functional - // interface) -- override one explicitly with an anonymous class. - LogOutput logOutput = new LogOutput() { - @Override - public void log(String formattedMessage, Level level) { - System.out.println("[engine:" + level + "] " + formattedMessage); - } - }; - SonarLintLogger.get().setTarget(logOutput); - - // ---- 1. Load the plugin from disk (file: classloading only) -------- - // PluginsLoader.Configuration(Set jars, Set langs, - // boolean enableDataflowBugDetection, - // Optional nodeVersion) - PluginsLoader.Configuration pluginConfig = new PluginsLoader.Configuration( - Set.of(pluginJar), - Set.of(SonarLanguage.JAVA), - false, - Optional.empty()); - PluginsLoadResult loadResult = new PluginsLoader().load(pluginConfig, Set.of()); - LoadedPlugins loadedPlugins = loadResult.getLoadedPlugins(); - System.out.println("[spike] loaded plugins: " - + loadedPlugins.getAllPluginInstancesByKeys().keySet()); - require(loadedPlugins.getAllPluginInstancesByKeys().containsKey("java"), - "java plugin failed to load"); - - // ---- 2. Build the scheduler ---------------------------------------- - // AnalysisSchedulerConfiguration.Builder: setWorkDir / setClientPid / - // setExtraProperties / setModulesProvider (NO setFileSystemProvider in - // the published jar -- master HEAD diverged). No modules => omit it. - AnalysisSchedulerConfiguration schedulerConfig = AnalysisSchedulerConfiguration.builder() - .setWorkDir(workDir) - .setClientPid(ProcessHandle.current().pid()) - .build(); - - // AnalysisScheduler(AnalysisSchedulerConfiguration, LoadedPlugins, LogOutput) - // -- the published jar exposes AnalysisScheduler, NOT AnalysisEngine. - AnalysisScheduler scheduler = new AnalysisScheduler(schedulerConfig, loadedPlugins, logOutput); - - int issueCount; - try { - // ---- 3. Active rules (explicit -- no built-in profile) --------- - // ActiveRule is org.sonarsource.sonarlint.core.analysis.api.ActiveRule, - // a CONCRETE class ActiveRule(String ruleKey, String languageKey) -- - // NOT the hinted org.sonar.api.batch.rule.ActiveRule interface. - ActiveRule s1118 = new ActiveRule(RULE_KEY, SonarLanguage.JAVA.getSonarLanguageKey()); - - FileClientInputFile inputFile = - new FileClientInputFile(fixtureFile, fixtureDir.relativize(fixtureFile).toString()); - - AnalysisConfiguration analysisConfig = AnalysisConfiguration.builder() - .setBaseDir(fixtureDir) - .addInputFile(inputFile) - .addActiveRule(s1118) - // The Java analyzer wants binaries; point it at the fixture - // dir so it does not abort. No compiled classes needed for - // a source-only rule like S1118. - .putExtraProperty("sonar.java.source", "17") - .putExtraProperty("sonar.java.binaries", fixtureDir.toString()) - .putExtraProperty("sonar.java.libraries", "") - .build(); - - // ---- 4. Post the analyze command ------------------------------- - List issues = new ArrayList<>(); - // 12-arg constructor (published jar). The engine creates the result - // future internally; retrieve it via getFutureResult(). - AnalyzeCommand command = new AnalyzeCommand( - "spike-module", - UUID.randomUUID(), - TriggerType.FORCED, - () -> analysisConfig, - issues::add, // Consumer - null, // Trace (nullable) - new SonarLintCancelMonitor(), - new TaskManager(), - files -> { }, // analysis-started consumer - () -> Boolean.TRUE, // isReady supplier - Set.of(), // files (URIs) -- unused here - Map.of()); // per-command extra props - - scheduler.post(command); - - CompletableFuture future = command.getFutureResult(); - AnalysisResults results = future.get(); - - System.out.println("[spike] analysis duration : " + results.getDuration()); - System.out.println("[spike] failed files : " + results.failedAnalysisFiles().size()); - require(results.failedAnalysisFiles().isEmpty(), "analyzer failed on the fixture file"); - - issueCount = issues.size(); - System.out.println("[spike] issues raised : " + issueCount); - for (Issue issue : issues) { - System.out.println(" - ruleKey=" + issue.getRuleKey() - + " message=\"" + issue.getMessage() + "\"" - + " line=" + (issue.getTextRange() != null - ? issue.getTextRange().getStartLine() : "?")); - } - - boolean sawTargetRule = issues.stream() - .anyMatch(i -> RULE_KEY.equals(i.getRuleKey())); - require(sawTargetRule, "expected rule " + RULE_KEY + " was not raised"); - } finally { - scheduler.stop(); - deleteRecursively(workDir); - } - - System.out.println("[spike] SUCCESS: engine embedded offline, " - + issueCount + " issue(s) raised, including " + RULE_KEY); - } - - /** - * Best-effort recursive delete of the engine work directory. Walks the tree - * and deletes children before parents; any IO error is swallowed so cleanup - * never masks a real analysis failure. - */ - private static void deleteRecursively(Path dir) { - try (Stream walk = Files.walk(dir)) { - walk.sorted(Comparator.reverseOrder()).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - // best-effort: ignore - } - }); - } catch (IOException e) { - // best-effort: ignore - } - } - - private static void require(boolean condition, String message) { - if (!condition) { - throw new IllegalStateException("SPIKE FAILED: " + message); - } - } - - private EngineSpike() { - } -} diff --git a/spike/src/main/java/dev/sonarcli/spike/FileClientInputFile.java b/spike/src/main/java/dev/sonarcli/spike/FileClientInputFile.java deleted file mode 100644 index 2623549..0000000 --- a/spike/src/main/java/dev/sonarcli/spike/FileClientInputFile.java +++ /dev/null @@ -1,71 +0,0 @@ -package dev.sonarcli.spike; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import org.sonarsource.sonarlint.core.analysis.api.ClientInputFile; - -/** - * Minimal {@link ClientInputFile} backed by a real file on disk. - * - * The interface (verified via javap against sonarlint-analysis-engine - * 10.24.0.81415) requires: getPath, isTest, getCharset, getClientObject, - * inputStream, contents, relativePath, uri. language() and isDirty() are - * defaults we leave alone. - */ -final class FileClientInputFile implements ClientInputFile { - - private final Path absolutePath; - private final String relativePath; - - FileClientInputFile(Path absolutePath, String relativePath) { - this.absolutePath = absolutePath.toAbsolutePath(); - this.relativePath = relativePath; - } - - @Override - public String getPath() { - return absolutePath.toString(); - } - - @Override - public boolean isTest() { - return false; - } - - @Override - public Charset getCharset() { - return StandardCharsets.UTF_8; - } - - @Override - @SuppressWarnings("unchecked") - public G getClientObject() { - return (G) absolutePath; - } - - @Override - public InputStream inputStream() throws IOException { - return Files.newInputStream(absolutePath); - } - - @Override - public String contents() throws IOException { - return Files.readString(absolutePath, StandardCharsets.UTF_8); - } - - @Override - public String relativePath() { - return relativePath; - } - - @Override - public URI uri() { - return absolutePath.toUri(); - } -} From 87427906614bd78acc71cc7e84cde5fd28bf0005 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 09:53:49 +0000 Subject: [PATCH 10/15] =?UTF-8?q?ci:=20parity=20report=20now=20compares=20?= =?UTF-8?q?coverage=20too=20(self=20=E2=86=94=20SonarQube=20Cloud)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity script previously diffed only the issue lists. Coverage is the other half of what SonarSource reports — and the failing SonarCloud quality gate on this PR was 'New Code Coverage 62.9% < 80%', not the issue count. Without parity on coverage, we can't say 'our self-scan and SonarCloud agree on what the code looks like'. What's added: - load_self_coverage() reads .coverage.overallPercent from scan.json (the CLI already emits this from the imported JaCoCo XMLs). - fetch_cloud_coverage() pulls coverage, line_coverage, branch_coverage, new_coverage, new_line_coverage via the /api/measures/component endpoint (PR scope when --pull-request is given, branch otherwise). - The report now has a Coverage section beside the Issues section: self line %, cloud line %, cloud overall, cloud branch, cloud new-code (for the PR gate), and self − cloud line delta in pp. - HTTP 404 on the measures endpoint is treated as 'no data' rather than crashing — the project may simply not be analyzed yet on Cloud. No new third-party deps; stdlib urllib + json only. Co-Authored-By: Claude Opus 4.7 --- scripts/scan_parity.py | 120 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/scripts/scan_parity.py b/scripts/scan_parity.py index a27db9e..4329767 100644 --- a/scripts/scan_parity.py +++ b/scripts/scan_parity.py @@ -45,6 +45,20 @@ SOURCE_CLOUD = "cloud" +def load_self_coverage(path: str) -> float | None: + """Pull `.coverage.overallPercent` from the self-scan JSON, or None.""" + with open(path, encoding="utf-8") as f: + doc = json.load(f) + cov = doc.get("coverage") + if not cov: + return None + pct = cov.get("overallPercent") + try: + return float(pct) if pct is not None else None + except (TypeError, ValueError): + return None + + def load_self_scan(path: str) -> list[dict[str, Any]]: with open(path, encoding="utf-8") as f: doc = json.load(f) @@ -74,6 +88,53 @@ def _normalize_path(p: str) -> str: return p +def fetch_cloud_coverage( + host: str, + project_key: str, + organization: str, + token: str, + branch: str | None, + pull_request: str | None, +) -> dict[str, float | None]: + """Pull headline coverage measures for the project (or PR scope) from SonarQube Cloud.""" + auth = base64.b64encode(f"{token}:".encode()).decode() + headers = {"Authorization": f"Basic {auth}", "Accept": "application/json"} + + params: dict[str, Any] = { + "component": project_key, + "organization": organization, + "metricKeys": "coverage,line_coverage,branch_coverage,new_coverage,new_line_coverage", + } + if pull_request: + params["pullRequest"] = pull_request + elif branch: + params["branch"] = branch + url = f"{host}/api/measures/component?{urllib.parse.urlencode(params)}" + req = urllib.request.Request(url, headers=headers) + out: dict[str, float | None] = {} + try: + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.load(resp) + for m in payload.get("component", {}).get("measures", []) or []: + metric = m.get("metric") + # On PR scope, the relevant value is in `period.value`; on branch/ + # full scope, it's in `value`. We try both. + raw = m.get("value") + if raw is None and m.get("period"): + raw = m["period"].get("value") + try: + out[metric] = float(raw) if raw is not None else None + except (TypeError, ValueError): + out[metric] = None + except urllib.error.HTTPError as e: + if e.code == 404: + # Project may not exist on Cloud yet, or hasn't been analyzed + # in this PR scope; treat as no-data rather than crashing. + return out + raise + return out + + def fetch_cloud_issues( host: str, project_key: str, @@ -132,7 +193,12 @@ def _key(issue: dict[str, Any]) -> tuple[str, str, int]: return (issue.get("ruleKey") or "", issue.get("file") or "", int(issue.get("line") or 0)) -def compare(self_issues: list[dict[str, Any]], cloud_issues: list[dict[str, Any]]) -> dict[str, Any]: +def compare( + self_issues: list[dict[str, Any]], + cloud_issues: list[dict[str, Any]], + self_coverage: float | None, + cloud_coverage: dict[str, float | None], +) -> dict[str, Any]: self_by_key = {_key(i): i for i in self_issues} cloud_by_key = {_key(i): i for i in cloud_issues} @@ -142,6 +208,14 @@ def compare(self_issues: list[dict[str, Any]], cloud_issues: list[dict[str, Any] only_self = self_keys - cloud_keys only_cloud = cloud_keys - self_keys + overall_cloud = cloud_coverage.get("coverage") + line_cloud = cloud_coverage.get("line_coverage") + branch_cloud = cloud_coverage.get("branch_coverage") + new_cloud = cloud_coverage.get("new_coverage") + delta = None + if self_coverage is not None and line_cloud is not None: + delta = round(self_coverage - line_cloud, 2) + return { "counts": { "self": len(self_issues), @@ -150,6 +224,14 @@ def compare(self_issues: list[dict[str, Any]], cloud_issues: list[dict[str, Any] "only_self": len(only_self), "only_cloud": len(only_cloud), }, + "coverage": { + "self_line_overall": self_coverage, + "cloud_line_overall": line_cloud, + "cloud_overall": overall_cloud, + "cloud_branch": branch_cloud, + "cloud_new_code": new_cloud, + "self_minus_cloud_line": delta, + }, "per_rule": _rule_breakdown(self_issues, cloud_issues), "only_self_samples": [self_by_key[k] for k in sorted(only_self)][:10], "only_cloud_samples": [cloud_by_key[k] for k in sorted(only_cloud)][:10], @@ -171,8 +253,11 @@ def _rule_breakdown(self_issues: list[dict[str, Any]], cloud_issues: list[dict[s def render_markdown(report: dict[str, Any]) -> str: c = report["counts"] + cov = report["coverage"] lines = ["## Scan parity — self-scan ↔ SonarQube Cloud", ""] lines += [ + "### Issues", + "", "| Metric | Value |", "| --- | --- |", f"| Self-scan total | {c['self']} |", @@ -185,6 +270,17 @@ def render_markdown(report: dict[str, Any]) -> str: f"**{_parity_pct(c):.1f}%** " f"(common ÷ union)", "", + "### Coverage", + "", + "| Metric | Value |", + "| --- | --- |", + f"| Self-scan line coverage | {_fmt_pct(cov['self_line_overall'])} |", + f"| SonarQube Cloud line coverage | {_fmt_pct(cov['cloud_line_overall'])} |", + f"| SonarQube Cloud overall coverage (line + branch) | {_fmt_pct(cov['cloud_overall'])} |", + f"| SonarQube Cloud branch coverage | {_fmt_pct(cov['cloud_branch'])} |", + f"| SonarQube Cloud new-code coverage (PR scope) | {_fmt_pct(cov['cloud_new_code'])} |", + f"| Self − Cloud line coverage | {_fmt_delta(cov['self_minus_cloud_line'])} |", + "", ] rule_rows = [r for r in report["per_rule"] if r["delta"] != 0] @@ -227,6 +323,17 @@ def _parity_pct(c: dict[str, int]) -> float: return 100.0 * c["common"] / union +def _fmt_pct(v: float | None) -> str: + return f"{v:.2f}%" if v is not None else "_n/a_" + + +def _fmt_delta(v: float | None) -> str: + if v is None: + return "_n/a_" + sign = "+" if v >= 0 else "" + return f"{sign}{v:.2f} pp" + + def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--self-scan", required=True) @@ -244,6 +351,7 @@ def main(argv: list[str]) -> int: return 2 self_issues = load_self_scan(args.self_scan) + self_coverage = load_self_coverage(args.self_scan) cloud_issues = fetch_cloud_issues( args.host, args.project_key, @@ -252,7 +360,15 @@ def main(argv: list[str]) -> int: args.branch, args.pull_request, ) - report = compare(self_issues, cloud_issues) + cloud_coverage = fetch_cloud_coverage( + args.host, + args.project_key, + args.organization, + token, + args.branch, + args.pull_request, + ) + report = compare(self_issues, cloud_issues, self_coverage, cloud_coverage) os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True) with open(args.out, "w", encoding="utf-8") as f: From 8d15fae93dcc6707c0be873eb93eea7f0dcd3920 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 09:54:01 +0000 Subject: [PATCH 11/15] ci: add missing urllib.error import to scan_parity.py --- scripts/scan_parity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/scan_parity.py b/scripts/scan_parity.py index 4329767..4c8e4f7 100644 --- a/scripts/scan_parity.py +++ b/scripts/scan_parity.py @@ -35,6 +35,7 @@ import json import os import sys +import urllib.error import urllib.parse import urllib.request from collections import Counter From f05b9305d33423afccca2e71fb06674a1a2d2f8b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 10:08:33 +0000 Subject: [PATCH 12/15] fix: harden parity workflow curl + cover new --save codepath in tests Two SonarCloud-blocking issues on PR #4, fixed: 1. Security hotspot at parity.yml:139 (githubactions:S6506): 'curl -fsSL -L' would follow a redirect onto an insecure http:// URL. Added '--proto-redir =https' so even a malicious redirect from the API endpoint can't downgrade us to plain HTTP. SONAR_HOST_URL is already https://, but the rule wants the explicit constraint. 2. New-code coverage gap (62.9% < 80% gate): The --save flag added in b0e8fb5 brought along ~80 lines of untested code (writeSummary, rollup, the file-write branch). Added two tests to CommandTest: - saveWritesReportAndPrintsSummary: full happy path; asserts the JSON report lands in the target file, stdout carries the compact summary with severity + type rollups (CRITICAL=1 MAJOR=1 / CODE_SMELL=2), and the file does NOT contain raw JSON in stdout. - saveCleanScanWritesEmptyReport: 0-issue case; asserts the file is still created, the count is reported as 0, and the rollup lines are correctly skipped (compact summary discipline). Both new tests cover writeSummary's two branches (issues > 0, == 0), rollup's preferred-order handling, and the file-write path. Verified locally: 153 cli tests pass, including both new ones. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/parity.yml | 5 +- .../java/dev/sonarcli/cli/CommandTest.java | 61 ++++++++++++++++++ .../__pycache__/scan_parity.cpython-314.pyc | Bin 15776 -> 21615 bytes 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 0df8990..7d76691 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -136,7 +136,10 @@ jobs: run: | START_TS=$(date +%s) for attempt in $(seq 1 18); do - RESP=$(curl -fsSL -u "$SONAR_TOKEN:" \ + # --proto-redir =https refuses to follow a redirect onto an insecure + # http:// URL (githubactions:S6506). Belt-and-braces with SONAR_HOST_URL + # already being https://, but the rule wants the explicit constraint. + RESP=$(curl -fsSL --proto-redir =https -u "$SONAR_TOKEN:" \ "${SONAR_HOST_URL}/api/project_analyses/search?project=${SONAR_PROJECT_KEY}&ps=1" || echo '{}') ANALYSIS_TS=$(echo "$RESP" | jq -r '.analyses[0].date // empty' || true) if [ -n "$ANALYSIS_TS" ]; then diff --git a/cli/src/test/java/dev/sonarcli/cli/CommandTest.java b/cli/src/test/java/dev/sonarcli/cli/CommandTest.java index ee78723..09a8946 100644 --- a/cli/src/test/java/dev/sonarcli/cli/CommandTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/CommandTest.java @@ -177,6 +177,67 @@ void checkJsonFormat(@TempDir Path dir) throws Exception { assertTrue(run.out().contains("\"issueCount\":1"), "JSON must report the issue count"); } + @Test + @DisplayName("--save writes the report to PATH and emits a compact summary on stdout") + void saveWritesReportAndPrintsSummary(@TempDir Path dir) throws Exception { + Path source = Files.writeString(dir.resolve("Bad.java"), "class Bad {}"); + Path target = dir.resolve(".sonar-predictor").resolve("scan.json"); + StubRpc rpc = rpc(); + rpc.analyzeResult = new AnalyzeResponse( + List.of( + issue("Bad.java", "java:S1118", "CRITICAL"), + issue("Bad.java", "java:S100", "MAJOR")), + List.of()); + + Run run = run(rpc, control(), + "--format", "json", + "--save", target.toString(), + "check", source.toString()); + + assertEquals(1, run.exitCode(), "issues found must exit 1 regardless of --save"); + + // The JSON report goes to the file, not stdout. + assertTrue(Files.exists(target), "--save target file must be created"); + String written = Files.readString(target); + assertTrue(written.contains("\"issueCount\":2"), + "saved JSON must hold the full report, got: " + written.substring(0, Math.min(200, written.length()))); + assertTrue(written.contains("java:S1118")); + + // Stdout carries the compact native summary instead of the JSON. + String stdout = run.out(); + assertFalse(stdout.contains("\"issueCount\""), + "stdout must not contain raw JSON when --save is used, got: " + stdout); + assertTrue(stdout.contains("sonar-predictor: 2 issues written to"), + "stdout must announce the count and target, got: " + stdout); + assertTrue(stdout.contains("severity:") && stdout.contains("CRITICAL=1") && stdout.contains("MAJOR=1"), + "stdout must include the severity rollup, got: " + stdout); + assertTrue(stdout.contains("type:") && stdout.contains("CODE_SMELL=2"), + "stdout must include the type rollup, got: " + stdout); + } + + @Test + @DisplayName("--save on a clean scan still writes the report and emits the summary, exit 0") + void saveCleanScanWritesEmptyReport(@TempDir Path dir) throws Exception { + Path source = Files.writeString(dir.resolve("Clean.java"), "class Clean {}"); + Path target = dir.resolve(".sonar-predictor").resolve("scan.json"); + StubRpc rpc = rpc(); + rpc.analyzeResult = new AnalyzeResponse(List.of(), List.of()); + + Run run = run(rpc, control(), + "--format", "json", + "--save", target.toString(), + "check", source.toString()); + + assertEquals(0, run.exitCode(), "no issues must exit 0 even with --save"); + assertTrue(Files.exists(target), "--save target file must be created even when clean"); + assertTrue(run.out().contains("sonar-predictor: 0 issues written to"), + "stdout must still announce the 0-issue result, got: " + run.out()); + // Severity / type rollup lines are skipped when there are no issues — + // assert their absence so the summary stays compact. + assertFalse(run.out().contains("severity:"), + "no severity rollup when there are no issues, got: " + run.out()); + } + @Test @DisplayName("--severity filters out issues below the minimum before the exit-code decision") void severityFilterChangesExitCode(@TempDir Path dir) throws Exception { diff --git a/scripts/__pycache__/scan_parity.cpython-314.pyc b/scripts/__pycache__/scan_parity.cpython-314.pyc index 1864537fbbe968a8e360926f4354f68e9bd66c1a..33d8c4b88f1510dde1288d05e0592f892aa9f032 100644 GIT binary patch delta 8805 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 delta 4194 zcmb_fYiu0V6`nggGy7gYcGs_XAGWcRCHDH6U?G^j;_8mTW;ZK>MAuYv$683|RQQYG3Sn1@AE390AIdN+&7 zk5=ma@y)s4z4zR6&zyVa%!k*=)rVdCT}~SZ?Vl@E4}PQd30EB%dSdTf-ZWt!cW90P zcb1cd3Fo*=a|JkA)GB02tCY>PoNTG((v@13Yz5h7$knnPWQQTUWhclkL-xoOAXgf) zSFQrN+K_8xH^?4Cu9dwY*BEl0T-ykpI-^s+*dW*Ue}&@`?Ht$0Et=;JvRCXLX|cWu zIxIHr;pGN4chJMWN!(=5=JVv5==G#is&*!kifd<7jkX7eGi!^BY~DOby4V4G3;zkX zdB6QM@BX)#)0R5=UnIIEnTcl;smwC_jdQwI7qUt+qYDqJS-^BQme5VhNsakj9X6dT z>83-)-0+6;AVU(#=?~rviUhU_ptGOij;coWzrn=K1qMOerV~HeH zQPff^$!t!Wq4l`bT2e>r(Ch{z1)57IX)X5mAovjc2mt_H)UsME9jauhy3_e4q?-|1 z5bi)g2k4HIv!~>dvFOP3?5WX^oA%?#00P=f4nSwilPau(*-5AoYbv| zQ8l%m)FmZGV=Jm|%F(oLqRCaTGN~$F-K@mc@P*O+sB9czlD*h`K#YQTeDiP313cN! ze66$OGFxf=d=O7qIBV&EA>J@vH>EOKh!~2>-lr}^FC^D!0@ht-KW;n6J`A__ik+qw zE+kP4u)1AMrkA2ARRyn82QrR$I-5%rDLZZ%gZH0*3?vnOw84GGd5QE9!khISTgG$n zMBy1p7(A_liE$pF*qk7kIF5Yc2X=#taFyICKF4=*IsPbwp29C+7#c0Y_wC3L{v1CA zs;rzp&nYH_kC6Xy8K(%K8aCchZ7Ql(#4=VWvC@2`y<`ZOWrn_ybMgfRie)}oAa?pz znUl$axT7D=Ra7POWh^PNrZU!CVudo+Qewq2Hg7G`ouVu)n2Q_ElfE4{%jN}Zk&Tp; zfWK@xK8$XLce(n5h^-yNXs6BaQ}ANIy)drWWJ~#&Y%SAg%@?=drSOOUy~0kR1-1#` zR($HqlC8vn6td}3Je$jCDn;K?iJjZu!nXDg*hM3pb(0D??nryUVy8h1$KKW^sy6Xd4upHgdJr$@abhlQ)&QBHX6uBuVd^@l*!sI8j4A3j=Xn2%SF zE+g16**>?q*!d}`#y2%X@#?(D>wLJ2=2*OYh+JjQchlAloQ-4j5damVpes0(VAY9VXU z&p+zz+K0+?qUt2BV$7AiT-aX4^$5?g&wKwxVyu5Goc}5GRXh;pk1o>q5yF+3Eqq*4 zjb0&ZlwCirlr?6ALe`ME@(@*sOk_R`@f6`@lPt($gve6)3VVtryL#YuyA3%{`C7RF4r%TwC8z*5b);Z%S^g@e*t7orry$xBZ3XiS z208bXPi4>a*H<<6lyY?djtTR+Lyi6bcie8AIrOD7bF%Nrfj!;4?8lJUb^QN!7NE%8 z%U*GK8g6|sO9|ElDfCX7VmA&A2v{Al=EG05JqW^j&86UMCcPF+WrFza?@;5hOz={W zV(MIPghAFZR4V|HW}hAItEVf#Q%sbHWIo*!zPG z^?1G4`3Hi90t`#VLKUVk@6Z`i$5w|f0)8+wCVdab>R8`B!+A6>BK)jGo7;!KFOD+ePFCf2lMYlv!pt|>z^rX3;X5tF`qSJEt;4Z#eA`FYa1)`9d8oN z5%b&tnB!&@9xL8O?Bedv2CVGS>FP~(e3+10_U_Dx^fagr`IvupY8*dqx~NciTTn@? zsk)?U5GRzjud znnlXBL%~lD{>JIdcQP>EhAU1Xpmz)Zn1){y*u^@`C>9Kt84+(B3oXpVR=^BhNIsO( z=nL%1NuTX6FdYq6zr}ugvTe-!9v66ztJ#vc3h##fdPU8KV_T{s)!X|d;@qz8B^BFg kLdaRNZGqpF?JgUs+-`u1V|$4RWbd{M?#!0VM(%6>4}?2ccmMzZ From 830df93c2f638f562c84d858a2386a071ee75071 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 10:09:14 +0000 Subject: [PATCH 13/15] chore: gitignore __pycache__ + drop tracked scan_parity .pyc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/__pycache__/scan_parity.cpython-*.pyc slipped into git via an earlier 'git add -A' when the parity workflow was added. Python bytecode is generated locally on every run (and version-specific — the file name encodes the Python interpreter version), so it shouldn't be tracked. .gitignore now excludes __pycache__/ and *.pyc. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 5 +++++ scripts/__pycache__/scan_parity.cpython-314.pyc | Bin 21615 -> 0 bytes 2 files changed, 5 insertions(+) delete mode 100644 scripts/__pycache__/scan_parity.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 2316f52..c3b257c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ daemon/plugins/*.jar # sonar-predictor scan output, written by `bin/sonar agent-scan`. Never commit. .sonar-predictor/ + +# Python bytecode (scan_parity.py compiles to scripts/__pycache__ when run locally). +__pycache__/ +*.pyc + diff --git a/scripts/__pycache__/scan_parity.cpython-314.pyc b/scripts/__pycache__/scan_parity.cpython-314.pyc deleted file mode 100644 index 33d8c4b88f1510dde1288d05e0592f892aa9f032..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21615 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 ayT0M-hS-*`H6`uUZ|Vf-dUR#1CjSqG*u>KS From 79b3da0258b41a834774e16f45de2e5a64fc42c5 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 10:19:05 +0000 Subject: [PATCH 14/15] fix: tighten parity-curl HTTPS constraint + add full-rollup coverage test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to address the SonarCloud quality-gate failures still on PR #4 (77% < 80% new coverage; 1 hotspot at parity.yml:142): 1. Hotspot — add --proto =https alongside the existing --proto-redir =https on the parity workflow's poll-curl. The rule (githubactions:S6506) wants the initial request constrained, not just any redirect; with SONAR_HOST_URL already https://, this is belt-and-braces but it makes the rule's invariant locally checkable at the curl call site. 2. Coverage — the writeSummary + rollup helpers added in b0e8fb5 had uncovered branches: the per-bucket preferred-order traversal beyond CRITICAL/MAJOR/CODE_SMELL, and the type-vs-severity distinction. Added a third CommandTest case (saveSummaryRollupsCoverAllBuckets) that fires one issue per severity bucket (BLOCKER/CRITICAL/MINOR/INFO) and one per type bucket (BUG/VULNERABILITY/SECURITY_HOTSPOT/CODE_SMELL), then asserts the rollup lists them in the expected preferred order and the saved JSON holds the full report. Local: 3 --save tests pass, 154 total cli tests green. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/parity.yml | 9 ++-- .../java/dev/sonarcli/cli/CommandTest.java | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 7d76691..f1ce6d1 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -136,10 +136,11 @@ jobs: run: | START_TS=$(date +%s) for attempt in $(seq 1 18); do - # --proto-redir =https refuses to follow a redirect onto an insecure - # http:// URL (githubactions:S6506). Belt-and-braces with SONAR_HOST_URL - # already being https://, but the rule wants the explicit constraint. - RESP=$(curl -fsSL --proto-redir =https -u "$SONAR_TOKEN:" \ + # Constrain both the initial request (--proto =https) AND any redirect + # (--proto-redir =https) to HTTPS. Without --proto, githubactions:S6506 + # still fires even when SONAR_HOST_URL is already https://; the rule + # wants both belts explicit at the curl-call site. + RESP=$(curl -fsSL --proto =https --proto-redir =https -u "$SONAR_TOKEN:" \ "${SONAR_HOST_URL}/api/project_analyses/search?project=${SONAR_PROJECT_KEY}&ps=1" || echo '{}') ANALYSIS_TS=$(echo "$RESP" | jq -r '.analyses[0].date // empty' || true) if [ -n "$ANALYSIS_TS" ]; then diff --git a/cli/src/test/java/dev/sonarcli/cli/CommandTest.java b/cli/src/test/java/dev/sonarcli/cli/CommandTest.java index 09a8946..35ca16e 100644 --- a/cli/src/test/java/dev/sonarcli/cli/CommandTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/CommandTest.java @@ -215,6 +215,54 @@ void saveWritesReportAndPrintsSummary(@TempDir Path dir) throws Exception { "stdout must include the type rollup, got: " + stdout); } + @Test + @DisplayName("--save summary rollups cover every severity + type preferred-order bucket") + void saveSummaryRollupsCoverAllBuckets(@TempDir Path dir) throws Exception { + Path source = Files.writeString(dir.resolve("Bad.java"), "class Bad {}"); + Path target = dir.resolve(".sonar-predictor").resolve("scan.json"); + StubRpc rpc = rpc(); + // One issue per severity bucket and per type bucket — exercises every + // branch of the rollup helper, including the per-bucket ordering and + // the type-vs-severity distinction. + rpc.analyzeResult = new AnalyzeResponse( + List.of( + new Issue("java:Sb", "Bad.java", 1, 0, 1, 5, "BLOCKER", "BUG", "m1"), + new Issue("java:Sv", "Bad.java", 2, 0, 2, 5, "MINOR", "VULNERABILITY", "m2"), + new Issue("java:Sh", "Bad.java", 3, 0, 3, 5, "INFO", "SECURITY_HOTSPOT", "m3"), + new Issue("java:Sc", "Bad.java", 4, 0, 4, 5, "CRITICAL", "CODE_SMELL", "m4")), + List.of()); + + Run run = run(rpc, control(), + "--format", "json", + "--save", target.toString(), + "check", source.toString()); + + assertEquals(1, run.exitCode()); + String stdout = run.out(); + + // Severity rollup — all four buckets we generated appear, in preferred + // order (BLOCKER before CRITICAL before MINOR before INFO). + int blocker = stdout.indexOf("BLOCKER=1"); + int critical = stdout.indexOf("CRITICAL=1"); + int minor = stdout.indexOf("MINOR=1"); + int info = stdout.indexOf("INFO=1"); + assertTrue(blocker >= 0 && critical >= 0 && minor >= 0 && info >= 0, + "every severity bucket must appear in the rollup, got: " + stdout); + assertTrue(blocker < critical && critical < minor && minor < info, + "severity rollup must preserve preferred order, got: " + stdout); + + // Type rollup — BUG / CODE_SMELL / VULNERABILITY / SECURITY_HOTSPOT + // each contribute one issue. + assertTrue(stdout.contains("BUG=1") && stdout.contains("CODE_SMELL=1") + && stdout.contains("VULNERABILITY=1") && stdout.contains("SECURITY_HOTSPOT=1"), + "every type bucket must appear in the rollup, got: " + stdout); + + // And the saved file is the full JSON, not the summary. + String written = Files.readString(target); + assertTrue(written.contains("\"issueCount\":4"), + "saved JSON must hold the full report"); + } + @Test @DisplayName("--save on a clean scan still writes the report and emits the summary, exit 0") void saveCleanScanWritesEmptyReport(@TempDir Path dir) throws Exception { From 37b9b77093c6adef874c36db62819d6a3baab6da Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 23 May 2026 12:15:56 +0000 Subject: [PATCH 15/15] fix: cover IOException + unknown-rollup branches in --save; bump CI heap to 2g MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two SonarCloud blockers on PR #4 still live after 79b3da0: 1. New-code coverage stuck at 77.93% (gate ≥ 80%). The previously-uncovered lines in SonarCommand.java that my earlier tests didn't reach were: - L197-199: the IOException catch + IllegalStateException rethrow in the --save block. - L261-263: the rollup helper's 'unknown bucket appended in sorted order' branch (only triggers when an issue carries a severity or type outside the preferred-order list). Added two new tests: - saveToUnwritableTargetExitsTwo: --save target = the temp directory itself, which Files.writeString refuses → IOException → rethrow → exit 2 with a 'could not write report' stderr message. - saveRollupHandlesUnknownTypeBuckets: two issues with synthetic 'OTHER' / 'WEIRDTYPE' types (BLOCKER severity to pass the floor) exercise the post-preferred-order alphabetical sorting branch. Both branches now exercised; the 7 uncovered-but-targetable lines in SonarCommand drop, taking new-code coverage from 77.93% to ~83% — clear of the 80% gate. 2. self-scan workflow OOMed at maven-assembly-plugin:single with 'Execution exception: Java heap space'. Maven's default heap is small (~256 MB) and the dist module packages ~150 MB of analyzer JARs + fat jars into the skill bundle zip. Set MAVEN_OPTS=-Xmx2g at the job level in both sonar.yml (where it OOMed) and parity.yml (same dist step, same risk on a less-fortunate runner). Local: 5 --save tests pass; full cli reactor green. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/parity.yml | 4 ++ .github/workflows/sonar.yml | 7 +++ .../java/dev/sonarcli/cli/CommandTest.java | 53 +++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index f1ce6d1..c92f44b 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -40,6 +40,10 @@ jobs: # Skip when the token isn't reachable (fork PRs) — the standalone # self-scan workflow still gates fork PRs on our daemon's findings. if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + env: + # Same dist-assembly heap concern as sonar.yml — 2 GB to keep the + # 150 MB skill-bundle zip step from OOMing maven-assembly-plugin. + MAVEN_OPTS: -Xmx2g steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 94e343b..54bb3e0 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -23,6 +23,13 @@ jobs: self-scan: name: Self-scan runs-on: ubuntu-latest + env: + # The dist-assembly step packages the ~150 MB skill bundle (CLI + daemon + # fat jars + 10 analyzer plugins). Maven's default heap is too small for + # that on ubuntu-latest — we'd get 'Execution exception: Java heap space' + # from maven-assembly-plugin:single. 2 GB is plenty and well under the + # runner's ~7 GB available memory. + MAVEN_OPTS: -Xmx2g steps: # fetch-depth: 0 keeps the full history available so we can switch to # `--diff`-style semantics later without re-checking out the repo. diff --git a/cli/src/test/java/dev/sonarcli/cli/CommandTest.java b/cli/src/test/java/dev/sonarcli/cli/CommandTest.java index 35ca16e..96496d0 100644 --- a/cli/src/test/java/dev/sonarcli/cli/CommandTest.java +++ b/cli/src/test/java/dev/sonarcli/cli/CommandTest.java @@ -263,6 +263,59 @@ void saveSummaryRollupsCoverAllBuckets(@TempDir Path dir) throws Exception { "saved JSON must hold the full report"); } + @Test + @DisplayName("--save to an unwritable target exits 2 with a clear error") + void saveToUnwritableTargetExitsTwo(@TempDir Path dir) throws Exception { + Path source = Files.writeString(dir.resolve("Bad.java"), "class Bad {}"); + // Pass the temp directory itself as the --save path. Files.writeString + // refuses to write a regular file at a directory's path, which exercises + // the IOException catch + rethrow-as-IllegalStateException branch. + StubRpc rpc = rpc(); + rpc.analyzeResult = new AnalyzeResponse( + List.of(issue("Bad.java", "java:S1118", "MAJOR")), List.of()); + + Run run = run(rpc, control(), + "--format", "json", + "--save", dir.toString(), + "check", source.toString()); + + assertEquals(2, run.exitCode(), + "an unwritable --save target must exit 2 (tool error)"); + assertTrue(run.err().toLowerCase().contains("could not write report"), + "stderr must explain the write failure, got: " + run.err()); + } + + @Test + @DisplayName("--save rollup appends unknown type-buckets in sorted order after the preferred ones") + void saveRollupHandlesUnknownTypeBuckets(@TempDir Path dir) throws Exception { + Path source = Files.writeString(dir.resolve("Bad.java"), "class Bad {}"); + Path target = dir.resolve(".sonar-predictor").resolve("scan.json"); + StubRpc rpc = rpc(); + // BLOCKER severity passes the severity floor. Both issues carry a + // 'type' that's outside the preferred-order list (BUG / CODE_SMELL / + // VULNERABILITY / SECURITY_HOTSPOT), forcing the rollup to fall + // through to its unknown-bucket sorting branch. + rpc.analyzeResult = new AnalyzeResponse( + List.of( + new Issue("custom:R1", "Bad.java", 1, 0, 1, 5, "BLOCKER", "WEIRDTYPE", "m1"), + new Issue("custom:R2", "Bad.java", 2, 0, 2, 5, "BLOCKER", "OTHER", "m2")), + List.of()); + + Run run = run(rpc, control(), + "--format", "json", + "--save", target.toString(), + "check", source.toString()); + + assertEquals(1, run.exitCode()); + String stdout = run.out(); + int other = stdout.indexOf("OTHER=1"); + int weird = stdout.indexOf("WEIRDTYPE=1"); + assertTrue(other >= 0 && weird >= 0, + "unknown type buckets must appear in the rollup, got: " + stdout); + assertTrue(other < weird, + "unknown type buckets must be sorted alphabetically, got: " + stdout); + } + @Test @DisplayName("--save on a clean scan still writes the report and emits the summary, exit 0") void saveCleanScanWritesEmptyReport(@TempDir Path dir) throws Exception {