fix(ci): make SonarQube actually analyse the frontend (#67)
SonarQube was analysing none of frontend/src. All 34 files were indexed and reported ncloc 0, so the quality gate had been measuring roughly a third of the codebase while appearing to cover it. The scanner log gives the cause: frontend/tsconfig.json sets "moduleResolution": "bundler", which is correct for Vite, and the TypeScript bundled with SonarQube 9.9 predates 5.0 and rejects it. Building the frontend program throws, every frontend file is dropped, and the scan still exits EXECUTION SUCCESS — which is why a green job hid this indefinitely. Adds frontend/tsconfig.sonar.json, an analysis-only mirror that differs only in using "node", and points the scan at it via sonar.typescript.tsconfigPaths. The app's own tsconfig is deliberately untouched: "bundler" is right for the build, and changing it to satisfy an old analyser would let the tool dictate the build. The mirror cannot use `extends` — the old compiler validates the base file while reading it, so the error just moves to pointing at tsconfig.json. Verified locally against a scratch project: 59/59 files analysed, no skips. Analysed lines go from 2,069 to 5,481, code smells from 2 to 14, security hotspots from 3 to 4, and technical debt from 21 to 85 minutes. The frontend had been hiding twelve code smells and a hotspot, which is part of why the React problems behind #60 and #62 had to be found by hand. A standalone copy drifts, and drift here does not fail anything — it silently returns to skipping the frontend while reporting success. scripts/check-sonar-tsconfig.js compares the two and fails when they diverge in anything but moduleResolution, and runs before the scan so the scan is never what discovers it. Confirmed it catches drift by introducing some. Moves scan settings into sonar-project.properties at the repo root so a local scan and the CI scan analyse the same thing, leaving only the host and token in secrets. Adds scripts/scan-local.sh, which runs the scanner in Docker because it needs Java 11+ and the dev machine has Java 8, and which defaults to a scratch project key: the server is Community edition with no branch analysis, so any scan overwrites the single main analysis of whichever key it is given. Closes #67
This commit is contained in:
@@ -236,6 +236,16 @@ The severity split is deliberate and is the whole design: every preset is downgr
|
||||
|
||||
`@typescript-eslint/no-misused-promises` runs with `checksVoidReturn: { attributes: false }`, because `onClick={async () => ...}` is idiomatic React and safe when the handler catches its own errors — left at the default the rule flags every antd button in the admin screens.
|
||||
|
||||
## SonarQube
|
||||
|
||||
Server is **SonarQube 9.9.8 LTA, Community edition**, at the URL in `SONARQUBE_URL`. Scan settings live in `sonar-project.properties` at the repo root, not as inline `-D` args, so a local scan and the CI scan analyse the same thing; only the host and token come from Gitea secrets.
|
||||
|
||||
**Community edition has no branch analysis.** Every scan overwrites the single `main` analysis of whatever project key it is given, so scanning a feature branch under the real key silently replaces CI's picture of main with your working tree. `scripts/scan-local.sh` therefore defaults to the scratch key `redefined-designs-local`; pass `redefined-designs` explicitly to publish for real. The scanner runs in Docker because it needs Java 11+ and the dev machine's Java is 8.
|
||||
|
||||
**`frontend/tsconfig.sonar.json` is load-bearing, and its failure mode is silent.** SonarQube 9.9's bundled TypeScript predates 5.0 and rejects `"moduleResolution": "bundler"`, which `frontend/tsconfig.json` needs for Vite. Without the shim the frontend program fails to build, all 34 frontend files are skipped, and **the scan still exits `EXECUTION SUCCESS`** — the state #67 found, where the gate had been reporting on a third of the codebase while looking complete. The shim cannot use `extends`: the old compiler validates the base file while reading it, so the error fires before any override applies. `scripts/check-sonar-tsconfig.js` runs before the scan in CI and fails if the copy drifts from the real tsconfig in anything but `moduleResolution`. Delete the shim, the check, and the `sonar.typescript.tsconfigPaths` line together once the server is new enough to parse `bundler`.
|
||||
|
||||
Take a green SonarQube job as weak evidence. It exits success on a partially-failed analysis, so the number worth checking after a scan is `ncloc` — if it drops sharply, something is being skipped.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests**: `cd backend && npm run test:unit` — no DB required
|
||||
|
||||
@@ -37,14 +37,19 @@ jobs:
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
|
||||
# frontend/tsconfig.sonar.json is a standalone copy that cannot use
|
||||
# `extends`, so it can drift. Drift does not fail the scan — it silently
|
||||
# returns to skipping the frontend while still reporting success, which is
|
||||
# the failure #67 was about. Checked before scanning, so the scan is never
|
||||
# the thing that discovers it.
|
||||
- name: Check the Sonar tsconfig has not drifted
|
||||
run: node scripts/check-sonar-tsconfig.js
|
||||
|
||||
# Scan settings live in sonar-project.properties at the repo root, so a
|
||||
# local scan and this one analyse the same thing. Only the host and token
|
||||
# come from secrets.
|
||||
- name: SonarQube Scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.projectKey=redefined-designs
|
||||
-Dsonar.login=${{ secrets.SONAR_TOKEN }}
|
||||
-Dsonar.sources=backend/src,frontend/src
|
||||
-Dsonar.exclusions=**/node_modules/**,**/dist/**
|
||||
|
||||
@@ -10,3 +10,4 @@ playwright-report/
|
||||
test-results/
|
||||
.env
|
||||
.superpowers/
|
||||
.scannerwork/
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"//": "Analysis-only copy of tsconfig.json. SonarQube 9.9 bundles a TypeScript older than 5.0, which rejects moduleResolution \"bundler\" outright — the program fails to build and every frontend file is skipped while the scan still reports success. It cannot `extends` tsconfig.json either: the old compiler validates the base file as it reads it, so the error fires before any override applies. Keep this in step with tsconfig.json; scripts/check-sonar-tsconfig.js fails the CI scan if they drift. Delete it once the server is new enough to parse \"bundler\".",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
// frontend/tsconfig.sonar.json is a standalone copy of frontend/tsconfig.json
|
||||
// that exists only so SonarQube 9.9 can build a TypeScript program — see #67.
|
||||
// It cannot use `extends`, because the analyser's bundled compiler validates the
|
||||
// base file while reading it and rejects "moduleResolution": "bundler" before
|
||||
// any override applies.
|
||||
//
|
||||
// A copy drifts. When it does, the scan does not fail: it goes back to skipping
|
||||
// every frontend file and still reports EXECUTION SUCCESS, which is exactly the
|
||||
// silent failure #67 was about. So the copy is checked rather than trusted.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const dir = path.join(__dirname, '..', 'frontend');
|
||||
const REAL = path.join(dir, 'tsconfig.json');
|
||||
const SONAR = path.join(dir, 'tsconfig.sonar.json');
|
||||
|
||||
// The one option the copy is allowed to differ on — the whole reason it exists.
|
||||
const ALLOWED_DIFF = 'moduleResolution';
|
||||
|
||||
// Strips comments and the "//" documentation key so the two are compared on
|
||||
// their actual settings.
|
||||
function load(file) {
|
||||
const text = fs.readFileSync(file, 'utf-8').replace(/^\s*\/\/.*$/gm, '');
|
||||
const parsed = JSON.parse(text);
|
||||
delete parsed['//'];
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const real = load(REAL);
|
||||
const sonar = load(SONAR);
|
||||
const problems = [];
|
||||
|
||||
const realOpts = real.compilerOptions || {};
|
||||
const sonarOpts = sonar.compilerOptions || {};
|
||||
|
||||
for (const key of new Set([...Object.keys(realOpts), ...Object.keys(sonarOpts)])) {
|
||||
if (key === ALLOWED_DIFF) continue;
|
||||
const a = JSON.stringify(realOpts[key]);
|
||||
const b = JSON.stringify(sonarOpts[key]);
|
||||
if (a !== b) problems.push(`compilerOptions.${key}: tsconfig.json has ${a}, tsconfig.sonar.json has ${b}`);
|
||||
}
|
||||
|
||||
if (JSON.stringify(real.include) !== JSON.stringify(sonar.include)) {
|
||||
problems.push(`include: tsconfig.json has ${JSON.stringify(real.include)}, tsconfig.sonar.json has ${JSON.stringify(sonar.include)}`);
|
||||
}
|
||||
|
||||
// A copy that no longer overrides the option it exists to override is worse than
|
||||
// no copy at all — the scan would silently fall back to skipping the frontend.
|
||||
if (sonarOpts[ALLOWED_DIFF] === 'bundler' || sonarOpts[ALLOWED_DIFF] === undefined) {
|
||||
problems.push(`compilerOptions.${ALLOWED_DIFF} in tsconfig.sonar.json must be a value SonarQube 9.9 accepts ('node', 'classic', 'node16', 'nodenext'), not ${JSON.stringify(sonarOpts[ALLOWED_DIFF])}`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error('frontend/tsconfig.sonar.json has drifted from frontend/tsconfig.json:\n');
|
||||
for (const problem of problems) console.error(` - ${problem}`);
|
||||
console.error(`\nBring them back into step. Everything except "${ALLOWED_DIFF}" must match, or SonarQube analyses the frontend against settings the app is not built with. See #67.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`frontend/tsconfig.sonar.json matches tsconfig.json (differing only on ${ALLOWED_DIFF}, as intended).`);
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run a SonarQube analysis from this machine, using the same
|
||||
# sonar-project.properties that CI uses.
|
||||
#
|
||||
# Publishes to a SCRATCH project key by default, and that default is the point
|
||||
# of this script. The server is SonarQube Community, which has no branch
|
||||
# analysis: every scan overwrites the single "main" analysis of whatever project
|
||||
# key it is given. Scanning a feature branch under the real key would replace
|
||||
# CI's picture of main with your working tree, silently.
|
||||
#
|
||||
# ./scripts/scan-local.sh # -> redefined-designs-local
|
||||
# ./scripts/scan-local.sh redefined-designs # -> the real project, deliberate
|
||||
#
|
||||
# Needs SONARQUBE_URL and SONARQUBE_TOKEN in the environment, and Docker. The
|
||||
# scanner runs in a container because it needs Java 11+, and the Java on the dev
|
||||
# machine is 8.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_KEY="${1:-redefined-designs-local}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if [[ -z "${SONARQUBE_URL:-}" || -z "${SONARQUBE_TOKEN:-}" ]]; then
|
||||
echo "SONARQUBE_URL and SONARQUBE_TOKEN must be set." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$PROJECT_KEY" == "redefined-designs" ]]; then
|
||||
echo "Publishing to the REAL project — this replaces CI's analysis of main until the next CI run."
|
||||
echo "Ctrl-C within 5s to abort."
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# Docker needs a Windows-style path here; MSYS_NO_PATHCONV stops Git Bash
|
||||
# rewriting the container-side path.
|
||||
HOST_PATH="$REPO_ROOT"
|
||||
if [[ "$(uname -s)" == MINGW* || "$(uname -s)" == MSYS* ]]; then
|
||||
HOST_PATH="$(cd "$REPO_ROOT" && pwd -W)"
|
||||
fi
|
||||
|
||||
echo "Scanning $HOST_PATH -> $PROJECT_KEY on $SONARQUBE_URL"
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-e SONAR_HOST_URL="$SONARQUBE_URL" \
|
||||
-e SONAR_TOKEN="$SONARQUBE_TOKEN" \
|
||||
-v "$HOST_PATH:/usr/src" \
|
||||
sonarsource/sonar-scanner-cli:5 \
|
||||
-Dsonar.projectKey="$PROJECT_KEY" \
|
||||
-Dsonar.working.directory=/tmp/scannerwork \
|
||||
"${@:2}"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Scan configuration, kept here rather than as inline -D arguments in
|
||||
# .gitea/workflows/sonarqube.yml so that a local scan and a CI scan analyse the
|
||||
# same thing. Only the host URL and token stay in CI secrets.
|
||||
|
||||
sonar.projectKey=redefined-designs
|
||||
sonar.projectName=redefined-designs
|
||||
sonar.sources=backend/src,frontend/src
|
||||
sonar.exclusions=**/node_modules/**,**/dist/**
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Without this the analyser auto-discovers frontend/tsconfig.json, chokes on its
|
||||
# "moduleResolution": "bundler" — unrecognised by the TypeScript bundled with
|
||||
# SonarQube 9.9 — and silently drops all 34 frontend files while still exiting
|
||||
# EXECUTION SUCCESS. See #67. tsconfig.sonar.json is an analysis-only mirror;
|
||||
# both it and this line go away once the server can parse "bundler".
|
||||
sonar.typescript.tsconfigPaths=backend/tsconfig.json,frontend/tsconfig.sonar.json
|
||||
Reference in New Issue
Block a user