erplibre/test/test_assistant_gpt_loader.py
Mathieu Benoit 7df58956ea [FIX] tests assistant : juger la frontière dans un interpréteur neuf
Six tests asserted that an assistant module does not import script.todo.todo
by looking at sys.modules of the running process. Under the full suite another
test had already imported todo, so all six failed whatever the module did. Each
now imports its module in a fresh interpreter and reads the answer there.
Checked: the six pass after test_todo_menu, the order that broke them, and a
module made to import todo fails its test.

--- FR ---

Six tests affirmaient qu'un module de l'assistant n'importe pas
script.todo.todo en regardant le sys.modules du processus en cours. Dans la
suite complète, un autre test avait déjà importé todo : les six échouaient
quoi que fasse le module. Chacun importe désormais son module dans un
interpréteur neuf et y lit la réponse. Vérifié : les six passent après
test_todo_menu, l'ordre qui les faisait tomber, et un module qu'on fait
importer todo fait échouer son test.

Assisted-by: Claude Opus 5
2026-09-16 05:36:13 -04:00

418 lines
17 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce que le chargeur de gpts doit refuser, et ce qu'il ne doit jamais taire.
`yaml.safe_load` n'est pas un validateur, et c'est là que ce chargeur se
joue : un en-tête qui est une liste rend une `list`, un scalaire nu une `str`,
un fichier vide `None`, et une clé RÉPÉTÉE est résolue sans un mot sur la
dernière. Aucun des quatre ne lève. Un chargeur qui n'attraperait que
`yaml.YAMLError` admettrait donc trois formes malformées et tomberait plus
loin, sur un attribut manquant.
La clé répétée est le cas qui coûte le plus cher : deux blocs `requires` font
passer un gpt de « boucle locale seulement » à « n'importe où », donc changent
sa classe de sûreté en silence. C'est pourquoi l'en-tête est relu en TEXTE
BRUT avant d'être analysé.
Les cas malformés viennent avant le cas heureux, dans ce fichier comme dans
l'ordre d'écriture : un chargeur se juge sur ce qu'il refuse.
Aucun test ne lit le disque de la machine : les racines sont des répertoires
temporaires, et le répertoire personnel est détourné là où il compte.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if RACINE not in sys.path:
sys.path.insert(0, RACINE)
from script.todo.assistant import gpt as G # noqa: E402
# Le corps minimal acceptable : le marqueur de question est obligatoire.
CORPS = "\n<!-- [question] -->\nRéécris : {phrase}\n"
# Un en-tête complet, dont chaque champ est exercé quelque part plus bas.
ENTETE = """---
gpt: 1
name: Comment hygiene - rewrite narrative as mechanism
description: Rewrite each flagged sentence so the code is the subject
requires:
hosting: lan
context_window: 8000
parameters: 12
params:
temperature: 0.2
max_tokens: 700
inputs:
- name: path
type: repo_path
required: true
context:
files:
- .claude/rules/04-code-conventions.md
---"""
def cles(problemes):
"""Les clés de problème, pour affirmer sans dépendre d'un libellé."""
return [souci.key for souci in problemes]
class CeQuiEstRefuse(unittest.TestCase):
"""Les formes qu'un analyseur YAML laisse passer sans lever."""
def test_un_fichier_vide_est_refuse(self):
gpt, problemes = G.parse("", stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.SANS_ENTETE])
def test_sans_en_tete_est_un_probleme_liste(self):
gpt, problemes = G.parse("juste un corps\n", stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.SANS_ENTETE])
def test_un_en_tete_non_ferme_est_refuse(self):
gpt, problemes = G.parse("---\ngpt: 1\n" + CORPS, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.ENTETE_NON_FERMEE])
def test_un_en_tete_qui_est_une_liste_est_refuse(self):
"""`safe_load` rend une `list` sans lever : le type est donc contrôlé
avant toute lecture de champ."""
gpt, problemes = G.parse(
"---\n- un\n- deux\n---" + CORPS, stem="essai"
)
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.ENTETE_PAS_UN_DICTIONNAIRE])
self.assertEqual(problemes[0].detail, "list")
def test_un_en_tete_scalaire_est_refuse(self):
gpt, problemes = G.parse("---\ndu texte\n---" + CORPS, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(problemes[0].detail, "str")
def test_un_en_tete_indente_par_tabulation_est_un_probleme_liste(self):
"""La tabulation est l'un des deux seuls cas où `safe_load` lève ;
il ne doit pas remonter en trace."""
gpt, problemes = G.parse("---\ngpt:\t1\n---" + CORPS, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.ENTETE_ILLISIBLE])
def test_une_cle_dupliquee_est_signalee_pas_perdue(self):
"""Le cas qui coûte le plus cher : `safe_load` garde la DERNIÈRE, donc
un second bloc `requires` change la classe de sûreté sans un mot."""
texte = (
"---\ngpt: 1\nname: N\ndescription: D\n"
"requires:\n hosting: loopback\n"
"requires:\n hosting: any\n---" + CORPS
)
gpt, problemes = G.parse(texte, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.CLE_REPETEE])
self.assertEqual(problemes[0].detail, "requires")
def test_un_nom_absent_est_un_probleme_liste(self):
texte = "---\ngpt: 1\ndescription: D\n---" + CORPS
gpt, problemes = G.parse(texte, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.NOM_ABSENT])
def test_une_description_absente_est_un_probleme_liste(self):
texte = "---\ngpt: 1\nname: N\n---" + CORPS
gpt, problemes = G.parse(texte, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.DESCRIPTION_ABSENTE])
def test_un_schema_absent_est_refuse(self):
texte = "---\nname: N\ndescription: D\n---" + CORPS
gpt, problemes = G.parse(texte, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.SCHEMA_ABSENT])
def test_une_version_de_schema_inconnue_grise_au_lieu_de_deviner(self):
"""Deviner le sens d'un champ inconnu est la façon la plus sûre de
trahir un gpt : la version trop récente se refuse en le disant."""
texte = "---\ngpt: 99\nname: N\ndescription: D\n---" + CORPS
gpt, problemes = G.parse(texte, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.SCHEMA_TROP_RECENT])
self.assertEqual(problemes[0].detail, "99")
def test_un_marqueur_question_absent_est_un_probleme_liste(self):
texte = (
"---\ngpt: 1\nname: N\ndescription: D\n---\n"
"<!-- [system] -->\nUne invite, et rien à demander.\n"
)
gpt, problemes = G.parse(texte, stem="essai")
self.assertIsNone(gpt)
self.assertEqual(cles(problemes), [G.MARQUEUR_QUESTION_ABSENT])
def test_un_requires_qui_n_est_pas_un_dictionnaire_est_jete(self):
"""Une liste sous `requires` vient d'une indentation fautive. En
tirer des exigences inventerait une classe de sûreté."""
texte = (
"---\ngpt: 1\nname: N\ndescription: D\n"
"requires:\n - hosting\n---" + CORPS
)
gpt, problemes = G.parse(texte, stem="essai")
self.assertIsNotNone(gpt)
self.assertEqual(gpt.requires, {})
class CeQuiEstSignaleSansEtreFatal(unittest.TestCase):
"""Un gpt utilisable peut porter quelque chose à relire."""
def test_une_cle_inconnue_est_signalee_et_le_gpt_reste(self):
"""Une faute de frappe sur « requires » retirerait toutes les
exigences en silence, donc la clé inconnue se dit."""
texte = (
"---\ngpt: 1\nname: N\ndescription: D\nrequiers: {}\n---" + CORPS
)
gpt, problemes = G.parse(texte, stem="essai")
self.assertIsNotNone(gpt)
self.assertEqual(cles(problemes), [G.CLE_INCONNUE])
self.assertEqual(problemes[0].detail, "requiers")
self.assertFalse(problemes[0].fatal)
def test_name_fr_dans_le_depot_est_signale(self):
"""Le fichier des traductions est la source unique ; un `name_fr` ici
en créerait une seconde."""
texte = (
"---\ngpt: 1\nname: N\ndescription: D\nname_fr: Nom\n---" + CORPS
)
gpt, problemes = G.parse(texte, stem="essai", from_repo=True)
self.assertIsNotNone(gpt)
self.assertEqual(cles(problemes), [G.NAME_FR_HORS_PLACE])
self.assertFalse(problemes[0].fatal)
class HorsDuDepot(unittest.TestCase):
"""Un fichier que personne n'a relu est de la configuration, pas une
donnée."""
BASE = "---\ngpt: 1\nname: N\ndescription: D\n"
def test_un_gpt_hors_du_depot_est_force_a_loopback(self):
texte = self.BASE + "requires:\n hosting: any\n---" + CORPS
gpt, _ = G.parse(texte, stem="essai", from_repo=False)
self.assertIsNotNone(gpt)
self.assertEqual(gpt.requires["hosting"], "loopback")
def test_un_gpt_du_depot_garde_son_hosting(self):
texte = self.BASE + "requires:\n hosting: any\n---" + CORPS
gpt, _ = G.parse(texte, stem="essai", from_repo=True)
self.assertEqual(gpt.requires["hosting"], "any")
def test_un_gpt_hors_du_depot_ne_peut_declarer_aucune_commande(self):
texte = (
self.BASE
+ "context:\n commands:\n - argv: [echo, salut]\n---"
+ CORPS
)
gpt, problemes = G.parse(texte, stem="essai", from_repo=False)
self.assertIsNone(gpt)
self.assertEqual(
cles(problemes),
[G.HORS_DEPOT_SANS_COMMANDE],
)
def test_un_gpt_hors_du_depot_garde_son_name_fr(self):
"""Un fichier hors du dépôt ne peut pas ajouter une clé à un fichier
versionné : l'exception est structurelle, non stylistique."""
texte = self.BASE + "name_fr: Mon outil\n---" + CORPS
gpt, problemes = G.parse(texte, stem="essai", from_repo=False)
self.assertIsNotNone(gpt)
self.assertEqual(gpt.name_fr, "Mon outil")
self.assertEqual(problemes, [])
class LeCasHeureux(unittest.TestCase):
"""Ce qu'un gpt bien formé rend, une fois tout le reste écarté."""
def setUp(self):
self.gpt, self.problemes = G.parse(
ENTETE + "\n\n<!-- [system] -->\nTu réécris." + CORPS,
stem="comment-hygiene",
)
def test_un_gpt_bien_forme_ne_porte_aucun_probleme(self):
self.assertEqual(self.problemes, [])
self.assertIsNotNone(self.gpt)
def test_le_radical_du_nom_de_fichier_est_l_identite(self):
"""Il n'y a pas de champ `id` : deux sources de vérité pour un nom
finissent par diverger."""
self.assertEqual(self.gpt.stem, "comment-hygiene")
def test_le_corps_se_coupe_en_systeme_et_question(self):
self.assertEqual(self.gpt.system, "Tu réécris.")
self.assertEqual(self.gpt.question, "Réécris : {phrase}")
def test_les_exigences_et_les_parametres_sont_lus(self):
self.assertEqual(self.gpt.requires["hosting"], "lan")
self.assertEqual(self.gpt.requires["context_window"], 8000)
self.assertEqual(self.gpt.params["temperature"], 0.2)
def test_une_entree_sans_nom_est_ecartee(self):
"""Elle ne pourrait ni se demander ni se substituer au gabarit."""
texte = (
"---\ngpt: 1\nname: N\ndescription: D\n"
"inputs:\n - type: repo_path\n - name: chemin\n---" + CORPS
)
gpt, _ = G.parse(texte, stem="essai")
self.assertEqual([e["name"] for e in gpt.inputs], ["chemin"])
class LesRacines(unittest.TestCase):
"""Deux racines, deux niveaux de confiance, et un recouvrement par nom."""
BON = "---\ngpt: 1\nname: N\ndescription: D\n---" + CORPS
def setUp(self):
self.depot = tempfile.TemporaryDirectory()
self.maison = tempfile.TemporaryDirectory()
self.addCleanup(self.depot.cleanup)
self.addCleanup(self.maison.cleanup)
def _ecrire(self, dossier, nom, texte):
chemin = Path(dossier) / nom
chemin.write_text(texte, encoding="utf-8")
return chemin
def test_une_racine_absente_n_est_pas_une_erreur(self):
"""Le répertoire de l'utilisateur n'existe d'ordinaire pas."""
gpts, problemes = G.load_all(
roots=[(Path(self.depot.name) / "inexistant", True)]
)
self.assertEqual(gpts, [])
self.assertEqual(problemes, [])
def test_un_fichier_illisible_ne_cache_pas_les_autres(self):
self._ecrire(self.depot.name, "bon.md", self.BON)
self._ecrire(self.depot.name, "casse.md", "---\n- liste\n---" + CORPS)
gpts, problemes = G.load_all(roots=[(self.depot.name, True)])
self.assertEqual([g.stem for g in gpts], ["bon"])
self.assertEqual(cles(problemes), [G.ENTETE_PAS_UN_DICTIONNAIRE])
def test_une_racine_posterieure_ecrase_par_nom_et_le_dit(self):
self._ecrire(self.depot.name, "outil.md", self.BON)
self._ecrire(
self.maison.name,
"outil.md",
"---\ngpt: 1\nname: Autre\ndescription: D\n---" + CORPS,
)
gpts, problemes = G.load_all(
roots=[(self.depot.name, True), (self.maison.name, False)]
)
self.assertEqual([g.stem for g in gpts], ["outil"])
self.assertEqual(gpts[0].name, "Autre")
self.assertIn(G.ECRASE, cles(problemes))
def test_un_ecrasement_refuse_laisse_le_gpt_du_depot(self):
"""Le refus d'un fichier hors du dépôt ne doit pas emporter avec lui
celui que le dépôt livrait."""
self._ecrire(self.depot.name, "outil.md", self.BON)
self._ecrire(
self.maison.name,
"outil.md",
"---\ngpt: 1\nname: N\ndescription: D\n"
"context:\n commands:\n - argv: [echo]\n---" + CORPS,
)
gpts, problemes = G.load_all(
roots=[(self.depot.name, True), (self.maison.name, False)]
)
self.assertEqual([g.stem for g in gpts], ["outil"])
self.assertEqual(gpts[0].name, "N")
self.assertIn(
G.HORS_DEPOT_SANS_COMMANDE,
cles(problemes),
)
def test_un_nom_de_fichier_base_md_est_refuse(self):
"""`make doc_markdown` ne balaie que les `*.base.md` : un gpt ainsi
nommé serait réécrit par la chaîne de documentation."""
self._ecrire(self.depot.name, "outil.base.md", self.BON)
gpts, problemes = G.load_all(roots=[(self.depot.name, True)])
self.assertEqual(gpts, [])
self.assertEqual(cles(problemes), [G.NOM_BASE_MD_REFUSE])
def test_seuls_les_fichiers_md_sont_lus(self):
self._ecrire(self.depot.name, "outil.md", self.BON)
self._ecrire(self.depot.name, "notes.txt", "rien")
self._ecrire(self.depot.name, "README", "rien")
gpts, _ = G.load_all(roots=[(self.depot.name, True)])
self.assertEqual([g.stem for g in gpts], ["outil"])
def test_le_catalogue_est_trie_par_radical(self):
for nom in ("zeta.md", "alpha.md", "mu.md"):
self._ecrire(self.depot.name, nom, self.BON)
gpts, _ = G.load_all(roots=[(self.depot.name, True)])
self.assertEqual([g.stem for g in gpts], ["alpha", "mu", "zeta"])
def test_une_lecture_qui_leve_est_un_probleme_liste(self):
self._ecrire(self.depot.name, "outil.md", self.BON)
def refuser(chemin):
raise OSError("permission refusée")
gpts, problemes = G.load_all(
roots=[(self.depot.name, True)], read=refuser
)
self.assertEqual(gpts, [])
self.assertEqual(cles(problemes), [G.FICHIER_ILLISIBLE])
def test_le_catalogue_s_ouvre_sans_pyyaml(self):
"""Le catalogue est un supplément ; la question libre marche sans
lui, donc son absence se dit et ne lève pas."""
with patch.object(G, "_yaml_disponible", return_value=False):
gpts, problemes = G.load_all(roots=[(self.depot.name, True)])
self.assertEqual(gpts, [])
self.assertEqual(
cles(problemes),
[G.PYYAML_ABSENT],
)
def test_le_repertoire_personnel_est_detournable(self):
"""Sans ce détournement, un test lirait les gpts de la machine qui le
lance, et son résultat dépendrait de qui l'exécute."""
racines = G.default_roots(home=self.maison.name)
chemins = [str(chemin) for chemin, _ in racines]
self.assertTrue(chemins)
self.assertTrue(chemins[-1].startswith(self.maison.name))
self.assertEqual([relu for _, relu in racines], [True, False])
class LaFrontiere(unittest.TestCase):
"""Le paquet doit rester importable sans le CLI."""
def test_le_chargeur_n_importe_pas_todo(self):
# Dans un interpréteur NEUF : la suite complète importe todo par
# ailleurs, et le sys.modules de ce processus en garderait la trace
# quel que soit le module éprouvé ici.
sortie = subprocess.run(
[
sys.executable,
"-c",
"import sys, script.todo.assistant.gpt;"
" print('script.todo.todo' in sys.modules)",
],
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(sortie.returncode, 0, sortie.stderr)
self.assertEqual(sortie.stdout.strip(), "False")
if __name__ == "__main__":
unittest.main()