chore(ci): scan this repo with SonarQube (phase 4, slice 0)
Until now this was the one part of the platform that had never been scanned.
The 75 files here arrived in the Phase 3 extraction and left their Sonar
history behind in core's project, so a whole module's worth of shipped code
has no dashboard at all.
Adds sonar-project.properties (project key Module-uo) and a sonarqube.yml
mirroring website's: push to main, never a PR gate, nothing waiting on the
quality gate.
Two things differ from core's config, both because this repo is shaped
differently:
- There is no src/ to point sonar.sources at — the server half keeps
boot.js/core.js/index.js at server/ root beside its subdirectories — so
the whole tree is included and the non-source parts are excluded. That
direction is deliberate: a new top-level server directory is scanned by
default rather than silently unscanned.
- server/scripts and client/scripts are IN. checkImports.js and
checkExternals.js are the enforcement of MODULE_API.md 5.1 and 3.6, they
carry their own test suites, and both have already shipped defects a
reviewer missed. Build code that decides whether a release is allowed out
is not throwaway code.
The workflow builds the client chunk before running either suite, for the
reason pr-checks.yml already calls load-bearing: build.test.js and
registration.test.js read dist/entry.js and SKIP without it, so the other
order reports coverage for a suite that quietly asked less than it looks like
it did.
Both suites run from the repo root rather than with --prefix, so the LCOV SF:
paths come out repo-root-relative and resolve against sonar.sources. That is
why the server suite's --require is spelled out here instead of reusing
`npm test --prefix server`, whose path is relative to server/.
Verified locally: 385 server test cases across 57 covered files and 40 client
cases, both LCOV and Generic Test Execution XML well-formed with
repo-root-relative paths.
Needs one-time setup in the Gitea UI before it can run — secret SONAR_TOKEN
and variable SONAR_HOST_URL, same as the other repos.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
103
.gitea/workflows/sonarqube.yml
Normal file
103
.gitea/workflows/sonarqube.yml
Normal file
@@ -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 }}
|
||||||
@@ -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`
|
branch and no cutover, unlike `website`, whose module work accumulates on `edge`
|
||||||
and reaches `main` once.
|
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
|
### Commit messages
|
||||||
|
|
||||||
We use [Conventional Commits](https://www.conventionalcommits.org/) —
|
We use [Conventional Commits](https://www.conventionalcommits.org/) —
|
||||||
|
|||||||
64
scripts/sonar-test-reporter.mjs
Normal file
64
scripts/sonar-test-reporter.mjs
Normal file
@@ -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/
|
||||||
|
// <testExecutions version="1">
|
||||||
|
// <file path="server/test/foo.test.js">
|
||||||
|
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
|
||||||
|
// </file>
|
||||||
|
// </testExecutions>
|
||||||
|
//
|
||||||
|
// 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 '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
|
||||||
|
for (const [file, cases] of byFile) {
|
||||||
|
yield ` <file path="${xmlEscape(file)}">\n`
|
||||||
|
for (const c of cases) {
|
||||||
|
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
|
||||||
|
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
|
||||||
|
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
|
||||||
|
else yield ` <testCase ${attrs}/>\n`
|
||||||
|
}
|
||||||
|
yield ' </file>\n'
|
||||||
|
}
|
||||||
|
yield '</testExecutions>\n'
|
||||||
|
}
|
||||||
54
sonar-project.properties
Normal file
54
sonar-project.properties
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user