diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9fb404b3..64b7212d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,6 +83,7 @@ Last updated: 2026-03-11 - The desktop app is scaffolded as `Tauri + Vite + React`, but initial verification keeps Rust packaging out of the default quickcheck path. - The desktop shell uses an explicit Tauri CSP that only allows self-hosted assets, inline styles, Tauri IPC, and loopback development traffic. - Mechanical gates focus on lint, typecheck, unit tests, coverage for Python, and documentation presence. +- Python quality gates also require 100% docstring coverage via `package.json` script `check:python-docstrings`, enforced with Ruff rules `D100` through `D107` across tracked packages, modules, classes, nested classes, functions, methods (including `__init__`), `services/analysis-engine` tests, and repo-owned Python scripts. - Mechanical gates also enforce security document presence, plan `Security Notes`, and basic forbidden-pattern checks. - Security context is part of architecture, not just implementation detail; docs and plans must record the trust boundary touched by risky changes. - Supply-chain controls are part of the bootstrap architecture, not a release-afterthought. diff --git a/package.json b/package.json index 7338ae7d..b5d12dcf 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,10 @@ "check:security-gates": "python3 scripts/checks/security_gates.py", "check:supply-chain": "python3 scripts/checks/verify_supply_chain.py", "check:github-bootstrap": "python3 scripts/checks/verify_github_bootstrap_policy.py", + "check:python-docstrings": "sh -c 'cd services/analysis-engine && uv run ruff check src tests ../../scripts --select D100,D101,D102,D103,D104,D105,D106,D107'", "ruff:check": "sh -c 'cd services/analysis-engine && uv run ruff check src tests'", "ruff:format:check": "sh -c 'cd services/analysis-engine && uv run ruff format --check src tests'", - "lint": "npm run lint:workspaces && npm run check:docs && npm run check:security-notes && npm run check:security-gates && npm run check:supply-chain && npm run check:github-bootstrap && npm run ruff:check && npm run ruff:format:check", + "lint": "npm run lint:workspaces && npm run check:docs && npm run check:security-notes && npm run check:security-gates && npm run check:supply-chain && npm run check:github-bootstrap && npm run check:python-docstrings && npm run ruff:check && npm run ruff:format:check", "typecheck": "npm run typecheck --workspaces --if-present && sh -c 'cd services/analysis-engine && uv run mypy src'", "test": "npm run test --workspaces --if-present && sh -c 'cd services/analysis-engine && uv run pytest tests --cov=src/bandscope_analysis --cov-report=term-missing --cov-fail-under=100'", "build": "npm run build --workspaces --if-present", diff --git a/scripts/checks/security_gates.py b/scripts/checks/security_gates.py index 995e30e4..ce20fa5e 100644 --- a/scripts/checks/security_gates.py +++ b/scripts/checks/security_gates.py @@ -1,3 +1,5 @@ +"""Scan repository workspace source files for disallowed security patterns.""" + from pathlib import Path import re import sys @@ -32,12 +34,14 @@ def should_scan(path: Path) -> bool: + """Return whether a path should be scanned for security-pattern violations.""" return path.suffix in TARGET_EXTENSIONS and not any( part in EXCLUDED_PARTS for part in path.parts ) def main() -> int: + """Return a failing exit code when a forbidden security pattern is found.""" violations: list[str] = [] for path in Path(".").rglob("*"): diff --git a/scripts/checks/verify_docs.py b/scripts/checks/verify_docs.py index a826ae33..54417a7a 100644 --- a/scripts/checks/verify_docs.py +++ b/scripts/checks/verify_docs.py @@ -1,3 +1,5 @@ +"""Verify that required repository documentation files and references exist.""" + from pathlib import Path import sys @@ -62,6 +64,7 @@ def main() -> int: + """Return a failing exit code when required docs or references are missing.""" missing = [str(path) for path in REQUIRED_PATHS if not path.exists()] if missing: print("Missing required docs:") diff --git a/scripts/checks/verify_github_bootstrap_policy.py b/scripts/checks/verify_github_bootstrap_policy.py index df2f61cb..85dca95a 100644 --- a/scripts/checks/verify_github_bootstrap_policy.py +++ b/scripts/checks/verify_github_bootstrap_policy.py @@ -1,3 +1,5 @@ +"""Verify that GitHub bootstrap policy docs are present and referenced.""" + from pathlib import Path @@ -16,6 +18,7 @@ def main() -> int: + """Return a failing exit code when bootstrap policy docs drift out of sync.""" if not REQUIRED_PATH.exists(): print(f"Missing GitHub bootstrap policy: {REQUIRED_PATH}") return 1 diff --git a/scripts/checks/verify_security_notes.py b/scripts/checks/verify_security_notes.py index a9e78154..c89acca8 100644 --- a/scripts/checks/verify_security_notes.py +++ b/scripts/checks/verify_security_notes.py @@ -1,3 +1,5 @@ +"""Verify that design-plan documents include a complete Security Notes section.""" + from pathlib import Path import sys @@ -15,6 +17,7 @@ def security_notes_section(content: str) -> str: + """Extract the lowercased Security Notes section from a plan document.""" lowered = content.lower() marker = SECURITY_NOTES_TEXT.lower() start = lowered.find(marker) @@ -34,6 +37,7 @@ def security_notes_section(content: str) -> str: def main() -> int: + """Return a failing exit code when Security Notes or required subsections are missing.""" missing: list[str] = [] for path in sorted(PLAN_DIR.glob("*.md")): content = path.read_text(encoding="utf-8") diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index 25f0a5ca..2391a1cc 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -1,3 +1,5 @@ +"""Verify that repository-controlled supply-chain controls stay in place.""" + from pathlib import Path import re @@ -28,10 +30,12 @@ def verify_required_files() -> list[str]: + """Return missing files required by the supply-chain baseline.""" return [str(path) for path in REQUIRED_FILES if not path.exists()] def verify_pinned_actions() -> list[str]: + """Return workflow actions that are not pinned to immutable SHAs.""" violations: list[str] = [] workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted( Path(".github/workflows").glob("*.yaml") @@ -53,6 +57,7 @@ def verify_pinned_actions() -> list[str]: def verify_dependabot_coverage() -> list[str]: + """Return missing Dependabot ecosystems from the repo configuration.""" path = Path(".github/dependabot.yml") if not path.exists(): return [f"missing file: {path}"] @@ -65,6 +70,7 @@ def verify_dependabot_coverage() -> list[str]: def read_workflow(path: Path, label: str, missing: list[str]) -> str: + """Read a workflow file, recording a missing-file violation when absent.""" if not path.exists(): missing.append(f"missing file: {path}") return "" @@ -72,6 +78,7 @@ def read_workflow(path: Path, label: str, missing: list[str]) -> str: def verify_workflow_coverage() -> list[str]: + """Return workflow trigger and artifact coverage violations.""" missing: list[str] = [] ci = read_workflow(Path(".github/workflows/ci.yml"), "ci", missing) for token in ["develop", "main", "pull_request", "push", "ci / build-and-test"]: @@ -153,6 +160,7 @@ def verify_workflow_coverage() -> list[str]: def main() -> int: + """Return a failing exit code when supply-chain controls are incomplete.""" violations: list[str] = [] violations.extend(f"missing file: {item}" for item in verify_required_files()) violations.extend(verify_pinned_actions()) diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index ceee43e7..592734e5 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -1,3 +1,5 @@ +"""Package desktop build outputs into traceable release artifacts.""" + from __future__ import annotations from pathlib import Path @@ -8,6 +10,7 @@ def sha256_file(path: Path) -> str: + """Return the SHA-256 digest for a file.""" digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): @@ -16,9 +19,16 @@ def sha256_file(path: Path) -> str: def normalized_platform() -> str: + """Return the normalized artifact platform label for the current environment.""" if artifact_platform := os.environ.get("BANDSCOPE_ARTIFACT_OS"): return artifact_platform + target_triple = os.environ.get("BANDSCOPE_TARGET_TRIPLE", "") + if "windows" in target_triple: + return "windows" + if "apple-darwin" in target_triple: + return "macos" + system = platform.system().lower() if system == "darwin": return "macos" @@ -27,9 +37,16 @@ def normalized_platform() -> str: def normalized_architecture() -> str: + """Return the normalized artifact architecture label for the current environment.""" if artifact_arch := os.environ.get("BANDSCOPE_ARTIFACT_ARCH"): return artifact_arch + target_triple = os.environ.get("BANDSCOPE_TARGET_TRIPLE", "") + if target_triple.startswith(("x86_64", "amd64")): + return "amd64" + if target_triple.startswith(("aarch64", "arm64")): + return "arm64" + machine = platform.machine().lower() if machine in {"x86_64", "amd64"}: return "amd64" @@ -39,10 +56,15 @@ def normalized_architecture() -> str: return machine +def resolved_artifact_target() -> tuple[str, str]: + """Return the normalized platform and architecture for the current artifact target.""" + return normalized_platform(), normalized_architecture() + + def artifact_identity() -> dict[str, str]: + """Build the archive and manifest names for the current artifact target.""" git_sha = os.environ.get("GITHUB_SHA", "local")[:12] - target_platform = normalized_platform() - target_arch = normalized_architecture() + target_platform, target_arch = resolved_artifact_target() suffix = f"bandscope-{target_platform}-{target_arch}-{git_sha}" return { "platform": target_platform, @@ -53,17 +75,25 @@ def artifact_identity() -> dict[str, str]: def expected_binary_path(repo_root: Path) -> Path: - system = normalized_platform() + """Return the expected desktop binary path for the selected target triple.""" + target_triple = os.environ.get("BANDSCOPE_TARGET_TRIPLE") + if target_triple and "windows" in target_triple: + system = "windows" + elif target_triple and "apple-darwin" in target_triple: + system = "macos" + else: + system = normalized_platform() binary_name = ( "bandscope-desktop.exe" if system == "windows" else "bandscope-desktop" ) target_root = repo_root / "apps" / "desktop" / "src-tauri" / "target" - if target_triple := os.environ.get("BANDSCOPE_TARGET_TRIPLE"): + if target_triple: target_root = target_root / target_triple return target_root / "release" / binary_name def main() -> int: + """Package the desktop binary, frontend assets, and metadata into a zip archive.""" repo_root = Path(__file__).resolve().parents[2] binary_path = expected_binary_path(repo_root) frontend_dist = repo_root / "apps" / "desktop" / "dist" diff --git a/services/analysis-engine/src/bandscope_analysis/health.py b/services/analysis-engine/src/bandscope_analysis/health.py index f2be6abb..d56de3f6 100644 --- a/services/analysis-engine/src/bandscope_analysis/health.py +++ b/services/analysis-engine/src/bandscope_analysis/health.py @@ -4,6 +4,8 @@ class HealthReport(TypedDict): + """Typed health payload returned by the analysis-engine bootstrap API.""" + service: Literal["bandscope-analysis"] status: Literal["ready"] pipeline_stages: list[Literal["decode", "draft", "separate", "persist"]] diff --git a/services/analysis-engine/tests/conftest.py b/services/analysis-engine/tests/conftest.py index b942d149..b700b7ec 100644 --- a/services/analysis-engine/tests/conftest.py +++ b/services/analysis-engine/tests/conftest.py @@ -1,3 +1,5 @@ +"""Shared pytest helpers for analysis-engine and harness verification tests.""" + from __future__ import annotations from importlib.util import module_from_spec, spec_from_file_location @@ -6,6 +8,7 @@ def load_module(relative_path: str, module_name: str) -> ModuleType: + """Load a repository Python module from a path outside the package root.""" repo_root = Path(__file__).resolve().parents[3] module_path = repo_root / relative_path spec = spec_from_file_location(module_name, module_path) diff --git a/services/analysis-engine/tests/test_api.py b/services/analysis-engine/tests/test_api.py index c59f0537..5ccb9db9 100644 --- a/services/analysis-engine/tests/test_api.py +++ b/services/analysis-engine/tests/test_api.py @@ -1,7 +1,10 @@ +"""Tests for the public analysis-engine API helpers.""" + from bandscope_analysis.api import get_analysis_status def test_get_analysis_status_returns_health_payload() -> None: + """Ensure the API helper returns the expected bootstrap status payload.""" assert get_analysis_status() == { "service": "bandscope-analysis", "status": "ready", diff --git a/services/analysis-engine/tests/test_health.py b/services/analysis-engine/tests/test_health.py index 9eb48491..f1526f92 100644 --- a/services/analysis-engine/tests/test_health.py +++ b/services/analysis-engine/tests/test_health.py @@ -1,7 +1,10 @@ +"""Tests for the analysis-engine health helpers.""" + from bandscope_analysis.health import build_health_report def test_build_health_report_exposes_bootstrap_defaults() -> None: + """Ensure the bootstrap health payload exposes the expected default stages.""" assert build_health_report() == { "service": "bandscope-analysis", "status": "ready", diff --git a/services/analysis-engine/tests/test_release_packaging.py b/services/analysis-engine/tests/test_release_packaging.py index f24f0336..ee6ad3e1 100644 --- a/services/analysis-engine/tests/test_release_packaging.py +++ b/services/analysis-engine/tests/test_release_packaging.py @@ -1,3 +1,5 @@ +"""Tests for desktop release packaging helpers and artifact metadata.""" + from __future__ import annotations from pathlib import Path @@ -8,6 +10,7 @@ def test_release_packaging_includes_architecture_in_artifact_identity( monkeypatch, ) -> None: + """Ensure artifact names encode the selected platform and architecture.""" packaging = load_module( "scripts/release/package_desktop_artifact.py", "package_desktop_artifact" ) @@ -26,7 +29,34 @@ def test_release_packaging_includes_architecture_in_artifact_identity( } +def test_release_packaging_derives_artifact_identity_from_target_triple( + monkeypatch, +) -> None: + """Ensure target triples drive archive naming when explicit artifact env vars are absent.""" + packaging = load_module( + "scripts/release/package_desktop_artifact.py", + "package_desktop_artifact_identity_target", + ) + + monkeypatch.setenv("GITHUB_SHA", "fedcba9876543210") + monkeypatch.delenv("BANDSCOPE_ARTIFACT_OS", raising=False) + monkeypatch.delenv("BANDSCOPE_ARTIFACT_ARCH", raising=False) + monkeypatch.setenv("BANDSCOPE_TARGET_TRIPLE", "x86_64-pc-windows-msvc") + monkeypatch.setattr(packaging.platform, "system", lambda: "Darwin") + monkeypatch.setattr(packaging.platform, "machine", lambda: "arm64") + + artifact = packaging.artifact_identity() + + assert artifact == { + "platform": "windows", + "arch": "amd64", + "archive_name": "bandscope-windows-amd64-fedcba987654.zip", + "manifest_name": "bandscope-windows-amd64-fedcba987654.manifest.txt", + } + + def test_expected_binary_path_uses_target_triple_when_provided(monkeypatch, tmp_path: Path) -> None: + """Ensure target triples redirect packaging to the expected Tauri output path.""" packaging = load_module( "scripts/release/package_desktop_artifact.py", "package_desktop_artifact_target" ) @@ -47,7 +77,34 @@ def test_expected_binary_path_uses_target_triple_when_provided(monkeypatch, tmp_ ) +def test_expected_binary_path_derives_windows_extension_from_target_triple( + monkeypatch, tmp_path: Path +) -> None: + """Ensure Windows target triples select the .exe packaging path on non-Windows hosts.""" + packaging = load_module( + "scripts/release/package_desktop_artifact.py", "package_desktop_artifact_windows_target" + ) + + monkeypatch.delenv("BANDSCOPE_ARTIFACT_OS", raising=False) + monkeypatch.setenv("BANDSCOPE_TARGET_TRIPLE", "x86_64-pc-windows-msvc") + monkeypatch.setattr(packaging.platform, "system", lambda: "Darwin") + + binary_path = packaging.expected_binary_path(tmp_path) + + assert binary_path == ( + tmp_path + / "apps" + / "desktop" + / "src-tauri" + / "target" + / "x86_64-pc-windows-msvc" + / "release" + / "bandscope-desktop.exe" + ) + + def test_release_packaging_maps_darwin_to_macos(monkeypatch) -> None: + """Ensure Darwin hosts map to the repository's canonical macOS label.""" packaging = load_module( "scripts/release/package_desktop_artifact.py", "package_desktop_artifact_platform" ) @@ -59,6 +116,7 @@ def test_release_packaging_maps_darwin_to_macos(monkeypatch) -> None: def test_release_packaging_main_writes_arch_specific_manifest(monkeypatch, tmp_path: Path) -> None: + """Ensure the packaging entry point writes an architecture-aware manifest.""" packaging = load_module( "scripts/release/package_desktop_artifact.py", "package_desktop_artifact_main" ) diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index 7a347894..c38dcdab 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -1,3 +1,5 @@ +"""Tests for repository supply-chain and workflow coverage checks.""" + from __future__ import annotations from pathlib import Path @@ -6,6 +8,7 @@ def test_supply_chain_check_requires_multi_arch_runner_labels(monkeypatch, tmp_path: Path) -> None: + """Ensure missing multi-arch workflow tokens are reported as violations.""" supply_chain = load_module("scripts/checks/verify_supply_chain.py", "verify_supply_chain") workflow_dir = tmp_path / ".github" / "workflows" @@ -50,6 +53,7 @@ def test_supply_chain_check_requires_multi_arch_runner_labels(monkeypatch, tmp_p def test_supply_chain_check_accepts_repo_multi_arch_workflow(monkeypatch) -> None: + """Ensure the checked-in multi-arch workflow satisfies the baseline policy.""" supply_chain = load_module("scripts/checks/verify_supply_chain.py", "verify_supply_chain_repo") repo_root = Path(__file__).resolve().parents[3]