Merge pull request 'fix(ci): make SonarQube actually analyse the frontend (#67)' (#69) from bugfix/67-sonar-frontend-skipped into main
Reviewed-on: #69
This commit was merged in pull request #69.
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