diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 0000000..c92f44b --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,207 @@ +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 }} + 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 + 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 + # 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 + 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/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000..54bb3e0 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,169 @@ +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 +# 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 + 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. + - 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. + # + # 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. + # `// ([.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 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/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..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 @@ -488,18 +569,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..96496d0 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. } } @@ -174,6 +177,168 @@ 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 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 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 { + 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/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/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/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/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 diff --git a/pom.xml b/pom.xml index 7783fba..53d69f5 100644 --- a/pom.xml +++ b/pom.xml @@ -116,8 +116,40 @@ maven-surefire-plugin 3.2.5 + + org.jacoco + jacoco-maven-plugin + + 0.8.13 + + + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + prepare-agent + + + report + verify + report + + + + diff --git a/scripts/scan_parity.py b/scripts/scan_parity.py new file mode 100644 index 0000000..4c8e4f7 --- /dev/null +++ b/scripts/scan_parity.py @@ -0,0 +1,389 @@ +#!/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.error +import urllib.parse +import urllib.request +from collections import Counter +from typing import Any + + +SOURCE_SELF = "self" +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) + 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_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, + 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]], + 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} + + 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 + + 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), + "cloud": len(cloud_issues), + "common": len(both), + "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], + } + + +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"] + cov = report["coverage"] + lines = ["## Scan parity — self-scan ↔ SonarQube Cloud", ""] + lines += [ + "### Issues", + "", + "| 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)", + "", + "### 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] + 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 _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) + 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) + self_coverage = load_self_coverage(args.self_scan) + cloud_issues = fetch_cloud_issues( + args.host, + args.project_key, + args.organization, + token, + args.branch, + args.pull_request, + ) + 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: + 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:])) 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 257cb69..0000000 --- a/spike/src/main/java/dev/sonarcli/spike/EngineSpike.java +++ /dev/null @@ -1,209 +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"; - - 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(); - } -}