From c3d6bf88b9b734d29ab8cbf143f2c4294d594098 Mon Sep 17 00:00:00 2001 From: Yvv Date: Wed, 21 Oct 2026 07:41:58 +0200 Subject: [PATCH] =?UTF-8?q?v3=20:=20septi=C3=A8me=20relecture=20=E2=80=94?= =?UTF-8?q?=20collages=20limit=C3=A9s,=20titre=20jug=C3=A9=20=C3=A0=20part?= =?UTF-8?q?,=20d=C3=A9coupage=20contr=C3=B4l=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mots collés ou coupés : deux au plus, jamais sur une négation (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond). - Titre et corps jugés chacun pour soi, puis ensemble : le titre n'absorbe plus le début du corps. - Caractères invisibles refusés aussi au découpage d'un brouillon et à la soumission. - Tabulation et retour chariot ne sont plus des espaces libres ; les lettres latines rares (ǃ, Ʒ…) font partie de la forme. - Le diff renvoyé après une coquille couvre le titre. Boucle : 62 tests Rust, svelte-check, 9 vitest, e2e 12/12. Co-Authored-By: Claude Opus 5.5 --- HANDOFF.md | 26 +++++--- crates/ld-engine/src/edit.rs | 59 +++++++++++++++---- crates/ld-server/src/proposals.rs | 23 ++++++-- crates/ld-server/tests/api.rs | 19 ++++++ docs/adr/0003-corriger-n-est-pas-voter.md | 10 +++- docs/chantiers/acte-certification/CHANTIER.md | 13 +++- .../chantiers/acte-certification/CONTROLES.md | 1 + 7 files changed, 120 insertions(+), 31 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 488729e..4a9f958 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,10 +4,13 @@ ## Où on en est -**Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer. +**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org +depuis le 2026-10-21 (version `fa8e8dc`, pipeline Woodpecker n° 30). La maquette part sur +maquette.decision.librodrome.org. Sur la branche du chantier, les trois points laissés par la +septième relecture sont corrigés (pas encore en ligne). - v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`. -- Boucle : Rust (fmt, clippy `-D warnings`, 60 tests dont 14 scénarios d'API et la +- Boucle : Rust (fmt, clippy `-D warnings`, 62 tests dont 14 scénarios d'API et la publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) · e2e 12 / 12 (C1–C11, M1). L'image Docker rejoue tout et a été essayée localement : santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent @@ -17,7 +20,8 @@ sur maquette.decision.librodrome.org, seulement si le produit a réussi). - Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 : ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`), - toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens. La + toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis + `fa8e8dc : ok` (mis en ligne). La règle est désormais simple et explicable : **une coquille touche les lettres et les espaces, rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une @@ -29,12 +33,16 @@ ## Ce qui attend Yvv 1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`. -2. **Créer le dépôt des textes** sur git.duniter.org : `Yvv/libredecision-textes`, **public**, - vide ; y ajouter la **clé de déploiement** du serveur, **en écriture** (sa partie publique - s'affiche dans trustinWallet, `/acces`, section Publication). Le serveur fait lui-même le - premier envoi. -3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation), - puis créer l'**invitation de Chiara** (groupes Monnaie libre + groupe d'essai). +2. **Créer le dépôt des textes**, public et vide : `Yvv/libredecision-textes` sur + git.duniter.org (choix du formulaire, « là où regardent Cesium et Ğecko ») — question rouverte + le 2026-10-21 : git.open.us.org à la place ? (une ligne, `LD_PUBLISH_REMOTE` dans + `docker/app-compose.yml`). Y ajouter la **clé de déploiement** du serveur, **en écriture** (sa + partie publique s'affiche dans trustinWallet, `/acces`, section Publication). Le serveur + réessaie toutes les 60 s et fait lui-même le premier envoi. +3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un + seul usage : il crée l'identité administratrice), puis créer l'**invitation de Chiara** + (groupes Monnaie libre + groupe d'essai). Question ouverte : un « lien d'équipe » + réutilisable (une identité par ouverture, jamais l'administration) ? 4. Donner à Chiara un compte **git.open.us.org** collaboratrice de `yvv/decision`. 5. Décider : la **licence** du code ; le texte F1.2 du seed (annonce C = 0 et 30 jours). diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs index 8ad5039..eb80412 100644 --- a/crates/ld-engine/src/edit.rs +++ b/crates/ld-engine/src/edit.rs @@ -80,15 +80,18 @@ fn skeleton(s: &str) -> String { fold(s).chars().filter(|c| c.is_alphanumeric()).collect() } -/// A letter of the Latin alphabet, precomposed accents included (after NFC): -/// the only letters a typo fix may touch. Any other script may hold look-alikes -/// (Cyrillic « а ») or reorder the display (Hebrew, Arabic). +/// A letter of the Latin alphabet as French, English and Spanish write it, +/// precomposed accents included (after NFC, Latin-1 and Latin Extended-A): the +/// only letters a typo fix may touch. Any other letter may be a look-alike +/// (Cyrillic « а », « ǃ » for « ! », « Ʒ » for « 3 ») or reorder the display +/// (Hebrew, Arabic): it is kept as it is. fn is_latin_letter(c: char) -> bool { - c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF) + c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F) } -/// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is. -const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t']; +/// The only spaces free to change; any other (carriage return, tab, hair, +/// thin, ogham…) is kept as it is. +const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n']; /// Invisible format characters (zero-width, bidirectional overrides and /// isolates, byte order mark): they can make "12" display as "21" ("Trojan @@ -196,20 +199,28 @@ fn words(s: &str) -> Vec { fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect() } -/// Same letters, words only glued or split: « latransmission » → « la -/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »). +/// Same letters, at most two words glued or split, never a negation: +/// « latransmission » → « la transmission ». A boundary moved is not a typo +/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ». fn only_glued_or_split(a: &[String], b: &[String]) -> bool { - let (mut i, mut j) = (0, 0); + let joined = |x: &String, y: &String| !NEGATIONS.contains(&x.as_str()) && !NEGATIONS.contains(&y.as_str()); + let (mut i, mut j, mut moves) = (0, 0, 0); while i < a.len() && j < b.len() { if a[i] == b[j] { (i, j) = (i + 1, j + 1); - } else if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) { + continue; + } + if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&b[j], &b[j + 1]) { (i, j) = (i + 1, j + 2); - } else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) { + } else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&a[i], &a[i + 1]) { (i, j) = (i + 2, j + 1); } else { return false; } + moves += 1; + if moves > MAX_TYPO_WORDS { + return false; + } } i == a.len() && j == b.len() } @@ -361,6 +372,32 @@ mod tests { assert_eq!(classify(old, new), EditKind::Typographic); } + #[test] + fn glued_or_split_words_stay_few_and_never_touch_a_negation() { + // Seventh independent review, 2026-10-21: a text in force disfigured at will. + for (old, new) in [ + ("à ne certifier que", "à necertifier que"), + ("à ne certifier que", "àne certifier que"), + ("Si non, la demande attend", "Sinon, la demande attend"), + ("la transmission de ce document à chacun", "latransmission dece documentà chacun"), + ] { + assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); + } + assert_eq!( + classify("de latransmission dece document", "de la transmission de ce document"), + EditKind::Typographic + ); + } + + #[test] + fn odd_spaces_and_look_alike_letters_are_kept() { + assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive); + assert_eq!(classify("un texte\rde référence", "un texte de référence"), EditKind::Substantive); + assert_eq!(classify("toute certification", "toute certiǃfication"), EditKind::Substantive); + assert_eq!(classify("toute certification", "toute certifiƷcation"), EditKind::Substantive); + assert_eq!(classify("le coeur de l'ŒUVRE, niño", "le Coeur de l'œuvre, Niño"), EditKind::Typographic); + } + #[test] fn accents_case_apostrophes_quotes() { assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic); diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index c8702da..8bfbfbe 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -316,15 +316,20 @@ pub async fn edit( } // Measured against the text that was voted, not the current one: successive // typo fixes are judged together and never add up to a change of meaning. + // Title and body are judged apart (a title never absorbs the start of the + // body) and together (two slips at most in all). let next = format!("{title}\n{body}"); - let kind = if next == format!("{}\n{}", p.title, p.body) { + let kind = if title == p.title && body == p.body { EditKind::Unchanged } else { let (voted_title, voted_body) = voted_text(&state, &p).await?; - match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) { - EditKind::Unchanged => EditKind::Typographic, // back to the voted text - k => k, - } + let kinds = [ + edit::classify(&voted_title, title), + edit::classify(&voted_body, body), + edit::classify(&format!("{voted_title}\n{voted_body}"), &next), + ]; + // All unchanged: back to the voted text, itself a correction. + if kinds.contains(&EditKind::Substantive) { EditKind::Substantive } else { EditKind::Typographic } }; match kind { EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))), @@ -363,7 +368,7 @@ pub async fn edit( if p.status == "in_force" { adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?; } - Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&p.body, body) }))) + Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) }))) } EditKind::Substantive => { if p.status == "in_force" { @@ -418,6 +423,9 @@ pub async fn submit( if p.status != "draft" { return Err(AppError::Conflict("not_draft")); } + if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) { + return Err(AppError::Invalid("hidden_characters")); + } let now = util::now(); sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?") .bind(&now) @@ -484,6 +492,9 @@ pub async fn split( if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) { return Err(AppError::Invalid("parts")); } + if parts.iter().any(|s| edit::has_hidden_controls(s)) { + return Err(AppError::Invalid("hidden_characters")); + } sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?") .bind(parts[0]) .bind(util::now()) diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 9a99593..fe3ae76 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -251,6 +251,17 @@ async fn editing_is_not_voting() { ) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters"))); + // The title never absorbs the start of the body. + let title = e2["title"].as_str().expect("title"); + let (head, rest) = body.split_once(' ').expect("two words"); + let (s, v) = b + .call( + "PATCH", + &format!("/api/proposals/{e2_id}"), + Some(json!({ "title": format!("{title} {head}"), "body": rest })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}"); // Typo fixes are measured against the voted text: slips never add up. let patch = |body: String| json!({ "title": e2["title"], "body": body }); let (_, v) = b @@ -396,6 +407,14 @@ async fn split_a_draft() { .call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" }))) .await; let p = v["id"].as_str().expect("id").to_owned(); + let (s, v) = a + .call( + "POST", + &format!("/api/proposals/{p}/split"), + Some(json!({ "parts": ["Première.", "Au \u{202e}01\u{202c}."] })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters"))); let (s, v) = a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await; assert_eq!(s, StatusCode::OK, "{v}"); diff --git a/docs/adr/0003-corriger-n-est-pas-voter.md b/docs/adr/0003-corriger-n-est-pas-voter.md index 5b9fa40..8a1b38b 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -12,7 +12,9 @@ ou de fond), jamais contre la correction précédente : comptent pour un même signe) ; - les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves restent identiques à la lettre ; -- les espaces ne font que coller ou couper des mots, jamais déplacer une frontière ; +- les espaces (espace, insécable, fine insécable, saut de ligne) ne font que coller ou couper + des mots, deux au plus, jamais sur une négation, jamais en déplaçant une frontière ; +- le titre et le corps sont jugés chacun pour soi, puis ensemble ; - au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre ajoutée, retirée ou deux voisines inversées ; jamais aux deux premières ni aux deux dernières lettres ; jamais une lettre remplacée ; jamais sur un mot de négation, de quantité ou de @@ -28,7 +30,11 @@ donne un conflit. Les brouillons s'éditent librement. près ». Six relectures indépendantes (2026-10-21) ont trouvé, l'une après l'autre, des coquilles qui changeaient le sens : mots retournés (« valide » → « invalide »), caractères invisibles, sosies de chiffres, corrections enchaînées, adresse typosquattée, signe moins -devant un nombre en gras. D'où le passage d'une liste noire à cette règle. +devant un nombre en gras. D'où le passage d'une liste noire à cette règle. La septième +(`fa8e8dc : ok`) a laissé trois points, corrigés aussitôt : mots collés ou coupés sans limite +(« Si non » → « Sinon »), titre jugé avec le corps (le titre en absorbait le début), caractères +invisibles non contrôlés au découpage d'un brouillon ; et deux remarques : tabulation et retour +chariot ne sont plus des espaces libres, les lettres latines rares font partie de la forme. **Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 900d7d8..e75c847 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -95,12 +95,19 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u - la **forme** du texte (tout ce qui n'est ni lettre latine ni espace, dans l'ordre : ponctuation, chiffres de toute écriture, signes, emphase, parenthèses, sosies, marques superposées, autres alphabets) ne bouge pas ; une espace à côté d'un chiffre en fait partie (« 1 5 » n'est pas - « 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ; + « 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ; « lettre + latine » s'entend de celles du français, de l'anglais et de l'espagnol (les lettres latines + rares qui imitent un signe, « ǃ » ou « Ʒ », font partie de la forme) ; « espace » s'entend de + l'espace, de l'insécable, de la fine insécable et du saut de ligne (ni tabulation ni retour + chariot) ; - les **adresses** (liens, domaines, courriels) et les **identifiants qui contiennent un chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu entre accents graves aussi ; -- les espaces ne font que **coller ou couper** des mots, jamais déplacer une frontière (« les - avoir » → « le savoir » est du fond) ; +- les espaces ne font que **coller ou couper** des mots, deux au plus, jamais sur un mot de + négation (« Si non » → « Sinon », « ne certifier » → « necertifier » sont du fond), et jamais + déplacer une frontière (« les avoir » → « le savoir » est du fond) ; +- le **titre** et le **corps** sont jugés chacun pour soi (un titre n'absorbe pas le début du + corps), puis ensemble (deux mots au plus en tout) ; - un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux premières ni aux deux dernières lettres (préfixes « valide » → « invalide », terminaisons « certifierai » → « certifierais »), jamais une lettre remplacée (« deux » → « dix »), jamais sur diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index c9f2510..1dc26a3 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -42,3 +42,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa |---|---|---|---|---| | 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts | | 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts | +| 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts |