fix(ci): make SonarQube actually analyse the frontend (#61) #69

Merged
bermudalamb merged 1 commits from bugfix/67-sonar-frontend-skipped into main 2026-08-19 14:52:09 -05:00
7 changed files with 164 additions and 6 deletions
+10
View File
@@ -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. `@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 ## Testing
- **Unit tests**: `cd backend && npm run test:unit` — no DB required - **Unit tests**: `cd backend && npm run test:unit` — no DB required
+11 -6
View File
@@ -37,14 +37,19 @@ jobs:
run: npm run build run: npm run build
working-directory: frontend 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 - name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@v4 uses: sonarsource/sonarqube-scan-action@v4
env: env:
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 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/**
+1
View File
@@ -10,3 +10,4 @@ playwright-report/
test-results/ test-results/
.env .env
.superpowers/ .superpowers/
.scannerwork/
+16
View File
@@ -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"]
}
+61
View File
@@ -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).`);
+49
View File
@@ -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}"
+16
View File
@@ -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