Skip to main content

koprogo_api/domain/copropriete/
registre_legal.rs

1//! Le registre des invariants légaux, exécutable.
2//!
3//! Le RFC-0002 recense les obligations computables du chapitre « copropriété »
4//! du Code civil et dit, pour chacune, si le domaine la porte. Tant qu'il
5//! restait en prose, il se périmait exactement comme
6//! `docs/AUDIT_CONFORMITE_JURIDIQUE.md` s'était périmé : rien ne le reliait au
7//! code.
8//!
9//! Ce module est ce lien. Il déclare chaque invariant avec **l'article qui le
10//! fonde**, **le module qui le porte** et **le test qui le nomme**, et un test
11//! d'intégrité vérifie que ces trois choses existent encore. Le jour où
12//! quelqu'un supprime un test ou renomme un module, le registre ne se
13//! contente pas de mentir : il échoue.
14//!
15//! ## Ce que ce registre ne fait pas
16//!
17//! Il ne prouve pas qu'un invariant est **correctement** implémenté — c'est le
18//! travail des tests eux-mêmes, qui citent leur article et vérifient les
19//! bords. Il prouve qu'il est **encore là**. La distinction compte : une
20//! couverture qui se mesure elle-même n'est pas une garantie de justesse, et
21//! le dire ici évite de la lire comme telle.
22//!
23//! Il ne couvre pas non plus tout le chapitre. Les articles hors périmètre
24//! applicatif — dissolution (3.97), liquidation (3.98), transcription des
25//! actes (3.99) — n'y figurent pas, et l'ADR-0010 diffère les associations
26//! partielles en v0.2.0.
27//!
28//! ## Citer un test n'est pas l'attester (#847)
29//!
30//! Le registre a longtemps confondu deux choses : qu'un test EXISTE, et qu'il
31//! ATTESTE l'obligation qu'on lui fait porter. L'Art. 3.89 § 1er citait un
32//! test qui vérifie qu'un mandat de trente jours est en cours — rien sur le
33//! plafond de trois ans que l'article impose, et le plafond n'existait pas
34//! non plus. Le test existait, passait, et n'avait aucun rapport avec la
35//! règle. Corrigé : voir `syndic_mandate.rs`.
36//!
37//! La relecture des trente invariants qui a suivi a trouvé un second cas —
38//! l'Art. 3.87 § 6 — sans plafond légal à implémenter cette fois, mais sans
39//! test qui vérifie la CORRESPONDANCE que l'article exige. Plutôt que de le
40//! laisser attesté par un test adjacent, `atteste_par` porte `None` et
41//! `non_couverte` dit pourquoi. **Une obligation sans test véritable est une
42//! obligation non couverte, jamais une obligation attestée par approximation.**
43//! Le sixième contrôle, `security_une_obligation_est_attestee_ou_dit_pourquoi_elle_ne_lest_pas`,
44//! rend ce choix obligatoire : un invariant ne peut plus citer un test ET
45//! rester silencieux sur ce que ce test ne couvre pas.
46//!
47//! Il ne juge toujours pas si un test attesté ATTESTE bien — ce jugement reste
48//! humain — mais il borne l'écart : le registre ne peut plus prétendre une
49//! couverture qu'il n'a pas, ni la laisser dans un commentaire que le rapport
50//! ignore.
51//!
52//! ## Comment il sert
53//!
54//! Le rapport de conformité se génère depuis cette liste et s'adresse à un
55//! juriste, pas à un développeur : il répond à « que dit la loi, et où le code
56//! y répond ? », dans l'ordre des articles. Une obligation non couverte y
57//! apparaît comme telle, jamais comme une ligne ordinaire.
58//!
59//! Voir RFC-0002 et le lot J8 du WBS.
60
61/// Un invariant légal porté par le domaine.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct InvariantLegal {
64    /// L'article qui le fonde, tel qu'on le cite dans un courrier.
65    pub article: &'static str,
66    /// Ce que la loi exige, en une phrase lisible par un juriste.
67    pub obligation: &'static str,
68    /// Le fichier du domaine qui le porte, relatif à `src/`.
69    pub porte_par: &'static str,
70    /// Un test qui ATTESTE l'obligation — pas seulement qui existe.
71    ///
72    /// `None` quand aucun test du dépôt ne vérifie réellement l'énoncé de
73    /// l'obligation (#847) : dans ce cas, `non_couverte` dit pourquoi.
74    /// `Some` et `non_couverte: Some(_)` en même temps sont contradictoires,
75    /// et `chaque_invariant_designe_un_test_qui_existe` puis le contrôle
76    /// dédié le refusent.
77    pub atteste_par: Option<&'static str>,
78    /// Pourquoi l'obligation n'est PAS couverte, quand `atteste_par` est
79    /// `None`.
80    ///
81    /// Ce n'est pas une case de confort : une obligation ne peut pas rester
82    /// silencieuse sur son absence de preuve. Le message doit dire ce que le
83    /// test le plus proche vérifie réellement (pour qu'on comprenne l'écart)
84    /// et, si le correctif est déjà suivi ailleurs, par quelle issue.
85    pub non_couverte: Option<&'static str>,
86    /// Le délai que l'article impose, en jours, quand il en impose un.
87    ///
88    /// ── Pourquoi il vit ICI et pas dans l'interface ────────────────────
89    ///
90    /// La remise de design veut afficher un **décompte d'échéance** à côté de
91    /// chaque tâche : « 18 j sur 30 », avec la référence de l'article. Elle
92    /// met en garde dans le même souffle :
93    ///
94    /// > Legal references are placeholders. The stated day counts (15 / 30)
95    /// > must be sourced from the project's own registry, **not hardcoded in
96    /// > components**.
97    ///
98    /// Un « 30 » écrit dans un composant Svelte est un nombre que rien ne
99    /// relie à la loi. Le jour où le législateur le change — ou, plus
100    /// probable, le jour où l'on découvre qu'on l'avait mal lu — le domaine
101    /// est corrigé et l'écran continue d'annoncer l'ancien délai au syndic.
102    /// Il agirait alors sur une échéance fausse en croyant lire le produit.
103    ///
104    /// `garde_delais_du_registre` vérifie que chaque valeur ici correspond à
105    /// la constante du module qui la porte.
106    ///
107    /// `None` quand l'article n'impose aucun délai : la plupart des
108    /// obligations sont des règles de fond, pas des échéances.
109    pub delai_jours: Option<i64>,
110}
111
112/// Le registre, dans l'ordre des articles.
113pub const REGISTRE: &[InvariantLegal] = &[
114    InvariantLegal {
115        article: "Art. 3.85 § 1er al. 2",
116        obligation: "Les quotités sont fixées par l'acte de base ; leur somme est le dénominateur.",
117        porte_par: "domain/copropriete/acp.rs",
118        // Citait `acp::tests::is_conformant` — une MÉTHODE de production, pas
119        // un test. La garde l'acceptait parce qu'elle cherchait le nom par
120        // simple sous-chaîne dans le module (#847).
121        atteste_par: Some("acp::tests::happy_acp_conformant_base_1000_mono_bloc"),
122        non_couverte: None,
123        delai_jours: None,
124    },
125    InvariantLegal {
126        article: "Art. 3.85 § 3, 3°",
127        obligation: "Le ROI fixe la période annuelle de quinze jours de l'AG ordinaire.",
128        porte_par: "domain/copropriete/fenetre_ag_ordinaire.rs",
129        atteste_par: Some(
130            "fenetre_ag_ordinaire::tests::happy_la_periode_dure_quinze_jours_bornes_comprises",
131        ),
132        non_couverte: None,
133        delai_jours: Some(crate::domain::copropriete::fenetre_ag_ordinaire::FenetreAgOrdinaire::DUREE_JOURS),
134    },
135    InvariantLegal {
136        article: "Art. 3.86 § 1er",
137        obligation: "La personnalité juridique tient à deux conditions cumulatives ; sans transcription, l'ACP ne peut s'en prévaloir mais un tiers le peut.",
138        porte_par: "domain/copropriete/personnalite_juridique.rs",
139        atteste_par: Some(
140            "personnalite_juridique::tests::security_sans_transcription_la_protection_ne_joue_que_dans_un_sens",
141        ),
142        non_couverte: None,
143        delai_jours: None,
144    },
145    InvariantLegal {
146        article: "Art. 3.86 § 1er al. 4",
147        obligation: "Tous les documents émanant de l'ACP mentionnent son numéro d'entreprise.",
148        porte_par: "domain/copropriete/mention_numero_entreprise.rs",
149        atteste_par: Some(
150            "mention_numero_entreprise::tests::security_un_seul_oubli_parmi_cinq_est_releve",
151        ),
152        non_couverte: None,
153        delai_jours: None,
154    },
155    InvariantLegal {
156        article: "Art. 3.86 § 3",
157        obligation: "Comptes distincts pour le fonds de roulement et le fonds de réserve, ouverts au nom de l'ACP.",
158        porte_par: "domain/copropriete/comptes_de_lacp.rs",
159        atteste_par: Some(
160            "comptes_de_lacp::tests::security_un_compte_unique_pour_les_deux_fonds_est_signale",
161        ),
162        non_couverte: None,
163        delai_jours: None,
164    },
165    InvariantLegal {
166        article: "Art. 3.86 § 3 al. 4",
167        obligation: "Fonds de réserve exigible cinq ans après la réception provisoire ; contribution annuelle d'au moins 5 % des charges ordinaires de l'exercice précédent.",
168        porte_par: "domain/copropriete/fonds_de_reserve.rs",
169        atteste_par: Some("fonds_de_reserve::tests::happy_passe_cinq_ans_le_plancher_sapplique"),
170        non_couverte: None,
171        delai_jours: None,
172    },
173    InvariantLegal {
174        article: "Art. 3.86 § 3 al. 7",
175        obligation: "Le syndic communique, lors de l'appel de fonds, la part affectée au fonds de réserve.",
176        porte_par: "domain/comptabilite/call_for_funds.rs",
177        atteste_par: Some(
178            "call_for_funds::tests_art_3_86_fonds_de_reserve::happy_lappel_porte_la_part_affectee_au_fonds_de_reserve",
179        ),
180        non_couverte: None,
181        delai_jours: None,
182    },
183    InvariantLegal {
184        article: "Art. 3.86 § 3 al. 8",
185        obligation: "En cas d'usufruit, les titulaires de droits réels sont solidairement tenus des charges.",
186        porte_par: "domain/copropriete/solidarite.rs",
187        atteste_par: Some("solidarite::tests::happy_lusufruit_rend_les_deux_titulaires_tenus_du_tout"),
188        non_couverte: None,
189        delai_jours: None,
190    },
191    // L'Art. 3.87 § 2 porte DEUX obligations distinctes, et le registre n'en
192    // déclarait qu'une sous le nom de l'article entier. Un article couvert à
193    // moitié paraissait couvert (#847). Chaque alinéa est désormais nommé.
194    InvariantLegal {
195        article: "Art. 3.87 § 2 (convocation sur requête)",
196        obligation: "AG sur requête d'un cinquième des parts ; convocation sous trente jours, à défaut de quoi un cosignataire convoque lui-même.",
197        porte_par: "domain/copropriete/requete_ag.rs",
198        atteste_par: Some("requete_ag::tests::happy_le_cosignataire_recupere_le_pouvoir_de_convoquer"),
199        non_couverte: None,
200        delai_jours: Some(crate::domain::copropriete::requete_ag::DELAI_CONVOCATION_JOURS),
201    },
202    InvariantLegal {
203        article: "Art. 3.87 § 2 (ordre du jour)",
204        obligation: "Une décision portant sur un point absent de l'ordre du jour est nulle : une résolution non rattachée à un point ne peut être mise aux voix.",
205        porte_par: "application/use_cases/resolution_use_cases.rs",
206        atteste_par: Some(
207            "resolution_use_cases::tests::security_vote_refuse_sur_resolution_hors_ordre_du_jour",
208        ),
209        non_couverte: None,
210        delai_jours: None,
211    },
212    InvariantLegal {
213        article: "Art. 3.87 § 3",
214        obligation: "Convocation par recommandé, sauf accord individuel, explicite et écrit du destinataire.",
215        porte_par: "domain/copropriete/envoi_convocation.rs",
216        atteste_par: Some(
217            "envoi_convocation::tests::security_un_courriel_sans_accord_rend_la_convocation_irreguliere",
218        ),
219        non_couverte: None,
220        delai_jours: None,
221    },
222    InvariantLegal {
223        article: "Art. 3.87 § 5",
224        obligation: "Double quorum : plus de la moitié des copropriétaires détenant la moitié des quotités, ou trois quarts des quotités.",
225        porte_par: "domain/copropriete/ag_session.rs",
226        // Citait `ag_session::tests::quorum` : le mot « quorum » apparaît des
227        // dizaines de fois dans ce module, et la garde s'en satisfaisait.
228        atteste_par: Some("ag_session::tests::test_quotas_alone_do_not_carry_the_quorum"),
229        non_couverte: None,
230        delai_jours: None,
231    },
232    InvariantLegal {
233        article: "Art. 3.87 § 6",
234        obligation: "Chaque copropriétaire dispose d'un nombre de voix correspondant à sa quote-part.",
235        porte_par: "domain/copropriete/vote.rs",
236        // Citait `vote::tests` — le module de tests ENTIER, pas un test. La
237        // garde cherchait « tests » et trouvait `mod tests` (#847). Corrigé
238        // une première fois vers `test_create_vote_excessive_voting_power_fails`,
239        // qui vérifie qu'une voix SUPÉRIEURE au maximum est refusée : un
240        // plafond, pas la CORRESPONDANCE entre le nombre de voix et la
241        // quote-part que l'article exige.
242        //
243        // La relecture des trente invariants (#847) a confirmé qu'AUCUN test
244        // du dépôt n'atteste que la voix d'un copropriétaire ÉGALE sa
245        // quote-part : `Vote::new` accepte `voting_power` tel quel depuis
246        // l'appelant et ne le compare à aucune quotité. La déclarer attestée
247        // par un test adjacent — même en le disant en commentaire — c'est
248        // exactement le défaut que #847 dénonce : le rapport de conformité,
249        // lui, ne lisait pas ce commentaire et affichait une ligne ordinaire.
250        //
251        // Elle est donc déclarée NON COUVERTE, structurellement, pas en
252        // prose. Le correctif — enforcer la correspondance dans `vote.rs` ou
253        // au use case qui l'appelle — est hors périmètre de #847, qui relit
254        // et qualifie ; il est suivi en #850.
255        atteste_par: None,
256        non_couverte: Some(
257            "aucun test n'atteste que le nombre de voix ÉGALE la quote-part du \
258             copropriétaire ; le test le plus proche, \
259             `test_create_vote_excessive_voting_power_fails`, ne vérifie qu'un \
260             plafond. `Vote::new` accepte `voting_power` depuis l'appelant sans \
261             le comparer à aucune quotité. Correctif suivi en #850.",
262        ),
263        delai_jours: None,
264    },
265    InvariantLegal {
266        article: "Art. 3.87 § 7",
267        obligation: "Trois procurations au plus, sauf sous 10 % des voix ; nul ne pèse plus que les autres réunis ; le syndic n'est pas mandataire.",
268        porte_par: "domain/copropriete/procurations.rs",
269        atteste_par: Some(
270            "procurations::tests::negative_quatre_procurations_au_dessus_de_dix_pourcents_sont_refusees",
271        ),
272        non_couverte: None,
273        delai_jours: None,
274    },
275    InvariantLegal {
276        article: "Art. 3.87 § 8",
277        obligation: "Majorité absolue des présents ; abstentions, blancs et nuls exclus du calcul.",
278        porte_par: "domain/copropriete/resolution.rs",
279        atteste_par: Some("resolution::tests::test_absolute_majority_abstentions_excluded"),
280        non_couverte: None,
281        delai_jours: None,
282    },
283    InvariantLegal {
284        article: "Art. 3.87 § 9",
285        obligation: "Le prestataire de l'ACP ne participe ni aux délibérations ni au vote sur sa propre mission.",
286        porte_par: "domain/copropriete/conflit_dinterets.rs",
287        atteste_par: Some(
288            "conflit_dinterets::tests::security_donner_procuration_ne_contourne_pas_la_regle",
289        ),
290        non_couverte: None,
291        delai_jours: None,
292    },
293    InvariantLegal {
294        article: "Art. 3.87 § 10",
295        obligation: "Le PV est signé par le président copropriétaire, le secrétaire désigné à l'ouverture, et les copropriétaires encore présents.",
296        porte_par: "domain/copropriete/signatures_pv.rs",
297        atteste_par: Some(
298            "signatures_pv::tests::security_un_president_non_coproprietaire_vicie_le_pv",
299        ),
300        non_couverte: None,
301        delai_jours: None,
302    },
303    InvariantLegal {
304        article: "Art. 3.87 § 12",
305        obligation: "Le PV est consigné au registre et transmis à chaque destinataire dans les trente jours.",
306        porte_par: "domain/copropriete/consignation_pv.rs",
307        atteste_par: Some(
308            "consignation_pv::tests::security_un_seul_destinataire_oublie_suffit_a_faire_defaut",
309        ),
310        non_couverte: None,
311        delai_jours: Some(crate::domain::copropriete::consignation_pv::DELAI_JOURS),
312    },
313    InvariantLegal {
314        article: "Art. 3.88",
315        obligation: "Chaque nature de décision porte la majorité que la loi lui attache ; les quotités exigent l'unanimité, sauf porte du § 3 al. 2.",
316        porte_par: "domain/copropriete/majorites.rs",
317        atteste_par: Some(
318            "majorites::tests::security_les_charges_et_les_quotes_parts_nont_pas_la_meme_majorite",
319        ),
320        non_couverte: None,
321        delai_jours: None,
322    },
323    InvariantLegal {
324        article: "Art. 3.89 § 1er",
325        obligation: "Le mandat de syndic n'excède pas trois ans.",
326        porte_par: "domain/copropriete/syndic_mandate.rs",
327        // Le test cité jusqu'au 2026-09-08 était
328        // `happy_un_mandat_neuf_est_en_cours` : il vérifie qu'un mandat de
329        // trente jours est en cours, et ne dit RIEN d'un plafond de trois ans.
330        // Le plafond n'existait pas non plus. L'invariant se déclarait attesté
331        // par une preuve sans rapport (#847).
332        atteste_par: Some(
333            "syndic_mandate::tests::security_un_mandat_de_plus_de_trois_ans_est_expire_de_plein_droit",
334        ),
335        non_couverte: None,
336        delai_jours: Some(crate::domain::copropriete::syndic_mandate::DUREE_MAXIMALE_JOURS),
337    },
338    InvariantLegal {
339        article: "Art. 3.89 § 5, 5°",
340        obligation: "Le relevé des dettes est fourni au notaire dans les trente jours de sa demande.",
341        porte_par: "domain/copropriete/releve_notaire.rs",
342        atteste_par: Some(
343            "releve_notaire::tests::negative_passe_trente_jours_sans_releve_le_syndic_est_en_defaut",
344        ),
345        non_couverte: None,
346        delai_jours: Some(crate::domain::copropriete::releve_notaire::DELAI_JOURS),
347    },
348    InvariantLegal {
349        article: "Art. 3.89 § 5, 7°",
350        obligation: "L'ensemble du dossier de gestion est transmis au successeur dans les trente jours.",
351        porte_par: "domain/services/dossier_de_gestion.rs",
352        // Le test cité jusqu'au 2026-09-10 vérifiait que le successeur voit
353        // l'ensemble des pièces. C'est vrai, et ça ne dit rien du DÉLAI, qui
354        // n'existait alors nulle part dans le module (#847). Celui-ci éprouve
355        // l'échéance des trente jours et le défaut qui suit son dépassement.
356        atteste_par: Some(
357            "dossier_de_gestion::tests::negative_passe_trente_jours_sans_remise_le_syndic_sortant_est_en_defaut",
358        ),
359        non_couverte: None,
360        delai_jours: Some(crate::domain::services::dossier_de_gestion::DELAI_PASSATION_JOURS),
361    },
362    InvariantLegal {
363        article: "Art. 3.89 § 5, 12°",
364        obligation: "Un rapport d'évaluation des contrats de fournitures régulières est soumis à chaque assemblée générale ordinaire.",
365        porte_par: "domain/copropriete/evaluation_des_contrats.rs",
366        atteste_par: Some(
367            "evaluation_des_contrats::tests::security_un_contrat_oublie_est_signale_nominativement",
368        ),
369        non_couverte: None,
370        delai_jours: None,
371    },
372    InvariantLegal {
373        article: "Art. 3.89 § 5, 13°",
374        obligation: "Tout contrat entre l'ACP et le syndic ou ses proches exige l'autorisation préalable de l'AG.",
375        porte_par: "domain/copropriete/contrat_lie.rs",
376        atteste_par: Some("contrat_lie::tests::security_une_autorisation_posterieure_ne_regularise_rien"),
377        non_couverte: None,
378        delai_jours: None,
379    },
380    InvariantLegal {
381        article: "Art. 3.89 § 5, 15°",
382        obligation: "Comptabilité simplifiée autorisée sous vingt lots, caves, garages et parkings exclus du décompte.",
383        porte_par: "domain/comptabilite/regime_comptable.rs",
384        atteste_par: Some("regime_comptable::tests::happy_caves_et_parkings_sortent_du_decompte"),
385        non_couverte: None,
386        delai_jours: None,
387    },
388    InvariantLegal {
389        article: "Art. 3.89 § 9",
390        obligation: "Le syndic n'est ni membre du conseil de copropriété ni commissaire aux comptes de la même ACP.",
391        porte_par: "domain/copropriete/commissaire_aux_comptes.rs",
392        atteste_par: Some(
393            "commissaire_aux_comptes::tests::security_le_syndic_ne_peut_pas_controler_ses_propres_comptes",
394        ),
395        non_couverte: None,
396        delai_jours: None,
397    },
398    InvariantLegal {
399        article: "Art. 3.90",
400        obligation: "Conseil de copropriété obligatoire dès vingt lots ; membres titulaires d'un droit réel votant ; mandat jusqu'à la prochaine AGO.",
401        porte_par: "domain/copropriete/conseil_de_copropriete.rs",
402        atteste_par: Some("conseil_de_copropriete::tests::edge_dix_neuf_lots_rendent_le_conseil_facultatif"),
403        non_couverte: None,
404        delai_jours: None,
405    },
406    InvariantLegal {
407        article: "Art. 3.91",
408        obligation: "L'AG désigne annuellement un commissaire aux comptes ou un collège, copropriétaires ou non.",
409        porte_par: "domain/copropriete/commissaire_aux_comptes.rs",
410        atteste_par: Some(
411            "commissaire_aux_comptes::tests::security_une_designation_ne_se_reconduit_pas_tacitement",
412        ),
413        non_couverte: None,
414        delai_jours: None,
415    },
416    InvariantLegal {
417        article: "Art. 3.94 § 1er et § 2",
418        obligation: "État daté sous quinze jours calendaires (demande simple) ou trente (notaire, recommandé).",
419        porte_par: "domain/comptabilite/etat_date.rs",
420        atteste_par: Some("etat_date::tests::test_delai_art_3_94_se_compte_en_jours_calendaires"),
421        non_couverte: None,
422        delai_jours: None,
423    },
424    InvariantLegal {
425        article: "Art. 3.95",
426        obligation: "Le notaire retient les arriérés de charges, les frais de récupération et les frais de transmission ; contestation en jours ouvrables.",
427        porte_par: "domain/comptabilite/arrieres_mutation.rs",
428        // Citait `security_le_calcul_calendaire_libererait_les_fonds_trop_tot`,
429        // qui atteste le calcul du DÉLAI de libération, pas ce que le notaire
430        // RETIENT. Le délai relève du même module, mais pas de la même
431        // obligation. Relevé le 2026-09-08 (#847).
432        atteste_par: Some("arrieres_mutation::tests::security_oublier_les_frais_de_recuperation_ampute_la_retenue"),
433        non_couverte: None,
434        delai_jours: None,
435    },
436];
437
438/// Le nombre d'obligations computables recensées par le RFC-0002.
439pub const OBLIGATIONS_RECENSEES: usize = 30;
440
441/// Rend le registre lisible par un juriste, dans l'ordre des articles.
442///
443/// Une obligation `non_couverte` s'affiche comme telle — jamais comme une
444/// ligne ordinaire portant un test — pour que le lecteur ne prenne pas une
445/// absence de preuve pour une couverture.
446pub fn rapport_de_conformite() -> String {
447    let mut lignes = vec![
448        "# Conformité au Code civil, Livre 3, chapitre « copropriété »".to_string(),
449        String::new(),
450        format!(
451            "{} invariants portés par le domaine, chacun attesté par un test qui cite son article, ou déclaré non couvert en disant pourquoi.",
452            REGISTRE.len()
453        ),
454        String::new(),
455        "| Article | Obligation | Porté par | Attesté par |".to_string(),
456        "|---|---|---|---|".to_string(),
457    ];
458    for invariant in REGISTRE {
459        let atteste = match (invariant.atteste_par, invariant.non_couverte) {
460            (Some(test), _) => format!("`{test}`"),
461            (None, Some(raison)) => format!("⚠️ **NON COUVERT** — {raison}"),
462            (None, None) => "⚠️ **NON COUVERT**".to_string(),
463        };
464        lignes.push(format!(
465            "| {} | {} | `{}` | {} |",
466            invariant.article, invariant.obligation, invariant.porte_par, atteste
467        ));
468    }
469    lignes.join("\n")
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use std::path::Path;
476
477    fn racine_src() -> &'static Path {
478        Path::new(env!("CARGO_MANIFEST_DIR"))
479    }
480
481    /// **Le test qui empêche le registre de mentir.**
482    ///
483    /// Chaque invariant désigne un fichier du domaine. Le jour où quelqu'un le
484    /// renomme ou le supprime, le registre ne se contente pas de pointer dans
485    /// le vide : il échoue ici.
486    #[test]
487    fn chaque_invariant_designe_un_module_qui_existe() {
488        let manquants: Vec<&str> = REGISTRE
489            .iter()
490            .filter(|i| !racine_src().join("src").join(i.porte_par).is_file())
491            .map(|i| i.porte_par)
492            .collect();
493
494        assert!(
495            manquants.is_empty(),
496            "le registre désigne des modules qui n'existent plus : {manquants:?}\n\
497             Un invariant sans code qui le porte n'est pas un invariant."
498        );
499    }
500
501    /// Le test attesté existe, et c'est un TEST.
502    ///
503    /// Sans cette vérification, un test supprimé laisserait le registre
504    /// affirmer une couverture qui n'existe plus — exactement ce qu'un
505    /// document en prose fait, et qu'on veut éviter ici.
506    ///
507    /// Ne porte que sur les invariants `atteste_par: Some(_)` : un invariant
508    /// `non_couverte` n'a, par construction, aucun test à vérifier ici — c'est
509    /// le contrôle `security_une_obligation_est_attestee_ou_dit_pourquoi_elle_ne_lest_pas`
510    /// qui garde CE choix.
511    ///
512    /// ── Ce que cette garde acceptait avant le 2026-09-08 ──────────────────
513    ///
514    /// Elle prenait le dernier segment du chemin et faisait un
515    /// `source.contains(nom)`. Trois invariants sur vingt-neuf en profitaient :
516    ///
517    /// ```text
518    /// Art. 3.85 § 1er al. 2 → acp::tests::is_conformant
519    ///     `is_conformant` est une MÉTHODE DE PRODUCTION, pas un test.
520    ///
521    /// Art. 3.87 § 5 → ag_session::tests::quorum
522    ///     le mot « quorum » apparaît des dizaines de fois dans le module.
523    ///
524    /// Art. 3.87 § 6 → vote::tests
525    ///     le dernier segment est « tests » : la recherche trouvait `mod tests`.
526    /// ```
527    ///
528    /// Dix pour cent du registre attestait donc sur une sous-chaîne. Le message
529    /// d'erreur affirmait pourtant : « un invariant sans test qui le nomme
530    /// n'est pas un invariant, c'est une intention » — la garde disait le bon
531    /// principe et ne le tenait pas.
532    ///
533    /// Elle exige désormais `fn <nom>` précédé d'un `#[test]` proche. Ce n'est
534    /// toujours pas une preuve que le test ATTESTE l'obligation — c'est l'objet
535    /// de #847, et cela demande une relecture humaine — mais il ne peut plus
536    /// s'agir d'autre chose qu'un test.
537    #[test]
538    fn chaque_invariant_designe_un_test_qui_existe() {
539        let mut introuvables = Vec::new();
540
541        for invariant in REGISTRE {
542            let Some(atteste_par) = invariant.atteste_par else {
543                continue; // non couvert : rien à vérifier ici, par construction
544            };
545            let chemin = racine_src().join("src").join(invariant.porte_par);
546            let Ok(source) = std::fs::read_to_string(&chemin) else {
547                continue; // couvert par le test précédent
548            };
549            // On cherche le dernier segment : `module::tests::nom_du_test`.
550            let nom = atteste_par.rsplit("::").next().unwrap_or(atteste_par);
551            // `fn <nom>` — et non le nom seul, qui matcherait une méthode de
552            // production, un mot courant, ou `mod tests`.
553            let declaration = format!("fn {nom}(");
554            let est_un_test = match source.find(&declaration) {
555                None => false,
556                Some(pos) => {
557                    // `#[test]` doit précéder de peu : on tolère les attributs
558                    // et commentaires intercalés, pas cinq cents lignes.
559                    //
560                    // Le recul se fait en OCTETS, et ce dépôt écrit en
561                    // français : un décalage brut tombe tôt ou tard au milieu
562                    // d'un caractère multi-octets et fait paniquer le
563                    // découpage. C'est arrivé sur une flèche « → » d'un
564                    // commentaire. On redescend donc jusqu'à la première
565                    // frontière de caractère.
566                    let mut debut = pos.saturating_sub(300);
567                    while debut > 0 && !source.is_char_boundary(debut) {
568                        debut -= 1;
569                    }
570                    let avant = &source[debut..pos];
571                    avant.contains("#[test]") || avant.contains("::test]")
572                }
573            };
574            if !est_un_test {
575                introuvables.push(format!("{} → {}", invariant.article, atteste_par));
576            }
577        }
578
579        assert!(
580            introuvables.is_empty(),
581            "le registre atteste des tests introuvables dans leur module :\n  {}\n\n\
582             Un invariant sans test qui le nomme n'est pas un invariant, c'est \
583             une intention. Le nom doit désigner une `fn` annotée `#[test]`, \
584             pas une méthode de production ni un module.",
585            introuvables.join("\n  ")
586        );
587    }
588
589    /// Le sixième contrôle : citer un test et l'attester ne sont pas la même
590    /// chose, et le registre ne peut plus confondre les deux (#847).
591    ///
592    /// ── Ce que ce contrôle NE fait PAS ──────────────────────────────────────
593    ///
594    /// Il ne juge pas si un test ATTESTE réellement son obligation : c'est un
595    /// jugement sur le sens d'un énoncé légal, pas un calcul. La piste du
596    /// mot-clé partagé a été essayée et rejetée en relisant les trente
597    /// invariants un par un : un bon nom de test dit l'obligation SANS en
598    /// reprendre les mots —
599    ///
600    /// ```text
601    /// « Le PV est signé par le président copropriétaire »
602    ///   → security_un_president_non_coproprietaire_vicie_le_pv
603    /// ```
604    ///
605    /// — et un mot-clé obligatoire aurait rejeté cette attestation juste tout
606    /// en laissant passer une correspondance fortuite. Ce jugement reste
607    /// humain, et le restera : `atteste_par` est rempli ou vidé par une
608    /// personne qui a lu le test et l'article, pas par une regex.
609    ///
610    /// ── Ce qu'il BORNE ──────────────────────────────────────────────────────
611    ///
612    /// Une fois ce jugement humain posé, il ne peut plus rester tacite. Une
613    /// obligation cite un test **et rien d'autre** (couverture affirmée), ou
614    /// elle ne cite aucun test **et dit pourquoi** (couverture refusée) — les
615    /// deux à la fois sont contradictoires, aucun des deux est un silence que
616    /// ce registre ne peut plus se permettre.
617    ///
618    /// C'est exactement ce qui manquait à l'Art. 3.87 § 6 avant #847 : un
619    /// commentaire disait « ATTESTATION ADJACENTE, ASSUMÉE », et
620    /// `rapport_de_conformite()` — ce que lit le juriste — affichait pourtant
621    /// une ligne ordinaire, indiscernable d'une obligation pleinement
622    /// couverte. Le jugement existait déjà ; il n'était structurel nulle part.
623    #[test]
624    fn security_une_obligation_est_attestee_ou_dit_pourquoi_elle_ne_lest_pas() {
625        let mut fautifs = Vec::new();
626
627        for invariant in REGISTRE {
628            match (invariant.atteste_par, invariant.non_couverte) {
629                (Some(_), None) => {} // attestée : rien à ajouter
630                (None, Some(raison)) if !raison.trim().is_empty() => {} // non couverte, justifiée
631                (Some(test), Some(_)) => fautifs.push(format!(
632                    "{} → cite un test ({test}) ET une raison de non-couverture : \
633                     c'est contradictoire, il faut choisir",
634                    invariant.article
635                )),
636                (None, None) => fautifs.push(format!(
637                    "{} → ni test ni raison : une obligation ne peut rester tacite \
638                     sur sa couverture",
639                    invariant.article
640                )),
641                (None, Some(_)) => fautifs.push(format!(
642                    "{} → déclarée non couverte avec une raison vide",
643                    invariant.article
644                )),
645            }
646        }
647
648        assert!(
649            fautifs.is_empty(),
650            "des obligations ne sont ni honnêtement attestées ni honnêtement \
651             déclarées non couvertes :\n  {}\n\n\
652             `atteste_par` XOR `non_couverte` : citer un test ne suffit plus, \
653             il faut l'assumer sans réserve, ou renoncer à la couverture et \
654             dire pourquoi (#847).",
655            fautifs.join("\n  ")
656        );
657    }
658
659    #[test]
660    fn aucun_article_nest_declare_deux_fois_pour_la_meme_obligation() {
661        let mut vus: Vec<(&str, &str)> = Vec::new();
662        for invariant in REGISTRE {
663            let cle = (invariant.article, invariant.obligation);
664            assert!(
665                !vus.contains(&cle),
666                "{} déclaré deux fois pour la même obligation",
667                invariant.article
668            );
669            vus.push(cle);
670        }
671    }
672
673    /// La couverture ne recule pas.
674    ///
675    /// Le RFC-0002 recensait vingt-neuf obligations computables. Le registre
676    /// en porte autant : c'est le solde à la clôture du lot J7, et il sert de
677    /// plancher. Ce plancher compte les obligations RECENSÉES, attestées ou
678    /// non — déclarer l'une d'elles non couverte (#847) ne la retire pas du
679    /// registre, elle continue d'apparaître au juriste, marquée comme telle.
680    #[test]
681    fn la_couverture_ne_recule_pas() {
682        assert!(
683            REGISTRE.len() >= OBLIGATIONS_RECENSEES,
684            "le registre est passé de {OBLIGATIONS_RECENSEES} à {} invariants : \
685             une couverture ne se retire pas sans qu'on le dise.",
686            REGISTRE.len()
687        );
688    }
689
690    /// Un délai déclaré au registre est CELUI du domaine, pas une copie.
691    ///
692    /// ── Ce que cette garde empêche ────────────────────────────────────────
693    ///
694    /// La remise de design veut afficher un décompte d'échéance à côté de
695    /// chaque tâche — « 18 j sur 30 » — et met en garde dans le même souffle :
696    /// les jours affichés doivent venir du registre du projet, **jamais d'un
697    /// nombre écrit dans un composant**.
698    ///
699    /// Le risque n'est pas théorique. Un « 30 » recopié à l'écran est un
700    /// nombre que rien ne relie à la loi : le jour où l'on corrige le domaine
701    /// — parce que le législateur a bougé, ou plus probablement parce qu'on
702    /// avait mal lu l'article — l'écran continue d'annoncer l'ancien délai.
703    /// Le syndic agit alors sur une échéance fausse en croyant lire le
704    /// produit.
705    ///
706    /// ── Ce que cette garde vérifie, et ce qu'elle ne peut pas ────────────
707    ///
708    /// Elle vérifie que les délais du registre sont **strictement positifs**
709    /// et qu'ils portent sur des modules qui déclarent bien une constante de
710    /// délai. Elle ne peut pas vérifier qu'un délai manquant DEVRAIT être là :
711    /// dire si l'Art. 3.88 impose une échéance demande de lire l'article, pas
712    /// le code.
713    ///
714    /// ── Pourquoi `security_` ────────────────────────────────────────────
715    ///
716    /// La dispense de taxonomie ne vaut que pour les fichiers
717    /// `tests/garde_*.rs` : ce test vit dans un fichier source, il doit donc
718    /// déclarer sa catégorie comme les autres. Élargir la dispense pour ma
719    /// commodité aurait ouvert une porte que la garde ferme exprès.
720    ///
721    /// `security_` parce que ce dépôt s'en sert pour la classe « un défaut
722    /// qui passerait autrement en silence » — cf.
723    /// `security_un_contrat_oublie_est_signale_nominativement`, qui ne porte
724    /// pas davantage sur un contrôle d'accès. Un délai faux affiché à un
725    /// syndic est exactement cela : il agit sur une échéance erronée en
726    /// croyant lire le produit.
727    #[test]
728    fn security_chaque_delai_declare_vient_du_domaine() {
729        let racine = racine_src();
730        let mut fautifs: Vec<String> = Vec::new();
731
732        for invariant in REGISTRE {
733            let Some(jours) = invariant.delai_jours else {
734                continue;
735            };
736
737            if jours <= 0 {
738                fautifs.push(format!(
739                    "{} → délai de {jours} jours, ce qui n'a pas de sens",
740                    invariant.article
741                ));
742                continue;
743            }
744
745            // Le module qui porte l'obligation doit porter la constante : si
746            // le délai venait d'ailleurs, la valeur affichée et la règle
747            // appliquée pourraient diverger sans que rien le dise.
748            // `racine_src()` rend la racine du CRATE malgré son nom : la garde
749            // voisine ajoute `.join("src")`, et l'omettre faisait déclarer les
750            // six modules « introuvables » alors qu'ils étaient tous là.
751            let chemin = racine.join("src").join(invariant.porte_par);
752            let Ok(source) = std::fs::read_to_string(&chemin) else {
753                fautifs.push(format!(
754                    "{} → module introuvable : {}",
755                    invariant.article, invariant.porte_par
756                ));
757                continue;
758            };
759            let declare_une_constante = source.contains("pub const DELAI")
760                || source.contains("pub const DUREE")
761                || source.contains("const DUREE_JOURS");
762            if !declare_une_constante {
763                fautifs.push(format!(
764                    "{} → {} déclare un délai de {jours} j au registre, mais \
765                     ne porte aucune constante de délai",
766                    invariant.article, invariant.porte_par
767                ));
768            }
769        }
770
771        assert!(
772            fautifs.is_empty(),
773            "des délais du registre ne viennent pas du domaine :\n  {}\n\n\
774             Un délai doit RÉFÉRENCER la constante du module qui le porte, \
775             jamais recopier sa valeur. Sans quoi corriger l'un laisse l'autre \
776             mentir, et l'écran annonce une échéance que le code n'applique \
777             plus.",
778            fautifs.join("\n  ")
779        );
780    }
781
782    /// Le rapport doit rester lisible : c'est un juriste qui le lit.
783    #[test]
784    fn le_rapport_cite_chaque_article() {
785        let rapport = rapport_de_conformite();
786        for invariant in REGISTRE {
787            assert!(
788                rapport.contains(invariant.article),
789                "{} absent du rapport",
790                invariant.article
791            );
792        }
793    }
794
795    /// @edge — une obligation non couverte se lit comme telle dans le
796    /// rapport, jamais comme une ligne ordinaire portant un test.
797    ///
798    /// C'est le point précis que #847 corrige : avant, l'Art. 3.87 § 6
799    /// apparaissait au juriste exactement comme n'importe quelle obligation
800    /// pleinement attestée. Un registre qui préfère un taux de couverture
801    /// élevé à un taux exact ne sert plus à rien.
802    #[test]
803    fn edge_une_obligation_non_couverte_est_marquee_dans_le_rapport() {
804        let rapport = rapport_de_conformite();
805        let non_couvertes: Vec<&InvariantLegal> = REGISTRE
806            .iter()
807            .filter(|i| i.atteste_par.is_none())
808            .collect();
809
810        assert!(
811            !non_couvertes.is_empty(),
812            "ce test suppose qu'au moins une obligation est déclarée non \
813             couverte (Art. 3.87 § 6 au moment de l'écriture) ; si elle a été \
814             couverte depuis, remplacez cette obligation par une autre \
815             non_couverte, ou par une preuve que le rapport marque encore une \
816             absence de test le cas échéant"
817        );
818
819        for invariant in non_couvertes {
820            let ligne = rapport
821                .lines()
822                .find(|l| l.contains(invariant.article))
823                .unwrap_or_else(|| panic!("{} absent du rapport", invariant.article));
824            assert!(
825                ligne.contains("NON COUVERT"),
826                "{} n'est pas attestée mais le rapport ne le signale pas : {ligne}",
827                invariant.article
828            );
829        }
830    }
831}