From 1eac6f856a7b663456a1aa7d1aa67719a7bcda8a Mon Sep 17 00:00:00 2001 From: Yvv Date: Wed, 21 Oct 2026 05:58:36 +0200 Subject: [PATCH] =?UTF-8?q?v3=20:=20quatri=C3=A8me=20relecture=20=E2=80=94?= =?UTF-8?q?=20coquille=20en=20liste=20blanche,=20mesur=C3=A9e=20contre=20l?= =?UTF-8?q?e=20texte=20vot=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Une coquille ne touche que des lettres latines, des espaces et la ponctuation ordinaire ; tout le reste est figé : chiffres de toute écriture, signes, relations, parenthèses, sosies (U+201A, U+2044, pleine chasse), marques superposées (U+0336, U+0338), autres alphabets, rayure Markdown, contenu entre accents graves, séparateurs et tirets collés à un chiffre. - Adresses (domaines, liens, courriels) comparées à l'identique. - Glissement de clavier au cœur du mot seulement (ni les deux premières ni les deux dernières lettres : préfixes, nombre, genre, temps, mode) ; mots de position ajoutés (dessus/dessous, avant/après…). - Chaque correction est mesurée contre le texte voté (dernière révision initiale ou de fond) : les glissements ne s'additionnent plus ; le texte soumis d'un brouillon devient sa révision initiale. - Déploiement : « previous » = l'image du conteneur en service, pas la dernière construite ; migrations inconnues tolérées pour qu'une image précédente démarre. Co-Authored-By: Claude Opus 5.5 --- .woodpecker/app.yml | 7 +- HANDOFF.md | 10 +- crates/ld-engine/src/edit.rs | 309 +++++++----------- crates/ld-server/src/lib.rs | 6 +- crates/ld-server/src/proposals.rs | 35 +- crates/ld-server/tests/api.rs | 28 ++ docs/adr/0003-corriger-n-est-pas-voter.md | 17 +- docs/chantiers/acte-certification/CHANTIER.md | 30 +- 8 files changed, 236 insertions(+), 206 deletions(-) diff --git a/.woodpecker/app.yml b/.woodpecker/app.yml index e54b189..256e9e3 100644 --- a/.woodpecker/app.yml +++ b/.woodpecker/app.yml @@ -34,8 +34,6 @@ steps: - /var/run/docker.sock:/var/run/docker.sock commands: - BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8) - # La version en service est gardée : « retablir » la relance si ce déploiement échoue. - - docker image inspect libredecision-app:latest >/dev/null 2>&1 && docker tag libredecision-app:latest libredecision-app:previous || true - docker build -t libredecision-app:latest -f docker/app.Dockerfile --target production --build-arg LD_BUILD=$BUILD . - echo "Image app construite ($BUILD)" @@ -127,6 +125,11 @@ steps: docker stop "$C" >/dev/null done done + # 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. + - | + 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 # Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1. - cd /opt/libredecision-app && docker compose stop - | diff --git a/HANDOFF.md b/HANDOFF.md index a018741..ddcf49f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -15,10 +15,12 @@ - Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la 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). -- Trois relectures indépendantes avant mise en ligne (`1f9c241 : ok`, `94907af : ok`, - `ac9c904 : à reprendre`), chacune traitée avant de livrer. La troisième a montré qu'une - « coquille » pouvait encore inverser un sens (« valide » → « invalide », « deux » → « dix », - caractères invisibles) : la règle a été resserrée (voir CHANTIER.md). Les autres points : ordre des workflows, anciens conteneurs en +- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 : + ok`, `94907af : ok`, `ac9c904 : à reprendre`, `9203fd2 : à reprendre`. Les deux 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, diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs index 724aff1..7efa075 100644 --- a/crates/ld-engine/src/edit.rs +++ b/crates/ld-engine/src/edit.rs @@ -34,154 +34,37 @@ const NEGATIONS: &[&str] = &[ "nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", ]; -/// Words that carry a quantity or a scope (folded); changing one is never a typo. +/// Words that carry a quantity, a scope or a relation (folded); changing one is +/// never a typo. +#[rustfmt::skip] const QUANTITIES: &[&str] = &[ // fr - "un", - "une", - "deux", - "trois", - "quatre", - "cinq", - "six", - "sept", - "huit", - "neuf", - "dix", - "onze", - "douze", - "treize", - "quatorze", - "quinze", - "seize", - "vingt", - "trente", - "quarante", - "cinquante", - "soixante", - "septante", - "huitante", - "octante", - "nonante", - "cent", - "cents", - "mille", - "million", - "millions", - "milliard", - "milliards", - "premier", - "premiere", - "premiers", - "premieres", - "second", - "seconde", - "deuxieme", - "troisieme", - "dernier", - "derniere", - "double", - "triple", - "moitie", - "tiers", - "quart", - "moins", - "minimum", - "maximum", - "majorite", - "minorite", - "unanimite", - "tous", - "toutes", - "tout", - "toute", - "chaque", - "seul", - "seule", - "seuls", - "seules", // en - "one", - "two", - "three", - "four", - "five", - "six", - "seven", - "eight", - "nine", - "ten", - "eleven", - "twelve", - "twenty", - "thirty", - "forty", - "fifty", - "sixty", - "hundred", - "thousand", - "million", - "first", - "second", - "third", - "last", - "half", - "twice", - "less", - "more", - "least", - "most", - "majority", - "minority", - "all", - "every", - "each", - "only", // es - "uno", - "dos", - "tres", - "cuatro", - "cinco", - "seis", - "siete", - "ocho", - "nueve", - "diez", - "once", - "doce", - "veinte", - "treinta", - "cuarenta", - "cincuenta", - "cien", - "ciento", - "mil", - "millon", - "primero", - "primera", - "segundo", - "segunda", - "ultimo", - "ultima", - "mitad", - "doble", - "menos", - "mas", - "minimo", - "maximo", - "mayoria", - "minoria", - "todos", - "todas", - "todo", - "toda", - "cada", - "solo", - "sola", + "un", "une", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "onze", "douze", "treize", + "quatorze", "quinze", "seize", "vingt", "trente", "quarante", "cinquante", "soixante", "septante", "huitante", + "octante", "nonante", "cent", "cents", "mille", "million", "millions", "milliard", "milliards", "premier", + "premiere", "premiers", "premieres", "second", "seconde", "deuxieme", "troisieme", "dernier", "derniere", "double", + "triple", "moitie", "tiers", "quart", "moins", "minimum", "maximum", "majorite", "minorite", "unanimite", "tous", + "toutes", "tout", "toute", "chaque", "seul", "seule", "seuls", "seules", "dessus", "dessous", "superieur", + "superieure", "inferieur", "inferieure", "avant", "apres", "sous", "entre", "hors", "sauf", "inclus", "exclus", + // en + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "twenty", + "thirty", "forty", "fifty", "sixty", "hundred", "thousand", "million", "first", "second", "third", "last", "half", + "twice", "less", "more", "least", "most", "majority", "minority", "all", "every", "each", "only", "above", "below", + "over", "under", "before", "after", "within", "except", + // es + "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "veinte", + "treinta", "cuarenta", "cincuenta", "cien", "ciento", "mil", "millon", "primero", "primera", "segundo", "segunda", + "ultimo", "ultima", "mitad", "doble", "menos", "mas", "minimo", "maximo", "mayoria", "minoria", "todos", "todas", + "todo", "toda", "cada", "solo", "sola", "encima", "debajo", "antes", "despues", "sobre", "bajo", "excepto", ]; const MAX_TYPO_WORDS: usize = 2; /// Shorter words have too many neighbours one letter away ("can" / "cant"). -const MIN_TYPO_WORD: usize = 4; +const MIN_TYPO_WORD: usize = 5; +/// Letters at each end of a word that a slip never touches: the start carries +/// prefixes ("valide" / "invalide"), the end carries number, gender, tense and +/// mood ("certifierai" / "certifierais", "será" / "sería"). +const FIXED_ENDS: usize = 2; /// Lowercase, strip diacritics. fn fold(s: &str) -> String { @@ -197,15 +80,20 @@ fn skeleton(s: &str) -> String { fold(s).chars().filter(|c| c.is_alphanumeric()).collect() } -/// Symbols that carry a value, a relation or a grouping, wherever they stand. -const RELATIONS: &[char] = &[ - '+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√', '/', '(', ')', '[', - ']', '{', '}', '`', -]; -/// Signs: meaningful next to a figure ("-5", "5-10"). -const SIGNS: &[char] = &['-', '−', '–']; -/// Separators: meaningful between two figures ("10 000", "10,000", "182.625"). -const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’']; +/// 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). +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] = + &['.', ',', ';', ':', '!', '?', '\'', '’', '‘', '"', '“', '”', '«', '»', '…', '‐', '-', '–', '—', '*', '_']; +/// Separators that become part of a number between two figures ("10 000", "10,000", "182.625"). +const NUMBER_SEPARATORS: &[char] = &['.', ',', '\'', '’']; +/// Dashes that become a sign next to a figure ("-5", "5-10"). +const DASHES: &[char] = &['‐', '-', '–', '—']; /// Invisible format characters (zero-width, bidirectional overrides and /// isolates, byte order mark): they can make "12" display as "21" ("Trojan @@ -216,68 +104,104 @@ 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. +/// Spaces and punctuation count too where they belong to a number. +fn signature(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()); + cs.iter() + .enumerate() + .filter(|&(i, &c)| { + let (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1))); + if is_latin_letter(c) { + false + } else if c.is_whitespace() { + before && after + } else if NUMBER_SEPARATORS.contains(&c) && before && after { + true + } else if DASHES.contains(&c) { + before || after + } else { + !PUNCTUATION.contains(&c) + } + }) + .map(|(_, &c)| c) + .collect() +} + /// What stands between backticks: formulas and identifiers, compared exactly. fn code_spans(s: &str) -> Vec<&str> { s.split('`').skip(1).step_by(2).collect() } -/// A single slip of the keyboard in a word: one letter added, removed, or two -/// neighbours swapped — never at its start, where prefixes turn a meaning -/// around ("valide" / "invalide", "limitée" / "illimitée"), and never a letter -/// replaced by another ("deux" / "dix", "majorité" / "minorité"). +/// 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 { + 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('@') + || (1..cs.len().saturating_sub(1)) + .any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric()) + }) + .map(str::to_owned) + .collect() +} + +/// A single slip of the keyboard inside a word: one letter added, removed, or +/// two neighbours swapped — never at either end (see `FIXED_ENDS`), and never a +/// letter replaced by another ("deux" / "dix", "majorité" / "minorité"). fn one_slip(x: &str, y: &str) -> bool { let (a, b): (Vec, Vec) = (x.chars().collect(), y.chars().collect()); - if a.len().min(b.len()) < MIN_TYPO_WORD || a[..2] != b[..2] { + if a.len().min(b.len()) < MIN_TYPO_WORD { return false; } + let inside = |i: usize, len: usize| i >= FIXED_ENDS && i + FIXED_ENDS < len; match a.len().abs_diff(b.len()) { 1 => { let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) }; - (2..long.len()).any(|i| { + (0..long.len()).any(|i| { let mut l = long.clone(); l.remove(i); - &l == short + inside(i, long.len()) && &l == short }) } 0 => { let at: Vec = (0..a.len()).filter(|&i| a[i] != b[i]).collect(); - at.len() == 2 && at[1] == at[0] + 1 && a[at[0]] == b[at[1]] && a[at[1]] == b[at[0]] + at.len() == 2 + && at[1] == at[0] + 1 + && a[at[0]] == b[at[1]] + && a[at[1]] == b[at[0]] + && inside(at[0], a.len()) + && inside(at[1], a.len()) } _ => false, } } -/// What the text says in figures and symbols, as written. Two texts with the -/// same letters but a different figure, sign, separator inside a number or -/// relation ("≥" → "≤", "+" → "-", "50" → "5,0", "182.625" → "182 625") do not -/// say the same thing. -fn figures(s: &str) -> String { - let cs: Vec = s.chars().collect(); - let is_figure = |i: Option| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric()); - cs.iter() - .enumerate() - .filter(|&(i, c)| { - let (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1))); - c.is_numeric() - || RELATIONS.contains(c) - || (SIGNS.contains(c) && (before || after)) - || (SEPARATORS.contains(c) && before && after) - }) - .map(|(_, c)| *c) - .collect() -} - /// Folded words (runs of letters/digits). fn words(s: &str) -> Vec { fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect() } +/// 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. pub fn classify(old: &str, new: &str) -> EditKind { if old == new { return EditKind::Unchanged; } - // A figure, a sign or a relation is never a typo: it changes what the text says. - if figures(old) != figures(new) || code_spans(old) != code_spans(new) { + if has_hidden_controls(new) + || signature(old) != signature(new) + || code_spans(old) != code_spans(new) + || addresses(old) != addresses(new) + { return EditKind::Substantive; } if skeleton(old) == skeleton(new) { @@ -468,6 +392,26 @@ mod tests { ("M(n)/N(n)", "M(n)N(n)"), ("DU(n) - c", "DU(n) / c"), ("la formule `a - b`", "la formule `a * b`"), + // Fourth independent review: look-alikes, overlays, addresses, endings. + ("au moins 50 voix", "au moins 5\u{201a}0 voix"), + ("**12 mois**", "**1\u{201a}2 mois**"), + ("15 jours", "1\u{2044}5 jours"), + ("2 ans", "\u{ff0d}2 ans"), + (">= 5", "\u{226f}= 5"), + ("a = b", "a \u{2260} b"), + ("a = b", "a =\u{0338} b"), + ("ne jamais certifier", "ne j\u{0336}a\u{0336}m\u{0336}a\u{0336}i\u{0336}s\u{0336} certifier"), + ("la clé est vérifiée", "la clé est ~~vérifiée~~"), + ("une personne connue", "une personne conn\u{05d5}ue"), + ("une personne connue", "une personne c\u{043e}nnue"), + ( + "https://git.duniter.org/documents/g1_monetary_license", + "https://git.dunniter.org/documents/g1_monetary_license", + ), + ("au-dessus de 50 %", "au-dessous de 50 %"), + ("je certifierai", "je certifierais"), + ("será válido", "sería válido"), + ("they must certify", "they mustn certify"), ] { assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); } @@ -482,7 +426,8 @@ mod tests { #[test] fn misspelt_words() { - assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic); + // The end of a word carries number and tense: a plural fixed is a revision. + assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Substantive); assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic); assert_eq!(classify("un acceuil chaleureux", "un accueil chaleureux"), EditKind::Typographic); // A letter replaced by another is not a slip: the author revises. diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 67f480f..327c0ee 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -61,7 +61,11 @@ pub async fn open_db(url: &str) -> anyhow::Result { .busy_timeout(Duration::from_secs(5)); let max = if url.contains(":memory:") { 1 } else { 8 }; let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?; - sqlx::migrate!("./migrations").run(&pool).await?; + // A previous image (rollback) must still start on a database a newer one has + // migrated: migrations are additive, the unknown ones are tolerated. + let mut migrator = sqlx::migrate!("./migrations"); + migrator.set_ignore_missing(true); + migrator.run(&pool).await?; Ok(pool) } diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 4a13c0e..f2c60e5 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -71,6 +71,18 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> { Ok(()) } +/// The text the votes were cast on: its last initial or substantive revision. +async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> { + let row: Option<(String, String)> = sqlx::query_as( + "SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive') + ORDER BY id DESC LIMIT 1", + ) + .bind(&p.id) + .fetch_optional(&state.db) + .await?; + Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone()))) +} + async fn record_revision( state: &AppState, proposal_id: &str, @@ -302,7 +314,18 @@ pub async fn edit( if !matches!(p.status.as_str(), "open" | "in_force") { return Err(AppError::Conflict("closed")); } - let kind = edit::classify(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}")); + // 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. + let next = format!("{title}\n{body}"); + let kind = if next == format!("{}\n{}", p.title, 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, + } + }; match kind { EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))), EditKind::Typographic => { @@ -379,6 +402,16 @@ pub async fn submit( .bind(&id) .execute(&state.db) .await?; + // A draft is edited freely: the text submitted is the one votes will be cast on. + let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'") + .bind(&p.title) + .bind(&p.body) + .bind(&id) + .execute(&state.db) + .await?; + if initial.rows_affected() == 0 { + record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?; + } let entered = adoption::run(&state, &st.doc.id).await?; Ok(Json(json!({ "status": "open", "entered": entered }))) } diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 21ab922..9a99593 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -251,6 +251,34 @@ async fn editing_is_not_voting() { ) .await; assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters"))); + // 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 + .call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1)))) + .await; + assert_eq!(v["edit"], "typo", "one slip is a typo"); + let (s, v) = b + .call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1)))) + .await; + assert_eq!( + (s, v["error"].as_str()), + (StatusCode::CONFLICT, Some("substantive_in_force")), + "a second slip on top is not" + ); + let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await; + assert_eq!(v["edit"], "typo", "back to the voted text"); + // An address is compared exactly. + let x11 = unit(&doc, "X1.1")["proposals"][0].clone(); + let x11_body = x11["body"].as_str().expect("body"); + assert!(x11_body.contains("git.duniter.org")); + let (s, v) = b + .call( + "PATCH", + &format!("/api/proposals/{}", x11["id"].as_str().expect("id")), + Some(json!({ "title": x11["title"], "body": x11_body.replacen("git.duniter.org", "git.dunniter.org", 1) })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author"))); // A member proposal: typo keeps votes, substance asks to reconfirm. let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned(); diff --git a/docs/adr/0003-corriger-n-est-pas-voter.md b/docs/adr/0003-corriger-n-est-pas-voter.md index ea80dd4..d187efc 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -3,13 +3,18 @@ **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é. -**Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`) : -- **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre, une relation ou - une parenthèse change, ni si quoi que ce soit change entre accents graves (formules) ; +**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 un séparateur ou un tiret collé à un chiffre ; - **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 — une lettre ajoutée, retirée - ou deux voisines inversées, jamais au début du mot (les préfixes inversent le sens), jamais une - lettre remplacée, jamais sur un mot de négation ou de quantité ; elle s'applique sans vote, + 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 ; diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 4bfac66..24aea7a 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -76,19 +76,24 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p | 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) | 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** (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 | | 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 — trois relectures indépendantes l'ont éprouvé. Sont toujours « de -fond » : un chiffre, un signe, un séparateur dans un nombre, une relation, une parenthèse -changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - », -`ceil(N^(1/5))` → `ceil(N^1/5)`) ; tout ce qui est entre accents graves (formules) ; un mot de -négation ou de quantité touché (ne, pas, jamais, deux, dix, majorité, tous…) ; une lettre -remplacée par une autre (« majorité » → « minorité ») ; le début d'un mot touché (« valide » → -« invalide », « limitée » → « illimitée »). Les caractères invisibles (inversion du sens -d'écriture, espaces de largeur nulle) sont refusés : ils peuvent faire lire « 21 » là où le texte -dit « 12 ». Moteur : `crates/ld-engine/src/edit.rs`. +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), et un séparateur ou un tiret collé à un chiffre (« 50 » → « 5,0 », +« 182.625 » → « 182 625 », « -5 »). 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`. ### Deux articles qui se contredisent @@ -159,6 +164,11 @@ Les raisons longues sont dans `docs/adr/`. **et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une. - Un administrateur peut créer un nouveau lien pour une identité d'invitation existante (pour la dépanner) — jamais pour une identité Ğ1. +- Des accents ou une ponctuation qui portent du sens restent des coquilles (« ou » / « où », + « a » / « à », une virgule autour d'une relative) : le diff de chaque correction est visible + dans l'historique de l'article. +- Les traductions (en, es) sont éditoriales : tout membre les corrige sans vote, et elles sont + republiées — le français fait foi. ## Questions ouvertes (à Yvv)