"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083). Sans dépendance (pas de PyYAML) : analyse ligne à ligne de `.gitea/workflows/ci.yml`, suffisante pour les conventions de ce fichier. """ from __future__ import annotations import re from pathlib import Path CI_YML = Path(__file__).resolve().parent.parent / ".gitea" / "workflows" / "ci.yml" REPO_ROOT = Path(__file__).resolve().parent.parent def _run_bodies() -> list[tuple[int, str]]: """Toutes les lignes shell de chaque bloc `run:` → [(n° ligne, code)].""" lines = CI_YML.read_text(encoding="utf-8").splitlines() bodies: list[tuple[int, str]] = [] i = 0 while i < len(lines): m = re.match(r"^(\s*)run:(?:\s*\|\s*)?$", lines[i]) inline = re.match(r"^(\s*)run:\s+(\S.*)$", lines[i]) if m: base = len(m.group(1)) i += 1 while i < len(lines): cur = lines[i] if not cur.strip(): i += 1 continue if len(cur) - len(cur.lstrip()) <= base: break bodies.append((i + 1, cur.strip())) i += 1 elif inline: bodies.append((i + 1, inline.group(2).strip())) i += 1 else: i += 1 return bodies class TestRunnerProofScripts: def test_no_hash_inside_run_bodies(self): """BUG-083 : aucun `#` dans le code shell des `run:`. Le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée → `unexpected EOF while looking for matching '"'` (job `security` rouge). Les lignes-commentaires shell (`# ...`) restent autorisées : leur troncature est sémantiquement neutre. """ offenders = [ f"L{n}: {code}" for n, code in _run_bodies() if not code.startswith("#") and "#" in code ] assert not offenders, ( "BUG-083 : `#` interdit dans le code des `run:` " f"(tronqué par le runner) :\n" + "\n".join(offenders) ) class TestSemgrepStep: def test_semgrep_local_rules_enforced(self): """#87 T7 : semgrep bloquant sur règles locales (aucun registre).""" text = CI_YML.read_text(encoding="utf-8") assert "semgrep --config semgrep-rules/ backend/" in text, ( "#87 T7 : étape semgrep locale attendue dans le job security" ) rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml" assert rules.exists(), "ruleset semgrep manquant" class TestFrontendStepsHaveTheirDeps: @staticmethod def _root_step_files() -> list[str]: """Fichiers `node tests/frontend/` de l'étape racine (sans jsdom).""" text = CI_YML.read_text(encoding="utf-8") root_part = text.split("Frontend JSDOM tests", 1)[0] root_steps = root_part.split("Frontend unit tests", 1)[1] return re.findall(r"node tests/frontend/(\S+\.mjs)", root_steps) @staticmethod def _has_static_jsdom_import(rel: str) -> bool: path = REPO_ROOT / "tests" / "frontend" / rel return any( re.match(r"^\s*import\b.*\bfrom\s+['\"]jsdom['\"]", line) or re.match(r"""\brequire\(\s*['"]jsdom['"]\s*\)""", line) for line in path.read_text(encoding="utf-8").splitlines() ) def test_root_step_files_need_no_jsdom(self): """BUG-082 : l'étape racine tourne sans `tests/frontend/node_modules` (installé seulement par l'étape JSDOM) : aucun de ses fichiers ne doit importer `jsdom` statiquement — sinon `ERR_MODULE_NOT_FOUND` et `lint` rouge (cas `upload.test.mjs`, puis `config-ai-keys.test.mjs`). """ offenders = [f for f in self._root_step_files() if self._has_static_jsdom_import(f)] assert not offenders, ( "BUG-082 : ces fichiers importent `jsdom` mais tournent dans " "l'étape racine (sans node_modules) — les déplacer dans l'étape " f"JSDOM :\n" + "\n".join(offenders) ) def test_jsdom_dependent_tests_run_in_jsdom_step(self): """BUG-082 : les suites à import statique `jsdom` tournent bien dans l'étape JSDOM (les deux branches).""" text = CI_YML.read_text(encoding="utf-8") jsdom_part = text.split("Frontend JSDOM tests", 1)[1] for suite in ("node upload.test.mjs", "node config-ai-keys.test.mjs"): assert jsdom_part.count(suite) >= 2, ( f"BUG-082 : `{suite}` attendu dans les deux branches de " "l'étape JSDOM" )