diff --git a/.claude/project-context.md b/.claude/project-context.md index c9dd8f8..b8a5f2b 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -226,6 +226,16 @@ sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c - **Design specs live in `docs/superpowers/specs/YYYY-MM-DD--design.md`** and are committed before implementation starts. - **`.superpowers/` stays gitignored, but design artifacts inside it must be lifted out before they are lost.** That directory is scratch state belonging to the brainstorming tool and contains a session token, PID files, and absolute local paths — none of which belong in the repo. The mockups it holds *are* worth keeping, so copy them into `docs/superpowers/specs/--mockups/` and wrap them as standalone pages (they are served as fragments inside a tool-provided frame, so they need its style tokens and `toggleSelect` helper inlined to open on their own). Keep the rejected options, not just the chosen one — the value is in the comparison. +## 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 diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index d56c013..113c27c 100755 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -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/** diff --git a/.gitignore b/.gitignore index 9812144..b31eefc 100755 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ playwright-report/ test-results/ .env .superpowers/ +.scannerwork/ diff --git a/frontend/tsconfig.sonar.json b/frontend/tsconfig.sonar.json new file mode 100644 index 0000000..f808215 --- /dev/null +++ b/frontend/tsconfig.sonar.json @@ -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"] +} diff --git a/scripts/check-sonar-tsconfig.js b/scripts/check-sonar-tsconfig.js new file mode 100644 index 0000000..9abcd8f --- /dev/null +++ b/scripts/check-sonar-tsconfig.js @@ -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).`); diff --git a/scripts/scan-local.sh b/scripts/scan-local.sh new file mode 100644 index 0000000..0fc436b --- /dev/null +++ b/scripts/scan-local.sh @@ -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}" diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..b6f7c2d --- /dev/null +++ b/sonar-project.properties @@ -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