ADR-010: SonarCloud for Continuous Code Quality¶
Status¶
accepted
Date¶
2026-03-07
Context¶
APTL had no automated code quality analysis. Code quality was assessed through manual review only—first during development, then formally in the team review documented in notes/team-review-synthesis.md. That review found 30 issues (17 major, 13 minor), including any-typed MCP args, duplicated code, dead config fields, and a process-global SSL disable.
As the codebase grew to include both Python (src/aptl/, 587+ tests) and TypeScript (mcp/, vitest tests), a continuous quality gate was needed to catch regressions before they accumulate.
Decision¶
Integrate SonarCloud via GitHub Actions for continuous code quality analysis across both language stacks.
Configuration¶
sonar-project.properties:
- Sources: src (Python) and mcp (TypeScript)
- Tests: tests (Python) and mcp/aptl-mcp-common/tests (TypeScript)
- Exclusions: node_modules, __pycache__, dist, .venv, build, containers, and mcp/**/tests/** (to prevent test files from being double-indexed as both source and test)
Coverage pipeline (.github/workflows/sonarcloud.yml):
1. Python: pytest --cov generates coverage.xml
2. TypeScript: vitest --coverage in mcp/aptl-mcp-common/ generates lcov.info
3. SonarCloud scan reads both coverage reports
Key Implementation Details¶
- Test file separation: MCP test files under
mcp/**/tests/**are added tosonar.exclusions(excluded from source analysis) while simultaneously listed insonar.tests(recognized as test sources). Without this, SonarCloud reported "can't be indexed twice" errors (v4.6.8). - Coverage bootstrapping: The initial CI workflow ran SonarCloud without running tests first, resulting in 0% coverage. Fixed by adding pytest and vitest steps before the scan (v4.5.0, v4.6.8).
- Scanner migration: Migrated from deprecated
SonarSource/sonarcloud-github-actiontoSonarSource/sonarqube-scan-action@v7(v4.7.0). - CI action versions: Upgraded to Node.js 24-compatible versions (
actions/checkout@v6,actions/setup-python@v6,actions/setup-node@v6) in v4.7.0.
Consequences¶
Positive¶
- Continuous quality gate: Every PR and push to main gets automated quality analysis
- Dual-language coverage: Both Python and TypeScript codebases have measured test coverage
- Historical tracking: SonarCloud maintains quality metrics over time, showing trends
Negative¶
- External dependency: SonarCloud is a third-party service. Outages block quality gate checks.
- CI pipeline complexity: The workflow must install Python, Node.js, run two test suites, and then scan—adding ~3-5 minutes to every CI run.
Risks¶
- SonarCloud free tier limits may be reached as the codebase grows (currently well within limits for open-source projects)
- Coverage thresholds are not yet enforced as quality gates—currently informational only
Update (2026-06-21): SonarCloud gate is required again (issue #502)¶
The SonarCloud job in .github/workflows/checks.yml is a required merge
gate again: it runs with -Dsonar.qualitygate.wait=true and invokes
tools/sonar/assert_no_new_issues.py so any open issue in the new-code leak
period fails CI. Advisory vulnerability scanners (Trivy, OSV-scanner,
dependency-audit) stay non-blocking per ADR-026.
Branch protection on main and dev uses strict required status checks
(documented in .github/branch-protection-baseline.json): pre-commit, docs,
Python/MCP/web test jobs, and SonarCloud. Admin bypass is retained
(enforce_admins: false).
The hard in-repo per-function complexity gates (ruff C901, ESLint
complexity) remain unchanged and continue to gate through the required
Pre-commit hooks job.
Update (2026-05-10): SonarCloud gate stayed advisory; hard complexity gate added¶
The SonarCloud analysis lived in .github/workflows/checks.yml (the
former sonarcloud.yml was folded in), and the quality-gate job was run
advisory (continue-on-error, no -Dsonar.qualitygate.wait): the
"Sonar way" gate's Coverage on New Code ≥ 80% condition would otherwise
block infra-only commits (Dockerfiles, compose, workflows, scripts) that add
no covered code. Issue #502 reverts that posture now that the repo also
carries the new-code issue gate script.
To keep god-methods / non-modular code out of the tree without the coverage
trap and without the new-code-only blind spot, a dedicated, hard,
repo-wide per-function complexity gate runs in pre-commit and CI: ruff
check src/ with only C901 enabled ([tool.ruff.lint] select =
["C901"]) and [tool.ruff.lint.mccabe] max-complexity = 15—matching
SonarCloud's default cognitive-complexity threshold (cyclomatic complexity is
a fine proxy for "this method does too much"). This is the only ruff rule
turned on; it is a complexity guard, not a style linter; widen the rule set
deliberately if/when the team wants more from ruff.
TypeScript complexity extension guardrails¶
Issue #286 extends the same hard gate to the TypeScript surfaces. Treat that
as the same cross-cutting quality policy as ruff-complexity, not as a
package-local lint cleanup. The TypeScript gate must stay narrow: per-function
complexity only, threshold 15, no formatting/import/style/recommended-rule
overhaul bundled with it.
The canonical enforcement point is the required Pre-commit hooks check in
.github/workflows/checks.yml, matching the Python gate. If implementation
chooses a separate required CI job instead, keep the pre-commit hook for local
parity. In either form, the gate must run before slower vitest hooks and must
not be skipped by the existing CI SKIP list.
Scope is mcp/*/src/**/*.ts and web/src/**/*.{ts,svelte}. Generated output,
tests, node_modules, package build/ directories, and web/.svelte-kit/
remain out of scope. web/src contains Svelte components, so a TS-only glob
would miss functions in <script lang="ts"> blocks and does not satisfy the
contract.
Keep one source of truth for the threshold, target globs, and legacy
exemptions. Reuse the existing flat ESLint/TypeScript-parser pattern if ESLint
is selected, but do not copy the full mcp-red / mcp-reverse style lint
rules into every package. Biome is acceptable only if it covers the same
mcp and Svelte inputs and can express explicit legacy exemptions. Existing
offenders should be carved out with the narrowest tool-supported exemption and
listed in this ADR's backlog with measured scores; new code should not be
added to exempted files.
TypeScript complexity gate (realized)¶
Issue #286 ships the gate as ESLint's core complexity rule at ["error", {
max: 15 }], the same cyclomatic metric as ruff C901. The single source of
truth is the root eslint.config.js: it holds the threshold, the target globs
(mcp/*/src/**/*.ts and web/src/**/*.{ts,svelte}), and the out-of-scope
ignores (build/, dist/, node_modules/, web/.svelte-kit/, tests). It
currently carries no per-file exemptions (see the cleared backlog below). Only
the complexity rule is enabled (the
config loads the TypeScript and Svelte parsers but none of their rule plugins),
so it is a complexity guard, not a style linter, and it does not cascade into
the per-package mcp-red / mcp-reverse configs. Svelte components are
first-class: functions inside <script lang="ts"> are parsed by
svelte-eslint-parser and gated like any .ts function.
Enforcement is the ts-complexity hook in .pre-commit-config.yaml, a
language: node hook with pinned additional_dependencies, self-contained
like ruff-complexity's pinned ruff. It runs immediately after
ruff-complexity, before the slower vitest hooks, and is not in the CI
SKIP list, so it gates in the required Pre-commit hooks job and locally from
the same config (no separate CI job and no branch-protection change). The config
is CommonJS so require() resolves the pinned parsers through the NODE_PATH
that pre-commit's node environment exports; noInlineConfig keeps the gate
central and tamper-resistant (a function cannot be exempted with an inline //
eslint-disable), and the hook passes --quiet so the resulting "directive has
no effect" meta-warnings stay out of the output without hiding a real error.
TypeScript complexity backlog (cleared)¶
Empty. Issue #286 added the gate and, in the same change, split the three
functions that were initially over threshold into helpers, so eslint.config.js
carries no complexity: 'off' exemptions and every function under the scoped
globs is gated at 15. The historical offenders (all CC 18) were harvestSession
(mcp/aptl-mcp-common/src/captures.ts), predefined_query
(mcp/aptl-mcp-common/src/tools/api-handlers.ts), and buildBlockSequence
(web/src/lib/workbench.ts). If a future change must temporarily exempt a file,
add a whole-file complexity: 'off' override (never a per-function inline
// eslint-disable, which edits the function and drags its pre-existing
complexity into SonarCloud's new-code analysis) and record the offender here
with its measured score.
Python complexity backlog (cleared)¶
Empty. Issue #286 split all eight functions that were over threshold into
helpers, so pyproject.toml carries no [tool.ruff.lint.per-file-ignores]
entries and every function under src/ is gated at max-complexity 15. The
historical offenders (CC = ruff's McCabe cyclomatic complexity) were:
| Function | File | CC (before) |
|---|---|---|
compile_runtime_model |
src/aptl/core/runtime/compiler.py |
45 |
_verify_objectives |
src/aptl/core/sdl/validator.py |
43 |
_verify_workflows |
src/aptl/core/sdl/validator.py |
37 |
_validate_manifest |
src/aptl/core/runtime/planner.py |
31 |
_verify_infrastructure |
src/aptl/core/sdl/validator.py |
31 |
_verify_agents |
src/aptl/core/sdl/validator.py |
25 |
_collect_resources |
src/aptl/core/runtime/planner.py |
18 |
_expand_shorthands |
src/aptl/core/sdl/parser.py |
17 |
If a future change must temporarily exempt a file, re-add the per-file-ignore
table (whole-file, never a per-def # noqa: C901) and record the offender
here with its measured score.
(SonarCloud's cognitive-complexity rule flags a wider, partly different set—
many more functions in validator.py plus deeply nested ones in snapshot.py
/ collectors.py / env.py / detection.py / cli/runs.py, and a CC-66
function in lab.py on the older main analysis; the two metrics are
complementary and both worth driving to zero.)
Known wart: src/aptl/core/lab.py:63 has a malformed # noqa: delayed import
for mocking (it predates this gate)—ruff check prints a one-line warning
for it but still passes. Left as-is here because fixing it touches lab.py,
which would drag lab.py's pre-existing SonarCloud issues into this
config-only PR; fix it to # noqa: PLC0415 the next time lab.py is edited.
Update (2026-09-20): Platform dependency gate (issue #969)¶
The earlier #502 statement that dependency-audit remains non-blocking is
superseded for dev pull requests. The existing Dependency vulnerability
scan job is now required and blocks on shipped platform dependencies, while
the mixed target Trivy and OSV scanners remain advisory. ADR-026's dated
amendment defines this boundary. The SonarCloud quality gate and other existing
required contexts are unchanged.