diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml new file mode 100644 index 0000000..8882f65 --- /dev/null +++ b/.gitea/workflows/sonarqube.yml @@ -0,0 +1,103 @@ +# Run SonarQube static analysis against the code that just landed on `main` and +# report the results to the self-hosted SonarQube server for review. This is +# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge), +# not on pull_request, so it never gates a PR. It complements pr-checks.yml +# (which gates PRs) and release.yml (which publishes the bundle) — this one only +# feeds the dashboard. +# +# Mirrors RunicGateway/website's sonarqube.yml, for the same reason pr-checks.yml +# does: this module is two npm packages shaped like that repo's `server/` and +# `client/`, and it is loaded into that repo's process. Until now it was the one +# part of the platform that had never been scanned — 75 files that arrived in the +# Phase 3 extraction with core's Sonar history left behind in core's project. +# +# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions): +# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at +# My Account → Security in SonarQube for the +# Module-uo project (or a global one). +# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g. +# http://192.168.0.56:9000 +# (kept as a variable, not committed, so the internal address stays out of git.) +# +# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be +# able to reach SONAR_HOST_URL on your network. Nothing here waits on the +# SonarQube Quality Gate, so a failing gate does not fail this job — check the +# dashboard when you want to. + +name: SonarQube + +on: + push: + branches: [main] + # Allow re-running the analysis on demand from the Actions tab. + workflow_dispatch: {} + +concurrency: + group: sonarqube-${{ github.ref }} + cancel-in-progress: true + +jobs: + analysis: + runs-on: ubuntu-latest + steps: + - name: Check out (full history for accurate new-code + blame) + uses: actions/checkout@v4 + with: + # SonarQube uses git history to attribute issues to authors and to + # compute "new code". A shallow clone degrades both. + fetch-depth: 0 + + # Node 22, where pr-checks.yml pins 20: the built-in `lcov` coverage + # reporter this job depends on needs >= 22. The version that matters for + # correctness is the one in pr-checks.yml, which matches the core process + # this module is loaded into; nothing here ships. + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install deps for both halves + run: | + npm ci --prefix server + npm ci --prefix client + + # The chunk has to exist before the client suite runs: build.test.js and + # registration.test.js read `client/dist/entry.js`, and both SKIP when + # there is no build. Run the other way round they skip silently and this + # job reports coverage for a suite that quietly asked less than it looks + # like it did — the same ordering pr-checks.yml calls load-bearing. + - name: Build the client chunk + run: npm run build --prefix client + + # SonarQube runs static analysis only — it never executes the test suite, + # so we must produce the coverage report ourselves and hand it to the + # scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties). + # + # Both suites are invoked from the REPO ROOT rather than with `--prefix`, + # so the LCOV `SF:` paths come out repo-root-relative (`server/router/...`, + # `client/src/...`) and resolve against sonar.sources. That is also why the + # server suite's `--require` is spelled out here instead of reusing + # `npm test --prefix server`, whose path is relative to `server/`. + - name: Generate server test coverage (LCOV) + run: | + mkdir -p server/coverage + node --test --experimental-test-coverage \ + --require ./server/test/_setup.js \ + --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \ + --test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \ + server/test/*.test.js + + - name: Generate client test coverage (LCOV) + run: | + mkdir -p client/coverage + node --test --experimental-test-coverage \ + --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \ + --test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \ + client/test/*.test.js + + - name: Run SonarQube scan + uses: sonarsource/sonarqube-scan-action@v4 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a194d19..07d990c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,6 +96,19 @@ when someone builds on the server is not shippable. branch and no cutover, unlike `website`, whose module work accumulates on `edge` and reaches `main` once. +### Static analysis runs after the merge, not on the PR + +`.gitea/workflows/sonarqube.yml` scans `main` on push and reports to the +self-hosted SonarQube instance under the project key **`Module-uo`**. It is +deliberately non-blocking: it never gates a pull request, and a failing quality +gate does not fail the job. Check the dashboard when you want to; the things +that must not reach `main` are gated by `pr-checks.yml` instead. + +It runs both suites from the repo root to produce coverage, and builds the +client chunk first — two of the client tests read `dist/entry.js` and skip +without it, which would leave this job reporting on a suite that quietly asked +less than it appears to. + ### Commit messages We use [Conventional Commits](https://www.conventionalcommits.org/) — diff --git a/scripts/sonar-test-reporter.mjs b/scripts/sonar-test-reporter.mjs new file mode 100644 index 0000000..6805320 --- /dev/null +++ b/scripts/sonar-test-reporter.mjs @@ -0,0 +1,64 @@ +// Custom node:test reporter that emits SonarQube's Generic Test Execution XML. +// +// Node's built-in reporters give us coverage (`lcov`) and pass/fail output +// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a +// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the +// lcov report only populates Coverage, which is why the dashboard shows coverage +// while the Unit Tests tile stays "-". This reporter produces that missing report. +// +// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/ +// +// +// +// +// +// +// Paths are emitted repo-root-relative (POSIX separators) so they match the +// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the +// absolute `file` on each event strips cleanly against process.cwd(). +import path from 'node:path' + +function xmlEscape(s) { + return String(s).replace(/[<>&"']/g, (c) => ({ + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + })[c]) +} + +export default async function* sonarTestReporter(source) { + const byFile = new Map() + const cwd = process.cwd() + + for await (const event of source) { + if (event.type !== 'test:pass' && event.type !== 'test:fail') continue + const d = event.data + // Skip the container events (a `describe` suite) and anything without a file + // — only real test cases go in the report, so the count matches the runner's. + if (!d.file || (d.details && d.details.type === 'suite')) continue + + const rel = path.relative(cwd, d.file).split(path.sep).join('/') + if (!byFile.has(rel)) byFile.set(rel, []) + byFile.get(rel).push({ + name: d.name, + duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)), + failed: event.type === 'test:fail', + skipped: Boolean(d.skip || d.todo), + }) + } + + yield '\n\n' + for (const [file, cases] of byFile) { + yield ` \n` + for (const c of cases) { + const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"` + if (c.failed) yield ` \n` + else if (c.skipped) yield ` \n` + else yield ` \n` + } + yield ' \n' + } + yield '\n' +} diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..38cd0db --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,54 @@ +# SonarQube analysis config for module-uo. +# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main. +# The project key must match the one created in SonarQube (dashboard URL +# ?id=Module-uo). + +sonar.projectKey=Module-uo +sonar.projectName=Module-uo + +# Analysed application code. +# +# Unlike core's repo there is no `src/` directory to point at: the server half +# keeps its code at `server/` root (boot.js, core.js, index.js) beside its +# subdirectories, so the whole tree is included and the non-source parts are +# excluded below. That direction is deliberate — a new top-level server +# directory is scanned by default rather than silently unscanned, which is the +# safer way for this list to be wrong. +# +# `client/scripts` and `server/scripts` are in, not out: checkExternals.js and +# checkImports.js *are* the enforcement of MODULE_API.md §3.6 and §5.1, they +# each carry their own test suite, and both have already shipped defects that a +# reviewer missed (see MODULE_SYSTEM.md §2.7.1). Build code that decides whether +# a release is allowed out is not throwaway code. +sonar.sources=server,client/src,client/scripts + +# Test code is analysed separately from sources so coverage/metrics attribute +# correctly. Both halves run on Node's built-in test runner (no browser/DOM): +# the server suite is CommonJS behind test/_setup.js, the client's is ESM. +sonar.tests=server/test,client/test +sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js + +# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in +# test-coverage and writes an LCOV report for each BEFORE the scan runs; without +# them the dashboard shows 0% (the scanner never executes tests itself). Both +# suites are invoked from the repo root so the `SF:` paths come out +# repo-root-relative (server/..., client/src/...) and the scanner resolves them +# against the project base dir. +sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info + +# Test execution ("Unit Tests" measure). A SEPARATE report from coverage: the +# lcov files above only populate Coverage, so without this the dashboard shows a +# coverage % but an empty "Unit Tests" tile. Written by scripts/sonar-test-reporter.mjs, +# a copy of core's — a pure leaf build helper, which is the side of the vendoring +# line that may be copied (MODULE_SYSTEM.md §2.7.1). +sonar.testExecutionReportPaths=server/coverage/test-execution.xml,client/coverage/test-execution.xml + +# Never analyse dependencies, build output, generated artifacts, or fixtures. +# +# `client/dist` is the built chunk (gitignored, but the workflow builds it before +# scanning because client/test/{build,registration}.test.js import it). +# `server/swagger/doc.js` and the two committed generated artifacts at the repo +# root are inputs to and outputs of swagger-autogen, not hand-written code. +sonar.exclusions=**/node_modules/**,server/test/**,client/test/**,client/dist/**,server/swagger/**,server/data/**,server/coverage/**,client/coverage/**,**/*.min.js + +sonar.sourceEncoding=UTF-8