intermediate commit
This commit is contained in:
149
tools/checks/check-contracts.py
Normal file
149
tools/checks/check-contracts.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static cross-repository module contract check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||
|
||||
from govoplan_release.contracts import ( # noqa: E402
|
||||
collect_contracts,
|
||||
format_version_range,
|
||||
interface_impact_map,
|
||||
provided_interface_map,
|
||||
validate_contracts,
|
||||
)
|
||||
from govoplan_release.model import RepositorySnapshot, to_jsonable # noqa: E402
|
||||
from govoplan_release.workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--workspace-root",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Workspace root containing the repositories. Defaults to repositories.json default_parent.",
|
||||
)
|
||||
parser.add_argument("--include-website", action="store_true", help="Also scan website repositories.")
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
|
||||
parser.add_argument("--no-impact", action="store_true", help="Omit human-readable provider impact output.")
|
||||
parser.add_argument("--strict-warnings", action="store_true", help="Fail when warnings are present.")
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace_root = resolve_workspace_root(args.workspace_root)
|
||||
repositories = repository_snapshots(workspace_root=workspace_root, include_website=args.include_website)
|
||||
contracts = collect_contracts(repositories)
|
||||
issues = validate_contracts(contracts)
|
||||
providers = provided_interface_map(contracts)
|
||||
consumers = interface_impact_map(contracts)
|
||||
|
||||
payload = {
|
||||
"workspace_root": str(workspace_root),
|
||||
"repository_count": len(repositories),
|
||||
"contract_count": len(contracts),
|
||||
"provider_count": sum(len(contract.provides_interfaces) for contract in contracts),
|
||||
"requirement_count": sum(len(contract.requires_interfaces) for contract in contracts),
|
||||
"providers": provider_payload(providers, consumers),
|
||||
"issues": to_jsonable(issues),
|
||||
}
|
||||
|
||||
blockers = [issue for issue in issues if issue.severity == "blocker"]
|
||||
warnings = [issue for issue in issues if issue.severity == "warning"]
|
||||
exit_code = 1 if blockers or (args.strict_warnings and warnings) else 0
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
return exit_code
|
||||
|
||||
print("Static module contract scan")
|
||||
print(f"Workspace: {workspace_root}")
|
||||
print(
|
||||
f"Contracts: {payload['contract_count']} modules, "
|
||||
f"{payload['provider_count']} providers, {payload['requirement_count']} requirements"
|
||||
)
|
||||
|
||||
if not args.no_impact:
|
||||
print()
|
||||
print("Provider impact:")
|
||||
if providers:
|
||||
for interface_name, provider_items in providers.items():
|
||||
provider_text = ", ".join(
|
||||
f"{contract.module_id}@{provider.version} ({contract.repo})"
|
||||
for contract, provider in provider_items
|
||||
)
|
||||
consumer_text = ", ".join(
|
||||
f"{contract.module_id} ({format_version_range(version_min=req.version_min, version_max_exclusive=req.version_max_exclusive)})"
|
||||
for contract, req in consumers.get(interface_name, ())
|
||||
)
|
||||
if not consumer_text:
|
||||
consumer_text = "no consumers"
|
||||
print(f"- {interface_name}: {provider_text}; consumers: {consumer_text}")
|
||||
else:
|
||||
print("- no provided interfaces found")
|
||||
|
||||
if issues:
|
||||
print()
|
||||
print("Contract issues:")
|
||||
for issue in issues:
|
||||
location = issue.repo or "workspace"
|
||||
if issue.module_id:
|
||||
location += f"/{issue.module_id}"
|
||||
print(f"- [{issue.severity}] {issue.code}: {location}: {issue.message}")
|
||||
else:
|
||||
print()
|
||||
print("Contract check passed.")
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def repository_snapshots(*, workspace_root: Path, include_website: bool) -> tuple[RepositorySnapshot, ...]:
|
||||
snapshots: list[RepositorySnapshot] = []
|
||||
for spec in load_repository_specs(include_website=include_website):
|
||||
path = resolve_repo_path(spec, workspace_root)
|
||||
snapshots.append(
|
||||
RepositorySnapshot(
|
||||
spec=spec,
|
||||
absolute_path=str(path),
|
||||
exists=path.exists(),
|
||||
is_git=(path / ".git").exists(),
|
||||
)
|
||||
)
|
||||
return tuple(snapshots)
|
||||
|
||||
|
||||
def provider_payload(providers, consumers):
|
||||
payload: dict[str, dict[str, list[dict[str, str | bool | None]]]] = {}
|
||||
for interface_name, provider_items in providers.items():
|
||||
payload[interface_name] = {
|
||||
"providers": [
|
||||
{
|
||||
"repo": contract.repo,
|
||||
"module_id": contract.module_id,
|
||||
"version": provider.version,
|
||||
"manifest_path": contract.manifest_path,
|
||||
}
|
||||
for contract, provider in provider_items
|
||||
],
|
||||
"consumers": [
|
||||
{
|
||||
"repo": contract.repo,
|
||||
"module_id": contract.module_id,
|
||||
"version_min": requirement.version_min,
|
||||
"version_max_exclusive": requirement.version_max_exclusive,
|
||||
"optional": requirement.optional,
|
||||
"manifest_path": contract.manifest_path,
|
||||
}
|
||||
for contract, requirement in consumers.get(interface_name, ())
|
||||
],
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
12
tools/checks/check-contracts.sh
Normal file
12
tools/checks/check-contracts.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
META_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
VENV_ROOT="${GOVOPLAN_VENV_ROOT:-$META_ROOT/.venv}"
|
||||
PYTHON="${PYTHON:-$VENV_ROOT/bin/python}"
|
||||
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
PYTHON=python3
|
||||
fi
|
||||
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" "$@"
|
||||
@@ -33,39 +33,6 @@ PY
|
||||
"$PYTHON" - <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
prefix = pathlib.Path(sys.prefix)
|
||||
legacy_patterns = (
|
||||
"__editable__.govoplan_module_multimailer-*.pth",
|
||||
"__editable___govoplan_module_multimailer_*_finder.py",
|
||||
"govoplan_module_multimailer-*.dist-info",
|
||||
)
|
||||
problems: list[pathlib.Path] = []
|
||||
|
||||
for site_packages in (prefix / "lib").glob("python*/site-packages"):
|
||||
for pattern in legacy_patterns:
|
||||
problems.extend(site_packages.glob(pattern))
|
||||
|
||||
for dist in importlib.metadata.distributions():
|
||||
if dist.metadata.get("Name", "").lower() == "govoplan-module-multimailer":
|
||||
problems.append(pathlib.Path(str(dist.locate_file(""))))
|
||||
|
||||
if problems:
|
||||
print("Dependency hygiene failed: stale legacy multimailer install metadata found.", file=sys.stderr)
|
||||
for path in sorted(set(problems)):
|
||||
print(f" {path}", file=sys.stderr)
|
||||
print("Remove the stale files or recreate the venv. This package pins obsolete dependencies.", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("No stale legacy multimailer package metadata found.")
|
||||
PY
|
||||
|
||||
"$PYTHON" - <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
@@ -75,6 +42,7 @@ scan_roots = [
|
||||
root / "tests",
|
||||
root.parent / "govoplan-access" / "src",
|
||||
root.parent / "govoplan-admin" / "src",
|
||||
root.parent / "govoplan-addresses" / "src",
|
||||
root.parent / "govoplan-audit" / "src",
|
||||
root.parent / "govoplan-calendar" / "src",
|
||||
root.parent / "govoplan-campaign" / "src",
|
||||
|
||||
@@ -27,6 +27,7 @@ unset npm_config_tmp NPM_CONFIG_TMP
|
||||
cd "$ROOT"
|
||||
|
||||
GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh"
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||
|
||||
"$PYTHON" - <<'PY'
|
||||
import ast
|
||||
@@ -37,6 +38,7 @@ roots = [
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-access/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-admin/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-addresses/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-tenancy/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-organizations/src"),
|
||||
pathlib.Path("/mnt/DATA/git/govoplan-identity/src"),
|
||||
@@ -67,7 +69,7 @@ if errors:
|
||||
print(f"AST syntax check passed for {count} Python files")
|
||||
PY
|
||||
|
||||
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
||||
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_addresses.backend.manifest; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
||||
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||
"$PYTHON" -m unittest tests.test_module_system
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
||||
|
||||
@@ -18,6 +18,7 @@ if [[ ! -x "$NPM" ]]; then
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||
"$PYTHON" -m unittest tests.test_module_system tests.test_access_contracts
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ install_cloned_webui_dependencies() {
|
||||
}
|
||||
|
||||
run_step "Validate release refs and installed package metadata"
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||
"$PYTHON" - <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -104,9 +104,17 @@ if [[ "${#REPOS[@]}" -le 12 ]]; then
|
||||
fi
|
||||
|
||||
declare -a PY_ROOTS=()
|
||||
declare -a PY_PROD_ROOTS=()
|
||||
declare -a PY_TEST_ROOTS=()
|
||||
for repo in "${REPOS[@]}"; do
|
||||
[[ -d "$repo/src" ]] && PY_ROOTS+=("$repo/src")
|
||||
[[ -d "$repo/tests" ]] && PY_ROOTS+=("$repo/tests")
|
||||
if [[ -d "$repo/src" ]]; then
|
||||
PY_ROOTS+=("$repo/src")
|
||||
PY_PROD_ROOTS+=("$repo/src")
|
||||
fi
|
||||
if [[ -d "$repo/tests" ]]; then
|
||||
PY_ROOTS+=("$repo/tests")
|
||||
PY_TEST_ROOTS+=("$repo/tests")
|
||||
fi
|
||||
done
|
||||
|
||||
safe_name() {
|
||||
@@ -184,13 +192,25 @@ run_semgrep() {
|
||||
}
|
||||
|
||||
run_bandit() {
|
||||
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
|
||||
bandit -r "${PY_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json"
|
||||
local status=0
|
||||
if [[ "${#PY_PROD_ROOTS[@]}" -gt 0 ]]; then
|
||||
bandit -r "${PY_PROD_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json" || status=1
|
||||
fi
|
||||
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||
bandit -r "${PY_TEST_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit-tests.json" || true
|
||||
fi
|
||||
return "$status"
|
||||
}
|
||||
|
||||
run_ruff_security() {
|
||||
[[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0
|
||||
ruff check --select S --output-format json "${PY_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json"
|
||||
local status=0
|
||||
if [[ "${#PY_PROD_ROOTS[@]}" -gt 0 ]]; then
|
||||
ruff check --select S --output-format json "${PY_PROD_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json" || status=1
|
||||
fi
|
||||
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
|
||||
ruff check --select S --output-format json "${PY_TEST_ROOTS[@]}" > "$REPORTS_DIR/ruff-security-tests.json" || true
|
||||
fi
|
||||
return "$status"
|
||||
}
|
||||
|
||||
run_gitleaks() {
|
||||
@@ -272,12 +292,39 @@ run_osv_scanner() {
|
||||
}
|
||||
|
||||
run_jscpd() {
|
||||
local ignored_paths
|
||||
ignored_paths=(
|
||||
"**/.git/**"
|
||||
"**/node_modules/**"
|
||||
"**/.venv/**"
|
||||
"**/dist/**"
|
||||
"**/build/**"
|
||||
"**/audit-reports/**"
|
||||
"**/package-lock.json"
|
||||
"**/package-lock.release.json"
|
||||
"**/package.json"
|
||||
"package.json"
|
||||
"**/generatedTranslations.ts"
|
||||
"**/docs/gitea-labels.json"
|
||||
"**/*.md"
|
||||
"**/*.md:*"
|
||||
"*.md"
|
||||
"*.md:*"
|
||||
"**/*.svg"
|
||||
"*.svg"
|
||||
".gitea/workflows/*.yml"
|
||||
"**/.gitea/workflows/*.yml"
|
||||
"**/backend/schema/*.schema.json"
|
||||
)
|
||||
local ignored_path_pattern
|
||||
ignored_path_pattern="$(printf '%s,' "${ignored_paths[@]}")"
|
||||
ignored_path_pattern="${ignored_path_pattern%,}"
|
||||
jscpd \
|
||||
--silent \
|
||||
--min-tokens 80 \
|
||||
--reporters json \
|
||||
--output "$REPORTS_DIR/jscpd" \
|
||||
--ignore "**/.git/**,**/node_modules/**,**/.venv/**,**/dist/**,**/build/**,**/audit-reports/**,**/package-lock.json,**/package-lock.release.json,**/generatedTranslations.ts,**/docs/gitea-labels.json" \
|
||||
--ignore "$ignored_path_pattern" \
|
||||
"${REPOS[@]}"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ REPOS = {
|
||||
"core": CORE_ROOT / "src" / "govoplan_core",
|
||||
"access": REPOS_ROOT / "govoplan-access" / "src" / "govoplan_access",
|
||||
"admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin",
|
||||
"addresses": REPOS_ROOT / "govoplan-addresses" / "src" / "govoplan_addresses",
|
||||
"tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy",
|
||||
"organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations",
|
||||
"identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity",
|
||||
@@ -29,6 +30,7 @@ PREFIXES = {
|
||||
"core": "govoplan_core",
|
||||
"access": "govoplan_access",
|
||||
"admin": "govoplan_admin",
|
||||
"addresses": "govoplan_addresses",
|
||||
"tenancy": "govoplan_tenancy",
|
||||
"organizations": "govoplan_organizations",
|
||||
"identity": "govoplan_identity",
|
||||
@@ -39,16 +41,18 @@ PREFIXES = {
|
||||
"mail": "govoplan_mail",
|
||||
"campaign": "govoplan_campaign",
|
||||
}
|
||||
FEATURE_OWNERS = ("files", "mail", "campaign")
|
||||
FEATURE_OWNERS = ("addresses", "files", "mail", "campaign")
|
||||
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
||||
WEBUI_REPOS = {
|
||||
"core": CORE_ROOT / "webui",
|
||||
"addresses": REPOS_ROOT / "govoplan-addresses" / "webui",
|
||||
"files": REPOS_ROOT / "govoplan-files" / "webui",
|
||||
"mail": REPOS_ROOT / "govoplan-mail" / "webui",
|
||||
"campaign": REPOS_ROOT / "govoplan-campaign" / "webui",
|
||||
}
|
||||
WEBUI_PACKAGES = {
|
||||
"core": "@govoplan/core-webui",
|
||||
"addresses": "@govoplan/addresses-webui",
|
||||
"files": "@govoplan/files-webui",
|
||||
"mail": "@govoplan/mail-webui",
|
||||
"campaign": "@govoplan/campaign-webui",
|
||||
|
||||
@@ -34,6 +34,8 @@ COPY requirements-audit.txt /tmp/requirements-audit.txt
|
||||
|
||||
RUN python -m pip install --upgrade pip \
|
||||
&& python -m pip install --no-cache-dir -r /tmp/requirements-audit.txt \
|
||||
&& python -m pip install --no-cache-dir "semgrep>=1.140,<2" \
|
||||
&& python -m pip install --no-cache-dir --upgrade --no-deps "click>=8.3.3" \
|
||||
&& npm install -g jscpd \
|
||||
&& semgrep --version \
|
||||
&& bandit --version \
|
||||
|
||||
Reference in New Issue
Block a user