Chantier F - Typage Python - ruff S101 déplacé en per-file-ignores (pyproject.toml) - pyrightconfig.json : mode standard confirmé Chantier G - Assertions & bare-except - 16 blocs except documentés dans config.py - --cov-branch activé, seuil coverage 60→68% Chantier H - Cache settings - Stratégie documentée dans settings.py - _resolve_index_dir() logguée dans config.py Chantier I - Split gros fichiers JS - client.js 1509L → client.js 406L + clientAuth.js + clientOps.js + fetchUtils.js - toolbar.js 927L → toolbar.js 458L + toolbarActions.js + toolbarControls.js - FloatingViewer.js → FloatingViewer.impl.js + floatingViewer*(8 modules) - Viewer_impl.js → ViewerRuntime.js + viewerShell.js + viewerThemeStyles.js + viewerOverlayDismiss.js - model3dRenderer.js → model3dRenderer.impl.js + model3dCore.js + model3dLoaders.js + model3dSupport.js - dialogs.js → dialogs.js + DialogTemplates.js Chantier J - Tests frontend (Phases 1-2) - use_drag_drop.vitest.mjs : 5 tests - use_grid_state.vitest.mjs : 18 tests - security_client_headers, security_deep_merge, security_runtime_state, runtime_store, dialog_templates - Total: 80 fichiers test / 350 tests vitest Chantier K - Pinia migration - runtimeState.js entièrement migré vers Pinia - getOptionalRuntimeStore.js + runtimeEnrichmentState.js ajoutés Chantier L - Durcissement sécurité - tests/security/test_rate_limit.py : 8 tests rate-limit (state machine complète) - clientAuth.js : docstring sessionStorage security boundary - three.js 0.183.2 audité : 0 CVE (Snyk) Chantier M - Simplification config - deps.py, tool_detect.py, utils.py nettoyés docs: PLAN_REFACTO_V2_LONG_TERME.md ajouté et clôturé .gitignore: coverage/ ajouté
269 lines
8.7 KiB
Python
269 lines
8.7 KiB
Python
"""Run the canonical repository quality gate."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import tomllib
|
|
from packaging.specifiers import SpecifierSet
|
|
from packaging.version import Version
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PYTHON_TEXT_PATHS = [
|
|
"mjr_am_backend",
|
|
"mjr_am_shared",
|
|
"tests",
|
|
"scripts",
|
|
"__init__.py",
|
|
"pyproject.toml",
|
|
"mypy.ini",
|
|
"pytest.ini",
|
|
"tox.ini",
|
|
".pre-commit-config.yaml",
|
|
]
|
|
DOC_TEXT_PATHS = [
|
|
"README.md",
|
|
"CHANGELOG.md",
|
|
"docs",
|
|
".github",
|
|
]
|
|
PYTHON_COMPLEXITY_PATHS = [
|
|
"mjr_am_backend",
|
|
"mjr_am_shared",
|
|
"tests",
|
|
"__init__.py",
|
|
]
|
|
RUFF_TARGETS = ["mjr_am_backend", "mjr_am_shared", "tests", "scripts", "__init__.py"]
|
|
BANDIT_TARGETS = ["mjr_am_backend"]
|
|
DEFAULT_PYTEST_ARGS = [
|
|
"-q",
|
|
"--cov=mjr_am_backend",
|
|
"--cov=mjr_am_shared",
|
|
"--cov-branch",
|
|
"--cov-fail-under=68",
|
|
"--cov-report=xml",
|
|
"--cov-report=term-missing:skip-covered",
|
|
]
|
|
PIP_AUDIT_REQUIREMENTS = ["requirements.txt"]
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Run lint, typecheck, security, complexity, and test gates.")
|
|
parser.add_argument("--skip-tests", action="store_true", help="Skip backend pytest execution.")
|
|
parser.add_argument("--skip-js-tests", action="store_true", help="Skip frontend vitest execution.")
|
|
parser.add_argument("--skip-js-lint", action="store_true", help="Skip ESLint and Prettier checks.")
|
|
parser.add_argument("--skip-js-audit", action="store_true", help="Skip npm audit.")
|
|
parser.add_argument("--skip-pip-audit", action="store_true", help="Skip pip-audit.")
|
|
parser.add_argument("--skip-bandit", action="store_true", help="Skip bandit.")
|
|
parser.add_argument("--skip-mypy", action="store_true", help="Skip mypy.")
|
|
parser.add_argument("--skip-ruff", action="store_true", help="Skip ruff.")
|
|
parser.add_argument("--skip-complexity", action="store_true", help="Skip xenon/radon checks.")
|
|
parser.add_argument(
|
|
"--python-only",
|
|
action="store_true",
|
|
help="Shortcut for skipping JS tests and npm audit.",
|
|
)
|
|
parser.add_argument(
|
|
"--changed-only-complexity",
|
|
action="store_true",
|
|
help="Run the stricter changed-file complexity guard in addition to repo thresholds.",
|
|
)
|
|
parser.add_argument(
|
|
"--pytest-args",
|
|
nargs=argparse.REMAINDER,
|
|
default=None,
|
|
help="Additional args passed through to pytest after '--pytest-args'.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def _git_output(cmd: list[str]) -> str:
|
|
return subprocess.check_output(cmd, cwd=ROOT, text=True, stderr=subprocess.DEVNULL).strip()
|
|
|
|
|
|
def _changed_python_files() -> list[str]:
|
|
diff = ""
|
|
base_ref = os.environ.get("GITHUB_BASE_REF", "").strip()
|
|
try:
|
|
if base_ref:
|
|
diff = _git_output(["git", "diff", "--name-only", f"origin/{base_ref}...HEAD"])
|
|
else:
|
|
diff = _git_output(["git", "diff", "--name-only", "HEAD~1...HEAD"])
|
|
except Exception:
|
|
diff = ""
|
|
|
|
changed: list[str] = []
|
|
for raw in diff.splitlines():
|
|
candidate = raw.strip()
|
|
if not candidate:
|
|
continue
|
|
if " -> " in candidate:
|
|
candidate = candidate.split(" -> ", 1)[1].strip()
|
|
if not candidate.endswith(".py"):
|
|
continue
|
|
path = ROOT / candidate
|
|
if path.exists():
|
|
changed.append(candidate)
|
|
return sorted(dict.fromkeys(changed))
|
|
|
|
|
|
def _run(cmd: list[str], *, label: str, env: dict[str, str] | None = None) -> None:
|
|
print(f"\n==> {label}")
|
|
print(" ".join(cmd))
|
|
subprocess.run(cmd, cwd=ROOT, check=True, env=env)
|
|
|
|
|
|
def _python_cmd(module: str, *args: str) -> list[str]:
|
|
return [sys.executable, "-m", module, *args]
|
|
|
|
|
|
def _npm_cmd(*args: str) -> list[str]:
|
|
candidates = ["npm.cmd", "npm"] if os.name == "nt" else ["npm"]
|
|
for candidate in candidates:
|
|
if shutil.which(candidate):
|
|
return [candidate, *args]
|
|
raise SystemExit("npm is required for JS checks but was not found in PATH.")
|
|
|
|
|
|
def _existing_paths(values: list[str]) -> list[str]:
|
|
existing: list[str] = []
|
|
for value in values:
|
|
if (ROOT / value).exists():
|
|
existing.append(value)
|
|
return existing
|
|
|
|
|
|
def _pip_audit_cmd() -> list[str]:
|
|
requirement_files = _existing_paths(PIP_AUDIT_REQUIREMENTS)
|
|
cmd = _python_cmd("pip_audit", "--strict", "--skip-editable")
|
|
if requirement_files:
|
|
for requirement_file in requirement_files:
|
|
cmd.extend(["-r", requirement_file])
|
|
return cmd
|
|
return cmd
|
|
|
|
|
|
def _npm_available() -> bool:
|
|
return any(shutil.which(candidate) for candidate in (("npm.cmd", "npm") if os.name == "nt" else ("npm",)))
|
|
|
|
|
|
def _current_python_supported_by_project() -> bool:
|
|
pyproject_path = ROOT / "pyproject.toml"
|
|
if not pyproject_path.exists():
|
|
return True
|
|
try:
|
|
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return True
|
|
|
|
requires_python = ((data.get("project") or {}).get("requires-python") or "").strip()
|
|
if not requires_python:
|
|
return True
|
|
|
|
try:
|
|
supported_versions = SpecifierSet(requires_python)
|
|
current_version = Version(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
|
except Exception:
|
|
return True
|
|
return current_version in supported_versions
|
|
|
|
|
|
def _clear_mypy_cache() -> None:
|
|
cache_dir = ROOT / ".mypy_cache"
|
|
if not cache_dir.exists():
|
|
return
|
|
shutil.rmtree(cache_dir, ignore_errors=True)
|
|
|
|
|
|
def main() -> int:
|
|
args = _parse_args()
|
|
if args.python_only:
|
|
args.skip_js_tests = True
|
|
args.skip_js_lint = True
|
|
args.skip_js_audit = True
|
|
|
|
text_paths = _existing_paths(PYTHON_TEXT_PATHS + DOC_TEXT_PATHS)
|
|
complexity_paths = _existing_paths(PYTHON_COMPLEXITY_PATHS)
|
|
bandit_targets = _existing_paths(BANDIT_TARGETS)
|
|
changed_python = _changed_python_files()
|
|
|
|
_run([sys.executable, "scripts/check_no_bom.py", *text_paths], label="UTF-8 and BOM hygiene")
|
|
|
|
if not args.skip_ruff:
|
|
if changed_python:
|
|
changed_python_existing = _existing_paths(changed_python)
|
|
if not changed_python_existing:
|
|
print("\n==> Ruff lint")
|
|
print("No existing changed Python files detected; skipping incremental Ruff gate.")
|
|
else:
|
|
_run(
|
|
_python_cmd("ruff", "check", *changed_python_existing),
|
|
label="Ruff lint (changed Python files)",
|
|
)
|
|
else:
|
|
print("\n==> Ruff lint")
|
|
print("No changed Python files detected; skipping incremental Ruff gate.")
|
|
|
|
if not args.skip_mypy:
|
|
_clear_mypy_cache()
|
|
_run(_python_cmd("mypy", "--config-file", "mypy.ini"), label="Mypy")
|
|
|
|
if not args.skip_bandit:
|
|
_run(_python_cmd("bandit", "-r", *bandit_targets, "-ll", "-ii", "-x", "tests"), label="Bandit")
|
|
|
|
if not args.skip_pip_audit:
|
|
if _current_python_supported_by_project():
|
|
_run(
|
|
_pip_audit_cmd(),
|
|
label="pip-audit",
|
|
)
|
|
else:
|
|
print("\n==> pip-audit")
|
|
print(
|
|
"Skipping pip-audit because the active Python interpreter is outside the supported "
|
|
"range declared in pyproject.toml."
|
|
)
|
|
|
|
if not args.skip_complexity:
|
|
_run(
|
|
_python_cmd("xenon", "-b", "D", "-m", "D", "-a", "B", *complexity_paths),
|
|
label="Xenon repository complexity gate",
|
|
)
|
|
_run(
|
|
[sys.executable, "scripts/check_cc_changed.py"],
|
|
label="Changed-file complexity gate",
|
|
env={**os.environ, "MJR_COMPLEXITY_MAX_CC": "25"},
|
|
)
|
|
|
|
if not args.skip_tests:
|
|
pytest_args = args.pytest_args if args.pytest_args else DEFAULT_PYTEST_ARGS
|
|
_run(_python_cmd("pytest", *pytest_args), label="Pytest")
|
|
|
|
if not args.skip_js_lint:
|
|
if not _npm_available():
|
|
raise SystemExit("npm is required for JS lint but was not found in PATH.")
|
|
_run(_npm_cmd("run", "lint:js"), label="ESLint")
|
|
_run(_npm_cmd("run", "format:check"), label="Prettier")
|
|
|
|
if not args.skip_js_tests:
|
|
if not _npm_available():
|
|
raise SystemExit("npm is required for JS tests but was not found in PATH.")
|
|
_run(_npm_cmd("run", "test:js"), label="Vitest")
|
|
|
|
if not args.skip_js_audit:
|
|
if not _npm_available():
|
|
raise SystemExit("npm is required for npm audit but was not found in PATH.")
|
|
_run(_npm_cmd("audit", "--audit-level=high"), label="npm audit")
|
|
|
|
print("\nQuality gate passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|