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