diff --git a/.woodpecker/app.yml b/.woodpecker/app.yml index 5b98b0c..c06b0f5 100644 --- a/.woodpecker/app.yml +++ b/.woodpecker/app.yml @@ -101,9 +101,12 @@ steps: - DOMAIN=decision.librodrome.org # La version en service (celle qui tourne, validée par son propre healthcheck) est # gardée sous « previous » : « retablir » la relance si ce déploiement échoue. + # Un témoin resté d'un pipeline annulé : ce qui tourne n'a pas été validé, on garde l'ancien « previous ». - | IMG=$(docker inspect --format '{{if .State.Running}}{{.Image}}{{end}}' lpk-libredecision-app 2>/dev/null || true) - if [ -n "$IMG" ]; then docker tag "$IMG" libredecision-app:previous && echo "Version en service gardee ($IMG)"; fi + if [ -n "$IMG" ] && [ ! -e /opt/libredecision-app/deploying ]; then + docker tag "$IMG" libredecision-app:previous && echo "Version en service gardee ($IMG)" + fi # Témoin : à partir d'ici, un échec appelle « retablir ». Le healthcheck réussi l'efface. - touch /opt/libredecision-app/deploying - echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env diff --git a/HANDOFF.md b/HANDOFF.md index 9198ef4..488729e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,7 +7,7 @@ **Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer. - v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`. -- Boucle : Rust (fmt, clippy `-D warnings`, 59 tests dont 14 scénarios d'API et la +- Boucle : Rust (fmt, clippy `-D warnings`, 60 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 @@ -16,16 +16,15 @@ racine tout conteneur qui la revendique), puis `.woodpecker/maquette.yml` (la maquette part 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`, `ac9c904 : à reprendre`, `9203fd2 : à reprendre`, `1eac6f8 : à reprendre`. Les trois dernières ont - montré qu'une « coquille » (sans vote, publiée) pouvait encore changer un sens — mots - retournés, caractères invisibles, sosies de chiffres, corrections enchaînées, adresses. La - règle est désormais une liste blanche mesurée contre le texte voté (voir CHANTIER.md). Les - autres points : ordre des workflows, anciens conteneurs en - `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une personne, - une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant usage, - un nombre n'est jamais une coquille, redirection après connexion, en-têtes anti-encadrement, - cookies préfixés `__Host-`, retour arrière automatique du déploiement (maquette au premier, - version précédente ensuite), syft épinglé. + 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 + 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 + personne, une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant + usage, redirection après connexion, en-têtes anti-encadrement, cookies `__Host-`, retour + arrière automatique du déploiement (maquette au premier, version précédente ensuite), courses + entre correction et entrée en vigueur, syft épinglé. ## Ce qui attend Yvv diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs index 6d1b58f..8ad5039 100644 --- a/crates/ld-engine/src/edit.rs +++ b/crates/ld-engine/src/edit.rs @@ -3,12 +3,12 @@ //! Once a proposal is under vote, its author may still correct it. The engine //! tells two kinds of corrections apart: //! -//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most -//! two misspelt words fixed by a single slip each (one letter added, removed -//! or swapped, never at the start of the word, never on a negation or a -//! quantity) — and never a figure, a sign, a separator inside a number, a -//! relation, a bracket or anything between backticks. The votes stay; the -//! correction is shown with its diff. +//! - **typographic**: letters and spaces only — accents, case, the style of +//! apostrophes and quotes, words glued or split, or at most two misspelt +//! words fixed by a single slip each (one letter added, removed or swapped +//! in the middle of the word, never on a negation or a quantity). Never a +//! punctuation mark, a figure, a sign, an emphasis, a formula or an address. +//! The votes stay; the correction is shown with its diff. //! - **substantive**: anything else. It opens a new revision and every vote //! cast on the previous one waits for its author to reconfirm it. //! @@ -87,13 +87,8 @@ fn is_latin_letter(c: char) -> bool { c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF) } -/// Ordinary punctuation, free to change in a typo fix — except where noted below. -const PUNCTUATION: &[char] = - &['.', ',', ';', ':', '!', '?', '\'', '’', '‘', '"', '“', '”', '«', '»', '…', '‐', '-', '–', '—', '*', '_']; /// 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']; -/// Dashes become a sign when a figure follows or precedes, even across spaces ("- 5"). -const DASHES: &[char] = &['‐', '-', '–', '—']; /// Invisible format characters (zero-width, bidirectional overrides and /// isolates, byte order mark): they can make "12" display as "21" ("Trojan @@ -104,42 +99,43 @@ pub fn has_hidden_controls(s: &str) -> bool { ) } -/// Everything a typo fix may not change, in order. Built the other way round -/// from a list of dangerous symbols: what is kept is everything that is NOT a -/// Latin letter, a space or ordinary punctuation — figures in any script, signs, -/// relations, brackets, look-alikes (U+201A for a comma, U+2044 for a slash, -/// full-width forms), stray combining marks (U+0336, U+0338), other alphabets. -/// Anything that touches a figure counts too, spaces and punctuation included: -/// "5,0", "5,\u{202f}0", "1:5", "1…5" never read like "50" or "15". -fn signature(s: &str) -> String { +/// Apostrophes and quotation marks of every style are one and the same sign: +/// « Je m'engage » → « Je m’engage » is typography. +fn same_mark(c: char) -> char { + match c { + '’' | '‘' | 'ʼ' => '\'', + '“' | '”' | '«' | '»' => '"', + other => other, + } +} + +/// The shape of a text: every run of Latin letters becomes one mark, free spaces +/// vanish, and **everything else stays as it is, in order** — punctuation, +/// figures, signs, emphasis, brackets, look-alikes, other alphabets. A typo fix +/// keeps the shape: it touches letters and spaces only (2026-10-21, after six +/// independent reviews showed that any free punctuation can carry a meaning: +/// a minus sign before a bold number, a list merged into one condition…). +fn shape(s: &str) -> String { let cs: Vec = s.nfc().collect(); let is_figure = |i: Option| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric()); - // The next character that is not a free space, on either side. - let across = |i: usize, step: isize| { - let mut j = i as isize + step; - while j >= 0 && (j as usize) < cs.len() && FREE_SPACES.contains(&cs[j as usize]) { - j += step; - } - j >= 0 && is_figure(Some(j as usize)) - }; - cs.iter() - .enumerate() - .filter(|&(i, &c)| { - let touches_figure = is_figure(i.checked_sub(1)) || is_figure(Some(i + 1)); - if is_latin_letter(c) { - false - } else if touches_figure { - true - } else if FREE_SPACES.contains(&c) { - false - } else if DASHES.contains(&c) { - across(i, -1) || across(i, 1) - } else { - !PUNCTUATION.contains(&c) + let mut out = String::new(); + let mut in_word = false; + for (i, &c) in cs.iter().enumerate() { + if is_latin_letter(c) { + if !in_word { + out.push('a'); } - }) - .map(|(_, &c)| c) - .collect() + in_word = true; + } else if FREE_SPACES.contains(&c) && !is_figure(i.checked_sub(1)) && !is_figure(Some(i + 1)) { + // A space between letters may come and go: « latransmission » and + // « la transmission » share a shape (segmentation is checked apart). + // Next to a figure it stays: « 1 5 » is not « 15 ». + } else { + out.push(same_mark(c)); + in_word = false; + } + } + out } /// What stands between backticks: formulas and identifiers, compared exactly. @@ -147,15 +143,16 @@ fn code_spans(s: &str) -> Vec<&str> { s.split('`').skip(1).step_by(2).collect() } -/// Web and mail addresses, domains, paths: compared exactly ("duniter" / -/// "dunniter" is a typo of a word, not of an address). -fn addresses(s: &str) -> Vec { +/// Addresses (links, domains, mails, paths) and identifiers that hold a figure +/// (keys, fingerprints, references): compared exactly, case included. +fn exact_tokens(s: &str) -> Vec { s.split_whitespace() .map(|t| t.trim_matches(|c: char| !c.is_alphanumeric())) .filter(|t| { let cs: Vec = t.chars().collect(); t.contains("://") || t.contains('@') + || cs.iter().any(|c| c.is_numeric()) && cs.iter().any(|c| c.is_alphabetic()) || (1..cs.len().saturating_sub(1)) .any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric()) }) @@ -199,6 +196,24 @@ 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 »). +fn only_glued_or_split(a: &[String], b: &[String]) -> bool { + let (mut i, mut j) = (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]) { + (i, j) = (i + 1, j + 2); + } else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) { + (i, j) = (i + 2, j + 1); + } else { + return false; + } + } + i == a.len() && j == b.len() +} + /// Classify `new` against `old`. For a text under vote, `old` must be the text /// that was **voted** (its last initial or substantive revision), not the /// current one: typo fixes are measured together, so slips never add up. @@ -207,16 +222,17 @@ pub fn classify(old: &str, new: &str) -> EditKind { return EditKind::Unchanged; } if has_hidden_controls(new) - || signature(old) != signature(new) + || shape(old) != shape(new) || code_spans(old) != code_spans(new) - || addresses(old) != addresses(new) + || exact_tokens(old) != exact_tokens(new) { return EditKind::Substantive; } - if skeleton(old) == skeleton(new) { - return EditKind::Typographic; - } let (a, b) = (words(old), words(new)); + if skeleton(old) == skeleton(new) { + // Accents, case, apostrophes; words glued or split, never re-cut. + return if only_glued_or_split(&a, &b) { EditKind::Typographic } else { EditKind::Substantive }; + } if a.len() != b.len() { return EditKind::Substantive; } @@ -346,12 +362,36 @@ mod tests { } #[test] - fn accents_case_punctuation_emphasis() { - assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier !"), EditKind::Typographic); - assert_eq!(classify("un **être humain**", "un être humain"), EditKind::Typographic); + fn accents_case_apostrophes_quotes() { + assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic); + assert_eq!(classify("le \"Pacte\" de la monnaie", "le « Pacte » de la Monnaie"), EditKind::Typographic); assert_eq!(classify("same", "same"), EditKind::Unchanged); } + #[test] + fn punctuation_and_emphasis_go_through_a_proposal() { + // Sixth independent review: free punctuation can always carry a meaning. + for (old, new) in [ + ("Je m'engage à vérifier.", "Je m'engage à vérifier !"), + ("Je m'engage à vérifier.", "Je m'engage à vérifier ?"), + ("un **être humain**", "un être humain"), + ("tous les **5 jours**", "tous les -**5 jours**"), + ("Seuil = C + B^W", "Seuil = C + B^-W"), + ("max(0, T-C)", "max(0, TC)"), + ("(Y = ceil(N))", "(Y = -ceil(N))"), + ("- avoir un compte\n- être joignable", "- avoir un compte, être joignable"), + ("(sauf le cas a) et le cas b", "(sauf le cas a et le cas b)"), + ("libre - le texte", "libre — le texte"), + ("- une liste", "* une liste"), + ("clé DsEx1pS33abc", "clé dSeX1Ps33abc"), + ("les avoir vérifiés", "le savoir vérifiés"), + ] { + assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); + } + assert_eq!(classify("toute latransmission", "toute la transmission"), EditKind::Typographic); + assert_eq!(classify("la trans mission", "la transmission"), EditKind::Typographic); + } + #[test] fn a_figure_a_sign_or_a_relation_is_never_a_typo() { // Same letters, different meaning (independent reviews, 2026-10-21). @@ -369,13 +409,10 @@ mod tests { ] { assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); } - // Punctuation around figures, list markers, dashes between words stay typographic. - assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic); - assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic); + assert_eq!(classify("50 voix pour", "50 voix Pour"), EditKind::Typographic); // A bracket always counts, even as a list marker: conservative. assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Substantive); - assert_eq!(classify("1. premier point", "1. premier point."), EditKind::Typographic); - assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic); + assert_eq!(classify("1. premier point", "1. Premier point"), EditKind::Typographic); } #[test] diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index f2c60e5..c8702da 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -333,13 +333,25 @@ pub async fn edit( if !(is_author || p.origin == "seed" || p.status == "in_force") { return Err(AppError::Forbidden); } - sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?") - .bind(title) - .bind(body) - .bind(&now) - .bind(&id) - .execute(&state.db) - .await?; + // Written only over the text that was judged: an entry into force or another + // correction in between makes it a conflict, never a silent change. + let done = sqlx::query( + "UPDATE proposals SET title = ?, body = ?, updated_at = ? + WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?", + ) + .bind(title) + .bind(body) + .bind(&now) + .bind(&id) + .bind(p.revision) + .bind(&p.status) + .bind(&p.title) + .bind(&p.body) + .execute(&state.db) + .await?; + if done.rows_affected() != 1 { + return Err(AppError::Conflict("changed")); + } record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?; // A typo fix does not change the meaning: up-to-date translations stay up to date. sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?") @@ -361,14 +373,25 @@ pub async fn edit( return Err(AppError::Conflict("substantive_not_author")); } let revision = p.revision + 1; - sqlx::query("UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ? WHERE id = ?") - .bind(title) - .bind(body) - .bind(revision) - .bind(&now) - .bind(&id) - .execute(&state.db) - .await?; + // Only on the open proposal that was read: never on a text that entered into + // force in the meantime. + let done = sqlx::query( + "UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ? + WHERE id = ? AND revision = ? AND status = 'open' AND title = ? AND body = ?", + ) + .bind(title) + .bind(body) + .bind(revision) + .bind(&now) + .bind(&id) + .bind(p.revision) + .bind(&p.title) + .bind(&p.body) + .execute(&state.db) + .await?; + if done.rows_affected() != 1 { + return Err(AppError::Conflict("changed")); + } record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?; // Signals were about the former text: they lapse, their authors may signal again. sqlx::query( diff --git a/docs/adr/0003-corriger-n-est-pas-voter.md b/docs/adr/0003-corriger-n-est-pas-voter.md index 6cb787a..5b9fa40 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -1,28 +1,35 @@ # 0003 — Corriger n'est pas voter **Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une -coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté. +coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté — et tout +membre peut corriger un texte en vigueur, qui est republié aussitôt. -**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`), toujours -**contre le texte voté** (sa dernière révision initiale ou de fond) — jamais contre la dernière -correction, pour que les glissements ne s'additionnent pas : -- **jamais coquille** si change quoi que ce soit d'autre que des lettres latines, des espaces - et la ponctuation ordinaire (liste blanche : chiffres de toute écriture, signes, relations, - parenthèses, sosies, marques superposées, autres alphabets restent figés), ni une adresse, ni - le contenu entre accents graves, ni rien de ce qui touche un chiffre (espaces et ponctuation - comprises), ni un tiret devant ou après un chiffre même à travers des espaces ; seules quatre - espaces sont libres (ordinaire, insécable, fine insécable, saut de ligne) ; -- **coquille** si le squelette (sans accents, casse, ponctuation, espaces) est identique, ou si - au plus 2 mots diffèrent 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 lettres (préfixes) ni - aux deux dernières (nombre, genre, temps, mode), jamais une lettre remplacée, jamais sur un mot - de négation, de quantité ou de position ; elle s'applique sans vote, - garde les votes et les traductions ; -- les caractères invisibles de mise en forme (inversion du sens d'écriture, largeur nulle) sont - refusés partout où l'on écrit un texte ; -- **de fond** sinon : nouvelle révision d'une proposition ouverte (par son auteur), votes - antérieurs « à reconfirmer », signalements levés ; refusée sur un texte en vigueur. -L'éditeur montre le verdict avant d'enregistrer. Les brouillons s'éditent librement. +**Décision.** Une coquille touche **les lettres et les espaces, rien d'autre**. Le moteur +(`ld-engine/src/edit.rs`) la juge toujours **contre le texte voté** (dernière révision initiale +ou de fond), jamais contre la correction précédente : +- la **forme** du texte — tout ce qui n'est ni lettre latine ni espace, dans l'ordre, avec les + espaces qui touchent un chiffre — reste identique (apostrophes et guillemets de tous styles + 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 ; +- 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 + position) ; +- les caractères invisibles de mise en forme sont refusés partout où l'on écrit. -**Coût.** Le classement est prudent : certaines vraies coquilles (« persone » → « personne ») -passent pour du fond et demandent une proposition. On préfère ce sens d'erreur. +Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes +antérieurs « à reconfirmer », signalements levés ; refusé sur un texte en vigueur ou de départ. +L'écriture ne vaut que sur le texte jugé : un texte entré en vigueur ou changé entre-temps +donne un conflit. Les brouillons s'éditent librement. + +**Historique.** La première version tolérait la ponctuation et deux mots « à deux lettres +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. + +**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 +publié vaut plus qu'un point final. diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index aa33296..900d7d8 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -74,28 +74,44 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p ### Corriger n'est pas voter +**Une coquille touche les lettres et les espaces, rien d'autre.** Accents, majuscules, le style +des apostrophes et des guillemets, un mot collé ou coupé (« latransmission » → « la +transmission »), ou au plus deux mots mal tapés — chacun un seul glissement de clavier au cœur du +mot (« certifcation » → « certification »). Tout le reste passe par une proposition : la +ponctuation, les chiffres, les signes, l'emphase, les formules, les adresses, et tout changement +de sens. + | Geste | Qui | Effet sur les votes | |---|---|---| -| Corriger une **coquille** (accents, casse, ponctuation, espaces ; ou jusqu'à 2 mots mal tapés, une lettre ajoutée, retirée ou inversée chacun, au cœur du mot) — mesurée contre le texte **voté**, jamais contre la dernière correction | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour | +| Corriger une **coquille** | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour | | Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent | | Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version | -Le classement est prudent — quatre relectures indépendantes l'ont éprouvé, et il fonctionne -**par liste blanche** : une coquille ne peut toucher que des lettres latines, des espaces et la -ponctuation ordinaire. Tout le reste compte comme le sens et ne bouge pas : chiffres (de toute -écriture), signes, relations, parenthèses, sosies (« ‚ » pour une virgule, « ⁄ » pour une barre, -formes pleine chasse), marques superposées (rayure, barre), autres alphabets (cyrillique, -hébreu), rayure Markdown, tout ce qui est entre accents graves, les **adresses** (domaines, -liens, courriels), **tout ce qui touche un chiffre**, espaces et ponctuation comprises (« 50 » → -« 5,0 », « 5, 0 », « 1:5 » ; « 182.625 » → « 182 625 »), et un tiret devant ou après un chiffre, -même séparé par des espaces (« - 5 »). Seules quatre espaces sont libres (ordinaire, -insécable, fine insécable, saut de ligne) : les autres (fine, ultra-fine…) sont figées. Pour les mots : au plus deux mots, chacun **un seul -glissement de clavier au cœur du mot** — 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 un mot de négation, de quantité ou de position -(ne, jamais, deux, majorité, tous, dessus, avant…). Chaque correction est mesurée contre le -**texte voté** : les glissements ne s'additionnent pas d'une correction à l'autre. Les caractères -invisibles (inversion du sens d'écriture, largeur nulle) sont refusés partout. Moteur : `crates/ld-engine/src/edit.rs`. +Pourquoi si strict : six relectures indépendantes ont montré qu'une ponctuation laissée libre +finit toujours par porter un sens — un signe moins devant un nombre en gras, deux conditions +d'une liste fusionnées en une, une virgule glissée dans « 50 », une parenthèse déplacée qui +change la portée d'une exception. La règle est donc construite à l'envers d'une liste noire : + +- 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 ; +- 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) ; +- 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 + un mot de négation, de quantité ou de position (ne, jamais, deux, majorité, tous, dessus…) ; +- chaque correction est mesurée contre le **texte voté**, jamais contre la correction + précédente : les glissements ne s'additionnent pas ; +- les caractères invisibles (inversion du sens d'écriture, largeur nulle) sont refusés partout ; +- une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur + ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »). + +Moteur : `crates/ld-engine/src/edit.rs` ; règle d'écriture : `crates/ld-server/src/proposals.rs`. ### Deux articles qui se contredisent diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index eb97fd4..a9dd1c3 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -198,7 +198,7 @@ export const en: Messages = { 'history.by': 'by {name}', 'typo.title': 'Fix a typo', - 'typo.note': 'Accents, punctuation, typos: the fix applies without a vote, with its author and its diff. A change in meaning goes through a proposal.', + 'typo.note': 'Accents, capitals, typos, words run together or split: the fix applies without a vote, with its author and its diff. Punctuation, figures, formulas, addresses and any change in meaning go through a proposal.', 'typo.save': 'Save the fix', 'typo.done': 'Typo fixed, votes untouched.', 'typo.substantive': 'This edit affects the meaning: propose it as a change.', @@ -312,6 +312,7 @@ export const en: Messages = { 'error.invitation_invalid': 'This invitation link is no longer valid.', 'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.', 'error.hidden_characters': 'The text contains invisible characters (writing-direction overrides, zero-width spaces): remove them.', + 'error.changed': 'The text has changed in the meantime: reload it, then try again.', 'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.', 'error.retry': 'Try again', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 7a18056..795a7c2 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -198,7 +198,7 @@ export const es: Messages = { 'history.by': 'por {name}', 'typo.title': 'Corregir una errata', - 'typo.note': 'Acentos, puntuación, erratas: la corrección se aplica sin votación, con su autor y su diff. Un cambio de sentido pasa por una propuesta.', + 'typo.note': 'Acentos, mayúsculas, erratas, palabras pegadas o separadas: la corrección se aplica sin votación, con su autor y su diff. La puntuación, las cifras, las fórmulas, las direcciones y todo cambio de sentido pasan por una propuesta.', 'typo.save': 'Guardar la corrección', 'typo.done': 'Errata corregida, sin tocar los votos.', 'typo.substantive': 'Este cambio afecta al sentido: proponlo como modificación.', @@ -312,6 +312,7 @@ export const es: Messages = { 'error.invitation_invalid': 'Este enlace de invitación ya no es válido.', 'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.', 'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacios de ancho cero): quítalos.', + 'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.', 'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.', 'error.retry': 'Reintentar', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index 888a325..433c593 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -201,7 +201,7 @@ export const fr = { 'history.by': 'par {name}', 'typo.title': 'Corriger une coquille', - 'typo.note': "Accents, ponctuation, faute de frappe : la correction s'applique sans vote, avec son auteur et son diff. Un changement de sens passe par une proposition.", + 'typo.note': "Accents, majuscules, faute de frappe, mot collé ou coupé : la correction s'applique sans vote, avec son auteur et son diff. La ponctuation, les chiffres, les formules, les adresses et tout changement de sens passent par une proposition.", 'typo.save': 'Enregistrer la correction', 'typo.done': 'Coquille corrigée, sans toucher aux votes.', 'typo.substantive': 'Ce changement touche au sens : propose-le comme modification.', @@ -315,6 +315,7 @@ export const fr = { 'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.", 'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.", 'error.hidden_characters': 'Le texte contient des caractères invisibles (inversion du sens d\'écriture, espaces de largeur nulle) : retire-les.', + 'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.', 'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.', 'error.retry': 'Réessayer',