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
418 lines
17 KiB
Python
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()
|