Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ jobs:
- name: Run checks
run: scripts/check

coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
cache: pip
- name: Install package
run: scripts/python -m pip install -e '.[dev,gui,test-xlsx,test-pdf]'
- name: Run coverage gate
run: scripts/coverage-check

typecheck:
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ __pycache__/
.mypy_cache/
.basedpyright/
.coverage
coverage.json
coverage.xml
htmlcov/
build/
Expand Down
14 changes: 14 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,17 @@ repos:
stages: [pre-push]
always_run: true
pass_filenames: false
- id: coverage-check
name: Coverage check (fail-under 82%)
entry: scripts/coverage-check
language: system
stages: [pre-push]
always_run: true
pass_filenames: false
- id: check-complexity
name: Complexity and module size guard
entry: scripts/check-complexity
language: system
stages: [pre-push]
always_run: true
pass_filenames: false
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This project follows the structure from Keep a Changelog and intends to use Sema

### Added

- **Coverage, complexity, and module-size gates:** `scripts/coverage-check` runs the unittest suite under `coverage` (scoped to `src/github_usage`). Overall src coverage must stay ≥ `COVERAGE_TOTAL_MIN` (default 75; current ~76%). Any **new** `src` file (not in the committed tree) must meet `COVERAGE_FILE_MIN` (default 82%) so coverage cannot erode one file at a time; existing files are protected by the overall gate only. `scripts/check-complexity` fails if the worst cyclomatic complexity exceeds `MAX_CC` (default 28; current worst) and warns at `WARN_LINES`=575 / blocks at `BLOCK_LINES`=650 for module size (current largest 562). Both scripts are wired into `scripts/check`, added as pre-push hooks in `.pre-commit-config.yaml`, and coverage runs as its own `coverage` job in `ci.yml`. `coverage` and `radon` added to the `dev` extra; `uv.lock` regenerated.
- **Private-repo top consumers** ([plan](docs/superpowers/plans/archived/2026-07-31-private-top-consumers.md)): local full report (terminal + TUI), email text/HTML, and JSON exports gain private-only top lists for Actions minutes and billed storage alongside existing overall rankings; overall top-by-storage (billed avg MB) and artifact-scan private lists where they add signal. Shared `repo_consumers.py` helper with `private_list_is_redundant` guards; report cache version bumped to 3. HTML table helpers extracted to `_email_report_html_tables.py`.
- **Minutes-by-workflow for top private consumer** ([plan](docs/superpowers/plans/archived/2026-07-31-private-top-consumers.md)): estimated per-workflow Actions minutes from completed run wall-clock times for the top private-repo minutes consumer. New `report_workflow_minutes.py` with shared `runs_cache`; terminal and email sections include an approximation caveat (not billable — will not match billed repo totals). Quota estimates bump by +10 when the workflow path is enabled.
- **Private-usage emphasis for Actions free-tier reporting** ([plan](docs/superpowers/plans/archived/2026-07-30-private-usage-emphasis.md)): Limits Summary, utilization bars, and forecasts measure **private-repo** Actions minutes/storage against the free tier; public standard-runner usage is shown separately as free. Artifact storage section frames the 500 MB private allowance as GB-hrs accrual, splits artifacts vs release assets, and surfaces expiry/retention. Larger-runner SKUs are flagged `*`. Legacy reports include a Sources footer. Email `repo_consumers` gains `by_visibility`; report cache version bumped to 2 (stale v1 snapshots rejected). CSV/XLSX/PDF/JSON exports and TUI summary rows carry the same private-vs-public framing, storage analysis, and Sources section.
Expand Down
2 changes: 0 additions & 2 deletions TO_DO.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
- absolute local paths (e.g. `/Users/`, `C:\`, `/tmp/`, `/var/`)
- unredacted report output in **email bodies (plain-text + HTML)**, **text**, and **PDF** exports — email is the main gap today (`redact.py` covers file exports only, not email bodies; PDF output needs verification), plus generated report artifacts in any format (`.json`, `.txt`, `.pdf`, `.xlsx`, `.csv`)
- [ ] Add a dependency/vulnerability CI step: run `pip-audit` (already a `[dev]` dep, currently only invoked implicitly via `scripts/security`) as an explicit always-failing job in `.github/workflows/security.yml`.
- [ ] Add a code-complexity pre-commit hook (e.g. `xenon`/`radon` or ruff `C901`/`mccabe`) with a project-appropriate threshold, and fail CI on violations (watch `cli_runs.py` at 534 lines and `setup_config.py` at 507).
- [ ] Add a coverage CI job: a new `scripts/coverage` (or extend `scripts/check`) using `coverage.py`, enforce a baseline threshold that ratchets up, and gate PRs on it.
- [ ] Add tests for the above: content-check unit tests with absolute-path/report-artifact fixtures, redaction coverage for email/PDF output, and the dependency/complexity/coverage gates' behavior.
- [ ] Add an inventoried `scripts/sonarqube` helper that runs a local SonarQube scan (Docker `sonarqube` container + `sonar-scanner`, token via env var, output under `tmp/` or `reports/`), documented in the README scripts section and `docs/repo-harness-guidance.md` alongside `scripts/security`.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Plan: Coverage, Complexity & Module-Size Gates

- **Date:** 2026-08-07

> **Status:** COMPLETE

- **Branch:** `refactor/coverage-hooks-ci`
- **Goal:** Enforce test coverage, cyclomatic complexity, and module size in hooks and CI at thresholds set just below current measured baselines.

## Baseline Measurements (2026-08-07)

- **Coverage (scoped to `src/github_usage`):** overall **76%**. 39 of 89 `src` files below 82%; worst `setup_ci` (15%), `setup_secrets` (21%), `setup_launchd` (36%). (Earlier "86%" figure included `tests/` and `__init__` in the measured set; scoped measurement is the correct product-code number.)
- **Complexity:** 807 blocks; worst cyclomatic complexity = **28** (`setup_wizard._manage_profiles`, D-rank). 55 blocks at C-rank (11+) or worse, including three D-rank. No block exceeds 28.
- **Module size:** warn at 575, block at 650 (current largest 562). Changed from 600/700 to tighten the guard closer to the current ceiling.

## Tasks

- [x] Add `coverage` and `radon` to the `dev` optional-dependencies in `pyproject.toml`.
- [x] Regenerate `uv.lock` (`uv lock`; `uv lock --check` passes).
- [x] Create `scripts/coverage-check`: scoped to `src/github_usage`; overall must stay ≥ `COVERAGE_TOTAL_MIN` (75); **new** `src` files (not in the committed tree) must meet `COVERAGE_FILE_MIN` (82).
- [x] Create `scripts/check-complexity`: fails if worst block CC > `MAX_CC` (28, the current worst); module size warns at `WARN_LINES`=575 and blocks at `BLOCK_LINES`=650.
- [x] Wire both into `scripts/check` (after backup policy, before "check passed").
- [x] Add `coverage-check` and `check-complexity` as pre-push local hooks in `.pre-commit-config.yaml` (mirrors `run-scripts-check`).
- [x] Add a `coverage` job to `.github/workflows/ci.yml` running `scripts/coverage-check`.
- [x] Confirm `scripts/check` passes end-to-end (overall 76% ≥ 75%; worst CC 28 ≤ 28; max file 562 ≤ 700).
- [x] Validate new-file gate: a new 0% src file fails the gate.
- [x] Validate `.pre-commit-config.yaml` (`pre-commit validate-config` → rc 0).
- [x] Record in `CHANGELOG.md` `[Unreleased] > Added`.

## Notes / Deviations

- **Coverage measured scope:** coverage is measured with `--source=src/github_usage` so only product code is gated (test files excluded). Overall scoped coverage is **76%**, not the earlier 86% (which double-counted `tests/`).
- **Per-file baseline lock dropped:** an earlier design locked every file to a baseline in `coverage-baselines.json` ("don't get worse"). That baseline was environment-sensitive — macOS-generated numbers diverged from CI/Linux (e.g. `setup_launchd.py` 36.2% local vs 29.5% CI), causing CI failures. Simplified to a single portable **overall ≥ 75%** gate plus an **82% gate for new files only** (detected via `git ls-tree HEAD`). No baseline file to drift; fully portable across OSes.
- **Lower threshold, same strictness:** `COVERAGE_TOTAL_MIN=75` is just below the 76% overall; the stricter 82% applies to new files. Complexity ceiling `MAX_CC=28` is the current worst, so it blocks only *new* blocks worse than today (no need to exempt the 55 existing C+ blocks).
- `radon --max-cc` was evaluated but rejected: it exits non-zero whenever any C+ block exists regardless of the numeric threshold. The script instead computes the true max from JSON and compares to `MAX_CC`.
- Earlier analysis incorrectly reported "zero C-or-worse blocks" due to grepping a truncated sorted tail; corrected with full-output measurement.

## Verification

```
scripts/check # full harness, green
scripts/coverage-check # overall 76% >= 75%; new files >= 82%
scripts/check-complexity # worst CC 28 <= 28; warn >=575, block >650
uv lock --check # consistent
pre-commit validate-config
```
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ dev = [
"bandit>=1.7.10",
"basedpyright>=1.30",
"black>=24.0",
"coverage>=7.0",
"pip-audit>=2.7",
"pre-commit>=3.7",
"radon>=6.0",
"ruff>=0.6",
]
# Export-format optional dependencies. PEP 621 does not support nested extras,
Expand Down
6 changes: 6 additions & 0 deletions scripts/check
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ scripts/typecheck
echo "==> Backup policy"
scripts/check-backups

echo "==> Coverage"
scripts/coverage-check

echo "==> Complexity and module size"
scripts/check-complexity

echo "check passed"
58 changes: 58 additions & 0 deletions scripts/check-complexity
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

# Complexity & module-size policy (thresholds set at current baselines):
# - Cyclomatic complexity: the worst existing block is 28
# (setup_wizard._manage_profiles). Any block exceeding MAX_CC fails.
# (No grandfather list needed: 28 is already the current max, so this
# blocks any new block worse than what exists today.)
# - Module size: WARN at WARN_LINES (default 575) but only BLOCK at
# BLOCK_LINES (default 650). Current largest module is 562 lines.
# Run with: scripts/check-complexity

MAX_CC="${MAX_CC:-28}"
WARN_LINES="${WARN_LINES:-575}"
BLOCK_LINES="${BLOCK_LINES:-650}"

echo "==> Cyclomatic complexity (worst block must not exceed ${MAX_CC})"
cc_json="$(PYTHONPATH=src scripts/python -m radon cc src/github_usage --json 2>/dev/null)"
actual="$(printf '%s' "$cc_json" | scripts/python -c "
import json,sys
d=json.load(sys.stdin)
mx=max((b.get('complexity',0) for bs in d.values() for b in bs), default=0)
print(mx)
")"
if [ "$actual" -gt "$MAX_CC" ]; then
echo "ERROR: worst cyclomatic complexity is ${actual}, exceeds ${MAX_CC}." >&2
echo " Current C-rank-or-worse blocks:" >&2
PYTHONPATH=src scripts/python -m radon cc src/github_usage -s 2>/dev/null \
| grep -E "\b[C-F] \(" >&2
exit 1
fi
echo " OK (worst block = ${actual} <= ${MAX_CC})"

echo "==> Module size (warn >= ${WARN_LINES}, block > ${BLOCK_LINES})"
blocked=0
warned=0
while IFS= read -r line; do
file="${line%%:*}"
n="${line##*:}"
if [ "$n" -gt "$BLOCK_LINES" ]; then
echo " BLOCK $file: ${n} lines (> ${BLOCK_LINES})" >&2
blocked=1
elif [ "$n" -ge "$WARN_LINES" ]; then
echo " WARN $file: ${n} lines (>= ${WARN_LINES})" >&2
warned=1
fi
done < <(wc -l src/github_usage/*.py | sed '$d' | awk '{print $2":"$1}')
if [ "$blocked" -ne 0 ]; then
echo "ERROR: source modules exceed ${BLOCK_LINES} lines" >&2
exit 1
fi
if [ "$warned" -ne 0 ]; then
echo " (warnings above; not blocking)"
fi
echo " OK (no module over ${BLOCK_LINES} lines)"
57 changes: 57 additions & 0 deletions scripts/coverage-check
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

# Coverage policy:
# - Overall src coverage must stay at/above COVERAGE_TOTAL_MIN (default 75;
# current overall ~76%). This is the single portable gate.
# - Any NEW src file (not present in the previous commit's tree) must meet
# COVERAGE_FILE_MIN (default 82). Existing files are not individually
# gated; the overall number protects them.
SRC="src/github_usage"
TOTAL_MIN="${COVERAGE_TOTAL_MIN:-75}"
FILE_MIN="${COVERAGE_FILE_MIN:-82}"

echo "==> Running tests under coverage (src only)"
PYTHONPATH=src scripts/python -m coverage run --source="$SRC" -m unittest discover -s tests

echo "==> Coverage report"
PYTHONPATH=src scripts/python -m coverage report

# Overall gate.
overall=$(PYTHONPATH=src scripts/python -m coverage report --format=total | tail -1)
if ! PYTHONPATH=src scripts/python -c "import sys; sys.exit(0 if float('$overall') >= $TOTAL_MIN else 1)"; then
echo "ERROR: overall src coverage ${overall}% is below minimum ${TOTAL_MIN}%." >&2
exit 1
fi
echo " OK overall ${overall}% >= ${TOTAL_MIN}%"

# New-file gate: compare the current src tree against the committed tree to
# find files that did not exist before. A brand-new source file must meet the
# target minimum so coverage does not erode one file at a time.
PYTHONPATH=src scripts/python -m coverage json -o /tmp/coverage.current.json >/dev/null 2>&1
base_tree=$(git ls-tree -r --name-only HEAD -- "$SRC" 2>/dev/null || true)
fails=0
while IFS= read -r line; do
f="${line%%:*}"
pct="${line##*:}"
if ! printf '%s\n' "$base_tree" | grep -qx "$f"; then
if ! PYTHONPATH=src scripts/python -c "import sys; sys.exit(0 if float('$pct') >= $FILE_MIN else 1)"; then
echo " NEW FILE $f: ${pct}% < ${FILE_MIN}% (target for new files)" >&2
fails=1
fi
fi
done < <(PYTHONPATH=src scripts/python -c "
import json
d=json.load(open('/tmp/coverage.current.json'))['files']
for f,info in d.items():
print(f'{f}:{round(info[\"summary\"][\"percent_covered\"],1)}')
")

if [ "$fails" -ne 0 ]; then
echo "ERROR: new-file coverage below ${FILE_MIN}% (see above)." >&2
exit 1
fi
echo " OK (new src files >= ${FILE_MIN}%)"
Loading