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}