diff --git a/VERSION b/VERSION index 837e3ff..df4fd5b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.4-alpha.2 +0.1.4-alpha.2.fix.1 diff --git a/deltas/0.1.4/alpha.2.fix.1.md b/deltas/0.1.4/alpha.2.fix.1.md new file mode 100644 index 0000000..3522678 --- /dev/null +++ b/deltas/0.1.4/alpha.2.fix.1.md @@ -0,0 +1,209 @@ + + + +# Delta 0.1.4-alpha.2.fix.1 — hygiène EOF et prévention automatique + +## Diagnostic + +La gate opérateur de `0.1.4-alpha.2` a validé les `48/48` tests Python, les trois audits Markdown et le contrôle normatif Tauri, puis a échoué sur les deux contrôles Git d'hygiène : + +```text +docs/studies/002-TAURI_SKELETON_DECISION.md:299: new blank line at EOF. +``` + +Le même défaut a été signalé par : + +```text +git diff --check +git diff --cached --check +``` + +`history/0.1.4/alpha.2.md` enregistre donc `FAIL`. Le contenu normatif Tauri de `alpha.2` reste conservé ; le présent fix corrige uniquement l'hygiène mécanique et la lacune de prévention qui a permis la réapparition d'un défaut déjà rencontré historiquement. + +Version après application : + +```text +0.1.4-alpha.2.fix.1 +``` + +## Objet + +Corriger la ligne vide surnuméraire à EOF de l'étude Tauri et faire de cette classe de défaut une propriété automatiquement contrôlée avant les futures livraisons PGR. + +Le correctif introduit un audit monotâche read-only au lieu d'ajouter une vérification ad hoc dans chaque delta. + +## Scope + +REPLACE : + +```text +VERSION +docs/rules/210-WORKFLOW.md +docs/studies/002-TAURI_SKELETON_DECISION.md +docs/validation/001-VALIDATION_MATRIX.md +``` + +ADD : + +```text +deltas/0.1.4/alpha.2.fix.1.md +scripts/audit_text_hygiene.py +tests/test_text_hygiene.py +``` + +Les autres décisions de `alpha.2`, notamment `docs/rules/710-TAURI.md`, le plan `010` et `docs/000-README.md`, MUST rester inchangées. + +## Correction EOF + +`docs/studies/002-TAURI_SKELETON_DECISION.md` conserve son contenu et sa révision interne `3`. La seule modification de ce fichier est mécanique : sa terminaison passe de deux caractères LF successifs à exactement un LF final. + +Aucune décision Tauri n'est modifiée par cette correction. + +## Audit `text_hygiene` + +`scripts/audit_text_hygiene.py` possède quatre propriétés mécaniques sur les fichiers texte sélectionnés : + +```text +LF uniquement +aucun whitespace terminal +newline finale obligatoire +exactement une newline finale, donc aucune ligne vide supplémentaire à EOF +``` + +L'audit reste read-only, accepte des fichiers ou répertoires explicites et ignore les répertoires générés usuels. Il ne remplace pas un formatter, un parser Markdown ou `git diff --check`. + +`tests/test_text_hygiene.py` couvre au minimum : + +- fichier propre avec exactement un LF final ; +- ligne vide supplémentaire à EOF ; +- newline finale absente ; +- CRLF ; +- espace ou tabulation terminale ; +- exclusion d'un répertoire généré ; +- sélection invalide. + +## Contrat de workflow + +`docs/rules/210-WORKFLOW.md` passe de la révision `7` à `8` et rend explicite que le générateur doit contrôler l'hygiène mécanique avant livraison, plutôt que déléguer sa découverte à la gate opérateur. + +`docs/validation/001-VALIDATION_MATRIX.md` passe de la révision `14` à `15` et ajoute `PGR-TEXT-HYGIENE-001` comme audit faible coût déclenché lorsqu'un fichier texte gouverné est modifié. + +Le contrôle Git reste conservé comme filet indépendant. + +## Gate opérateur + +Après application puis commit/push du history `FAIL` de `alpha.2`, appliquer ce delta et exécuter : + +```bash +cat VERSION + +test -f history/0.1.4/alpha.2.md +grep -A2 '^## Statut$' history/0.1.4/alpha.2.md | grep -Fx 'FAIL' +test -f deltas/0.1.4/alpha.2.fix.1.md +test ! -e history/0.1.4/alpha.2.fix.1.md + +test -f scripts/audit_text_hygiene.py +test -f tests/test_text_hygiene.py + +python3 -B -m unittest discover -s tests -v + +python3 -B scripts/audit_text_hygiene.py \ + VERSION README.md RULES.md ROADMAP.md CHANGELOG.md \ + docs deltas history prompts scripts tests templates .gitignore .settings + +python3 -B scripts/audit_markdown_headers.py \ + README.md RULES.md ROADMAP.md CHANGELOG.md docs deltas history prompts +python3 -B scripts/audit_markdown_tables.py \ + README.md RULES.md ROADMAP.md CHANGELOG.md docs deltas history prompts +python3 -B scripts/audit_markdown_links.py \ + README.md RULES.md ROADMAP.md CHANGELOG.md docs deltas history prompts + +python3 -B - <<'PY' +from pathlib import Path +import re + +assert Path('VERSION').read_text(encoding='utf-8').strip() == '0.1.4-alpha.2.fix.1' + +history = Path('history/0.1.4/alpha.2.md').read_text(encoding='utf-8') +assert re.search(r'## Statut\s+FAIL', history) + +study_path = Path('docs/studies/002-TAURI_SKELETON_DECISION.md') +study = study_path.read_bytes() +assert study.endswith(b'\n') +assert not study.endswith(b'\n\n') +assert study_path.read_text(encoding='utf-8').startswith( + '\n' + '' +) + +workflow = Path('docs/rules/210-WORKFLOW.md').read_text(encoding='utf-8') +assert workflow.startswith( + '\n' + '' +) +for phrase in ( + '## Hygiène mécanique des artefacts texte', + 'exactement une seule newline finale', + 'scripts/audit_text_hygiene.py', + 'Un générateur MUST corriger un défaut mécanique détectable avant livraison', +): + assert phrase in workflow, phrase + +matrix = Path('docs/validation/001-VALIDATION_MATRIX.md').read_text(encoding='utf-8') +assert matrix.startswith( + '\n' + '' +) +assert '`PGR-TEXT-HYGIENE-001`' in matrix +assert 'exactement une seule newline finale' in matrix + +assert Path('scripts/audit_text_hygiene.py').exists() +assert Path('tests/test_text_hygiene.py').exists() + +for path, version in ( + ('docs/rules/710-TAURI.md', '7'), + ('docs/plans/010-V0_1_4_TAURI_REEVALUATION_PLAN.md', '2'), + ('docs/000-README.md', '42'), +): + lines = Path(path).read_text(encoding='utf-8').splitlines() + assert lines[1] == f'', (path, lines[1]) + +assert not Path('docs/skeletons/004-TAURI.md').exists() +assert not Path('history/0.1.4/alpha.2.fix.1.md').exists() + +print( + '0.1.4 alpha.2.fix.1 hygiene audit: clean ' + '(EOF corrected, reusable text hygiene audit active, Tauri decisions preserved)' +) +PY + +git diff --check + +git add -A +git diff --cached --check +git status --short +``` + +Le scope indexé attendu est : + +```text +M VERSION +A deltas/0.1.4/alpha.2.fix.1.md +M docs/rules/210-WORKFLOW.md +M docs/studies/002-TAURI_SKELETON_DECISION.md +M docs/validation/001-VALIDATION_MATRIX.md +A scripts/audit_text_hygiene.py +A tests/test_text_hygiene.py +``` + +L'ordre d'affichage peut varier ; l'ensemble des chemins doit être identique. + +## Transition + +### PASS + +Produire `history/0.1.4/alpha.2.fix.1.md`, commit/push sous l'identité `0.1.4-alpha.2.fix.1`, puis poursuivre avec `alpha.3` sur cette baseline propre. Les gates suivantes de `0.1.4` doivent sélectionner `PGR-TEXT-HYGIENE-001` pour les fichiers texte modifiés. + +### FAIL + +Enregistrer le résultat réel dans `history/0.1.4/alpha.2.fix.1.md` et poursuivre sous `alpha.2.fix.N`. Ne pas commencer `alpha.3` tant que l'hygiène texte et le scope du correctif ne sont pas `PASS`. diff --git a/docs/rules/210-WORKFLOW.md b/docs/rules/210-WORKFLOW.md index aeb856e..4cb295e 100644 --- a/docs/rules/210-WORKFLOW.md +++ b/docs/rules/210-WORKFLOW.md @@ -1,5 +1,5 @@ - + # Workflow artifact rules @@ -163,6 +163,16 @@ Les deux archives restent séparées. Cette optimisation de flux n'autorise jama Le delta suivant SHOULD NOT être anticipé lorsqu'une décision opérateur, un REPLAN non résolu, un abandon/supersession, une preuve externe post-tag ou toute autre dépendance peut encore modifier sa baseline ou son scope. +## Hygiène mécanique des artefacts texte + +Les fichiers texte de gouvernance ajoutés ou remplacés MUST être livrés avec des fins de ligne LF, sans whitespace terminal et avec exactement une seule newline finale. Une ligne vide supplémentaire à EOF constitue un défaut de livraison même si le contenu sémantique du document est inchangé. + +Avant remise d'un delta ou d'un history, le générateur MUST contrôler cette propriété sur les fichiers texte concernés. Le dépôt PGR fournit `scripts/audit_text_hygiene.py` comme audit read-only monotâche réutilisable ; il contrôle les CR, le whitespace terminal, l'absence de newline finale et les lignes vides terminales surnuméraires. + +`git diff --check` et `git diff --cached --check` restent obligatoires lorsqu'ils sont sélectionnés par la gate Git applicable. Ils constituent un filet indépendant et ne remplacent pas l'audit d'hygiène lorsque celui-ci est disponible dans le projet. + +Un générateur MUST corriger un défaut mécanique détectable avant livraison plutôt que compter sur la gate opérateur pour le découvrir. Si la gate opérateur détecte malgré tout ce défaut, le résultat réel reste `FAIL` et la correction suit le workflow `.fix.N`. + ## Commit de tranche Le commit d'une tranche doit représenter l'état complet de cette tranche, y compris son history. diff --git a/docs/studies/002-TAURI_SKELETON_DECISION.md b/docs/studies/002-TAURI_SKELETON_DECISION.md index d3d924d..7f911e9 100644 --- a/docs/studies/002-TAURI_SKELETON_DECISION.md +++ b/docs/studies/002-TAURI_SKELETON_DECISION.md @@ -296,4 +296,3 @@ Le réexamen ne démontre toujours pas le besoin de créer en `alpha.2` : - un owner iOS, serveur Rust ou deployment/operations par proximité. Ces décisions restent à réévaluer après matérialisation de la specification, conformément au plan `0.1.4`. - diff --git a/docs/validation/001-VALIDATION_MATRIX.md b/docs/validation/001-VALIDATION_MATRIX.md index 4b65651..9e7dad0 100644 --- a/docs/validation/001-VALIDATION_MATRIX.md +++ b/docs/validation/001-VALIDATION_MATRIX.md @@ -1,5 +1,5 @@ - + # Matrice de validation PGR @@ -13,20 +13,23 @@ Les catégories et règles de sélection sont définies dans [`../rules/400-VALI ## Contrôles actifs -| ID | Catégorie | Commande / contrôle | Prérequis | Trigger | Phase | Coût | Acteur | -|---------------------|-----------|--------------------------------------------------------------------------------------|------------------------|---------------------------------|----------------------|--------|-----------------| -| `PGR-GIT-001` | `CHECK` | `git diff --check` ou `git diff --cached --check` selon l'état de l'index | Git | toute modification suivie | alpha/beta/RC/stable | faible | opérateur | -| `PGR-VERSION-001` | `CHECK` | comparer `VERSION` à la version attendue par le delta courant | fichier `VERSION` | changement de tranche/version | alpha/beta/RC/stable | faible | opérateur | -| `PGR-LAYOUT-001` | `AUDIT` | comparer les fichiers ajoutés/remplacés/supprimés au scope déclaré du delta | scope du delta | changement de structure | alpha/beta/RC/stable | faible | opérateur/revue | -| `PGR-DOC-001` | `AUDIT` | vérifier chemins, rôles et responsabilités des documents touchés | fichiers documentaires | modification documentaire | alpha/beta/RC/stable | faible | opérateur/revue | -| `PGR-MD-HEADER-001` | `AUDIT` | `python3 -B scripts/audit_markdown_headers.py ` | Python 3 | Markdown gouverné modifié | alpha/beta/RC/stable | faible | opérateur/CI | -| `PGR-MD-TABLE-001` | `AUDIT` | `python3 -B scripts/audit_markdown_tables.py ` | Python 3 | table Markdown ajoutée/modifiée | alpha/beta/RC/stable | faible | opérateur/CI | -| `PGR-NAV-001` | `AUDIT` | `python3 -B scripts/audit_markdown_links.py ` | Python 3 | navigation ou réconciliation | alpha/beta/RC/stable | faible | opérateur/CI | -| `PGR-SCOPE-001` | `AUDIT` | vérifier que les documents vivants pointent vers les propriétaires normatifs actuels | documents vivants | réconciliation de phase | beta/RC/stable | faible | revue | -| `PGR-STATUS-001` | `CHECK` | `git status --short` | Git | avant fermeture d'une tranche | alpha/beta/RC/stable | faible | opérateur | +| ID | Catégorie | Commande / contrôle | Prérequis | Trigger | Phase | Coût | Acteur | +|------------------------|-----------|--------------------------------------------------------------------------------------|------------------------|---------------------------------|----------------------|--------|-----------------| +| `PGR-GIT-001` | `CHECK` | `git diff --check` ou `git diff --cached --check` selon l'état de l'index | Git | toute modification suivie | alpha/beta/RC/stable | faible | opérateur | +| `PGR-TEXT-HYGIENE-001` | `AUDIT` | `python3 -B scripts/audit_text_hygiene.py ` | Python 3 | fichier texte gouverné modifié | alpha/beta/RC/stable | faible | opérateur/CI | +| `PGR-VERSION-001` | `CHECK` | comparer `VERSION` à la version attendue par le delta courant | fichier `VERSION` | changement de tranche/version | alpha/beta/RC/stable | faible | opérateur | +| `PGR-LAYOUT-001` | `AUDIT` | comparer les fichiers ajoutés/remplacés/supprimés au scope déclaré du delta | scope du delta | changement de structure | alpha/beta/RC/stable | faible | opérateur/revue | +| `PGR-DOC-001` | `AUDIT` | vérifier chemins, rôles et responsabilités des documents touchés | fichiers documentaires | modification documentaire | alpha/beta/RC/stable | faible | opérateur/revue | +| `PGR-MD-HEADER-001` | `AUDIT` | `python3 -B scripts/audit_markdown_headers.py ` | Python 3 | Markdown gouverné modifié | alpha/beta/RC/stable | faible | opérateur/CI | +| `PGR-MD-TABLE-001` | `AUDIT` | `python3 -B scripts/audit_markdown_tables.py ` | Python 3 | table Markdown ajoutée/modifiée | alpha/beta/RC/stable | faible | opérateur/CI | +| `PGR-NAV-001` | `AUDIT` | `python3 -B scripts/audit_markdown_links.py ` | Python 3 | navigation ou réconciliation | alpha/beta/RC/stable | faible | opérateur/CI | +| `PGR-SCOPE-001` | `AUDIT` | vérifier que les documents vivants pointent vers les propriétaires normatifs actuels | documents vivants | réconciliation de phase | beta/RC/stable | faible | revue | +| `PGR-STATUS-001` | `CHECK` | `git status --short` | Git | avant fermeture d'une tranche | alpha/beta/RC/stable | faible | opérateur | Un contrôle manuel peut rester dans la matrice lorsqu'aucun audit automatisé fiable n'existe encore. PGR SHOULD automatiser uniquement les propriétés stables et objectivement contrôlables. +`PGR-TEXT-HYGIENE-001` est un contrôle mécanique indépendant du sens documentaire. Sur les fichiers texte sélectionnés, il exige des fins de ligne LF, interdit le whitespace terminal et exige exactement une seule newline finale ; une ligne vide supplémentaire à EOF est donc rejetée avant commit. Il complète les contrôles Git au lieu de les remplacer. + `PGR-MD-TABLE-001` reste volontairement monotâche : il contrôle uniquement la structure et l'alignement source des tableaux Markdown. Il couvre les tableaux Markdown ordinaires ainsi que les exemples placés dans des fences explicitement `markdown` ou `md`. Les autres blocs fenced restent ignorés afin de ne pas interpréter comme Markdown des sorties terminal, extraits de code ou tableaux ASCII d'un autre langage. `PGR-MD-HEADER-001` contrôle seulement les deux premières lignes des fichiers Markdown sélectionnés : chemin relatif depuis la racine et révision interne positive. La revue `PGR-DOC-001` reste responsable du rôle documentaire et de la pertinence réelle d'un incrément de révision. diff --git a/history/0.1.4/alpha.2.fix.1.md b/history/0.1.4/alpha.2.fix.1.md new file mode 100644 index 0000000..9962185 --- /dev/null +++ b/history/0.1.4/alpha.2.fix.1.md @@ -0,0 +1,94 @@ + + + +# History 0.1.4-alpha.2.fix.1 — hygiène texte rétablie + +## Statut + +PASS + +La gate opérateur de `0.1.4-alpha.2.fix.1` exécutée le `2026-09-29` est propre. Le défaut de ligne vide surnuméraire à EOF est corrigé, l'audit réutilisable d'hygiène texte est actif et les décisions Tauri validées en `alpha.2` restent inchangées. + +## Baseline opérateur + +La tranche `0.1.4-alpha.2` en échec a été committée puis publiée avant application du fix : + +```text +commit alpha.2 ed794d6 +push 7f47558..ed794d6 master -> master +``` + +La version observée pendant la gate du fix est : + +```text +0.1.4-alpha.2.fix.1 +``` + +`history/0.1.4/alpha.2.md` est présent avec le statut `FAIL`, tandis que `history/0.1.4/alpha.2.fix.1.md` était absent pendant la gate. + +## Tests Python + +La suite complète a affiché : + +```text +Ran 55 tests in 0.890s + +OK +``` + +Soit `55/55` tests `PASS`, dont les sept cas ciblés du nouvel audit `text_hygiene`. + +## Audit d'hygiène texte + +Le nouvel audit a affiché : + +```text +Text hygiene audit: clean (297 file(s)) +``` + +Il confirme sur le corpus sélectionné : + +- fins de ligne LF ; +- absence de whitespace terminal ; +- newline finale présente ; +- absence de ligne vide terminale surnuméraire. + +## Audits Markdown + +Les audits documentaires sont propres : + +```text +Markdown header audit: clean (270 file(s)) +Markdown table audit: clean (11 table(s), 270 file(s)) +Markdown link audit: clean (344 local link(s), 270 file(s)) +``` + +## Hygiène Git et scope observé + +`git diff --check` puis `git diff --cached --check` n'ont produit aucun diagnostic. + +Le scope indexé observé est exactement : + +```text +M VERSION +A deltas/0.1.4/alpha.2.fix.1.md +M docs/rules/210-WORKFLOW.md +M docs/studies/002-TAURI_SKELETON_DECISION.md +M docs/validation/001-VALIDATION_MATRIX.md +A scripts/audit_text_hygiene.py +A tests/test_text_hygiene.py +``` + +La révision interne de l'étude Tauri reste `3` : seule sa terminaison de fichier a été corrigée. + +## Observation de livraison post-gate + +Après la gate, l'opérateur a constaté que les ZIP remis pour `alpha.2.history` et `alpha.2.fix.1.delta` contenaient à tort un répertoire englobant `pgr/`. Le contenu appliqué au dépôt est correct et la gate présente reste `PASS`, mais ce défaut de packaging doit être corrigé dans les archives remises et explicitement interdit par le contrat de workflow avant les livraisons suivantes. + +Cette observation ne modifie pas les fichiers validés par le fix ; elle constitue un durcissement du mécanisme de livraison à intégrer dès `alpha.3`. + +## Décision + +`0.1.4-alpha.2.fix.1` est validée `PASS`. + +`alpha.3` peut matérialiser `docs/skeletons/004-TAURI.md` sur la baseline Tauri réconciliée. La tranche doit également clarifier que les entrées d'une archive delta/history sont relatives directement à la racine du dépôt, sans répertoire englobant `/`, afin que l'application canonique `unzip -o -d .` reste correcte. diff --git a/scripts/audit_text_hygiene.py b/scripts/audit_text_hygiene.py new file mode 100644 index 0000000..f868185 --- /dev/null +++ b/scripts/audit_text_hygiene.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# file: scripts/audit_text_hygiene.py +# version: 1 + +"""Validate mechanical hygiene of selected governed text files.""" + +from __future__ import annotations + +import argparse +import pathlib +import sys + +_IGNORED_DIRECTORIES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target"}) +_TEXT_SUFFIXES = frozenset( + { + ".cfg", + ".conf", + ".css", + ".gradle", + ".html", + ".ini", + ".js", + ".json", + ".jsx", + ".kts", + ".md", + ".prefs", + ".properties", + ".py", + ".rs", + ".scss", + ".sh", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + } +) +_TEXT_NAMES = frozenset({".gitignore", "VERSION"}) + + +def _is_governed_text(path: pathlib.Path) -> bool: + return path.name in _TEXT_NAMES or path.suffix.lower() in _TEXT_SUFFIXES + + +def _selected_files(raw_paths: list[str], root: pathlib.Path) -> tuple[list[pathlib.Path], list[str]]: + files: set[pathlib.Path] = set() + errors: list[str] = [] + + for raw_path in raw_paths: + path = pathlib.Path(raw_path) + if path.is_dir() or path.is_file(): + if not path.resolve().is_relative_to(root): + errors.append(f"Text hygiene audit: path outside project root: {path}") + continue + if path.is_dir(): + candidates = ( + candidate + for candidate in path.rglob("*") + if candidate.is_file() + and _is_governed_text(candidate) + and not any(part in _IGNORED_DIRECTORIES for part in candidate.parts) + ) + elif path.is_file() and _is_governed_text(path): + candidates = iter((path,)) + else: + errors.append(f"Text hygiene audit: unsupported or missing path: {path}") + continue + + for candidate in candidates: + canonical = candidate.resolve() + if not canonical.is_relative_to(root): + errors.append(f"Text hygiene audit: path outside project root: {candidate}") + else: + files.add(canonical) + + return sorted(files), errors + + +def _audit_file(path: pathlib.Path, root: pathlib.Path) -> list[str]: + relative = path.relative_to(root).as_posix() + data = path.read_bytes() + errors: list[str] = [] + + if b"\r" in data: + errors.append(f"{relative}: CR byte found; governed text must use LF line endings") + + if not data.endswith(b"\n"): + errors.append(f"{relative}: EOF: missing final LF") + elif data.endswith(b"\n\n"): + errors.append(f"{relative}: EOF: extra blank line; exactly one final LF is required") + + for line_number, line in enumerate(data.split(b"\n")[:-1], start=1): + if line.endswith((b" ", b"\t")): + errors.append(f"{relative}:{line_number}: trailing whitespace") + + return errors + + +def main() -> int: + """Run the read-only text hygiene audit from the project root.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", help="Governed text files or directories to audit") + args = parser.parse_args() + + root = pathlib.Path.cwd().resolve() + files, selection_errors = _selected_files(args.paths, root) + if selection_errors or not files: + for error in selection_errors or ["Text hygiene audit: no governed text files selected"]: + print(error, file=sys.stderr) + return 2 + + errors: list[str] = [] + for path in files: + try: + errors.extend(_audit_file(path, root)) + except OSError as error: + print(f"Text hygiene audit: cannot read {path}: {error}", file=sys.stderr) + return 2 + + for error in errors: + print(error, file=sys.stderr) + if errors: + print(f"Text hygiene audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) + return 1 + + print(f"Text hygiene audit: clean ({len(files)} file(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_text_hygiene.py b/tests/test_text_hygiene.py new file mode 100644 index 0000000..50490ac --- /dev/null +++ b/tests/test_text_hygiene.py @@ -0,0 +1,83 @@ +# file: tests/test_text_hygiene.py +# version: 1 + +"""Exercise the governed text hygiene audit's real file and CLI contracts.""" + +from __future__ import annotations + +import pathlib +import subprocess +import sys +import tempfile +import unittest + +_SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts/audit_text_hygiene.py" + + +class TextHygieneAuditTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory(prefix="pgr-text-hygiene-") + self.addCleanup(temporary.cleanup) + self.root = pathlib.Path(temporary.name) + + def source(self, name: str, contents: bytes) -> pathlib.Path: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(contents) + return path + + def run_audit(self, *paths: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-B", str(_SCRIPT), *paths], + cwd=self.root, + capture_output=True, + text=True, + check=False, + ) + + def test_exactly_one_final_lf_is_clean(self) -> None: + self.source("README.md", b"line one\nline two\n") + result = self.run_audit("README.md") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("clean (1 file(s))", result.stdout) + + def test_extra_blank_line_at_eof_is_rejected(self) -> None: + self.source("docs/page.md", b"content\n\n") + result = self.run_audit("docs/page.md") + self.assertEqual(result.returncode, 1) + self.assertIn("extra blank line", result.stderr) + + def test_missing_final_lf_is_rejected(self) -> None: + self.source("VERSION", b"1.2.3") + result = self.run_audit("VERSION") + self.assertEqual(result.returncode, 1) + self.assertIn("missing final LF", result.stderr) + + def test_crlf_is_rejected(self) -> None: + self.source("config.toml", b"value = true\r\n") + result = self.run_audit("config.toml") + self.assertEqual(result.returncode, 1) + self.assertIn("CR byte found", result.stderr) + + def test_trailing_space_and_tab_are_rejected(self) -> None: + self.source("docs/page.md", b"first \nsecond\t\n") + result = self.run_audit("docs/page.md") + self.assertEqual(result.returncode, 1) + self.assertIn("docs/page.md:1: trailing whitespace", result.stderr) + self.assertIn("docs/page.md:2: trailing whitespace", result.stderr) + + def test_generated_directory_is_skipped(self) -> None: + self.source("docs/visible.md", b"clean\n") + self.source("docs/target/generated.md", b"bad\n\n") + result = self.run_audit("docs") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("clean (1 file(s))", result.stdout) + + def test_missing_path_is_a_selection_error(self) -> None: + result = self.run_audit("missing.md") + self.assertEqual(result.returncode, 2) + self.assertIn("unsupported or missing path", result.stderr) + + +if __name__ == "__main__": + unittest.main()