S37: Complexipy Cognitive Complexity
Purpose: Add cognitive-complexity as a second structural gate alongside ruff’s McCabe, using complexipy with a snapshot watermark so new code is held to the threshold while existing offenders are grandfathered at their current scores.
Status: Implemented (2026-05-18)
Introduction
Section titled “Introduction”S34 enabled ruff’s McCabe cyclomatic-complexity rule (C90, threshold 10) and the PLR09xx size/branch/arg rules, but explicitly deferred cognitive-complexity tooling as open question 1: “Should complexipy (cognitive complexity) be adopted as a follow-on once the McCabe-driven refactor pass has reduced the top-six offenders? Out of scope for this spec; would be a future S35-or-later.”
This spec resolves that question with yes, and pins down how. The S34 McCabe refactor pass has landed; the top-six offenders identified there are no longer in the top tier. The next gap in the gate is the readability-focused metric — nesting penalties, control-flow structure — that McCabe is structurally unable to measure (McCabe is purely branch-count; nested branches and flat branches cost the same).
Cognitive complexity as defined by SonarSource (and implemented by complexipy) adds nesting penalties: a branch inside three nested ifs costs more than the same branch at function top-level. This catches the “deeply nested decision tree” pattern that McCabe misses.
complexipy (v5.4.1 as of 2026-05-05) is a Rust-implemented cognitive-complexity scanner with a --snapshot-create/snapshot watermark mechanism that fits the S34 grandfathering pattern: snapshot the current state, then any function whose complexity increases above its baseline fails the gate. New functions are held to the threshold.
Requirements
Section titled “Requirements”Tool and version
Section titled “Tool and version”complexipy >= 5.4.1is added to thedevdependency group inpyproject.toml.- Invoked via the
complexipyconsole script (uvx-installed). The package is not directly executable aspython -m complexipy— there is no__main__.
Threshold
Section titled “Threshold”max-complexity-allowed = 15— the upstream default. Aligns with SonarSource’s published reference threshold. Tighter than ruff’s McCabe of 10 in absolute number, but cognitive complexity scores higher than McCabe for the same code because of nesting penalties, so the two thresholds gate roughly the same shape of function.- This is the threshold for new code. Grandfathered functions retain their snapshot watermark; see below.
Grandfathering: snapshot watermark
Section titled “Grandfathering: snapshot watermark”complexipy does not support per-function # noqa suppression (unlike ruff). Grandfathering is done with a snapshot file at the repo root:
complexipy-snapshot.jsonis generated withcomplexipy --snapshot-create src/mddand committed.- On subsequent runs (without
--snapshot-create),complexipycompares each function’s complexity to its watermark in the snapshot. A function passes if its current complexity is<= max(snapshot_complexity, max-complexity-allowed). - A function whose complexity grows above its snapshot watermark fails CI. New functions are held to
max-complexity-allowed = 15.
This is the equivalent of S34’s # noqa: PLR0915-on-the-def-line convention, but file-scoped rather than line-scoped. The trade-off: less visible at the call site (a reviewer scanning a diff doesn’t see the watermark inline), but more accurate (the exact baseline complexity is stored, not just “this function is grandfathered at some level”).
When a refactor drops a function below the gate, its snapshot entry is removed; the snapshot file shrinks over time. Regenerating the snapshot (mise run complexipy-snapshot) drops the watermark for any function now under threshold.
Exclusions
Section titled “Exclusions”[tool.complexipy]paths = ["src/mdd"]max-complexity-allowed = 15exclude = ["tests/**", "scripts/**", "src/mdd/templates/**"]tests/**— test functions legitimately have repetitive branchy structure (parametrised expectations, mock setup). The S34 rationale for excludingtests/**from ARG* applies here too.scripts/**— one-off operational scripts, not production code. Already excluded from basedpyright.src/mdd/templates/**— bundled Quarto templates; not Python source.
Cache directory
Section titled “Cache directory”complexipy writes a .complexipy_cache/ directory in CWD on every run. This MUST be .gitignored. The snapshot file complexipy-snapshot.json MUST be committed.
CI integration
Section titled “CI integration”- A
[tasks.complexipy]task is added to.mise.tomlthat runsuv run complexipy src/mdd. [tasks.ci]gainscomplexipyin itsdependslist, so every CI pipeline picks it up automatically throughmise run ci— the workflow YAML only ever invokes mise tasks.- No new job in either CI YAML; no new cache key; no new image.
Snapshot regeneration task
Section titled “Snapshot regeneration task”- A
[tasks.complexipy-snapshot]task runsuv run complexipy --snapshot-create src/mdd. This is the only way the snapshot file should be regenerated. Manual edits tocomplexipy-snapshot.jsonare forbidden.
Design Approach
Section titled “Design Approach”Complexipy over a second ruff plugin. Ruff does not implement cognitive complexity and there is no public roadmap to do so as of mid-2026. The closest ruff-compatible option (a hypothetical Sonar-style plugin) does not exist. Complexipy is the established tool in the cognitive-complexity-for-Python space and is implemented in Rust with negligible runtime overhead on a repo this size (sub-second on src/mdd).
Threshold 15, not 10. Cognitive complexity scores higher than McCabe on the same function because nesting compounds. Using 10 (the McCabe threshold from S34) would gate too aggressively — the survey at threshold 10 produced 135 violations vs. 75 at threshold 15. 15 is the upstream default, matches SonarSource’s reference, and gives the same shape of gate as ruff’s McCabe ≤ 10 in practice.
Snapshot watermark over # noqa. Complexipy has no per-function suppression syntax. The snapshot mechanism is the supported grandfathering primitive. The file is small (~12 KB at baseline, JSON), reviewable in PR diffs, and shrinks as refactors land. Per-function # noqa: CXP would require a fork of complexipy and is rejected on maintenance grounds.
Separate mise run complexipy task, not bundled into mise run lint. lint is ruff-only by convention (S34 invariant: “Ruff is the single static-analysis surface”). Putting complexipy under lint would muddy that invariant. A dedicated task surfaces complexipy failures as a distinct CI step and keeps the two tools cleanly separable if a future decision swaps one out.
No CI YAML changes. The CI pipelines only invoke mise run ci. Adding complexipy to the ci task’s depends list propagates to every pipeline without YAML edits, which is the property that arrangement exists to give.
src/mdd as the only analysed path. Limiting to src/mdd (instead of .) avoids accidentally scanning .venv/, build/, docs/, or vendored content. Matches the basedpyright include = ["src", "tests"] pattern; tests/ is excluded specifically per the rationale above.
Implementation Notes
Section titled “Implementation Notes”Rollout in a single PR (no staged rollout needed; the snapshot mechanism is self-grandfathering):
- Add
complexipy>=5.4.1to[dependency-groups].devinpyproject.toml. - Add
[tool.complexipy]config block withpaths,max-complexity-allowed,exclude. - Run
uv lockto update the lockfile. - Run
uv run complexipy --snapshot-create src/mddfrom the repo root to createcomplexipy-snapshot.json. - Add
.complexipy_cache/to.gitignore. - Add
[tasks.complexipy]and[tasks.complexipy-snapshot]to.mise.toml. - Add
complexipyto[tasks.ci].depends. - Verify
mise run cipasses locally. - Commit
complexipy-snapshot.jsonalong with the config + task changes.
Snapshot file size
Section titled “Snapshot file size”The initial snapshot is ~12 KB / ~640 lines of JSON listing all functions with complexity > 0 (sorted by path). It will grow with the codebase but shrink as refactors land. No size threshold gate is needed — the file is text-diffable, and large deltas show up clearly in PR diffs.
Reviewer guidance for snapshot changes
Section titled “Reviewer guidance for snapshot changes”When complexipy-snapshot.json changes in a PR:
- New entry added — a refactor or new function introduced complexity. Reviewer should ask: is the new complexity necessary? Can the function be split?
- Existing entry’s
complexityvalue increased — a function got more complex. This should rarely happen; CI would have failed unless--snapshot-createwas re-run, which should only happen viamise run complexipy-snapshotin a deliberate refactor PR. - Existing entry removed — refactor landed; complexity dropped below threshold. Good.
mise run complexipy-snapshot MUST NOT be run as a routine “make CI green” reflex. Regenerating to raise watermarks defeats the gate. Snapshot regeneration is for deliberate refactor PRs that drop watermarks downward.
Related upstream specs
Section titled “Related upstream specs”- 000-specs — shared conventions
- CI is the gate that enforces this spec: pipelines invoke
mise run ciand inherit the new dependency automatically. - S34 — this spec resolves S34’s open question 1; structural and cognitive complexity together form the complete complexity gate.
- S36 — file-length is heuristic-only; per-function gates are the machine-checked surface. Complexipy adds the cognitive-complexity layer to that surface.
Open questions
Section titled “Open questions”- Should
complexipy-snapshot.jsonregeneration be gated by a CI check that the regenerating PR also lowers some watermarks (i.e. cannot be net-upward)? Deferred — reviewer guidance is enough for now, and tooling for the check does not exist. - Should the threshold be tightened from 15 → 10 once the highest-watermark functions have been refactored? Likely yes; revisit when the snapshot file has fewer than ~20 entries.
- Should
--check-scriptbe enabled to also gate module-level (script) cognitive complexity? Deferred — module-level code is generally short in this codebase; revisit if module-level branching grows.
Out of scope
Section titled “Out of scope”- Refactoring existing high-complexity functions. Each refactor is a separate PR; this spec only adds the gate and snapshot.
- Replacing ruff’s McCabe with complexipy. Both are kept; McCabe catches branch-count growth, complexipy catches nesting growth.
- Cognitive-complexity tooling other than complexipy (
cognitive_complexityPython package, SonarQube). Considered and rejected:cognitive_complexityis unmaintained since 2022; SonarQube was explicitly rejected in S34 on hosting-overhead grounds. - Per-PR delta gating via
complexipy --diff main --ratchet. The snapshot watermark is a superset of this in practice and avoids the complication of teaching CI which ref to diff against. - IDE integration. The complexipy VS Code extension exists; adoption is per-developer, not project-level.
Site built 2026-07-30.